chore: import upstream snapshot with attribution
This commit is contained in:
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# Analyze quotio repository for interesting patterns and features
|
||||
# Usage: ./Scripts/analyze_quotio.sh [feature-area]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
AREA=${1:-all}
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${BLUE}==> Fetching latest quotio...${NC}"
|
||||
git fetch quotio 2>/dev/null || {
|
||||
echo -e "${YELLOW}Adding quotio remote...${NC}"
|
||||
git remote add quotio https://github.com/nguyenphutrong/quotio.git
|
||||
git fetch quotio
|
||||
}
|
||||
remote_default_branch() {
|
||||
local remote=$1
|
||||
local branch=""
|
||||
local candidate
|
||||
|
||||
branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true)
|
||||
if [ -z "$branch" ]; then
|
||||
branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true)
|
||||
fi
|
||||
if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then
|
||||
echo "$branch"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for candidate in main master; do
|
||||
if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${RED}Error: Could not resolve default branch for remote '$remote'.${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
QUOTIO_BRANCH=$(remote_default_branch quotio)
|
||||
QUOTIO_REF="quotio/${QUOTIO_BRANCH}"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}==> Quotio Repository Analysis (${QUOTIO_REF})${NC}"
|
||||
echo ""
|
||||
|
||||
# Show recent activity
|
||||
echo -e "${BLUE}Recent Activity (last 30 days):${NC}"
|
||||
git log --oneline --graph "$QUOTIO_REF" --since="30 days ago" | head -20 || true
|
||||
echo ""
|
||||
|
||||
# Analyze file structure
|
||||
echo -e "${BLUE}File Structure:${NC}"
|
||||
git ls-tree -r --name-only "$QUOTIO_REF" | grep -E '\.(swift|md)$' | head -30 || true
|
||||
echo ""
|
||||
|
||||
# Find interesting patterns based on area
|
||||
case $AREA in
|
||||
"providers"|"all")
|
||||
echo -e "${BLUE}Provider Implementations:${NC}"
|
||||
git ls-tree -r --name-only "$QUOTIO_REF" | grep -i provider | head -20 || true
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
|
||||
case $AREA in
|
||||
"ui"|"all")
|
||||
echo -e "${BLUE}UI Components:${NC}"
|
||||
git ls-tree -r --name-only "$QUOTIO_REF" | grep -iE '(view|ui|menu)' | head -20 || true
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
|
||||
case $AREA in
|
||||
"auth"|"all")
|
||||
echo -e "${BLUE}Authentication/Session:${NC}"
|
||||
git ls-tree -r --name-only "$QUOTIO_REF" | grep -iE '(auth|session|cookie|login)' | head -20 || true
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
|
||||
# Show commit messages for pattern analysis
|
||||
echo -e "${BLUE}Recent Commit Messages (for pattern analysis):${NC}"
|
||||
git log --oneline "$QUOTIO_REF" --since="60 days ago" | head -30 || true
|
||||
echo ""
|
||||
|
||||
# Create analysis report
|
||||
REPORT_FILE="quotio-analysis-$(date +%Y%m%d).md"
|
||||
cat > "$REPORT_FILE" << EOF
|
||||
# Quotio Analysis Report
|
||||
**Date:** $(date +%Y-%m-%d)
|
||||
**Purpose:** Identify patterns and features for CodexBar fork inspiration
|
||||
**Source ref:** \`$QUOTIO_REF\`
|
||||
|
||||
## Recent Activity
|
||||
\`\`\`
|
||||
$(git log --oneline --graph "$QUOTIO_REF" --since="30 days ago" | head -20 || true)
|
||||
\`\`\`
|
||||
|
||||
## File Structure
|
||||
\`\`\`
|
||||
$(git ls-tree -r --name-only "$QUOTIO_REF" | grep -E '\.(swift|md)$' | head -50 || true)
|
||||
\`\`\`
|
||||
|
||||
## Recent Commits
|
||||
\`\`\`
|
||||
$(git log --oneline "$QUOTIO_REF" --since="60 days ago" | head -30 || true)
|
||||
\`\`\`
|
||||
|
||||
## Areas of Interest
|
||||
|
||||
### Providers
|
||||
- [ ] Review provider implementations
|
||||
- [ ] Compare with CodexBar approach
|
||||
- [ ] Identify improvements
|
||||
|
||||
### UI/UX
|
||||
- [ ] Menu bar organization
|
||||
- [ ] Settings layout
|
||||
- [ ] Status indicators
|
||||
|
||||
### Authentication
|
||||
- [ ] Session management
|
||||
- [ ] Cookie handling
|
||||
- [ ] OAuth flows
|
||||
|
||||
### Multi-Account
|
||||
- [ ] Account switching
|
||||
- [ ] Account storage
|
||||
- [ ] UI patterns
|
||||
|
||||
## Action Items
|
||||
- [ ] Review specific files of interest
|
||||
- [ ] Document patterns (not code)
|
||||
- [ ] Create implementation plan
|
||||
- [ ] Implement independently
|
||||
|
||||
## Notes
|
||||
Remember: We're looking for PATTERNS and IDEAS, not copying code.
|
||||
All implementations must be original and follow CodexBar conventions.
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}Analysis report saved to: $REPORT_FILE${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo ""
|
||||
echo "1. View specific files:"
|
||||
echo " ${GREEN}git show $QUOTIO_REF:path/to/file${NC}"
|
||||
echo ""
|
||||
echo "2. Compare implementations:"
|
||||
echo " ${GREEN}git diff main $QUOTIO_REF -- path/to/similar/file${NC}"
|
||||
echo ""
|
||||
echo "3. Review commit details:"
|
||||
echo " ${GREEN}git log -p $QUOTIO_REF --since='30 days ago'${NC}"
|
||||
echo ""
|
||||
echo "4. Document patterns in:"
|
||||
echo " ${GREEN}docs/QUOTIO_ANALYSIS.md${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Remember: Adapt patterns, don't copy code!${NC}"
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
npx --yes tailwindcss@3.4.19 \
|
||||
--config Scripts/tailwind.site.config.cjs \
|
||||
--input Scripts/site-tailwind.input.css \
|
||||
--output docs/site-utilities.css \
|
||||
--minify
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ICON_FILE=${1:-Icon.icon}
|
||||
BASENAME=${2:-Icon}
|
||||
OUT_ROOT=${3:-build/icon}
|
||||
XCODE_APP=${XCODE_APP:-/Applications/Xcode.app}
|
||||
|
||||
ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/ictool"
|
||||
if [[ ! -x "$ICTOOL" ]]; then
|
||||
ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/icontool"
|
||||
fi
|
||||
if [[ ! -x "$ICTOOL" ]]; then
|
||||
echo "ictool/icontool not found. Set XCODE_APP if Xcode is elsewhere." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ICONSET_DIR="$OUT_ROOT/${BASENAME}.iconset"
|
||||
TMP_DIR="$OUT_ROOT/tmp"
|
||||
mkdir -p "$ICONSET_DIR" "$TMP_DIR"
|
||||
|
||||
MASTER_ART="$TMP_DIR/icon_art_824.png"
|
||||
MASTER_1024="$TMP_DIR/icon_1024.png"
|
||||
|
||||
# Render inner art (no margin) with macOS Default appearance
|
||||
"$ICTOOL" "$ICON_FILE" \
|
||||
--export-preview macOS Default 824 824 1 -45 "$MASTER_ART"
|
||||
|
||||
# Pad to 1024x1024 with transparent border
|
||||
sips --padToHeightWidth 1024 1024 "$MASTER_ART" --out "$MASTER_1024" >/dev/null
|
||||
|
||||
# Generate required sizes
|
||||
sizes=(16 32 64 128 256 512 1024)
|
||||
for sz in "${sizes[@]}"; do
|
||||
out="$ICONSET_DIR/icon_${sz}x${sz}.png"
|
||||
sips -z "$sz" "$sz" "$MASTER_1024" --out "$out" >/dev/null
|
||||
if [[ "$sz" -ne 1024 ]]; then
|
||||
dbl=$((sz*2))
|
||||
out2="$ICONSET_DIR/icon_${sz}x${sz}@2x.png"
|
||||
sips -z "$dbl" "$dbl" "$MASTER_1024" --out "$out2" >/dev/null
|
||||
fi
|
||||
done
|
||||
|
||||
# 512x512@2x already covered by 1024; ensure it exists
|
||||
cp "$MASTER_1024" "$ICONSET_DIR/icon_512x512@2x.png"
|
||||
|
||||
iconutil -c icns "$ICONSET_DIR" -o Icon.icns
|
||||
|
||||
echo "Icon.icns generated at $(pwd)/Icon.icns"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
exec "$SCRIPT_DIR/mac-release" changelog-html "$@"
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const resources = path.join(repoRoot, "Sources/CodexBar/Resources");
|
||||
const english = readCatalog("en");
|
||||
const englishKeys = Object.keys(english).sort();
|
||||
const strictLocales = ["ar", "ca", "fa", "th"];
|
||||
const languageKeys = ["language_arabic", "language_persian", "language_thai"];
|
||||
const isTest = process.argv.includes("--test");
|
||||
|
||||
function readCatalog(locale) {
|
||||
const file = path.join(resources, `${locale}.lproj/Localizable.strings`);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const output = execFileSync("plutil", ["-convert", "json", "-o", "-", file], { encoding: "utf8" });
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
function tokenSignature(value) {
|
||||
// Exclude explicit `%%`, which does not consume an argument.
|
||||
const withoutEscapedPercents = value.replace(/%%/g, "");
|
||||
const printfRaw = withoutEscapedPercents.match(/%(?:\d+\$)?(?:\.\d+)?(?:@|d|f)/g) ?? [];
|
||||
|
||||
const printf = {};
|
||||
let implicitIndex = 1;
|
||||
for (const token of printfRaw) {
|
||||
const match = token.match(/%(\d+)\$.*?([@df])/);
|
||||
if (match) {
|
||||
printf[Number.parseInt(match[1], 10)] = match[2];
|
||||
} else {
|
||||
printf[implicitIndex] = token.at(-1);
|
||||
implicitIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { printf, swift: swiftInterpolationTokens(value).sort() };
|
||||
}
|
||||
|
||||
function formatKeyList(keys, limit = 12) {
|
||||
const shown = keys.slice(0, limit).join(", ");
|
||||
const remaining = keys.length - limit;
|
||||
return remaining > 0 ? `${shown}, ... +${remaining} more` : shown;
|
||||
}
|
||||
|
||||
function blankKeys(catalog, referenceKeys) {
|
||||
return referenceKeys.filter((key) => Object.hasOwn(catalog, key) && !catalog[key]?.trim());
|
||||
}
|
||||
|
||||
function swiftInterpolationTokens(value) {
|
||||
const tokens = [];
|
||||
for (let index = 0; index < value.length - 1; index += 1) {
|
||||
if (value[index] !== "\\" || value[index + 1] !== "(") continue;
|
||||
|
||||
const start = index;
|
||||
let depth = 1;
|
||||
index += 2;
|
||||
while (index < value.length && depth > 0) {
|
||||
if (value[index] === "(") depth += 1;
|
||||
if (value[index] === ")") depth -= 1;
|
||||
index += 1;
|
||||
}
|
||||
tokens.push(value.slice(start, index));
|
||||
index -= 1;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
if (isTest) {
|
||||
assertEqual(tokenSignature("%1$@ · %2$d"), tokenSignature("%2$d · %1$@"), "positional reorder");
|
||||
assertNotEqual(tokenSignature("%1$@ · %2$d"), tokenSignature("%1$d · %2$@"), "positional type swap");
|
||||
assertEqual(tokenSignature("%.0f%% used"), tokenSignature("%.0f%% verbraucht"), "escaped percent");
|
||||
assertNotEqual(tokenSignature("\\(name): \\(usage)"), tokenSignature("\\(name): \\(value)"), "Swift tokens");
|
||||
assertEqual(
|
||||
tokenSignature("\\(self.store.metadata(for: self.provider).displayName) failed"),
|
||||
tokenSignature("Fehler: \\(self.store.metadata(for: self.provider).displayName)"),
|
||||
"nested Swift interpolation");
|
||||
assertNotEqual(
|
||||
tokenSignature("\\(self.store.metadata(for: self.provider).displayName) failed"),
|
||||
tokenSignature("\\(self.store.metadata(for: self.provider) failed"),
|
||||
"truncated Swift interpolation");
|
||||
assertEqual(formatKeyList(["alpha", "beta"]), "alpha, beta", "short key list");
|
||||
assertEqual(
|
||||
formatKeyList(["alpha", "beta", "gamma", "delta"], 2),
|
||||
"alpha, beta, ... +2 more",
|
||||
"truncated key list");
|
||||
assertEqual(
|
||||
blankKeys({ alpha: "", beta: " ", gamma: "ok" }, ["alpha", "beta", "gamma", "delta"]),
|
||||
["alpha", "beta"],
|
||||
"blank keys");
|
||||
console.log("app locale checker tests OK");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let hasErrors = false;
|
||||
let checkedCount = 0;
|
||||
|
||||
for (const strictLocale of strictLocales) {
|
||||
const dirPath = path.join(resources, `${strictLocale}.lproj`);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
console.error(`\x1b[31mError: Required strict locale catalog is completely missing: ${strictLocale}.lproj\x1b[0m`);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const directory of fs.readdirSync(resources).filter((name) => name.endsWith(".lproj"))) {
|
||||
const locale = directory.replace(/\.lproj$/, "");
|
||||
if (locale === "en" || locale === "Base") continue;
|
||||
|
||||
const catalog = readCatalog(locale);
|
||||
if (!catalog) continue;
|
||||
|
||||
checkedCount++;
|
||||
const catalogKeys = Object.keys(catalog);
|
||||
const emptyKeys = blankKeys(catalog, englishKeys);
|
||||
|
||||
// 1. Missing keys
|
||||
const missingKeys = englishKeys.filter((key) => !catalogKeys.includes(key));
|
||||
if (missingKeys.length > 0) {
|
||||
const missingKeyList = formatKeyList(missingKeys);
|
||||
if (strictLocales.includes(locale)) {
|
||||
console.error(
|
||||
`\x1b[31m[${locale}] Error: Missing ${missingKeys.length} keys in strict locale: ${missingKeyList}.\x1b[0m`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
console.warn(`\x1b[33m[${locale}] Warning: Missing ${missingKeys.length} keys: ${missingKeyList}.\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
const extraKeys = catalogKeys.filter((key) => !englishKeys.includes(key));
|
||||
if (strictLocales.includes(locale) && extraKeys.length > 0) {
|
||||
console.error(`\x1b[31m[${locale}] Error: Found ${extraKeys.length} extra keys in strict locale.\x1b[0m`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Ensure critical language keys are present in ALL locales
|
||||
for (const key of languageKeys) {
|
||||
if (!catalog[key] || !catalog[key].trim()) {
|
||||
console.error(`\x1b[31m[${locale}] Error: Missing critical language key "${key}".\x1b[0m`);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (emptyKeys.length > 0) {
|
||||
console.error(
|
||||
`\x1b[31m[${locale}] Error: Blank values for ${emptyKeys.length} keys: ${formatKeyList(emptyKeys)}.\x1b[0m`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// 2. Identical values count
|
||||
let identicalCount = 0;
|
||||
|
||||
for (const key of englishKeys) {
|
||||
if (!catalog[key]?.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (catalog[key] === english[key]) {
|
||||
identicalCount++;
|
||||
}
|
||||
|
||||
// 3. Format placeholder mismatch
|
||||
const tEn = tokenSignature(english[key]);
|
||||
const tLoc = tokenSignature(catalog[key]);
|
||||
if (JSON.stringify(tEn) !== JSON.stringify(tLoc)) {
|
||||
console.error(`\x1b[31m[${locale}] Error: Token mismatch for key "${key}"\x1b[0m`);
|
||||
console.error(` en: ${english[key]} Tokens: ${JSON.stringify(tEn)}`);
|
||||
console.error(` ${locale}: ${catalog[key]} Tokens: ${JSON.stringify(tLoc)}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if identical translation count exceeds 15% of the total keys (approx > 150 out of 1050)
|
||||
const identicalRatio = identicalCount / englishKeys.length;
|
||||
if (identicalRatio > 0.15) {
|
||||
console.warn(`\x1b[33m[${locale}] Warning: High number of identical translations: ${identicalCount}/${englishKeys.length} (${(identicalRatio * 100).toFixed(1)}%)\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
console.error("\n\x1b[31mApp locale checks failed.\x1b[0m");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n\x1b[32mApp locales OK: Checked ${checkedCount} catalogs against ${englishKeys.length} English keys.\x1b[0m`);
|
||||
|
||||
function assertEqual(actual, expected, label) {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotEqual(actual, expected, label) {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) {
|
||||
throw new Error(`${label}: signatures unexpectedly match`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const approvedRootDocumentation = new Set([
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"VISION.md",
|
||||
].map((relativePath) => path.join(repoRoot, relativePath)));
|
||||
|
||||
const readme = readText("README.md");
|
||||
const readmeLinks = [
|
||||
...markdownLinks(readme),
|
||||
...markdownImageLinks(readme),
|
||||
...htmlLinks(readme),
|
||||
].filter(isRepositoryDocReference);
|
||||
|
||||
assert(readmeLinks.length > 0, "README.md has no local documentation links");
|
||||
for (const link of readmeLinks) validateLocalDocLink(link, repoRoot, "README.md");
|
||||
|
||||
const providerLinks = inlineCodeDocLinks(readText("docs/providers.md"));
|
||||
assert(providerLinks.length > 0, "docs/providers.md has no provider detail links");
|
||||
for (const link of providerLinks) validateLocalDocLink(link, repoRoot, "docs/providers.md");
|
||||
|
||||
const docsLinks = markdownFiles("docs").flatMap((relativePath) => {
|
||||
const markdown = readText(relativePath);
|
||||
const links = [
|
||||
...markdownLinks(markdown),
|
||||
...markdownImageLinks(markdown),
|
||||
...htmlLinks(markdown),
|
||||
].filter(isLocalDocumentationReference);
|
||||
|
||||
return links.map((link) => ({ link, relativePath }));
|
||||
});
|
||||
|
||||
for (const { link, relativePath } of docsLinks) {
|
||||
validateLocalDocLink(link, path.join(repoRoot, path.dirname(relativePath)), relativePath);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`documentation links OK: ${readmeLinks.length + providerLinks.length + docsLinks.length} local links`,
|
||||
);
|
||||
|
||||
function readText(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function markdownLinks(markdown) {
|
||||
const source = markdownTextOutsideCode(markdown);
|
||||
const links = [];
|
||||
const inlinePattern = /(?<!!)\[(?:\\.|[^\]\\])+\]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)/g;
|
||||
for (const match of source.matchAll(inlinePattern)) {
|
||||
links.push(encodeSpaces(match[1] ?? match[2]));
|
||||
}
|
||||
|
||||
const referencePattern = /^\s*\[[^\]\n]+]:\s*(?:<([^>\n]+)>|([^\s]+))/gm;
|
||||
for (const match of source.matchAll(referencePattern)) {
|
||||
links.push(encodeSpaces(match[1] ?? match[2]));
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function markdownImageLinks(markdown) {
|
||||
const source = markdownTextOutsideCode(markdown);
|
||||
const pattern = /!\[(?:\\.|[^\]\\])*\]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)/g;
|
||||
return [...source.matchAll(pattern)].map((match) => match[1] ?? match[2]);
|
||||
}
|
||||
|
||||
function htmlLinks(markdown) {
|
||||
const source = markdownTextOutsideCode(markdown);
|
||||
const pattern = /<\s*(?:a|img)\b[^>]*?\b(?:href|src)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
|
||||
return [...source.matchAll(pattern)].map((match) => match[1] ?? match[2] ?? match[3]);
|
||||
}
|
||||
|
||||
function inlineCodeDocLinks(markdown) {
|
||||
return markdown.split("\n").flatMap((line) => {
|
||||
const trimmed = line.trim();
|
||||
const prefix = "- Details: `";
|
||||
if (!trimmed.startsWith(prefix)) return [];
|
||||
const rest = trimmed.slice(prefix.length);
|
||||
const end = rest.indexOf("`");
|
||||
return end === -1 ? [] : [rest.slice(0, end)];
|
||||
});
|
||||
}
|
||||
|
||||
function validateLocalDocLink(rawLink, baseDirectory, sourceLabel) {
|
||||
const sourcePath = path.join(repoRoot, sourceLabel);
|
||||
const { absolutePath, fragment } = localDocPath(rawLink, baseDirectory, sourcePath);
|
||||
assert(fs.existsSync(absolutePath), `${sourceLabel}: missing documentation target: ${rawLink}`);
|
||||
|
||||
if (path.extname(absolutePath).toLowerCase() !== ".md" || !fragment) return;
|
||||
const anchors = markdownHeadingAnchors(readText(path.relative(repoRoot, absolutePath)));
|
||||
assert(anchors.has(fragment), `${sourceLabel}: missing documentation anchor: ${rawLink}`);
|
||||
}
|
||||
|
||||
function isRepositoryDocReference(rawLink) {
|
||||
const parsed = parseRelativeURL(rawLink);
|
||||
if (!parsed || parsed.protocol || parsed.host) return false;
|
||||
let pathname = parsed.pathname;
|
||||
while (pathname.startsWith("./")) pathname = pathname.slice(2);
|
||||
return pathname === "docs" || pathname.startsWith("docs/");
|
||||
}
|
||||
|
||||
function isLocalDocumentationReference(rawLink) {
|
||||
const parsed = parseRelativeURL(rawLink);
|
||||
if (!parsed || parsed.protocol || parsed.host) return false;
|
||||
return Boolean(parsed.pathname || parsed.hash);
|
||||
}
|
||||
|
||||
function localDocPath(rawLink, baseDirectory, sourcePath) {
|
||||
const parsed = parseRelativeURL(rawLink);
|
||||
assert(
|
||||
parsed && !parsed.protocol && !parsed.host && (parsed.pathname || parsed.hash),
|
||||
`invalid documentation URL: ${rawLink}`,
|
||||
);
|
||||
|
||||
const rawPath = rawLink.split("#", 1)[0].split("?", 1)[0];
|
||||
const decodedPath = decodeURIComponent(rawPath);
|
||||
const absolutePath = decodedPath ? path.resolve(baseDirectory, decodedPath) : sourcePath;
|
||||
const docsRoot = path.resolve(repoRoot, "docs");
|
||||
const isInDocsTree = absolutePath === docsRoot || absolutePath.startsWith(`${docsRoot}${path.sep}`);
|
||||
assert(
|
||||
isInDocsTree || approvedRootDocumentation.has(absolutePath),
|
||||
`documentation link escapes approved documentation roots: ${rawLink}`,
|
||||
);
|
||||
return { absolutePath, fragment: parsed.hash ? decodeURIComponent(parsed.hash.slice(1)) : "" };
|
||||
}
|
||||
|
||||
function markdownFiles(relativeDir) {
|
||||
const dir = path.join(repoRoot, relativeDir);
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") return [];
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
if (entry.isDirectory()) return markdownFiles(relativePath);
|
||||
return entry.isFile() && entry.name.endsWith(".md") ? [relativePath] : [];
|
||||
}).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function parseRelativeURL(rawLink) {
|
||||
try {
|
||||
const parsed = new URL(rawLink, "relative://repo/");
|
||||
const isRelative = parsed.protocol === "relative:" && parsed.host === "repo";
|
||||
return {
|
||||
protocol: isRelative ? "" : parsed.protocol,
|
||||
host: isRelative ? "" : parsed.host,
|
||||
pathname: isRelative ? parsed.pathname.replace(/^\//, "") : parsed.pathname,
|
||||
hash: parsed.hash,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function markdownHeadingAnchors(markdown) {
|
||||
const occurrences = new Map();
|
||||
const anchors = new Set();
|
||||
const source = markdownTextOutsideFencedCode(markdown);
|
||||
for (const line of source.split("\n")) {
|
||||
const trimmed = line.replace(/^[ \t]+/, "");
|
||||
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(trimmed);
|
||||
if (!match) continue;
|
||||
const base = markdownHeadingSlug(match[2]);
|
||||
if (!base) continue;
|
||||
const occurrence = occurrences.get(base) ?? 0;
|
||||
anchors.add(occurrence === 0 ? base : `${base}-${occurrence}`);
|
||||
occurrences.set(base, occurrence + 1);
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
|
||||
function markdownHeadingSlug(heading) {
|
||||
const text = removeMarkdownFormatting(heading).toLowerCase();
|
||||
let slug = "";
|
||||
for (const char of text) {
|
||||
if (/[\p{Letter}\p{Number}_-]/u.test(char)) {
|
||||
slug += char;
|
||||
} else if (/\s/u.test(char)) {
|
||||
slug += "-";
|
||||
}
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
function removeMarkdownFormatting(text) {
|
||||
return text
|
||||
.replace(/`([^`]*)`/g, "$1")
|
||||
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
|
||||
.replace(/[*_~]/g, "");
|
||||
}
|
||||
|
||||
function markdownTextOutsideCode(markdown) {
|
||||
return markdownTextOutsideFencedCode(markdown)
|
||||
.split("\n")
|
||||
.map(removeInlineCode)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function markdownTextOutsideFencedCode(markdown) {
|
||||
let fence = null;
|
||||
return markdown.split("\n").map((line) => {
|
||||
if (fence) {
|
||||
if (isClosingFence(line, fence.marker, fence.count)) fence = null;
|
||||
return "";
|
||||
}
|
||||
const openingFence = parseOpeningFence(line);
|
||||
if (openingFence) {
|
||||
fence = openingFence;
|
||||
return "";
|
||||
}
|
||||
return line;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function parseOpeningFence(line) {
|
||||
const match = /^( {0,3})([`~]{3,})(.*)$/.exec(line);
|
||||
if (!match) return null;
|
||||
const marker = match[2][0];
|
||||
if (marker === "`" && match[3].includes("`")) return null;
|
||||
return { marker, count: match[2].length };
|
||||
}
|
||||
|
||||
function isClosingFence(line, marker, minimumCount) {
|
||||
const escaped = marker === "`" ? "`" : "~";
|
||||
const pattern = new RegExp(`^ {0,3}${escaped}{${minimumCount},}\\s*$`);
|
||||
return pattern.test(line);
|
||||
}
|
||||
|
||||
function removeInlineCode(line) {
|
||||
return line.replace(/(?<!`)(`+)(?!`)(.*?)(?<!`)\1(?!`)/g, "");
|
||||
}
|
||||
|
||||
function encodeSpaces(value) {
|
||||
return value.replaceAll(" ", "%20");
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
exec "$SCRIPT_DIR/mac-release" check-assets "$@"
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { localeCatalog, localeMessages } from "../docs/site-locales.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const indexHtml = fs.readFileSync(path.join(repoRoot, "docs/index.html"), "utf8");
|
||||
const providerSource = fs.readFileSync(
|
||||
path.join(repoRoot, "Sources/CodexBarCore/Providers/Providers.swift"),
|
||||
"utf8",
|
||||
);
|
||||
const providerEnumBody = providerSource.match(
|
||||
/public enum UsageProvider:[^{]+\{([\s\S]*?)\n\}/,
|
||||
)?.[1];
|
||||
assert(providerEnumBody, "could not locate UsageProvider cases");
|
||||
const providerIDs = [...providerEnumBody.matchAll(/^\s*case\s+(\w+)\s*$/gm)].map((match) => match[1]);
|
||||
assert(providerIDs.length > 0, "UsageProvider must define at least one provider");
|
||||
assertEqual(new Set(providerIDs).size, providerIDs.length, "UsageProvider IDs");
|
||||
const providerCount = providerIDs.length;
|
||||
|
||||
const publicCountFiles = [
|
||||
["README.md", `alt="CodexBar — every AI coding limit in your menu bar. ${providerCount} providers."`],
|
||||
["docs/providers.md", `CodexBar currently registers ${providerCount} provider IDs.`],
|
||||
["docs/social.html", `<strong>${providerCount} providers</strong>`],
|
||||
["docs/llms.txt", `across ${providerCount} providers`],
|
||||
];
|
||||
for (const [relativePath, expectedText] of publicCountFiles) {
|
||||
const contents = fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
assert(contents.includes(expectedText), `${relativePath} must advertise ${providerCount} providers`);
|
||||
}
|
||||
assert(indexHtml.includes(`across ${providerCount} providers`), `index metadata must advertise ${providerCount} providers`);
|
||||
assert(
|
||||
indexHtml.includes(`across ${providerCount} AI coding providers`),
|
||||
`index social metadata must advertise ${providerCount} providers`,
|
||||
);
|
||||
assert(
|
||||
indexHtml.includes(`>${providerCount} providers,{mobileBreak}one menu bar</span>`),
|
||||
`index provider heading must advertise ${providerCount} providers`,
|
||||
);
|
||||
|
||||
assert(!indexHtml.includes("cdn.tailwindcss.com"), "site must not load Tailwind from a runtime CDN");
|
||||
for (const match of indexHtml.matchAll(/<link rel="stylesheet" href="\.\/([^"?]+)(?:\?[^"']*)?"/g)) {
|
||||
assert(fs.existsSync(path.join(repoRoot, "docs", match[1])), `missing local stylesheet ${match[1]}`);
|
||||
}
|
||||
const expectedCodes = [
|
||||
"en", "zh-CN", "zh-TW", "ja-JP", "es", "pt-BR", "ko", "de", "fr", "ar", "it",
|
||||
"vi", "nl", "tr", "uk", "ru", "id", "pl", "fa", "th", "gl", "ca", "sv",
|
||||
];
|
||||
const catalogCodes = localeCatalog.map((locale) => locale.code);
|
||||
const appLanguageSource = fs.readFileSync(
|
||||
path.join(repoRoot, "Sources/CodexBar/PreferencesGeneralPane.swift"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assertEqual(catalogCodes, expectedCodes, "locale catalog");
|
||||
assertEqual(
|
||||
localeCatalog.filter((locale) => locale.direction === "rtl").map((locale) => locale.code),
|
||||
["ar", "fa"],
|
||||
"RTL locale catalog");
|
||||
const appCatalogCodes = [...appLanguageSource.matchAll(/case \w+ = "([^"]+)"/g)]
|
||||
.map((match) => match[1])
|
||||
.filter(Boolean)
|
||||
.map((code) => ({ "zh-Hans": "zh-CN", "zh-Hant": "zh-TW", ja: "ja-JP" })[code] ?? code);
|
||||
assertEqual(appCatalogCodes, expectedCodes, "app language catalog");
|
||||
|
||||
const englishKeys = Object.keys(localeMessages.en).sort();
|
||||
for (const locale of localeCatalog) {
|
||||
const messages = localeMessages[locale.code];
|
||||
assert(messages, `missing messages for ${locale.code}`);
|
||||
assertEqual(Object.keys(messages).sort(), englishKeys, `${locale.code} message keys`);
|
||||
|
||||
for (const key of ["meta.description", "meta.ogDescription", "providers.title"]) {
|
||||
const counts = [...messages[key].matchAll(/\d+/g)].map(Number);
|
||||
assertEqual(counts[0], providerCount, `${locale.code}.${key} provider count`);
|
||||
}
|
||||
|
||||
for (const key of englishKeys) {
|
||||
assert(messages[key].trim(), `${locale.code}.${key} is blank`);
|
||||
assertEqual(tokens(messages[key]), tokens(localeMessages.en[key]), `${locale.code}.${key} tokens`);
|
||||
}
|
||||
}
|
||||
|
||||
const referencedKeys = new Set();
|
||||
for (const match of indexHtml.matchAll(/data-i18n(?:-rich|-aria-label|-title|-alt)?="([^"]+)"/g)) {
|
||||
referencedKeys.add(match[1]);
|
||||
}
|
||||
for (const key of referencedKeys) {
|
||||
assert(englishKeys.includes(key), `index.html references unknown locale key ${key}`);
|
||||
}
|
||||
|
||||
const siteJs = fs.readFileSync(path.join(repoRoot, 'docs/site.js'), 'utf8');
|
||||
const hasLanguagePicker = indexHtml.includes('id="language-picker-list"')
|
||||
&& (indexHtml.includes('localeCatalog') || siteJs.includes('localeCatalog'));
|
||||
assert(hasLanguagePicker, 'site must include the language picker backed by localeCatalog');
|
||||
|
||||
for (const code of catalogCodes) {
|
||||
assert(indexHtml.includes(`href="https://codexbar.app/?lang=${code}"`), `missing hreflang URL for ${code}`);
|
||||
}
|
||||
|
||||
const providerCards = [...indexHtml.matchAll(/<li class="provider-card"([^>]*)>([\s\S]*?)<\/li>/g)];
|
||||
for (const [, attrs, body] of providerCards) {
|
||||
if (!attrs.includes('hidden')) {
|
||||
assert(body.includes('class="provider-card-link"'), 'provider cards must link to provider documentation');
|
||||
assert(body.includes('class="provider-logo'), 'provider cards must use logo assets');
|
||||
for (const match of body.matchAll(/src="\.\/([^"]+)"/g)) {
|
||||
assert(fs.existsSync(path.join(repoRoot, 'docs', match[1])), `missing provider logo asset ${match[1]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`app/site locales OK: ${catalogCodes.length} locales, ${englishKeys.length} site messages`);
|
||||
|
||||
function tokens(value) {
|
||||
return [...value.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]).sort();
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, label) {
|
||||
const actualJSON = JSON.stringify(actual);
|
||||
const expectedJSON = JSON.stringify(expected);
|
||||
if (actualJSON !== expectedJSON) {
|
||||
throw new Error(`${label}: expected ${expectedJSON}, got ${actualJSON}`);
|
||||
}
|
||||
}
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MAX_BYTES=$((2 * 1024 * 1024))
|
||||
failures=0
|
||||
tracked_files=0
|
||||
declare -a blob_paths=()
|
||||
declare -a blob_ids=()
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
while IFS= read -r -d '' entry; do
|
||||
metadata=${entry%%$'\t'*}
|
||||
path=${entry#*$'\t'}
|
||||
read -r mode object stage <<<"$metadata"
|
||||
[[ "$stage" == "0" ]] || continue
|
||||
tracked_files=$((tracked_files + 1))
|
||||
|
||||
case "$path" in
|
||||
*.app | *.app/* | *.dSYM | *.dSYM/* | *.xcarchive/* | *.xcresult/* | *.ipa | *.zip | *.delta | *.dmg | \
|
||||
*.pkg | *.tar.gz | *.tgz)
|
||||
printf 'ERROR: generated artifact is tracked: %s\n' "$path" >&2
|
||||
failures=$((failures + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
# Submodule entries name commits rather than file blobs.
|
||||
[[ "$mode" == "160000" ]] && continue
|
||||
blob_paths+=("$path")
|
||||
blob_ids+=("$object")
|
||||
done < <(git ls-files --stage -z)
|
||||
|
||||
if ((${#blob_ids[@]} > 0)); then
|
||||
index=0
|
||||
while read -r object type size; do
|
||||
path=${blob_paths[$index]}
|
||||
if [[ "$type" != "blob" ]]; then
|
||||
printf 'ERROR: tracked index entry is not a readable blob: %q (%s)\n' "$path" "$object" >&2
|
||||
failures=$((failures + 1))
|
||||
index=$((index + 1))
|
||||
continue
|
||||
fi
|
||||
if ((size > MAX_BYTES)); then
|
||||
printf 'ERROR: tracked file exceeds %d bytes: %q (%d bytes)\n' "$MAX_BYTES" "$path" "$size" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done < <(printf '%s\n' "${blob_ids[@]}" | git cat-file --batch-check='%(objectname) %(objecttype) %(objectsize)')
|
||||
fi
|
||||
|
||||
if ((failures > 0)); then
|
||||
printf 'Repository size check failed with %d violation(s).\n' "$failures" >&2
|
||||
printf 'Publish build/release artifacts outside Git and optimize required source assets.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'repository size OK: %d tracked files, maximum %d bytes each\n' "$tracked_files" "$MAX_BYTES"
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
# Check for new changes in upstream repositories
|
||||
# Usage: ./Scripts/check_upstreams.sh [upstream|quotio|all]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TARGET=${1:-all}
|
||||
DAYS=${2:-7}
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}==> Fetching upstream changes...${NC}"
|
||||
if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then
|
||||
git fetch upstream 2>/dev/null || {
|
||||
echo -e "${YELLOW}Adding upstream remote...${NC}"
|
||||
git remote add upstream https://github.com/steipete/CodexBar.git
|
||||
git fetch upstream
|
||||
}
|
||||
fi
|
||||
|
||||
if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then
|
||||
git fetch quotio 2>/dev/null || {
|
||||
echo -e "${YELLOW}Adding quotio remote...${NC}"
|
||||
git remote add quotio https://github.com/nguyenphutrong/quotio.git
|
||||
git fetch quotio
|
||||
}
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
remote_default_branch() {
|
||||
local remote=$1
|
||||
local branch=""
|
||||
local candidate
|
||||
|
||||
branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true)
|
||||
if [ -z "$branch" ]; then
|
||||
branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true)
|
||||
fi
|
||||
if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then
|
||||
echo "$branch"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for candidate in main master; do
|
||||
if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${RED}Error: Could not resolve default branch for remote '$remote'.${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check upstream (steipete)
|
||||
if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then
|
||||
echo -e "${BLUE}==> Upstream (steipete/CodexBar) changes:${NC}"
|
||||
UPSTREAM_BRANCH=$(remote_default_branch upstream)
|
||||
UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}"
|
||||
|
||||
UPSTREAM_COUNT=$(git log --oneline "main..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
if [ "$UPSTREAM_COUNT" -gt 0 ]; then
|
||||
echo -e "${GREEN}Found $UPSTREAM_COUNT new commits${NC}"
|
||||
echo ""
|
||||
git log --oneline --graph "main..${UPSTREAM_REF}" --no-merges | head -20 || true
|
||||
echo ""
|
||||
echo -e "${YELLOW}Files changed:${NC}"
|
||||
git diff --stat "main..${UPSTREAM_REF}" | tail -20 || true
|
||||
else
|
||||
echo -e "${GREEN}No new commits (up to date)${NC}"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check quotio
|
||||
if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then
|
||||
echo -e "${BLUE}==> Quotio changes (last $DAYS days):${NC}"
|
||||
QUOTIO_BRANCH=$(remote_default_branch quotio)
|
||||
QUOTIO_REF="quotio/${QUOTIO_BRANCH}"
|
||||
|
||||
QUOTIO_COUNT=$(git log --oneline "$QUOTIO_REF" --since="$DAYS days ago" 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
if [ "$QUOTIO_COUNT" -gt 0 ]; then
|
||||
echo -e "${GREEN}Found $QUOTIO_COUNT commits in last $DAYS days${NC}"
|
||||
echo ""
|
||||
git log --oneline --graph "$QUOTIO_REF" --since="$DAYS days ago" | head -20 || true
|
||||
echo ""
|
||||
echo -e "${YELLOW}Recent file changes:${NC}"
|
||||
# Show changes from last 10 commits
|
||||
git diff --stat "${QUOTIO_REF}~10..${QUOTIO_REF}" 2>/dev/null | tail -20 || echo "Unable to show diff"
|
||||
else
|
||||
echo -e "${GREEN}No new commits in last $DAYS days${NC}"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo -e "${BLUE}==> Summary${NC}"
|
||||
if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then
|
||||
echo "Upstream commits: $UPSTREAM_COUNT"
|
||||
fi
|
||||
if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then
|
||||
echo "Quotio commits (${DAYS}d): $QUOTIO_COUNT"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo " Review upstream: ./Scripts/review_upstream.sh upstream"
|
||||
echo " Review quotio: ./Scripts/review_upstream.sh quotio"
|
||||
echo " Detailed diff: git diff main..<resolved-remote>/<default-branch>"
|
||||
echo " View quotio: ./Scripts/analyze_quotio.sh"
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
changed_paths_file="${1:-}"
|
||||
|
||||
if [[ -z "$changed_paths_file" || ! -f "$changed_paths_file" ]]; then
|
||||
printf 'Usage: %s <changed-paths-file>\n' "$(basename "$0")" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
macos_tests=false
|
||||
macos_tests_reason=""
|
||||
path_count=0
|
||||
|
||||
require_macos_tests() {
|
||||
local path="$1"
|
||||
local reason="$2"
|
||||
|
||||
macos_tests=true
|
||||
if [[ -z "$macos_tests_reason" ]]; then
|
||||
macos_tests_reason="${path}: ${reason}"
|
||||
fi
|
||||
}
|
||||
|
||||
classify_path() {
|
||||
local path="$1"
|
||||
[[ -z "$path" ]] && return
|
||||
|
||||
path_count=$((path_count + 1))
|
||||
|
||||
case "$path" in
|
||||
AGENTS.md|docs/configuration.md)
|
||||
require_macos_tests "$path" "changes contributor or runtime configuration contracts"
|
||||
;;
|
||||
*.md)
|
||||
;;
|
||||
docs/.nojekyll|docs/CNAME|docs/index.html|docs/llms.txt|docs/site-locales.mjs|docs/site.css|docs/site.js|docs/social.html|docs/social.png)
|
||||
;;
|
||||
docs/*.png|docs/*.jpg|docs/*.jpeg|docs/*.webp|docs/*.ico|docs/*.svg)
|
||||
;;
|
||||
*)
|
||||
require_macos_tests "$path" "not covered by portable docs/site checks"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
invalid_row=false
|
||||
while IFS=$'\t' read -r status first_path second_path extra_path \
|
||||
|| [[ -n "${status:-}${first_path:-}${second_path:-}${extra_path:-}" ]]
|
||||
do
|
||||
[[ -z "${status}${first_path:-}${second_path:-}${extra_path:-}" ]] && continue
|
||||
|
||||
case "$status" in
|
||||
R*|C*)
|
||||
if ! [[ "$status" =~ ^[RC][0-9]{1,3}$ ]] \
|
||||
|| ((10#${status:1} > 100)) \
|
||||
|| [[ -z "${first_path:-}" || -z "${second_path:-}" || -n "${extra_path:-}" ]]
|
||||
then
|
||||
invalid_row=true
|
||||
break
|
||||
fi
|
||||
classify_path "$first_path"
|
||||
classify_path "$second_path"
|
||||
;;
|
||||
A|D|M|T|U|X|B)
|
||||
if [[ -z "${first_path:-}" || -n "${second_path:-}" || -n "${extra_path:-}" ]]; then
|
||||
invalid_row=true
|
||||
break
|
||||
fi
|
||||
classify_path "$first_path"
|
||||
;;
|
||||
*)
|
||||
invalid_row=true
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done < "$changed_paths_file"
|
||||
|
||||
if [[ "$invalid_row" == true ]]; then
|
||||
printf 'Invalid git name-status row; refusing to skip macOS tests.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "$path_count" -eq 0 ]]; then
|
||||
require_macos_tests '<empty diff>' 'no changed paths were reported'
|
||||
fi
|
||||
|
||||
if [[ "$macos_tests" == true ]]; then
|
||||
summary_reason="$macos_tests_reason"
|
||||
else
|
||||
summary_reason="docs/site-only changes covered by portable checks"
|
||||
fi
|
||||
|
||||
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
|
||||
printf 'macos-tests=%s\n' "$macos_tests" >> "$GITHUB_OUTPUT"
|
||||
printf 'macos-tests-reason=%s\n' "$summary_reason" >> "$GITHUB_OUTPUT"
|
||||
printf 'changed-path-count=%s\n' "$path_count" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if [[ "$macos_tests" == true ]]; then
|
||||
printf 'macOS Swift tests required for this change set: %s.\n' "$macos_tests_reason"
|
||||
else
|
||||
printf 'Skipping macOS Swift tests for docs/site-only changes covered by portable checks.\n'
|
||||
fi
|
||||
Executable
+341
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run SwiftPM tests in suite shards so CI cannot hang inside one aggregate run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestSelection:
|
||||
name: str
|
||||
filter_pattern: str
|
||||
suite_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunStats:
|
||||
discovered_selections: int = 0
|
||||
selected_selections: int = 0
|
||||
selected_groups: int = 0
|
||||
group_size: int = 0
|
||||
shard_index: int | None = None
|
||||
shard_count: int | None = None
|
||||
discovery_seconds: float = 0
|
||||
execution_seconds: float = 0
|
||||
total_seconds: float = 0
|
||||
first_pass_successful_groups: int = 0
|
||||
first_pass_failed_groups: int = 0
|
||||
full_group_retries: int = 0
|
||||
timed_out_groups: int = 0
|
||||
recovered_groups: int = 0
|
||||
isolated_selection_retries: int = 0
|
||||
|
||||
def summary_rows(self) -> list[tuple[str, str]]:
|
||||
shard = "none"
|
||||
if self.shard_index is not None and self.shard_count is not None:
|
||||
shard = f"{self.shard_index + 1}/{self.shard_count}"
|
||||
return [
|
||||
("Shard", shard),
|
||||
("Group size", str(self.group_size)),
|
||||
("Discovered selections", str(self.discovered_selections)),
|
||||
("Selected selections", str(self.selected_selections)),
|
||||
("Selected groups", str(self.selected_groups)),
|
||||
("First-pass successful groups", str(self.first_pass_successful_groups)),
|
||||
("First-pass failed groups", str(self.first_pass_failed_groups)),
|
||||
("Full-group retries", str(self.full_group_retries)),
|
||||
("Recovered groups", str(self.recovered_groups)),
|
||||
("Timed out groups", str(self.timed_out_groups)),
|
||||
("Isolated selection retries", str(self.isolated_selection_retries)),
|
||||
("Discovery seconds", f"{self.discovery_seconds:.1f}"),
|
||||
("Execution seconds", f"{self.execution_seconds:.1f}"),
|
||||
("Total seconds", f"{self.total_seconds:.1f}"),
|
||||
]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--group-size", type=int, default=12)
|
||||
parser.add_argument("--timeout", type=int, default=180)
|
||||
parser.add_argument("--limit-groups", type=int)
|
||||
parser.add_argument("--shard-index", type=int)
|
||||
parser.add_argument("--shard-count", type=int)
|
||||
parser.add_argument(
|
||||
"--no-retry-non-timeout-failures",
|
||||
action="store_false",
|
||||
dest="retry_non_timeout_failures",
|
||||
help="fail immediately when a group exits without timing out",
|
||||
)
|
||||
parser.add_argument("--list-only", action="store_true")
|
||||
parser.add_argument("--swift-command", default="swift")
|
||||
parser.add_argument("--swift-command-arg", action="append", default=[])
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def run_command(command: list[str], timeout: int | None = None) -> int:
|
||||
print(f"+ {' '.join(command)}", flush=True)
|
||||
process = subprocess.Popen(command, start_new_session=True)
|
||||
try:
|
||||
return process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"::warning::Command timed out after {timeout}s: {' '.join(command)}", flush=True)
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait()
|
||||
return 124
|
||||
|
||||
|
||||
def swift_test_list(swift_command: list[str]) -> list[TestSelection]:
|
||||
command = [*swift_command, "test", "list"]
|
||||
try:
|
||||
result = subprocess.run(command, check=True, capture_output=True, text=True)
|
||||
except subprocess.CalledProcessError as error:
|
||||
print(f"+ {swift_command[0]} test list", flush=True)
|
||||
if error.stdout:
|
||||
print(error.stdout, end="" if error.stdout.endswith("\n") else "\n", flush=True)
|
||||
if error.stderr:
|
||||
print(error.stderr, end="" if error.stderr.endswith("\n") else "\n", file=sys.stderr, flush=True)
|
||||
raise
|
||||
selections: set[TestSelection] = set()
|
||||
unknown: list[str] = []
|
||||
for line in result.stdout.splitlines():
|
||||
top_level = re.fullmatch(r"(?P<module>[^.]+)\.(?:`(?P<display>.+)`|(?P<function>[^()/]+))\(\)", line)
|
||||
if top_level is not None:
|
||||
module = top_level.group("module")
|
||||
test_name = top_level.group("display") or top_level.group("function")
|
||||
selections.add(
|
||||
TestSelection(
|
||||
name=line,
|
||||
# SwiftPM matches top-level Swift Testing functions by their display name,
|
||||
# not the backtick-wrapped identifier printed by `swift test list`.
|
||||
filter_pattern=rf"{re.escape(module)}\..*{re.escape(test_name)}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if "/" in line:
|
||||
suite = line.split("/", 1)[0]
|
||||
if "." in suite:
|
||||
selections.add(
|
||||
TestSelection(
|
||||
name=suite,
|
||||
filter_pattern=rf"^{re.escape(suite)}/",
|
||||
suite_name=suite,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
unknown.append(line)
|
||||
|
||||
if unknown:
|
||||
rendered = "\n".join(f"- {line}" for line in unknown)
|
||||
raise RuntimeError(f"Unrecognized `swift test list` output:\n{rendered}")
|
||||
return sorted(selections, key=lambda selection: selection.name)
|
||||
|
||||
|
||||
def append_github_summary(stats: RunStats) -> None:
|
||||
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if not summary_path:
|
||||
return
|
||||
|
||||
with open(summary_path, "a", encoding="utf-8") as summary:
|
||||
summary.write("### macOS Swift test timing\n\n")
|
||||
summary.write("| Field | Value |\n")
|
||||
summary.write("| --- | --- |\n")
|
||||
for field, value in stats.summary_rows():
|
||||
safe_value = value.replace("|", "\\|")
|
||||
summary.write(f"| {field} | `{safe_value}` |\n")
|
||||
summary.write("\n")
|
||||
|
||||
|
||||
def print_timing_summary(stats: RunStats) -> None:
|
||||
print("Swift test timing summary:", flush=True)
|
||||
for field, value in stats.summary_rows():
|
||||
print(f"- {field}: {value}", flush=True)
|
||||
|
||||
|
||||
def chunks(items: list[TestSelection], size: int) -> Iterable[list[TestSelection]]:
|
||||
for index in range(0, len(items), size):
|
||||
yield items[index : index + size]
|
||||
|
||||
|
||||
def shard_groups(groups: list[list[TestSelection]], shard_index: int | None, shard_count: int | None) -> list[list[TestSelection]]:
|
||||
if shard_index is None and shard_count is None:
|
||||
return groups
|
||||
if shard_index is None or shard_count is None:
|
||||
raise ValueError("--shard-index and --shard-count must be passed together")
|
||||
if shard_count < 1:
|
||||
raise ValueError("--shard-count must be positive")
|
||||
if shard_index < 0 or shard_index >= shard_count:
|
||||
raise ValueError("--shard-index must be in the range [0, --shard-count)")
|
||||
return [group for index, group in enumerate(groups) if index % shard_count == shard_index]
|
||||
|
||||
|
||||
def prioritized_suites(suites: list[TestSelection]) -> list[TestSelection]:
|
||||
priority = ["CodexBarTests.CLIEntryTests"]
|
||||
ordered = [suite for name in priority for suite in suites if suite.suite_name == name]
|
||||
ordered.extend(suite for suite in suites if suite.suite_name not in priority)
|
||||
return ordered
|
||||
|
||||
|
||||
def filtered_suites_for_environment(suites: list[TestSelection]) -> list[TestSelection]:
|
||||
if os.environ.get("GITHUB_ACTIONS") != "true" or sys.platform != "darwin":
|
||||
return suites
|
||||
|
||||
# SwiftPM hangs before suite output for this executable-target suite on the Intel macOS runner.
|
||||
# Linux CI still runs it in the full Swift test lane, and local macOS runs it directly.
|
||||
skipped = {"CodexBarTests.CLIEntryTests"}
|
||||
filtered = [suite for suite in suites if suite.suite_name not in skipped]
|
||||
if len(filtered) != len(suites):
|
||||
print(f"Skipping macOS CI-only suites: {', '.join(sorted(skipped))}", flush=True)
|
||||
return filtered
|
||||
|
||||
|
||||
def filter_for(suites: list[TestSelection]) -> str:
|
||||
return rf"({'|'.join(suite.filter_pattern for suite in suites)})"
|
||||
|
||||
|
||||
def run_group(suites: list[TestSelection], timeout: int, swift_command: list[str]) -> int:
|
||||
return run_command(
|
||||
[*swift_command, "test", "--skip-build", "--no-parallel", "--filter", filter_for(suites)],
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def retry_selections_individually(
|
||||
suites: list[TestSelection],
|
||||
timeout: int,
|
||||
swift_command: list[str],
|
||||
stats: RunStats,
|
||||
) -> int:
|
||||
for suite in suites:
|
||||
stats.isolated_selection_retries += 1
|
||||
print(f"::group::Swift test retry {suite.name}", flush=True)
|
||||
retry_result = run_group([suite], timeout, swift_command)
|
||||
print("::endgroup::", flush=True)
|
||||
if retry_result != 0:
|
||||
return retry_result
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
total_started = time.monotonic()
|
||||
args = parse_args()
|
||||
stats = RunStats(
|
||||
group_size=args.group_size,
|
||||
shard_index=args.shard_index,
|
||||
shard_count=args.shard_count,
|
||||
)
|
||||
if args.group_size < 1:
|
||||
print("--group-size must be positive", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
swift_command = [args.swift_command, *args.swift_command_arg]
|
||||
result = 0
|
||||
try:
|
||||
discovery_started = time.monotonic()
|
||||
try:
|
||||
suites = prioritized_suites(filtered_suites_for_environment(swift_test_list(swift_command)))
|
||||
finally:
|
||||
stats.discovery_seconds = time.monotonic() - discovery_started
|
||||
stats.discovered_selections = len(suites)
|
||||
|
||||
suite_groups = list(chunks(suites, args.group_size))
|
||||
try:
|
||||
suite_groups = shard_groups(suite_groups, args.shard_index, args.shard_count)
|
||||
except ValueError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
result = 2
|
||||
return result
|
||||
if args.limit_groups is not None:
|
||||
suite_groups = suite_groups[: args.limit_groups]
|
||||
stats.selected_selections = sum(len(group) for group in suite_groups)
|
||||
stats.selected_groups = len(suite_groups)
|
||||
|
||||
shard_suffix = ""
|
||||
if args.shard_index is not None and args.shard_count is not None:
|
||||
shard_suffix = f" in shard {args.shard_index + 1}/{args.shard_count}"
|
||||
print(
|
||||
f"Discovered {len(suites)} test selections; running {stats.selected_selections} selections "
|
||||
f"in {len(suite_groups)} groups{shard_suffix}",
|
||||
flush=True,
|
||||
)
|
||||
if args.list_only:
|
||||
for group in suite_groups:
|
||||
for suite in group:
|
||||
print(suite.name)
|
||||
return 0
|
||||
|
||||
if not suite_groups:
|
||||
print("No test groups selected.", flush=True)
|
||||
return 0
|
||||
|
||||
execution_started = time.monotonic()
|
||||
for group_index, group in enumerate(suite_groups, start=1):
|
||||
print(
|
||||
f"::group::Swift test group {group_index}/{len(suite_groups)} "
|
||||
f"({len(group)} selections)",
|
||||
flush=True,
|
||||
)
|
||||
group_result = run_group(group, args.timeout, swift_command)
|
||||
print("::endgroup::", flush=True)
|
||||
if group_result == 0:
|
||||
stats.first_pass_successful_groups += 1
|
||||
continue
|
||||
|
||||
stats.first_pass_failed_groups += 1
|
||||
group_timed_out = group_result == 124
|
||||
if group_timed_out:
|
||||
stats.timed_out_groups += 1
|
||||
if len(group) == 1:
|
||||
result = group_result
|
||||
return result
|
||||
|
||||
if group_result != 124:
|
||||
if not args.retry_non_timeout_failures:
|
||||
result = group_result
|
||||
return result
|
||||
|
||||
stats.full_group_retries += 1
|
||||
print(f"Group {group_index} failed with exit code {group_result}; retrying group once", flush=True)
|
||||
retry_result = run_group(group, args.timeout, swift_command)
|
||||
if retry_result == 0:
|
||||
stats.recovered_groups += 1
|
||||
continue
|
||||
if retry_result != 124:
|
||||
result = retry_result
|
||||
return result
|
||||
group_timed_out = True
|
||||
stats.timed_out_groups += 1
|
||||
|
||||
print(f"Group {group_index} timed out; retrying selections one at a time", flush=True)
|
||||
retry_result = retry_selections_individually(group, args.timeout, swift_command, stats)
|
||||
if retry_result != 0:
|
||||
result = retry_result
|
||||
return result
|
||||
if group_timed_out:
|
||||
stats.recovered_groups += 1
|
||||
|
||||
return result
|
||||
finally:
|
||||
stats.total_seconds = time.monotonic() - total_started
|
||||
if "execution_started" in locals():
|
||||
stats.execution_seconds = time.monotonic() - execution_started
|
||||
if not args.list_only:
|
||||
print_timing_summary(stats)
|
||||
append_github_summary(stats)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
lint_result="${1:-}"
|
||||
changes_result="${2:-}"
|
||||
macos_tests_required="${3:-}"
|
||||
macos_test_result="${4:-}"
|
||||
|
||||
if [[ "$lint_result" != "success" ]]; then
|
||||
printf 'lint job finished with %s\n' "${lint_result:-<empty>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$changes_result" != "success" ]]; then
|
||||
printf 'changes job finished with %s\n' "${changes_result:-<empty>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${macos_tests_required}:${macos_test_result}" in
|
||||
true:success)
|
||||
printf 'Lint and macOS Swift test shards passed.\n'
|
||||
;;
|
||||
false:skipped)
|
||||
printf 'Lint passed; macOS Swift tests skipped for docs/site-only changes.\n'
|
||||
;;
|
||||
*)
|
||||
printf 'macOS test gate/result mismatch: required=%s result=%s\n' \
|
||||
"${macos_tests_required:-<empty>}" "${macos_test_result:-<empty>}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+326
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reset CodexBar: kill running instances, build, package, relaunch, verify.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_BUNDLE="${ROOT_DIR}/CodexBar.app"
|
||||
APP_PROCESS_PATTERN="CodexBar.app/Contents/MacOS/CodexBar"
|
||||
DEBUG_PROCESS_PATTERN="${ROOT_DIR}/.build/debug/CodexBar"
|
||||
RELEASE_PROCESS_PATTERN="${ROOT_DIR}/.build/release/CodexBar"
|
||||
LOCK_KEY="$(printf '%s' "${ROOT_DIR}" | shasum -a 256 | cut -c1-8)"
|
||||
LOCK_DIR="${TMPDIR:-/tmp}/codexbar-compile-and-run-${LOCK_KEY}"
|
||||
LOCK_PID_FILE="${LOCK_DIR}/pid"
|
||||
WAIT_FOR_LOCK=0
|
||||
RUN_TESTS=0
|
||||
DEBUG_LLDB=0
|
||||
RELEASE_ARCHES=""
|
||||
SIGNING_MODE="${CODEXBAR_SIGNING:-}"
|
||||
CLEAR_ADHOC_KEYCHAIN=0
|
||||
|
||||
log() { printf '%s\n' "$*"; }
|
||||
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
delete_keychain_service_items() {
|
||||
local service="$1"
|
||||
security delete-generic-password -s "${service}" >/dev/null 2>&1 || true
|
||||
while security delete-generic-password -s "${service}" >/dev/null 2>&1; do
|
||||
:
|
||||
done
|
||||
}
|
||||
|
||||
# Ensure Swift >= 5.5 (required for --arch flag in swift build)
|
||||
ensure_swift_version() {
|
||||
local swift_output
|
||||
local swift_ver
|
||||
swift_output=$(swift --version 2>&1 || true)
|
||||
if [[ "$swift_output" =~ (Apple[[:space:]]+)?Swift[[:space:]]+version[[:space:]]+([0-9]+)\.([0-9]+)(\.[0-9]+)? ]]; then
|
||||
swift_ver="${BASH_REMATCH[2]}.${BASH_REMATCH[3]}${BASH_REMATCH[4]}"
|
||||
else
|
||||
fail "Swift >= 5.5 required (found ${swift_output:-none}). Install Xcode or update swiftly."
|
||||
fi
|
||||
local major minor
|
||||
major=$(echo "$swift_ver" | cut -d. -f1)
|
||||
minor=$(echo "$swift_ver" | cut -d. -f2)
|
||||
if [[ "${major:-0}" -ge 6 ]] || { [[ "${major:-0}" -eq 5 ]] && [[ "${minor:-0}" -ge 5 ]]; }; then
|
||||
return 0
|
||||
fi
|
||||
# Try Xcode toolchain
|
||||
local xcrun_swift
|
||||
xcrun_swift=$(xcrun --find swift 2>/dev/null || true)
|
||||
if [[ -n "$xcrun_swift" && -x "$xcrun_swift" ]]; then
|
||||
log "WARN: PATH swift is v${swift_ver}; switching to Xcode toolchain at $(dirname "$xcrun_swift")"
|
||||
export PATH="$(dirname "$xcrun_swift"):$PATH"
|
||||
return 0
|
||||
fi
|
||||
fail "Swift >= 5.5 required (found ${swift_ver:-none}). Install Xcode or update swiftly."
|
||||
}
|
||||
|
||||
has_signing_identity() {
|
||||
local identity="${1:-}"
|
||||
if [[ -z "${identity}" ]]; then
|
||||
return 1
|
||||
fi
|
||||
security find-identity -p codesigning -v 2>/dev/null | grep -F "${identity}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
detect_codesigning_identity() {
|
||||
local preferred_prefixes=(
|
||||
"Developer ID Application:"
|
||||
"Apple Development:"
|
||||
"Apple Distribution:"
|
||||
)
|
||||
local prefix
|
||||
local identities
|
||||
identities="$(security find-identity -p codesigning -v 2>/dev/null || true)"
|
||||
for prefix in "${preferred_prefixes[@]}"; do
|
||||
awk -v prefix="${prefix}" '
|
||||
index($0, "\"" prefix) {
|
||||
sub(/^[^\"]*\"/, "")
|
||||
sub(/\".*$/, "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
' <<<"${identities}"
|
||||
done | sed -n '1p'
|
||||
}
|
||||
|
||||
export_team_id_from_identity() {
|
||||
local identity="${1:-}"
|
||||
if [[ -n "${APP_TEAM_ID:-}" || -z "${identity}" ]]; then
|
||||
return
|
||||
fi
|
||||
local subject
|
||||
subject="$(security find-certificate -c "${identity}" -p 2>/dev/null \
|
||||
| openssl x509 -noout -subject -nameopt RFC2253 2>/dev/null || true)"
|
||||
if [[ "${subject}" =~ (^|,)OU=([A-Z0-9]{10})(,|$) ]]; then
|
||||
APP_TEAM_ID="${BASH_REMATCH[2]}"
|
||||
export APP_TEAM_ID
|
||||
return
|
||||
fi
|
||||
if [[ "${identity}" =~ \(([A-Z0-9]{10})\)$ ]]; then
|
||||
APP_TEAM_ID="${BASH_REMATCH[1]}"
|
||||
export APP_TEAM_ID
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_signing_mode() {
|
||||
if [[ -n "${SIGNING_MODE}" ]]; then
|
||||
export_team_id_from_identity "${APP_IDENTITY:-}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${APP_IDENTITY:-}" ]]; then
|
||||
if has_signing_identity "${APP_IDENTITY}"; then
|
||||
export_team_id_from_identity "${APP_IDENTITY}"
|
||||
SIGNING_MODE="identity"
|
||||
return
|
||||
fi
|
||||
log "WARN: APP_IDENTITY not found in Keychain; falling back to adhoc signing."
|
||||
SIGNING_MODE="adhoc"
|
||||
return
|
||||
fi
|
||||
|
||||
local candidate=""
|
||||
for candidate in \
|
||||
"Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
||||
"CodexBar Development"
|
||||
do
|
||||
if has_signing_identity "${candidate}"; then
|
||||
APP_IDENTITY="${candidate}"
|
||||
export APP_IDENTITY
|
||||
export_team_id_from_identity "${APP_IDENTITY}"
|
||||
SIGNING_MODE="identity"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
candidate="$(detect_codesigning_identity)"
|
||||
if [[ -n "${candidate}" ]]; then
|
||||
APP_IDENTITY="${candidate}"
|
||||
export APP_IDENTITY
|
||||
export_team_id_from_identity "${APP_IDENTITY}"
|
||||
SIGNING_MODE="identity"
|
||||
return
|
||||
fi
|
||||
|
||||
SIGNING_MODE="adhoc"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local label="$1"; shift
|
||||
log "==> ${label}"
|
||||
if ! "$@"; then
|
||||
fail "${label} failed"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ -d "${LOCK_DIR}" ]]; then
|
||||
rm -rf "${LOCK_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
acquire_lock() {
|
||||
while true; do
|
||||
if mkdir "${LOCK_DIR}" 2>/dev/null; then
|
||||
echo "$$" > "${LOCK_PID_FILE}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local existing_pid=""
|
||||
if [[ -f "${LOCK_PID_FILE}" ]]; then
|
||||
existing_pid="$(cat "${LOCK_PID_FILE}" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ -n "${existing_pid}" ]] && kill -0 "${existing_pid}" 2>/dev/null; then
|
||||
if [[ "${WAIT_FOR_LOCK}" == "1" ]]; then
|
||||
log "==> Another agent is compiling (pid ${existing_pid}); waiting..."
|
||||
while kill -0 "${existing_pid}" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
continue
|
||||
fi
|
||||
log "==> Another agent is compiling (pid ${existing_pid}); re-run with --wait."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
rm -rf "${LOCK_DIR}"
|
||||
done
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
kill_claude_probes() {
|
||||
# CodexBar spawns `claude /usage` + `/status` in a PTY; if we kill the app mid-probe we can orphan them.
|
||||
pkill -f "claude (/status|/usage) --allowed-tools" 2>/dev/null || true
|
||||
sleep 0.2
|
||||
pkill -9 -f "claude (/status|/usage) --allowed-tools" 2>/dev/null || true
|
||||
}
|
||||
|
||||
kill_all_codexbar() {
|
||||
is_running() {
|
||||
pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1 \
|
||||
|| pgrep -f "${DEBUG_PROCESS_PATTERN}" >/dev/null 2>&1 \
|
||||
|| pgrep -f "${RELEASE_PROCESS_PATTERN}" >/dev/null 2>&1 \
|
||||
|| pgrep -x "CodexBar" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Phase 1: request termination (give the app time to exit cleanly).
|
||||
for _ in {1..25}; do
|
||||
pkill -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true
|
||||
pkill -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true
|
||||
pkill -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true
|
||||
pkill -x "CodexBar" 2>/dev/null || true
|
||||
if ! is_running; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
# Phase 2: force kill any stragglers (avoids `open -n` creating multiple instances).
|
||||
pkill -9 -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true
|
||||
pkill -9 -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true
|
||||
pkill -9 -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true
|
||||
pkill -9 -x "CodexBar" 2>/dev/null || true
|
||||
|
||||
for _ in {1..25}; do
|
||||
if ! is_running; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
fail "Failed to kill all CodexBar instances."
|
||||
}
|
||||
|
||||
# 1) Ensure a single runner instance.
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--wait|-w) WAIT_FOR_LOCK=1 ;;
|
||||
--test|-t) RUN_TESTS=1 ;;
|
||||
--debug-lldb) DEBUG_LLDB=1 ;;
|
||||
--clear-adhoc-keychain) CLEAR_ADHOC_KEYCHAIN=1 ;;
|
||||
--release-universal) RELEASE_ARCHES="arm64 x86_64" ;;
|
||||
--release-arches=*) RELEASE_ARCHES="${arg#*=}" ;;
|
||||
--help|-h)
|
||||
log "Usage: $(basename "$0") [--wait] [--test] [--debug-lldb] [--clear-adhoc-keychain] [--release-universal] [--release-arches=\"arm64 x86_64\"]"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ensure_swift_version
|
||||
resolve_signing_mode
|
||||
if [[ "${CLEAR_ADHOC_KEYCHAIN}" == "1" && "${SIGNING_MODE}" != "adhoc" ]]; then
|
||||
fail "--clear-adhoc-keychain is only supported when using adhoc signing."
|
||||
fi
|
||||
if [[ "${SIGNING_MODE}" == "adhoc" ]]; then
|
||||
log "==> Signing: adhoc (set APP_IDENTITY or install a dev cert to avoid keychain prompts)"
|
||||
else
|
||||
log "==> Signing: ${APP_IDENTITY:-Developer ID Application}"
|
||||
fi
|
||||
|
||||
acquire_lock
|
||||
|
||||
# 2) Kill all running CodexBar instances (debug, release, bundled).
|
||||
log "==> Killing existing CodexBar instances"
|
||||
kill_all_codexbar
|
||||
kill_claude_probes
|
||||
|
||||
# 2.5) Optionally delete keychain entries to avoid permission prompts with adhoc signing
|
||||
# (adhoc signature changes on every build, making old keychain entries inaccessible)
|
||||
if [[ "${SIGNING_MODE:-adhoc}" == "adhoc" && "${CLEAR_ADHOC_KEYCHAIN}" == "1" ]]; then
|
||||
log "==> Clearing CodexBar keychain entries (adhoc signing)"
|
||||
# Clear both the legacy keychain store and the current cache service when developers explicitly want a clean reset
|
||||
# of CodexBar-owned keychain state for ad-hoc builds.
|
||||
delete_keychain_service_items "com.steipete.CodexBar"
|
||||
delete_keychain_service_items "com.steipete.codexbar.cache"
|
||||
elif [[ "${SIGNING_MODE:-adhoc}" == "adhoc" ]]; then
|
||||
log "==> Preserving CodexBar keychain entries (pass --clear-adhoc-keychain to reset adhoc keychain state)"
|
||||
fi
|
||||
|
||||
# 3) Package (release build happens inside package_app.sh).
|
||||
if [[ "${RUN_TESTS}" == "1" ]]; then
|
||||
run_step "sharded swift tests" "${ROOT_DIR}/Scripts/test.sh"
|
||||
fi
|
||||
if [[ "${DEBUG_LLDB}" == "1" && -n "${RELEASE_ARCHES}" ]]; then
|
||||
fail "--release-arches is only supported for release packaging"
|
||||
fi
|
||||
HOST_ARCH="$(uname -m)"
|
||||
ARCHES_VALUE="${HOST_ARCH}"
|
||||
if [[ -n "${RELEASE_ARCHES}" ]]; then
|
||||
ARCHES_VALUE="${RELEASE_ARCHES}"
|
||||
fi
|
||||
PACKAGE_ENV=(
|
||||
ARCHES="${ARCHES_VALUE}"
|
||||
)
|
||||
if [[ "${DEBUG_LLDB}" == "1" ]]; then
|
||||
run_step "package app" env CODEXBAR_ALLOW_LLDB=1 "${PACKAGE_ENV[@]}" "${ROOT_DIR}/Scripts/package_app.sh" debug
|
||||
else
|
||||
if [[ -n "${SIGNING_MODE}" ]]; then
|
||||
run_step "package app" env CODEXBAR_SIGNING="${SIGNING_MODE}" "${PACKAGE_ENV[@]}" "${ROOT_DIR}/Scripts/package_app.sh"
|
||||
else
|
||||
run_step "package app" env "${PACKAGE_ENV[@]}" "${ROOT_DIR}/Scripts/package_app.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4) Launch the packaged app.
|
||||
log "==> launch app"
|
||||
if ! open "${APP_BUNDLE}"; then
|
||||
log "WARN: launch app returned non-zero; falling back to direct binary launch."
|
||||
"${APP_BUNDLE}/Contents/MacOS/CodexBar" >/dev/null 2>&1 &
|
||||
disown
|
||||
fi
|
||||
|
||||
# 5) Verify the app stays up for at least a moment (launch can be >1s on some systems).
|
||||
for _ in {1..10}; do
|
||||
if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then
|
||||
log "OK: CodexBar is running."
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.4
|
||||
done
|
||||
fail "App exited immediately. Check crash logs in Console.app (User Reports)."
|
||||
Executable
+289
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env swift
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SurveyOptions {
|
||||
var root: URL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".codex", isDirectory: true)
|
||||
.appendingPathComponent("sessions", isDirectory: true)
|
||||
var days: Double = 30
|
||||
}
|
||||
|
||||
struct Survey {
|
||||
var files = 0
|
||||
var totalBytes: Int64 = 0
|
||||
var lines = 0
|
||||
var relevantLines = 0
|
||||
var lineLengths: [Int] = []
|
||||
var linesOver32KiB = 0
|
||||
var linesOver256KiB = 0
|
||||
var turnContextLines = 0
|
||||
var turnContextOver32KiB = 0
|
||||
var turnContextOver256KiB = 0
|
||||
var turnContextModelOffsets: [Int] = []
|
||||
var turnContextModelOffsetUnder32KiB = 0
|
||||
var turnContextModelOffsetUnder256KiB = 0
|
||||
var tokenCountLines = 0
|
||||
var tokenCountMissingExplicitModel = 0
|
||||
|
||||
mutating func recordFile(byteCount: Int64) {
|
||||
self.files += 1
|
||||
self.totalBytes += byteCount
|
||||
}
|
||||
|
||||
mutating func recordLine(_ line: Data) {
|
||||
guard !line.isEmpty else { return }
|
||||
|
||||
let length = line.count
|
||||
self.lines += 1
|
||||
self.lineLengths.append(length)
|
||||
if length > 32 * 1024 {
|
||||
self.linesOver32KiB += 1
|
||||
}
|
||||
if length > 256 * 1024 {
|
||||
self.linesOver256KiB += 1
|
||||
}
|
||||
|
||||
let isRelevant = line.contains(Marker.eventMessage)
|
||||
|| line.contains(Marker.turnContext)
|
||||
|| line.contains(Marker.sessionMetadata)
|
||||
if isRelevant {
|
||||
self.relevantLines += 1
|
||||
}
|
||||
|
||||
if line.contains(Marker.turnContext) {
|
||||
self.turnContextLines += 1
|
||||
if length > 32 * 1024 {
|
||||
self.turnContextOver32KiB += 1
|
||||
}
|
||||
if length > 256 * 1024 {
|
||||
self.turnContextOver256KiB += 1
|
||||
}
|
||||
if let offset = line.firstOffset(of: Marker.modelField)
|
||||
?? line.firstOffset(of: Marker.modelNameField)
|
||||
{
|
||||
self.turnContextModelOffsets.append(offset)
|
||||
if offset < 32 * 1024 {
|
||||
self.turnContextModelOffsetUnder32KiB += 1
|
||||
}
|
||||
if offset < 256 * 1024 {
|
||||
self.turnContextModelOffsetUnder256KiB += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if line.contains(Marker.tokenCount) {
|
||||
self.tokenCountLines += 1
|
||||
if !line.contains(Marker.modelField), !line.contains(Marker.modelNameField) {
|
||||
self.tokenCountMissingExplicitModel += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Marker {
|
||||
static let eventMessage = Data(#""type":"event_msg""#.utf8)
|
||||
static let turnContext = Data(#""type":"turn_context""#.utf8)
|
||||
static let sessionMetadata = Data(#""type":"session_meta""#.utf8)
|
||||
static let tokenCount = Data(#""token_count""#.utf8)
|
||||
static let modelField = Data(#""model""#.utf8)
|
||||
static let modelNameField = Data(#""model_name""#.utf8)
|
||||
}
|
||||
|
||||
extension Data {
|
||||
func contains(_ marker: Data) -> Bool {
|
||||
self.range(of: marker) != nil
|
||||
}
|
||||
|
||||
func firstOffset(of marker: Data) -> Int? {
|
||||
guard let range = self.range(of: marker) else { return nil }
|
||||
return self.distance(from: self.startIndex, to: range.lowerBound)
|
||||
}
|
||||
}
|
||||
|
||||
func parseOptions(arguments: [String]) throws -> SurveyOptions {
|
||||
var options = SurveyOptions()
|
||||
var index = 1
|
||||
while index < arguments.count {
|
||||
switch arguments[index] {
|
||||
case "--root":
|
||||
index += 1
|
||||
guard index < arguments.count else {
|
||||
throw UsageError.message("--root requires a path")
|
||||
}
|
||||
options.root = URL(fileURLWithPath: expandTilde(arguments[index]), isDirectory: true)
|
||||
case "--days":
|
||||
index += 1
|
||||
guard index < arguments.count, let days = Double(arguments[index]) else {
|
||||
throw UsageError.message("--days requires a number")
|
||||
}
|
||||
options.days = days
|
||||
case "--help", "-h":
|
||||
printUsage()
|
||||
Foundation.exit(0)
|
||||
default:
|
||||
throw UsageError.message("unknown argument: \(arguments[index])")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func expandTilde(_ path: String) -> String {
|
||||
guard path == "~" || path.hasPrefix("~/") else { return path }
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
if path == "~" {
|
||||
return home
|
||||
}
|
||||
return home + String(path.dropFirst())
|
||||
}
|
||||
|
||||
enum UsageError: Error, CustomStringConvertible {
|
||||
case message(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case let .message(text):
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
print(
|
||||
"""
|
||||
Usage: Scripts/cost_jsonl_shape_survey.swift [--root PATH] [--days N]
|
||||
|
||||
Scans local Codex JSONL logs and prints aggregate shape only. It does not
|
||||
print prompts, tool payloads, model values, file paths, or raw log lines.
|
||||
""")
|
||||
}
|
||||
|
||||
func jsonlFiles(root: URL, modifiedSince cutoff: Date) -> [URL] {
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .fileSizeKey],
|
||||
options: [.skipsHiddenFiles]) else { return [] }
|
||||
|
||||
var files: [URL] = []
|
||||
for case let fileURL as URL in enumerator {
|
||||
guard fileURL.pathExtension == "jsonl" else { continue }
|
||||
let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey])
|
||||
guard values?.isRegularFile == true else { continue }
|
||||
guard let modifiedAt = values?.contentModificationDate, modifiedAt >= cutoff else { continue }
|
||||
files.append(fileURL)
|
||||
}
|
||||
return files.sorted { $0.path < $1.path }
|
||||
}
|
||||
|
||||
func validateRoot(_ root: URL) throws {
|
||||
var isDirectory: ObjCBool = false
|
||||
let exists = FileManager.default.fileExists(atPath: root.path, isDirectory: &isDirectory)
|
||||
guard exists, isDirectory.boolValue else {
|
||||
throw UsageError.message("root does not exist or is not a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func scan(fileURL: URL, into survey: inout Survey) throws {
|
||||
let values = try fileURL.resourceValues(forKeys: [.fileSizeKey])
|
||||
survey.recordFile(byteCount: Int64(values.fileSize ?? 0))
|
||||
|
||||
let handle = try FileHandle(forReadingFrom: fileURL)
|
||||
defer { try? handle.close() }
|
||||
|
||||
var current = Data()
|
||||
current.reserveCapacity(4 * 1024)
|
||||
|
||||
while true {
|
||||
let chunk = try handle.read(upToCount: 256 * 1024) ?? Data()
|
||||
if chunk.isEmpty {
|
||||
survey.recordLine(current)
|
||||
break
|
||||
}
|
||||
|
||||
var segmentStart = chunk.startIndex
|
||||
while let newline = chunk[segmentStart...].firstIndex(of: 0x0A) {
|
||||
current.append(contentsOf: chunk[segmentStart..<newline])
|
||||
survey.recordLine(current)
|
||||
current.removeAll(keepingCapacity: true)
|
||||
segmentStart = chunk.index(after: newline)
|
||||
}
|
||||
|
||||
if segmentStart < chunk.endIndex {
|
||||
current.append(contentsOf: chunk[segmentStart..<chunk.endIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func percentile(_ values: [Int], _ percentile: Double) -> Int {
|
||||
guard !values.isEmpty else { return 0 }
|
||||
let sorted = values.sorted()
|
||||
let index = Int((percentile * Double(sorted.count - 1)).rounded())
|
||||
return sorted[max(0, min(index, sorted.count - 1))]
|
||||
}
|
||||
|
||||
func printSummary(_ survey: Survey, options: SurveyOptions) {
|
||||
print("root: \(redactedRootDescription(options.root))")
|
||||
print("window days: \(Int(options.days))")
|
||||
print("files: \(survey.files)")
|
||||
print("total bytes: \(survey.totalBytes)")
|
||||
print("lines: \(survey.lines)")
|
||||
print("relevant Codex scanner lines: \(survey.relevantLines)")
|
||||
print(
|
||||
"line length p50/p90/p95/p99/max: " +
|
||||
"\(percentile(survey.lineLengths, 0.50)) / " +
|
||||
"\(percentile(survey.lineLengths, 0.90)) / " +
|
||||
"\(percentile(survey.lineLengths, 0.95)) / " +
|
||||
"\(percentile(survey.lineLengths, 0.99)) / " +
|
||||
"\(percentile(survey.lineLengths, 1.00)) bytes")
|
||||
print("lines > 32 KiB: \(survey.linesOver32KiB)")
|
||||
print("lines > 256 KiB: \(survey.linesOver256KiB)")
|
||||
print("turn_context lines: \(survey.turnContextLines)")
|
||||
print("turn_context lines > 32 KiB: \(survey.turnContextOver32KiB)")
|
||||
print("turn_context lines > 256 KiB: \(survey.turnContextOver256KiB)")
|
||||
print(
|
||||
"turn_context model offset p50/p95/max: " +
|
||||
"\(percentile(survey.turnContextModelOffsets, 0.50)) / " +
|
||||
"\(percentile(survey.turnContextModelOffsets, 0.95)) / " +
|
||||
"\(percentile(survey.turnContextModelOffsets, 1.00)) bytes")
|
||||
print(
|
||||
"turn_context model offset < 32 KiB: " +
|
||||
"\(survey.turnContextModelOffsetUnder32KiB) / \(survey.turnContextLines)")
|
||||
print(
|
||||
"turn_context model offset < 256 KiB: " +
|
||||
"\(survey.turnContextModelOffsetUnder256KiB) / \(survey.turnContextLines)")
|
||||
print(
|
||||
"token_count rows missing an explicit model: " +
|
||||
"\(survey.tokenCountMissingExplicitModel) / \(survey.tokenCountLines)")
|
||||
}
|
||||
|
||||
func redactedRootDescription(_ root: URL) -> String {
|
||||
let defaultRoot = SurveyOptions().root.standardizedFileURL.path
|
||||
let currentRoot = root.standardizedFileURL.path
|
||||
if currentRoot == defaultRoot {
|
||||
return "default Codex sessions"
|
||||
}
|
||||
return "custom root (redacted)"
|
||||
}
|
||||
|
||||
do {
|
||||
let options = try parseOptions(arguments: CommandLine.arguments)
|
||||
try validateRoot(options.root)
|
||||
let cutoff = Date().addingTimeInterval(-options.days * 24 * 60 * 60)
|
||||
let files = jsonlFiles(root: options.root, modifiedSince: cutoff)
|
||||
guard !files.isEmpty else {
|
||||
throw UsageError.message("no .jsonl files found in the selected time window")
|
||||
}
|
||||
var survey = Survey()
|
||||
for fileURL in files {
|
||||
try scan(fileURL: fileURL, into: &survey)
|
||||
}
|
||||
printSummary(survey, options: options)
|
||||
} catch let error as UsageError {
|
||||
fputs("error: \(error.description)\n\n", stderr)
|
||||
printUsage()
|
||||
Foundation.exit(2)
|
||||
} catch {
|
||||
fputs("error: \(error.localizedDescription)\n", stderr)
|
||||
Foundation.exit(1)
|
||||
}
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DOCS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'docs');
|
||||
const EXCLUDED_DIRS = new Set(['archive', 'research']);
|
||||
|
||||
function walkMarkdownFiles(dir, base = dir) {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (EXCLUDED_DIRS.has(entry.name)) continue;
|
||||
files.push(...walkMarkdownFiles(fullPath, base));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
files.push(relative(base, fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function extractMetadata(fullPath) {
|
||||
const content = readFileSync(fullPath, 'utf8');
|
||||
|
||||
if (!content.startsWith('---')) {
|
||||
return { summary: null, readWhen: [], error: 'missing front matter' };
|
||||
}
|
||||
|
||||
const endIndex = content.indexOf('\n---', 3);
|
||||
if (endIndex === -1) {
|
||||
return { summary: null, readWhen: [], error: 'unterminated front matter' };
|
||||
}
|
||||
|
||||
const frontMatter = content.slice(3, endIndex).trim();
|
||||
const lines = frontMatter.split('\n');
|
||||
|
||||
let summaryLine = null;
|
||||
const readWhen = [];
|
||||
let collectingReadWhen = false;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('summary:')) {
|
||||
summaryLine = line;
|
||||
collectingReadWhen = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('read_when:')) {
|
||||
collectingReadWhen = true;
|
||||
const inline = line.slice('read_when:'.length).trim();
|
||||
if (inline.startsWith('[') && inline.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(inline.replace(/'/g, '"'));
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean)
|
||||
.forEach((v) => readWhen.push(v));
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed inline */
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (collectingReadWhen) {
|
||||
if (line.startsWith('- ')) {
|
||||
const hint = line.slice(2).trim();
|
||||
if (hint) readWhen.push(hint);
|
||||
} else if (line === '') {
|
||||
// allow blank spacer lines inside list
|
||||
} else {
|
||||
collectingReadWhen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!summaryLine) {
|
||||
return { summary: null, readWhen, error: 'summary key missing' };
|
||||
}
|
||||
|
||||
const summaryValue = summaryLine.slice('summary:'.length).trim();
|
||||
const normalized = summaryValue.replace(/^['"]|['"]$/g, '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return { summary: null, readWhen, error: 'summary is empty' };
|
||||
}
|
||||
|
||||
return { summary: normalized, readWhen };
|
||||
}
|
||||
|
||||
console.log('Listing all markdown files in docs folder:');
|
||||
|
||||
const markdownFiles = walkMarkdownFiles(DOCS_DIR);
|
||||
|
||||
for (const relativePath of markdownFiles) {
|
||||
const fullPath = join(DOCS_DIR, relativePath);
|
||||
const { summary, readWhen, error } = extractMetadata(fullPath);
|
||||
if (summary) {
|
||||
console.log(`${relativePath} - ${summary}`);
|
||||
if (readWhen.length > 0) {
|
||||
console.log(` Read when: ${readWhen.join('; ')}`);
|
||||
}
|
||||
} else {
|
||||
const reason = error ? ` - [${error}]` : '';
|
||||
console.log(`${relativePath}${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nReminder: keep docs up to date as behavior changes. When your task matches any "Read when" hint above (React hooks, cache directives, database work, tests, etc.), read that doc before coding, and suggest new coverage when it is missing.');
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const docsDir = path.join(repoRoot, "docs");
|
||||
const args = process.argv.slice(2);
|
||||
const mode = parseMode(args);
|
||||
const cname = fs.readFileSync(path.join(docsDir, "CNAME"), "utf8").trim();
|
||||
const origin = "https://" + cname;
|
||||
const productName = "CodexBar";
|
||||
const source = "https://github.com/steipete/CodexBar";
|
||||
const outputPath = path.join(docsDir, "llms.txt");
|
||||
|
||||
const pages = allHtml(docsDir)
|
||||
.map((file) => {
|
||||
const rel = path.relative(docsDir, file).replaceAll(path.sep, "/");
|
||||
if (rel === "404.html" || rel === "social.html") return null;
|
||||
const html = fs.readFileSync(file, "utf8");
|
||||
return {
|
||||
rel,
|
||||
title: textContent(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]) || titleize(path.basename(rel, ".html")),
|
||||
description: attr(html.match(/<meta\s+name=["']description["']\s+content=["']([^"']*)["'][^>]*>/i)?.[1] || ""),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (a.rel === "index.html" ? -1 : b.rel === "index.html" ? 1 : a.rel.localeCompare(b.rel)));
|
||||
const productDescription =
|
||||
pages.find((page) => page.rel === "index.html")?.description ||
|
||||
"CodexBar shows AI coding-provider usage limits in the macOS menu bar.";
|
||||
|
||||
const lines = [
|
||||
"# " + productName,
|
||||
"",
|
||||
productDescription,
|
||||
"",
|
||||
"Canonical documentation:",
|
||||
...pages.map((page) => "- " + page.title + ": " + pageUrl(page.rel) + (page.description ? " - " + page.description : "")),
|
||||
"",
|
||||
"Source: " + source,
|
||||
"",
|
||||
"Guidance for agents:",
|
||||
"- Prefer the canonical documentation URLs above over README excerpts or package metadata.",
|
||||
"- Fetch only the pages needed for the current task; this is an index, not a full-site corpus.",
|
||||
"",
|
||||
];
|
||||
const output = lines.join("\n");
|
||||
|
||||
if (mode === "check") {
|
||||
const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf8") : null;
|
||||
if (current !== output) {
|
||||
console.error(`${path.relative(repoRoot, outputPath)} is out of date; run node Scripts/generate-llms.mjs`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("llms index OK: " + path.relative(repoRoot, outputPath));
|
||||
} else {
|
||||
fs.writeFileSync(outputPath, output, "utf8");
|
||||
console.log("wrote " + path.relative(repoRoot, outputPath));
|
||||
}
|
||||
|
||||
function parseMode(values) {
|
||||
if (values.length === 0) return "write";
|
||||
if (values.length === 1 && (values[0] === "write" || values[0] === "--write")) return "write";
|
||||
if (values.length === 1 && (values[0] === "check" || values[0] === "--check")) return "check";
|
||||
console.error("Usage: node Scripts/generate-llms.mjs [write|--write|check|--check]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function allHtml(dir) {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.name === "node_modules" || entry.name.startsWith(".")) return [];
|
||||
if (entry.isDirectory()) return allHtml(full);
|
||||
return entry.name.endsWith(".html") ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function pageUrl(rel) {
|
||||
return rel === "index.html" ? origin + "/" : origin + "/" + rel;
|
||||
}
|
||||
|
||||
function textContent(value) {
|
||||
return attr(value || "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function attr(value) {
|
||||
return String(value || "")
|
||||
.replace(/—/g, "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function titleize(input) {
|
||||
return input.replaceAll("-", " ").replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
}
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TOOLS_DIR="${ROOT_DIR}/.build/lint-tools"
|
||||
BIN_DIR="${TOOLS_DIR}/bin"
|
||||
|
||||
SWIFTFORMAT_VERSION="0.61.1"
|
||||
SWIFTLINT_VERSION="0.65.0"
|
||||
|
||||
SWIFTFORMAT_SHA256_DARWIN="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584"
|
||||
SWIFTLINT_SHA256_DARWIN="d6cb0aa7a2f5f1ef306fc9e37bcb54dc9a26facc8f7784ac0c3dd3eccf5c6ba6"
|
||||
SWIFTFORMAT_SHA256_LINUX_X86_64="7bc8706e3fd51963f1f29eb99098ebdf482f3497fa527c68e6cf75cbee29c77a"
|
||||
SWIFTLINT_SHA256_LINUX_X86_64="79306a34e5c7cc55a220cd108cbb861dcad5f10138dcdf261e2624ae8b0a486b"
|
||||
SWIFTFORMAT_SHA256_LINUX_ARM64="42a35b557a6d56975fba3a48e78d39ab5388c8faac65d4819f25d3e20c7504c0"
|
||||
SWIFTLINT_SHA256_LINUX_ARM64="12d3b84bc5b69ae13a99a5a5c79904f9ce25867f099f6368d0037854f9ee6c26"
|
||||
|
||||
log() { printf '%s\n' "$*"; }
|
||||
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
INSTALL_SWIFTFORMAT=false
|
||||
INSTALL_SWIFTLINT=false
|
||||
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
INSTALL_SWIFTFORMAT=true
|
||||
INSTALL_SWIFTLINT=true
|
||||
else
|
||||
for tool in "$@"; do
|
||||
case "$tool" in
|
||||
all)
|
||||
INSTALL_SWIFTFORMAT=true
|
||||
INSTALL_SWIFTLINT=true
|
||||
;;
|
||||
swiftformat)
|
||||
INSTALL_SWIFTFORMAT=true
|
||||
;;
|
||||
swiftlint)
|
||||
INSTALL_SWIFTLINT=true
|
||||
;;
|
||||
*)
|
||||
fail "Unknown lint tool '${tool}'. Usage: $(basename "$0") [all|swiftformat|swiftlint]..."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
sha256_value() {
|
||||
local path="$1"
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$path" | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$path" | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
fail "Missing shasum/sha256sum."
|
||||
}
|
||||
|
||||
download_file() {
|
||||
local url="$1"
|
||||
local out="$2"
|
||||
curl -fL --retry 3 --retry-connrefused --retry-delay 2 -o "$out" "$url"
|
||||
}
|
||||
|
||||
install_zip_binary() {
|
||||
local label="$1"
|
||||
local url="$2"
|
||||
local expected_sha="$3"
|
||||
local binary_name="$4"
|
||||
local installed_name="${5:-$binary_name}"
|
||||
|
||||
local tmp_zip
|
||||
tmp_zip="$(mktemp -t "${label}.XXXX")"
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d -t "${label}.XXXX")"
|
||||
|
||||
log "==> Downloading ${label}"
|
||||
download_file "$url" "$tmp_zip"
|
||||
|
||||
local actual_sha
|
||||
actual_sha="$(sha256_value "$tmp_zip")"
|
||||
if [[ -n "$expected_sha" && "$actual_sha" != "$expected_sha" ]]; then
|
||||
rm -f "$tmp_zip"
|
||||
rm -rf "$tmp_dir"
|
||||
fail "${label} SHA256 mismatch (expected ${expected_sha}, got ${actual_sha})"
|
||||
fi
|
||||
|
||||
unzip -q "$tmp_zip" -d "$tmp_dir"
|
||||
|
||||
local extracted_path=""
|
||||
if [[ -f "${tmp_dir}/${binary_name}" ]]; then
|
||||
extracted_path="${tmp_dir}/${binary_name}"
|
||||
else
|
||||
extracted_path="$(find "$tmp_dir" -type f -name "$binary_name" | head -n 1 || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$extracted_path" || ! -f "$extracted_path" ]]; then
|
||||
rm -f "$tmp_zip"
|
||||
rm -rf "$tmp_dir"
|
||||
fail "${label} binary '${binary_name}' not found in archive"
|
||||
fi
|
||||
|
||||
install -m 0755 "$extracted_path" "${BIN_DIR}/${installed_name}"
|
||||
|
||||
rm -f "$tmp_zip"
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
swiftformat_installed() {
|
||||
[[ -x "${BIN_DIR}/swiftformat" ]] \
|
||||
&& [[ "$("${BIN_DIR}/swiftformat" --version 2>/dev/null || true)" == "${SWIFTFORMAT_VERSION}" ]]
|
||||
}
|
||||
|
||||
swiftlint_installed() {
|
||||
[[ -x "${BIN_DIR}/swiftlint" ]] \
|
||||
&& [[ "$("${BIN_DIR}/swiftlint" version 2>/dev/null || true)" == "${SWIFTLINT_VERSION}" ]]
|
||||
}
|
||||
|
||||
if { [[ "$INSTALL_SWIFTFORMAT" != true ]] || swiftformat_installed; } \
|
||||
&& { [[ "$INSTALL_SWIFTLINT" != true ]] || swiftlint_installed; }
|
||||
then
|
||||
log "==> Requested lint tools already installed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OS="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
case "$OS" in
|
||||
Darwin)
|
||||
SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip"
|
||||
SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip"
|
||||
|
||||
if [[ "$INSTALL_SWIFTFORMAT" == true ]] && ! swiftformat_installed; then
|
||||
install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "$SWIFTFORMAT_SHA256_DARWIN" "swiftformat"
|
||||
fi
|
||||
if [[ "$INSTALL_SWIFTLINT" == true ]] && ! swiftlint_installed; then
|
||||
install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "$SWIFTLINT_SHA256_DARWIN" "swiftlint"
|
||||
fi
|
||||
;;
|
||||
Linux)
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_linux.zip"
|
||||
SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_amd64.zip"
|
||||
SWIFTFORMAT_BINARY="swiftformat_linux"
|
||||
SWIFTFORMAT_SHA256="$SWIFTFORMAT_SHA256_LINUX_X86_64"
|
||||
SWIFTLINT_SHA256="$SWIFTLINT_SHA256_LINUX_X86_64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_linux_aarch64.zip"
|
||||
SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_arm64.zip"
|
||||
SWIFTFORMAT_BINARY="swiftformat_linux_aarch64"
|
||||
SWIFTFORMAT_SHA256="$SWIFTFORMAT_SHA256_LINUX_ARM64"
|
||||
SWIFTLINT_SHA256="$SWIFTLINT_SHA256_LINUX_ARM64"
|
||||
;;
|
||||
*)
|
||||
fail "Unsupported Linux arch: ${ARCH}"
|
||||
;;
|
||||
esac
|
||||
|
||||
if { [[ "$INSTALL_SWIFTFORMAT" == true ]] && [[ -z "$SWIFTFORMAT_SHA256" ]]; } \
|
||||
|| { [[ "$INSTALL_SWIFTLINT" == true ]] && [[ -z "$SWIFTLINT_SHA256" ]]; }
|
||||
then
|
||||
log "WARN: Linux SHA256 verification not configured for ${ARCH}; installing anyway."
|
||||
fi
|
||||
if [[ "$INSTALL_SWIFTFORMAT" == true ]] && ! swiftformat_installed; then
|
||||
install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "$SWIFTFORMAT_SHA256" "$SWIFTFORMAT_BINARY" "swiftformat"
|
||||
fi
|
||||
if [[ "$INSTALL_SWIFTLINT" == true ]] && ! swiftlint_installed; then
|
||||
install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "$SWIFTLINT_SHA256" "swiftlint"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
fail "Unsupported OS: ${OS}"
|
||||
;;
|
||||
esac
|
||||
|
||||
log "==> Installed lint tools to ${BIN_DIR}"
|
||||
if [[ "$INSTALL_SWIFTFORMAT" == true ]]; then
|
||||
"${BIN_DIR}/swiftformat" --version
|
||||
fi
|
||||
if [[ "$INSTALL_SWIFTLINT" == true ]]; then
|
||||
"${BIN_DIR}/swiftlint" version
|
||||
fi
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Simple script to launch CodexBar (kills existing instance first)
|
||||
# Usage: ./Scripts/launch.sh
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
APP_PATH="$PROJECT_ROOT/CodexBar.app"
|
||||
|
||||
echo "==> Killing existing CodexBar instances"
|
||||
pkill -x CodexBar || pkill -f CodexBar.app || true
|
||||
sleep 0.5
|
||||
|
||||
if [[ ! -d "$APP_PATH" ]]; then
|
||||
echo "ERROR: CodexBar.app not found at $APP_PATH"
|
||||
echo "Run ./Scripts/package_app.sh first to build the app"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Launching CodexBar from $APP_PATH"
|
||||
open -n "$APP_PATH"
|
||||
|
||||
# Wait a moment and check if it's running
|
||||
sleep 1
|
||||
if pgrep -x CodexBar > /dev/null; then
|
||||
echo "OK: CodexBar is running."
|
||||
else
|
||||
echo "ERROR: App exited immediately. Check crash logs in Console.app (User Reports)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BIN_DIR="${ROOT_DIR}/.build/lint-tools/bin"
|
||||
|
||||
ensure_swiftformat() {
|
||||
"${ROOT_DIR}/Scripts/install_lint_tools.sh" swiftformat
|
||||
}
|
||||
|
||||
ensure_swiftlint() {
|
||||
"${ROOT_DIR}/Scripts/install_lint_tools.sh" swiftlint
|
||||
}
|
||||
|
||||
check_codex_parser_hash() {
|
||||
"${ROOT_DIR}/Scripts/regenerate-codex-parser-hash.sh" --check
|
||||
}
|
||||
|
||||
check_package_product_paths() {
|
||||
"${ROOT_DIR}/Scripts/test_package_product_paths.sh"
|
||||
}
|
||||
|
||||
check_package_strip() {
|
||||
"${ROOT_DIR}/Scripts/test_package_strip.sh"
|
||||
}
|
||||
|
||||
check_package_signing() {
|
||||
"${ROOT_DIR}/Scripts/test_package_signing.sh"
|
||||
}
|
||||
|
||||
check_release_dsym_paths() {
|
||||
"${ROOT_DIR}/Scripts/test_release_dsym_paths.sh"
|
||||
}
|
||||
|
||||
check_sparkle_signing_paths() {
|
||||
"${ROOT_DIR}/Scripts/test_sparkle_signing_paths.sh"
|
||||
}
|
||||
|
||||
check_swift_test_sharding() {
|
||||
"${ROOT_DIR}/Scripts/test_swift_test_sharding.sh"
|
||||
}
|
||||
|
||||
check_ci_path_gate() {
|
||||
"${ROOT_DIR}/Scripts/test_ci_path_gate.sh"
|
||||
}
|
||||
|
||||
check_repository_size() {
|
||||
"${ROOT_DIR}/Scripts/check_repository_size.sh"
|
||||
"${ROOT_DIR}/Scripts/test_repository_size.sh"
|
||||
}
|
||||
|
||||
check_shell_scripts() {
|
||||
local count=0
|
||||
local script
|
||||
for script in "${ROOT_DIR}"/Scripts/*.sh "${ROOT_DIR}"/Scripts/mac-release; do
|
||||
[[ -f "$script" ]] || continue
|
||||
bash -n "$script"
|
||||
count=$((count + 1))
|
||||
done
|
||||
printf 'shell scripts OK: %d files\n' "$count"
|
||||
}
|
||||
|
||||
check_app_locales() {
|
||||
node "${ROOT_DIR}/Scripts/check-app-locales.mjs" --test
|
||||
node "${ROOT_DIR}/Scripts/check-app-locales.mjs"
|
||||
}
|
||||
|
||||
check_site_locales() {
|
||||
node "${ROOT_DIR}/Scripts/check-site-locales.mjs"
|
||||
node --check "${ROOT_DIR}/docs/site.js"
|
||||
}
|
||||
|
||||
check_documentation_links() {
|
||||
node "${ROOT_DIR}/Scripts/check-documentation-links.mjs"
|
||||
}
|
||||
|
||||
check_llms_index() {
|
||||
node "${ROOT_DIR}/Scripts/generate-llms.mjs" --check
|
||||
}
|
||||
|
||||
run_portable_checks() {
|
||||
check_codex_parser_hash
|
||||
check_package_product_paths
|
||||
check_package_strip
|
||||
check_package_signing
|
||||
check_release_dsym_paths
|
||||
check_sparkle_signing_paths
|
||||
check_swift_test_sharding
|
||||
check_ci_path_gate
|
||||
check_repository_size
|
||||
check_shell_scripts
|
||||
check_documentation_links
|
||||
check_llms_index
|
||||
check_site_locales
|
||||
}
|
||||
|
||||
run_swiftformat_lint() {
|
||||
ensure_swiftformat
|
||||
"${BIN_DIR}/swiftformat" Sources Tests --lint
|
||||
}
|
||||
|
||||
run_swiftlint() {
|
||||
ensure_swiftlint
|
||||
"${BIN_DIR}/swiftlint" --strict
|
||||
}
|
||||
|
||||
cmd="${1:-lint}"
|
||||
|
||||
case "$cmd" in
|
||||
lint)
|
||||
check_app_locales
|
||||
run_portable_checks
|
||||
run_swiftformat_lint
|
||||
run_swiftlint
|
||||
;;
|
||||
lint-linux)
|
||||
run_portable_checks
|
||||
run_swiftlint
|
||||
;;
|
||||
lint-macos)
|
||||
check_app_locales
|
||||
run_swiftformat_lint
|
||||
;;
|
||||
format)
|
||||
ensure_swiftformat
|
||||
"${BIN_DIR}/swiftformat" Sources Tests
|
||||
;;
|
||||
*)
|
||||
printf 'Usage: %s [lint|lint-linux|lint-macos|format]\n' "$(basename "$0")" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ -n "${MAC_RELEASE_TOOL:-}" ]]; then
|
||||
exec "$MAC_RELEASE_TOOL" "$@"
|
||||
fi
|
||||
|
||||
for candidate in \
|
||||
"$ROOT/../agent-scripts/skills/release-mac-app/scripts/mac-release" \
|
||||
"$HOME/Projects/agent-scripts/skills/release-mac-app/scripts/mac-release"; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
exec "$candidate" "$@"
|
||||
fi
|
||||
done
|
||||
|
||||
cat >&2 <<'EOF'
|
||||
Missing mac-release helper.
|
||||
Clone agent-scripts next to this repo or set MAC_RELEASE_TOOL=/path/to/mac-release.
|
||||
EOF
|
||||
exit 127
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
exec "$SCRIPT_DIR/mac-release" make-appcast "$@"
|
||||
Executable
+241
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
mimo-usage — local token usage tracker for cc-mimo
|
||||
|
||||
Scans ~/.claude-envs/mimo/.claude/projects/**/*.jsonl session files,
|
||||
sums input/output/cache tokens per time window (today/week/all),
|
||||
writes to ~/.codexbar/mimo-local-usage.json, and prints a human-readable
|
||||
summary by default.
|
||||
|
||||
Usage:
|
||||
mimo-usage # show summary (also refreshes cache)
|
||||
mimo-usage --update # refresh cache only, no output (for LaunchAgent/wrapper)
|
||||
mimo-usage --json # JSON output
|
||||
mimo-usage --short # 1-line status (for status line / widget)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
MIMO_HOME = Path(os.environ.get("MIMO_CLAUDE_HOME", Path.home() / ".claude-envs" / "mimo")).expanduser()
|
||||
PROJECTS_DIR = MIMO_HOME / ".claude" / "projects"
|
||||
CACHE_PATH = Path(
|
||||
os.environ.get("MIMO_LOCAL_USAGE_PATH", Path.home() / ".codexbar" / "mimo-local-usage.json")
|
||||
).expanduser()
|
||||
|
||||
|
||||
def parse_session_usage(jsonl_path: Path):
|
||||
"""Yield (identity, timestamp_iso, usage_dict) for each assistant message with usage."""
|
||||
try:
|
||||
with jsonl_path.open() as f:
|
||||
for line in f:
|
||||
try:
|
||||
d = json.loads(line)
|
||||
ts = d.get("timestamp")
|
||||
msg = d.get("message")
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
usage = msg.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
continue
|
||||
if not ts:
|
||||
continue
|
||||
metadata = d.get("metadata")
|
||||
message_metadata = msg.get("metadata")
|
||||
session_id = d.get("sessionId") or d.get("session_id")
|
||||
if not session_id and isinstance(metadata, dict):
|
||||
session_id = metadata.get("sessionId")
|
||||
if not session_id and isinstance(message_metadata, dict):
|
||||
session_id = message_metadata.get("sessionId")
|
||||
message_id = msg.get("id")
|
||||
request_id = d.get("requestId") or d.get("request_id")
|
||||
identity = None
|
||||
if all(isinstance(value, str) and value for value in (message_id, request_id)):
|
||||
identity = ("request", message_id, request_id)
|
||||
elif (
|
||||
request_id is None
|
||||
and isinstance(session_id, str)
|
||||
and session_id
|
||||
and isinstance(message_id, str)
|
||||
and message_id
|
||||
):
|
||||
identity = ("legacy", session_id, message_id)
|
||||
yield identity, ts, usage
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
except (OSError, IOError):
|
||||
return
|
||||
|
||||
|
||||
def aggregate_usage():
|
||||
"""Scan all mimo session jsonls and return windowed token sums."""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
# Week starts on Monday 00:00 UTC
|
||||
week_start = today_start - timedelta(days=today_start.weekday())
|
||||
|
||||
windows = {
|
||||
"today": {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "messages": 0},
|
||||
"week": {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "messages": 0},
|
||||
"all_time": {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "messages": 0},
|
||||
}
|
||||
sessions_scanned = 0
|
||||
last_activity = None
|
||||
keyed_rows = {}
|
||||
unkeyed_rows = []
|
||||
|
||||
if not PROJECTS_DIR.exists():
|
||||
return windows, sessions_scanned, last_activity
|
||||
|
||||
for jsonl in PROJECTS_DIR.rglob("*.jsonl"):
|
||||
sessions_scanned += 1
|
||||
for identity, ts_str, usage in parse_session_usage(jsonl):
|
||||
try:
|
||||
# Parse ISO timestamp (may end with Z)
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
row = (ts, usage)
|
||||
if identity is None:
|
||||
unkeyed_rows.append(row)
|
||||
else:
|
||||
previous = keyed_rows.get(identity)
|
||||
if previous is None or ts >= previous[0]:
|
||||
keyed_rows[identity] = row
|
||||
|
||||
for ts, usage in [*keyed_rows.values(), *unkeyed_rows]:
|
||||
input_t = int(usage.get("input_tokens", 0) or 0)
|
||||
output_t = int(usage.get("output_tokens", 0) or 0)
|
||||
cache_read_t = int(usage.get("cache_read_input_tokens", 0) or 0)
|
||||
cache_create_t = int(usage.get("cache_creation_input_tokens", 0) or 0)
|
||||
|
||||
if last_activity is None or ts > last_activity:
|
||||
last_activity = ts
|
||||
|
||||
# all_time
|
||||
w = windows["all_time"]
|
||||
w["input"] += input_t
|
||||
w["output"] += output_t
|
||||
w["cache_read"] += cache_read_t
|
||||
w["cache_create"] += cache_create_t
|
||||
w["messages"] += 1
|
||||
|
||||
if ts >= week_start:
|
||||
w = windows["week"]
|
||||
w["input"] += input_t
|
||||
w["output"] += output_t
|
||||
w["cache_read"] += cache_read_t
|
||||
w["cache_create"] += cache_create_t
|
||||
w["messages"] += 1
|
||||
|
||||
if ts >= today_start:
|
||||
w = windows["today"]
|
||||
w["input"] += input_t
|
||||
w["output"] += output_t
|
||||
w["cache_read"] += cache_read_t
|
||||
w["cache_create"] += cache_create_t
|
||||
w["messages"] += 1
|
||||
|
||||
return windows, sessions_scanned, last_activity
|
||||
|
||||
|
||||
def write_cache(windows, sessions_scanned, last_activity):
|
||||
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_activity": last_activity.isoformat() if last_activity else None,
|
||||
"sessions_scanned": sessions_scanned,
|
||||
"windows": windows,
|
||||
"source": "local-jsonl-scan",
|
||||
"note": "Local token accounting from cc-mimo session jsonl. Not a quota; mimo platform.xiaomimimo.com SSO cookie required for real quota.",
|
||||
}
|
||||
tmp = CACHE_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2))
|
||||
tmp.replace(CACHE_PATH)
|
||||
return payload
|
||||
|
||||
|
||||
def fmt_tokens(n: int) -> str:
|
||||
if n >= 1_000_000:
|
||||
return f"{n / 1_000_000:.1f}M"
|
||||
if n >= 1_000:
|
||||
return f"{n / 1_000:.1f}k"
|
||||
return str(n)
|
||||
|
||||
|
||||
def short_status(payload):
|
||||
"""1-line status line."""
|
||||
w = payload["windows"]["week"]
|
||||
total = w["input"] + w["output"] + w["cache_read"] + w["cache_create"]
|
||||
return f"mimo: {fmt_tokens(total)} tok this week ({w['messages']} msg)"
|
||||
|
||||
|
||||
def human_summary(payload):
|
||||
"""Multi-line human-readable summary."""
|
||||
last = payload.get("last_activity")
|
||||
if last:
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last)
|
||||
ago = datetime.now(timezone.utc) - last_dt
|
||||
if ago.total_seconds() < 60:
|
||||
ago_str = "just now"
|
||||
elif ago.total_seconds() < 3600:
|
||||
ago_str = f"{int(ago.total_seconds() / 60)}m ago"
|
||||
elif ago.total_seconds() < 86400:
|
||||
ago_str = f"{int(ago.total_seconds() / 3600)}h ago"
|
||||
else:
|
||||
ago_str = f"{ago.days}d ago"
|
||||
except (ValueError, TypeError):
|
||||
ago_str = last
|
||||
else:
|
||||
ago_str = "never"
|
||||
|
||||
lines = [
|
||||
"== MiMo (local tracker) ==",
|
||||
f"Sessions scanned: {payload['sessions_scanned']}",
|
||||
f"Last activity: {ago_str}",
|
||||
"",
|
||||
]
|
||||
for window_name, label in [("today", "Today"), ("week", "This week"), ("all_time", "All time")]:
|
||||
w = payload["windows"][window_name]
|
||||
in_t = fmt_tokens(w["input"])
|
||||
out_t = fmt_tokens(w["output"])
|
||||
cr_t = fmt_tokens(w["cache_read"])
|
||||
cc_t = fmt_tokens(w["cache_create"])
|
||||
total = w["input"] + w["output"] + w["cache_read"] + w["cache_create"]
|
||||
lines.append(f"{label:>10}: {fmt_tokens(total):>8} total | in={in_t} out={out_t} cache_r={cr_t} cache_c={cc_t} | msg={w['messages']}")
|
||||
lines.append("")
|
||||
lines.append("Note: this is local accounting from cc-mimo session jsonl.")
|
||||
lines.append("Real platform quota requires Chrome cookie (cookieSource=manual).")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
quiet = "--update" in args
|
||||
json_out = "--json" in args
|
||||
short = "--short" in args
|
||||
|
||||
windows, sessions_scanned, last_activity = aggregate_usage()
|
||||
payload = write_cache(windows, sessions_scanned, last_activity)
|
||||
|
||||
if quiet:
|
||||
return 0
|
||||
if json_out:
|
||||
print(json.dumps(payload, indent=2))
|
||||
return 0
|
||||
if short:
|
||||
print(short_status(payload))
|
||||
return 0
|
||||
|
||||
print(human_summary(payload))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+541
@@ -0,0 +1,541 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
resolve_package_signing_mode() {
|
||||
local requested="${CODEXBAR_SIGNING:-adhoc}"
|
||||
case "$requested" in
|
||||
adhoc|identity) ;;
|
||||
*)
|
||||
echo "ERROR: Unsupported CODEXBAR_SIGNING: $requested (expected adhoc or identity)" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
SIGNING_MODE="$requested"
|
||||
}
|
||||
|
||||
CONF=${1:-release}
|
||||
ALLOW_LLDB=${CODEXBAR_ALLOW_LLDB:-0}
|
||||
SIGNING_MODE=
|
||||
resolve_package_signing_mode
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
LOWER_CONF=$(printf "%s" "$CONF" | tr '[:upper:]' '[:lower:]')
|
||||
case "$LOWER_CONF" in
|
||||
debug|release) ;;
|
||||
*)
|
||||
echo "ERROR: Unsupported build configuration: $CONF (expected debug or release)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Load version info
|
||||
source "$ROOT/version.env"
|
||||
source "$ROOT/Scripts/package_product_paths.sh"
|
||||
source "$ROOT/Scripts/sparkle_signing_paths.sh"
|
||||
|
||||
# Clean build only when explicitly requested (slower).
|
||||
if [[ "${CODEXBAR_FORCE_CLEAN:-0}" == "1" ]]; then
|
||||
if [[ -d "$ROOT/.build" ]]; then
|
||||
if command -v trash >/dev/null 2>&1; then
|
||||
if ! trash "$ROOT/.build"; then
|
||||
echo "WARN: trash .build failed; continuing with swift package clean." >&2
|
||||
fi
|
||||
else
|
||||
rm -rf "$ROOT/.build" || echo "WARN: rm -rf .build failed; continuing with swift package clean." >&2
|
||||
fi
|
||||
fi
|
||||
swift package clean >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Build for host architecture by default; allow overriding via ARCHES (e.g., "arm64 x86_64" for universal).
|
||||
ARCH_LIST=( ${ARCHES:-} )
|
||||
if [[ ${#ARCH_LIST[@]} -eq 0 ]]; then
|
||||
HOST_ARCH=$(uname -m)
|
||||
case "$HOST_ARCH" in
|
||||
arm64) ARCH_LIST=(arm64) ;;
|
||||
x86_64) ARCH_LIST=(x86_64) ;;
|
||||
*) ARCH_LIST=("$HOST_ARCH") ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
patch_keyboard_shortcuts() {
|
||||
local util_path="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift"
|
||||
if [[ ! -f "$util_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if grep -q "keyboardShortcutsSafeBundle" "$util_path"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
chmod +w "$util_path" || true
|
||||
python3 - "$util_path" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
text = path.read_text()
|
||||
if ".keyboardShortcutsSafeBundle" in text:
|
||||
sys.exit(0)
|
||||
|
||||
text = text.replace(
|
||||
'NSLocalizedString(self, bundle: .module, comment: self)',
|
||||
'NSLocalizedString(self, bundle: .keyboardShortcutsSafeBundle, comment: self)',
|
||||
)
|
||||
|
||||
inject = """
|
||||
private extension Bundle {
|
||||
/// Safe lookup that avoids the fatal trap in the autogenerated `Bundle.module`
|
||||
/// when the resource bundle is not placed at the bundle root.
|
||||
static let keyboardShortcutsSafeBundle: Bundle = {
|
||||
#if os(macOS)
|
||||
if let url = Bundle.main.url(forResource: "KeyboardShortcuts_KeyboardShortcuts", withExtension: "bundle"),
|
||||
let bundle = Bundle(url: url) {
|
||||
return bundle
|
||||
}
|
||||
|
||||
let rootURL = Bundle.main.bundleURL.appendingPathComponent("KeyboardShortcuts_KeyboardShortcuts.bundle")
|
||||
if let bundle = Bundle(url: rootURL) {
|
||||
return bundle
|
||||
}
|
||||
#endif
|
||||
|
||||
let devURL = URL(fileURLWithPath: #file)
|
||||
.deletingLastPathComponent() // Utilities.swift
|
||||
.deletingLastPathComponent() // KeyboardShortcuts
|
||||
.deletingLastPathComponent() // Sources
|
||||
.appendingPathComponent("KeyboardShortcuts_KeyboardShortcuts.bundle")
|
||||
if let bundle = Bundle(url: devURL) {
|
||||
return bundle
|
||||
}
|
||||
|
||||
return Bundle.main
|
||||
}()
|
||||
}
|
||||
"""
|
||||
|
||||
marker = "}\n\n\nextension Data {"
|
||||
if marker not in text:
|
||||
raise SystemExit("Marker not found in Utilities.swift; patch failed.")
|
||||
|
||||
text = text.replace(marker, "}\n\n" + inject + "\n\nextension Data {")
|
||||
path.write_text(text)
|
||||
PY
|
||||
}
|
||||
|
||||
KEYBOARD_SHORTCUTS_UTIL="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift"
|
||||
if [[ ! -f "$KEYBOARD_SHORTCUTS_UTIL" ]]; then
|
||||
swift build -c "$CONF" --arch "${ARCH_LIST[0]}"
|
||||
fi
|
||||
patch_keyboard_shortcuts
|
||||
|
||||
# Resolve SwiftPM's current output path without relying on a fixed build-system layout.
|
||||
# The output variable keeps the per-arch cache in this shell instead of losing it to
|
||||
# command substitution.
|
||||
swiftpm_bin_path() {
|
||||
local arch="$1"
|
||||
local output_var="$2"
|
||||
local cache_var="SWIFTPM_BIN_PATH_${arch//[^A-Za-z0-9]/_}"
|
||||
if [[ -z "${!cache_var+set}" ]]; then
|
||||
local resolved
|
||||
if ! resolved=$(codexbar_swiftpm_bin_path "$CONF" "$arch"); then
|
||||
return 1
|
||||
fi
|
||||
printf -v "$cache_var" '%s' "$resolved"
|
||||
fi
|
||||
printf -v "$output_var" '%s' "${!cache_var}"
|
||||
}
|
||||
|
||||
binary_has_arch() {
|
||||
local binary="$1"
|
||||
local arch="$2"
|
||||
[[ -f "$binary" ]] && lipo -archs "$binary" 2>/dev/null | tr ' ' '\n' | grep -qx "$arch"
|
||||
}
|
||||
|
||||
# SwiftBuild can reuse one output directory for sequential per-arch builds. Snapshot
|
||||
# each fresh slice before the next build can replace it.
|
||||
PRODUCT_STAGE_ROOT="$ROOT/.build/package-products/$LOWER_CONF"
|
||||
rm -rf "$PRODUCT_STAGE_ROOT"
|
||||
|
||||
stage_build_products() {
|
||||
local arch="$1"
|
||||
local bin_dir stage_dir name product
|
||||
swiftpm_bin_path "$arch" bin_dir
|
||||
|
||||
stage_dir="$PRODUCT_STAGE_ROOT/$arch"
|
||||
mkdir -p "$stage_dir"
|
||||
for name in CodexBar CodexBarCLI CodexBarClaudeWatchdog; do
|
||||
if ! product=$(codexbar_require_product_file "$bin_dir" "$name" "$arch"); then
|
||||
return 1
|
||||
fi
|
||||
if ! binary_has_arch "$product" "$arch"; then
|
||||
echo "ERROR: ${product} does not contain required architecture: ${arch}" >&2
|
||||
return 1
|
||||
fi
|
||||
cp "$product" "$stage_dir/$name"
|
||||
done
|
||||
if [[ -d "$bin_dir/CodexBar.dSYM" ]]; then
|
||||
cp -R "$bin_dir/CodexBar.dSYM" "$stage_dir/"
|
||||
fi
|
||||
}
|
||||
|
||||
for ARCH in "${ARCH_LIST[@]}"; do
|
||||
swift build -c "$CONF" --arch "$ARCH"
|
||||
stage_build_products "$ARCH"
|
||||
done
|
||||
|
||||
APP_FINAL="$ROOT/CodexBar.app"
|
||||
APP_STAGE="$ROOT/.build/package/CodexBar.app"
|
||||
rm -rf "$APP_STAGE"
|
||||
APP="$APP_STAGE"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks"
|
||||
mkdir -p "$APP/Contents/Helpers" "$APP/Contents/PlugIns"
|
||||
|
||||
# Convert new .icon bundle to .icns if present (macOS 14+/IconStudio export)
|
||||
ICON_SOURCE="$ROOT/Icon.icon"
|
||||
ICON_TARGET="$ROOT/Icon.icns"
|
||||
if [[ -f "$ICON_SOURCE" ]]; then
|
||||
iconutil --convert icns --output "$ICON_TARGET" "$ICON_SOURCE"
|
||||
fi
|
||||
|
||||
BUNDLE_ID="com.steipete.codexbar"
|
||||
FEED_URL="https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml"
|
||||
AUTO_CHECKS=true
|
||||
if [[ "$LOWER_CONF" == "debug" ]]; then
|
||||
BUNDLE_ID="com.steipete.codexbar.debug"
|
||||
FEED_URL=""
|
||||
AUTO_CHECKS=false
|
||||
fi
|
||||
if [[ "$SIGNING_MODE" == "adhoc" ]]; then
|
||||
FEED_URL=""
|
||||
AUTO_CHECKS=false
|
||||
fi
|
||||
WIDGET_BUNDLE_ID="${BUNDLE_ID}.widget"
|
||||
APP_TEAM_ID="${APP_TEAM_ID:-Y5PE65HELJ}"
|
||||
APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar"
|
||||
if [[ "$BUNDLE_ID" == *".debug"* ]]; then
|
||||
APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar.debug"
|
||||
fi
|
||||
ENTITLEMENTS_DIR="$ROOT/.build/entitlements"
|
||||
APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements"
|
||||
WIDGET_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBarWidget.entitlements"
|
||||
mkdir -p "$ENTITLEMENTS_DIR"
|
||||
if [[ "$ALLOW_LLDB" == "1" && "$LOWER_CONF" != "debug" ]]; then
|
||||
echo "ERROR: CODEXBAR_ALLOW_LLDB requires debug configuration" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat > "$APP_ENTITLEMENTS" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>${APP_GROUP_ID}</string>
|
||||
</array>
|
||||
$(if [[ "$ALLOW_LLDB" == "1" ]]; then echo " <key>com.apple.security.get-task-allow</key><true/>"; fi)
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
cat > "$WIDGET_ENTITLEMENTS" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>${APP_GROUP_ID}</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
|
||||
cat > "$APP/Contents/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key><string>CodexBar</string>
|
||||
<key>CFBundleDisplayName</key><string>CodexBar</string>
|
||||
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
|
||||
<key>CFBundleExecutable</key><string>CodexBar</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>${MARKETING_VERSION}</string>
|
||||
<key>CFBundleVersion</key><string>${BUILD_NUMBER}</string>
|
||||
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
||||
<key>LSUIElement</key><true/>
|
||||
<key>CFBundleIconFile</key><string>Icon</string>
|
||||
<key>NSHumanReadableCopyright</key><string>© 2026 Peter Steinberger. MIT License.</string>
|
||||
<key>SUFeedURL</key><string>${FEED_URL}</string>
|
||||
<key>SUPublicEDKey</key><string>AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=</string>
|
||||
<key>SUEnableAutomaticChecks</key><${AUTO_CHECKS}/>
|
||||
<key>CodexBuildTimestamp</key><string>${BUILD_TIMESTAMP}</string>
|
||||
<key>CodexGitCommit</key><string>${GIT_COMMIT}</string>
|
||||
<key>CodexBarTeamID</key><string>${APP_TEAM_ID}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
# Resolve a built binary from the fresh per-arch snapshot or SwiftPM's reported directory.
|
||||
resolve_binary_path() {
|
||||
local name="$1"
|
||||
local arch="$2"
|
||||
local bin_dir candidate
|
||||
swiftpm_bin_path "$arch" bin_dir
|
||||
if ! candidate=$(codexbar_resolve_staged_or_reported_file \
|
||||
"$PRODUCT_STAGE_ROOT" "$bin_dir" "$name" "$arch"); then
|
||||
return 1
|
||||
fi
|
||||
if ! binary_has_arch "$candidate" "$arch"; then
|
||||
echo "ERROR: ${candidate} does not contain required architecture: ${arch}" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "$candidate"
|
||||
}
|
||||
|
||||
verify_binary_arches() {
|
||||
local binary="$1"; shift
|
||||
local expected=("$@")
|
||||
local actual
|
||||
actual=$(lipo -archs "$binary")
|
||||
local actual_count expected_count
|
||||
actual_count=$(wc -w <<<"$actual" | tr -d ' ')
|
||||
expected_count=${#expected[@]}
|
||||
if [[ "$actual_count" -ne "$expected_count" ]]; then
|
||||
echo "ERROR: $binary arch mismatch (expected: ${expected[*]}, actual: ${actual})" >&2
|
||||
exit 1
|
||||
fi
|
||||
for arch in "${expected[@]}"; do
|
||||
if [[ "$actual" != *"$arch"* ]]; then
|
||||
echo "ERROR: $binary missing arch $arch (have: ${actual})" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
install_binary() {
|
||||
local name="$1"
|
||||
local dest="$2"
|
||||
local binaries=()
|
||||
for arch in "${ARCH_LIST[@]}"; do
|
||||
local src
|
||||
if ! src=$(resolve_binary_path "$name" "$arch"); then
|
||||
exit 1
|
||||
fi
|
||||
binaries+=("$src")
|
||||
done
|
||||
if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then
|
||||
lipo -create "${binaries[@]}" -output "$dest"
|
||||
else
|
||||
cp "${binaries[0]}" "$dest"
|
||||
fi
|
||||
chmod +x "$dest"
|
||||
verify_binary_arches "$dest" "${ARCH_LIST[@]}"
|
||||
}
|
||||
|
||||
strip_release_binary() {
|
||||
local binary="$1"
|
||||
if [[ "$LOWER_CONF" != "release" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -f "$binary" ]]; then
|
||||
return 0
|
||||
fi
|
||||
xcrun strip -x "$binary"
|
||||
}
|
||||
|
||||
ensure_widget_extension_project() {
|
||||
local spec="$ROOT/WidgetExtension/project.yml"
|
||||
local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj"
|
||||
if [[ -f "$project_dir/project.pbxproj" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v xcodegen >/dev/null 2>&1; then
|
||||
echo "ERROR: Missing ${project_dir}; install xcodegen or restore the generated project." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The tracked project is authoritative. Regenerating it during packaging records the checkout
|
||||
# directory's spelling in a package file reference and leaves release worktrees dirty.
|
||||
xcodegen generate --spec "$spec" --project "$ROOT/WidgetExtension" --quiet
|
||||
}
|
||||
|
||||
build_widget_extension() {
|
||||
local xcode_conf="Release"
|
||||
if [[ "$LOWER_CONF" == "debug" ]]; then
|
||||
xcode_conf="Debug"
|
||||
fi
|
||||
|
||||
ensure_widget_extension_project
|
||||
|
||||
local derived_dir="$ROOT/.build/xcode-widget-extension-${LOWER_CONF}"
|
||||
local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj"
|
||||
local build_log="$derived_dir/xcodebuild.log"
|
||||
local timeout_seconds="${CODEXBAR_WIDGET_EXTENSION_TIMEOUT_SECONDS:-900}"
|
||||
local archs="${ARCH_LIST[*]}"
|
||||
|
||||
mkdir -p "$derived_dir"
|
||||
echo "Building CodexBarWidget Xcode extension (${xcode_conf}, ${archs})." >&2
|
||||
xcodebuild \
|
||||
-project "$project_dir" \
|
||||
-scheme CodexBarWidgetExtension \
|
||||
-configuration "$xcode_conf" \
|
||||
-destination "generic/platform=macOS" \
|
||||
-derivedDataPath "$derived_dir" \
|
||||
-skipPackageUpdates \
|
||||
-disableAutomaticPackageResolution \
|
||||
-skipMacroValidation \
|
||||
-skipPackagePluginValidation \
|
||||
CODEXBAR_WIDGET_BUNDLE_ID="$WIDGET_BUNDLE_ID" \
|
||||
CODEXBAR_TEAM_ID="$APP_TEAM_ID" \
|
||||
MARKETING_VERSION="$MARKETING_VERSION" \
|
||||
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
ARCHS="$archs" \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
build >"$build_log" 2>&1 &
|
||||
|
||||
local xcodebuild_pid=$!
|
||||
local elapsed=0
|
||||
while kill -0 "$xcodebuild_pid" 2>/dev/null; do
|
||||
if [[ "$elapsed" -ge "$timeout_seconds" ]]; then
|
||||
kill "$xcodebuild_pid" 2>/dev/null || true
|
||||
wait "$xcodebuild_pid" 2>/dev/null || true
|
||||
tail -80 "$build_log" >&2 || true
|
||||
echo "ERROR: Timed out building CodexBarWidget extension after ${timeout_seconds}s" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
if (( elapsed > 0 && elapsed % 60 == 0 )); then
|
||||
echo "Still building CodexBarWidget extension (${elapsed}s)..." >&2
|
||||
fi
|
||||
done
|
||||
if ! wait "$xcodebuild_pid"; then
|
||||
tail -120 "$build_log" >&2 || true
|
||||
echo "ERROR: Failed to build CodexBarWidget extension" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local appex="$derived_dir/Build/Products/${xcode_conf}/CodexBarWidget.appex"
|
||||
if [[ ! -f "$appex/Contents/MacOS/CodexBarWidget" ]]; then
|
||||
echo "ERROR: Missing Xcode-built CodexBarWidget.appex at ${appex}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$appex"
|
||||
}
|
||||
|
||||
install_widget_extension() {
|
||||
local src_appex
|
||||
src_appex="$(build_widget_extension)"
|
||||
local widget_app="$APP/Contents/PlugIns/CodexBarWidget.appex"
|
||||
rm -rf "$widget_app"
|
||||
mkdir -p "$APP/Contents/PlugIns"
|
||||
cp -R "$src_appex" "$widget_app"
|
||||
verify_binary_arches "$widget_app/Contents/MacOS/CodexBarWidget" "${ARCH_LIST[@]}"
|
||||
}
|
||||
|
||||
install_binary "CodexBar" "$APP/Contents/MacOS/CodexBar"
|
||||
strip_release_binary "$APP/Contents/MacOS/CodexBar"
|
||||
# Ship CodexBarCLI alongside the app for easy symlinking.
|
||||
install_binary "CodexBarCLI" "$APP/Contents/Helpers/CodexBarCLI"
|
||||
strip_release_binary "$APP/Contents/Helpers/CodexBarCLI"
|
||||
# Watchdog helper: ensures `claude` probes die when CodexBar crashes/gets killed.
|
||||
install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog"
|
||||
strip_release_binary "$APP/Contents/Helpers/CodexBarClaudeWatchdog"
|
||||
install_widget_extension
|
||||
strip_release_binary "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget"
|
||||
|
||||
swiftpm_bin_path "${ARCH_LIST[0]}" PREFERRED_BUILD_DIR
|
||||
|
||||
# Embed Sparkle.framework
|
||||
SPARKLE_SOURCE=$(codexbar_require_product_directory "$PREFERRED_BUILD_DIR" Sparkle.framework packaging)
|
||||
cp -R "$SPARKLE_SOURCE" "$APP/Contents/Frameworks/"
|
||||
chmod -R a+rX "$APP/Contents/Frameworks/Sparkle.framework"
|
||||
install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodexBar"
|
||||
# Re-sign Sparkle and all nested components with the selected package identity.
|
||||
SPARKLE="$APP/Contents/Frameworks/Sparkle.framework"
|
||||
if [[ "$SIGNING_MODE" == "adhoc" ]]; then
|
||||
CODESIGN_ID="-"
|
||||
CODESIGN_ARGS=(--force --sign "$CODESIGN_ID")
|
||||
elif [[ "$ALLOW_LLDB" == "1" ]]; then
|
||||
CODESIGN_ID="-"
|
||||
CODESIGN_ARGS=(--force --sign "$CODESIGN_ID")
|
||||
else
|
||||
CODESIGN_ID="${APP_IDENTITY:-Developer ID Application: Peter Steinberger (Y5PE65HELJ)}"
|
||||
CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$CODESIGN_ID")
|
||||
fi
|
||||
function resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; }
|
||||
# Validate Sparkle's nested layout before signing so framework layout drift fails clearly.
|
||||
SPARKLE_SIGNING_TARGETS=$(codexbar_sparkle_signing_targets "$SPARKLE")
|
||||
while IFS= read -r SPARKLE_TARGET; do
|
||||
resign "$SPARKLE_TARGET"
|
||||
done <<<"$SPARKLE_SIGNING_TARGETS"
|
||||
|
||||
if [[ -f "$ICON_TARGET" ]]; then
|
||||
cp "$ICON_TARGET" "$APP/Contents/Resources/Icon.icns"
|
||||
fi
|
||||
|
||||
# Bundle app resources (provider icons, etc.).
|
||||
APP_RESOURCES_DIR="$ROOT/Sources/CodexBar/Resources"
|
||||
if [[ -d "$APP_RESOURCES_DIR" ]]; then
|
||||
cp -R "$APP_RESOURCES_DIR/." "$APP/Contents/Resources/"
|
||||
fi
|
||||
if [[ ! -f "$APP/Contents/Resources/Icon-classic.icns" ]]; then
|
||||
echo "ERROR: Missing Icon-classic.icns in app bundle resources." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# SwiftPM resource bundles (e.g. KeyboardShortcuts) are emitted next to the built binary.
|
||||
shopt -s nullglob
|
||||
SWIFTPM_BUNDLES=("${PREFERRED_BUILD_DIR}/"*.bundle)
|
||||
shopt -u nullglob
|
||||
if [[ ${#SWIFTPM_BUNDLES[@]} -gt 0 ]]; then
|
||||
for bundle in "${SWIFTPM_BUNDLES[@]}"; do
|
||||
bundle_name="$(basename "$bundle")"
|
||||
cp -R "$bundle" "$APP/Contents/Resources/"
|
||||
done
|
||||
fi
|
||||
if [[ ! -d "$APP/Contents/Resources/KeyboardShortcuts_KeyboardShortcuts.bundle" ]]; then
|
||||
echo "ERROR: Missing KeyboardShortcuts SwiftPM resource bundle (Settings → Keyboard shortcut will crash)." >&2
|
||||
echo "Expected: ${PREFERRED_BUILD_DIR}/KeyboardShortcuts_KeyboardShortcuts.bundle" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure contents are writable before stripping attributes and signing.
|
||||
chmod -R u+w "$APP"
|
||||
|
||||
# Strip extended attributes to prevent AppleDouble (._*) files that break code sealing
|
||||
xattr -cr "$APP"
|
||||
find "$APP" -name '._*' -delete
|
||||
|
||||
# Sign helper binaries if present
|
||||
if [[ -f "${APP}/Contents/Helpers/CodexBarCLI" ]]; then
|
||||
codesign "${CODESIGN_ARGS[@]}" "${APP}/Contents/Helpers/CodexBarCLI"
|
||||
fi
|
||||
if [[ -f "${APP}/Contents/Helpers/CodexBarClaudeWatchdog" ]]; then
|
||||
codesign "${CODESIGN_ARGS[@]}" "${APP}/Contents/Helpers/CodexBarClaudeWatchdog"
|
||||
fi
|
||||
|
||||
# Sign widget extension if present
|
||||
if [[ -d "${APP}/Contents/PlugIns/CodexBarWidget.appex" ]]; then
|
||||
codesign "${CODESIGN_ARGS[@]}" \
|
||||
--entitlements "$WIDGET_ENTITLEMENTS" \
|
||||
"$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget"
|
||||
codesign "${CODESIGN_ARGS[@]}" \
|
||||
--entitlements "$WIDGET_ENTITLEMENTS" \
|
||||
"$APP/Contents/PlugIns/CodexBarWidget.appex"
|
||||
fi
|
||||
|
||||
# Finally sign the app bundle itself
|
||||
codesign "${CODESIGN_ARGS[@]}" \
|
||||
--entitlements "$APP_ENTITLEMENTS" \
|
||||
"$APP"
|
||||
|
||||
rm -rf "$APP_FINAL"
|
||||
mv "$APP" "$APP_FINAL"
|
||||
APP="$APP_FINAL"
|
||||
echo "Created $APP"
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
codexbar_swiftpm_bin_path() {
|
||||
local conf="$1"
|
||||
shift
|
||||
local command=(swift build --show-bin-path -c "$conf")
|
||||
local arch
|
||||
for arch in "$@"; do
|
||||
command+=(--arch "$arch")
|
||||
done
|
||||
|
||||
local path
|
||||
if ! path=$("${command[@]}"); then
|
||||
echo "ERROR: SwiftPM failed to report the ${conf} product directory for: $*" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$path" ]]; then
|
||||
echo "ERROR: SwiftPM reported an empty ${conf} product directory for: $*" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
codexbar_require_product_file() {
|
||||
local bin_dir="$1"
|
||||
local name="$2"
|
||||
local arch_label="$3"
|
||||
local product="$bin_dir/$name"
|
||||
if [[ ! -f "$product" ]]; then
|
||||
echo "ERROR: Missing ${name} for ${arch_label} at SwiftPM-reported path: ${product}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$product"
|
||||
}
|
||||
|
||||
codexbar_require_product_directory() {
|
||||
local bin_dir="$1"
|
||||
local name="$2"
|
||||
local context="$3"
|
||||
local product="$bin_dir/$name"
|
||||
if [[ ! -d "$product" ]]; then
|
||||
echo "ERROR: Missing ${name} for ${context} at SwiftPM-reported path: ${product}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$product"
|
||||
}
|
||||
|
||||
codexbar_resolve_staged_or_reported_file() {
|
||||
local stage_root="$1"
|
||||
local bin_dir="$2"
|
||||
local name="$3"
|
||||
local arch="$4"
|
||||
local staged="$stage_root/$arch/$name"
|
||||
if [[ -f "$staged" ]]; then
|
||||
printf '%s\n' "$staged"
|
||||
return
|
||||
fi
|
||||
codexbar_require_product_file "$bin_dir" "$name" "$arch"
|
||||
}
|
||||
|
||||
codexbar_resolve_dsym_path() {
|
||||
local stage_root="$1"
|
||||
local bin_dir="$2"
|
||||
local app_name="$3"
|
||||
local arch="$4"
|
||||
local staged="$stage_root/$arch/${app_name}.dSYM"
|
||||
if [[ -d "$staged" ]]; then
|
||||
printf '%s\n' "$staged"
|
||||
return
|
||||
fi
|
||||
codexbar_require_product_directory "$bin_dir" "${app_name}.dSYM" "$arch"
|
||||
}
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
# Prepare a clean branch for upstream PR submission
|
||||
# Usage: ./Scripts/prepare_upstream_pr.sh <feature-name>
|
||||
|
||||
set -e
|
||||
|
||||
FEATURE_NAME=$1
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
if [ -z "$FEATURE_NAME" ]; then
|
||||
echo -e "${RED}Error: Feature name required${NC}"
|
||||
echo "Usage: ./Scripts/prepare_upstream_pr.sh <feature-name>"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " ./Scripts/prepare_upstream_pr.sh fix-cursor-bonus"
|
||||
echo " ./Scripts/prepare_upstream_pr.sh improve-cookie-handling"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BRANCH_NAME="upstream-pr/$FEATURE_NAME"
|
||||
|
||||
echo -e "${BLUE}==> Fetching latest upstream...${NC}"
|
||||
git fetch upstream
|
||||
|
||||
echo -e "${BLUE}==> Creating upstream PR branch from upstream/main...${NC}"
|
||||
git checkout upstream/main
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}==> Branch created: $BRANCH_NAME${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚠️ IMPORTANT: This branch is for UPSTREAM submission${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Guidelines for upstream PRs:${NC}"
|
||||
echo ""
|
||||
echo "✅ DO include:"
|
||||
echo " - Bug fixes that affect all users"
|
||||
echo " - Performance improvements"
|
||||
echo " - Provider enhancements (generic)"
|
||||
echo " - Documentation improvements"
|
||||
echo " - Test coverage"
|
||||
echo ""
|
||||
echo "❌ DO NOT include:"
|
||||
echo " - Fork branding (About.swift, PreferencesAboutPane.swift)"
|
||||
echo " - Fork-specific features (multi-account, etc.)"
|
||||
echo " - References to topoffunnel.com"
|
||||
echo " - Experimental features"
|
||||
echo ""
|
||||
echo -e "${BLUE}Next steps:${NC}"
|
||||
echo ""
|
||||
echo "1. Cherry-pick your commits (clean, no fork branding):"
|
||||
echo " ${GREEN}git cherry-pick <commit-hash>${NC}"
|
||||
echo ""
|
||||
echo "2. Or manually apply changes:"
|
||||
echo " ${GREEN}# Edit files${NC}"
|
||||
echo " ${GREEN}git add <files>${NC}"
|
||||
echo " ${GREEN}git commit -m 'fix: description'${NC}"
|
||||
echo ""
|
||||
echo "3. Ensure tests pass:"
|
||||
echo " ${GREEN}make test${NC}"
|
||||
echo ""
|
||||
echo "4. Review changes:"
|
||||
echo " ${GREEN}git diff upstream/main${NC}"
|
||||
echo ""
|
||||
echo "5. Push to your fork:"
|
||||
echo " ${GREEN}git push origin $BRANCH_NAME${NC}"
|
||||
echo ""
|
||||
echo "6. Create PR on GitHub:"
|
||||
echo " ${GREEN}https://github.com/steipete/CodexBar/compare/main...topoffunnel:$BRANCH_NAME${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Remember: Keep PRs small and focused for better merge chances!${NC}"
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SOURCE_DIR="${ROOT_DIR}/Sources/CodexBarCore/Vendored/CostUsage"
|
||||
OUTPUT_FILE="${ROOT_DIR}/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift"
|
||||
MODE="${1:-write}"
|
||||
|
||||
case "$MODE" in
|
||||
write | --write)
|
||||
CHECK_ONLY=0
|
||||
;;
|
||||
check | --check)
|
||||
CHECK_ONLY=1
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [write|--write|check|--check]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
HASH_CMD=(shasum -a 256)
|
||||
elif command -v sha256sum >/dev/null 2>&1; then
|
||||
HASH_CMD=(sha256sum)
|
||||
else
|
||||
echo "error: shasum or sha256sum is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILE_LIST="$(mktemp)"
|
||||
trap 'rm -f "$FILE_LIST"' EXIT
|
||||
|
||||
find "$SOURCE_DIR" \
|
||||
-type f \
|
||||
-name '*.swift' \
|
||||
! -name '*Claude*' \
|
||||
-print |
|
||||
sed "s#^${ROOT_DIR}/##" |
|
||||
LC_ALL=C sort >"$FILE_LIST"
|
||||
|
||||
HASH="$(
|
||||
while IFS= read -r file; do
|
||||
printf '== %s ==\n' "$file"
|
||||
cat "${ROOT_DIR}/${file}"
|
||||
printf '\n'
|
||||
done <"$FILE_LIST" | "${HASH_CMD[@]}"
|
||||
)"
|
||||
HASH="${HASH%% *}"
|
||||
SHORT_HASH="${HASH:0:16}"
|
||||
|
||||
render_generated() {
|
||||
cat <<SWIFT
|
||||
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.
|
||||
|
||||
enum CodexParserHash {
|
||||
static let value = "$SHORT_HASH"
|
||||
}
|
||||
SWIFT
|
||||
}
|
||||
|
||||
if [[ "$CHECK_ONLY" -eq 1 ]]; then
|
||||
EXPECTED_FILE="$(mktemp)"
|
||||
trap 'rm -f "$FILE_LIST" "$EXPECTED_FILE"' EXIT
|
||||
render_generated >"$EXPECTED_FILE"
|
||||
if ! cmp -s "$EXPECTED_FILE" "$OUTPUT_FILE"; then
|
||||
echo "error: ${OUTPUT_FILE#${ROOT_DIR}/} is stale. Run Scripts/regenerate-codex-parser-hash.sh and commit the result." >&2
|
||||
if [[ -f "$OUTPUT_FILE" ]]; then
|
||||
diff -u "$OUTPUT_FILE" "$EXPECTED_FILE" >&2 || true
|
||||
else
|
||||
diff -u /dev/null "$EXPECTED_FILE" >&2 || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
echo "Codex parser hash is current (${SHORT_HASH})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$OUTPUT_FILE")"
|
||||
render_generated >"$OUTPUT_FILE"
|
||||
|
||||
echo "Updated ${OUTPUT_FILE#${ROOT_DIR}/} to ${SHORT_HASH}"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
exec "$SCRIPT_DIR/mac-release" release "$@"
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
codexbar_release_arch_label() {
|
||||
local raw="${1:-arm64 x86_64}"
|
||||
local normalized
|
||||
local has_arm64=0
|
||||
local has_x86_64=0
|
||||
local arch
|
||||
|
||||
normalized=$(printf "%s" "$raw" | tr ',' ' ')
|
||||
for arch in $normalized; do
|
||||
case "$arch" in
|
||||
arm64) has_arm64=1 ;;
|
||||
x86_64) has_x86_64=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$has_arm64" == "1" && "$has_x86_64" == "1" ]]; then
|
||||
printf "macos-universal"
|
||||
return
|
||||
fi
|
||||
if [[ "$has_arm64" == "1" ]]; then
|
||||
printf "macos-arm64"
|
||||
return
|
||||
fi
|
||||
if [[ "$has_x86_64" == "1" ]]; then
|
||||
printf "macos-x86_64"
|
||||
return
|
||||
fi
|
||||
|
||||
printf "macos-%s" "$(printf "%s" "$normalized" | tr ' ' '+')"
|
||||
}
|
||||
|
||||
codexbar_app_zip_name() {
|
||||
local version=$1
|
||||
local arches="${2:-arm64 x86_64}"
|
||||
printf "CodexBar-%s-%s.zip" "$(codexbar_release_arch_label "$arches")" "$version"
|
||||
}
|
||||
|
||||
codexbar_dsym_zip_name() {
|
||||
local version=$1
|
||||
local arches="${2:-arm64 x86_64}"
|
||||
printf "CodexBar-%s-%s.dSYM.zip" "$(codexbar_release_arch_label "$arches")" "$version"
|
||||
}
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
codexbar_dsym_dwarf_path() {
|
||||
local dsym_path="$1"
|
||||
local app_name="$2"
|
||||
local dwarf_path="${dsym_path}/Contents/Resources/DWARF/${app_name}"
|
||||
|
||||
if [[ ! -f "$dwarf_path" ]]; then
|
||||
echo "Missing fresh dSYM for ${app_name} at: ${dwarf_path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$dwarf_path"
|
||||
}
|
||||
|
||||
codexbar_require_dsym_dwarf_for_arch() {
|
||||
local dsym_path="$1"
|
||||
local app_name="$2"
|
||||
local arch="$3"
|
||||
local dwarf_path
|
||||
|
||||
if ! dwarf_path=$(codexbar_dsym_dwarf_path "$dsym_path" "$app_name"); then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! lipo -archs "$dwarf_path" | tr ' ' '\n' | grep -qx "$arch"; then
|
||||
echo "dSYM at ${dwarf_path} does not contain required architecture: ${arch}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$dwarf_path"
|
||||
}
|
||||
|
||||
codexbar_dwarf_uuid_for_arch() {
|
||||
local path="$1"
|
||||
local arch="$2"
|
||||
local uuid
|
||||
|
||||
uuid=$(dwarfdump --uuid "$path" | awk -v arch="(${arch})" '$1 == "UUID:" && $3 == arch { print $2; exit }')
|
||||
if [[ -z "$uuid" ]]; then
|
||||
echo "Missing UUID for ${arch} in: ${path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$uuid"
|
||||
}
|
||||
|
||||
codexbar_verify_dsym_matches_binary() {
|
||||
local app_binary="$1"
|
||||
local dsym_dwarf="$2"
|
||||
shift 2
|
||||
local arch app_uuid dsym_uuid
|
||||
|
||||
if [[ ! -f "$app_binary" ]]; then
|
||||
echo "Missing app binary for dSYM UUID verification: ${app_binary}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -f "$dsym_dwarf" ]]; then
|
||||
echo "Missing dSYM DWARF file for UUID verification: ${dsym_dwarf}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
for arch in "$@"; do
|
||||
if ! app_uuid=$(codexbar_dwarf_uuid_for_arch "$app_binary" "$arch"); then
|
||||
return 1
|
||||
fi
|
||||
if ! dsym_uuid=$(codexbar_dwarf_uuid_for_arch "$dsym_dwarf" "$arch"); then
|
||||
return 1
|
||||
fi
|
||||
if [[ "$app_uuid" != "$dsym_uuid" ]]; then
|
||||
echo "dSYM UUID mismatch for ${arch}: app=${app_uuid}, dSYM=${dsym_uuid}" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create a review branch for upstream changes
|
||||
# Usage: ./Scripts/review_upstream.sh [upstream|quotio]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
UPSTREAM=${1:-upstream}
|
||||
DATE=$(date +%Y%m%d)
|
||||
BRANCH_NAME="upstream-sync/${UPSTREAM}-${DATE}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
if [ "$UPSTREAM" != "upstream" ] && [ "$UPSTREAM" != "quotio" ]; then
|
||||
echo -e "${RED}Error: Must specify 'upstream' or 'quotio'${NC}"
|
||||
echo "Usage: ./Scripts/review_upstream.sh [upstream|quotio]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_remote() {
|
||||
local remote=$1
|
||||
local url=$2
|
||||
local origin_url
|
||||
|
||||
if git remote get-url "$remote" >/dev/null 2>&1; then
|
||||
echo "$remote"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$remote" = "upstream" ] && git remote get-url origin >/dev/null 2>&1; then
|
||||
origin_url=$(git remote get-url origin)
|
||||
case "$origin_url" in
|
||||
https://github.com/steipete/CodexBar|https://github.com/steipete/CodexBar.git|git@github.com:steipete/CodexBar.git)
|
||||
echo -e "${YELLOW}Remote 'upstream' missing; using origin for steipete/CodexBar.${NC}" >&2
|
||||
echo "origin"
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${YELLOW}Remote 'upstream' missing; origin is not steipete/CodexBar, adding upstream.${NC}" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Adding $remote remote...${NC}" >&2
|
||||
git remote add "$remote" "$url"
|
||||
echo "$remote"
|
||||
}
|
||||
|
||||
remote_default_branch() {
|
||||
local remote=$1
|
||||
local branch=""
|
||||
local candidate
|
||||
|
||||
branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true)
|
||||
if [ -z "$branch" ]; then
|
||||
branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true)
|
||||
fi
|
||||
if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then
|
||||
echo "$branch"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for candidate in main master; do
|
||||
if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${RED}Error: Could not resolve default branch for remote '$remote'.${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "$UPSTREAM" in
|
||||
upstream) REMOTE=$(ensure_remote upstream "https://github.com/steipete/CodexBar.git") ;;
|
||||
quotio) REMOTE=$(ensure_remote quotio "https://github.com/nguyenphutrong/quotio.git") ;;
|
||||
esac
|
||||
|
||||
echo -e "${BLUE}==> Fetching latest from $UPSTREAM...${NC}"
|
||||
git fetch "$REMOTE" --prune
|
||||
REMOTE_BRANCH=$(remote_default_branch "$REMOTE")
|
||||
REMOTE_REF="${REMOTE}/${REMOTE_BRANCH}"
|
||||
|
||||
echo -e "${BLUE}==> Creating review branch for $UPSTREAM (${REMOTE_REF})...${NC}"
|
||||
git switch main
|
||||
git switch -c "$BRANCH_NAME"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}==> Commits to review:${NC}"
|
||||
git log --oneline --graph "main..${REMOTE_REF}" | head -30 || true
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}==> File changes summary:${NC}"
|
||||
git diff --stat "main..${REMOTE_REF}"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}==> Review branch created: $BRANCH_NAME${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Next steps:${NC}"
|
||||
echo ""
|
||||
echo "1. Review commits in detail:"
|
||||
echo " ${GREEN}git log -p main..$REMOTE_REF${NC}"
|
||||
echo ""
|
||||
echo "2. View specific files:"
|
||||
echo " ${GREEN}git show $REMOTE_REF:path/to/file${NC}"
|
||||
echo ""
|
||||
echo "3. Cherry-pick specific commits:"
|
||||
echo " ${GREEN}git cherry-pick <commit-hash>${NC}"
|
||||
echo ""
|
||||
echo "4. Or merge all changes:"
|
||||
echo " ${GREEN}git merge $REMOTE_REF${NC}"
|
||||
echo ""
|
||||
echo "5. Test thoroughly:"
|
||||
echo " ${GREEN}./Scripts/compile_and_run.sh${NC}"
|
||||
echo ""
|
||||
echo "6. If satisfied, merge to main:"
|
||||
echo " ${GREEN}git checkout main && git merge $BRANCH_NAME${NC}"
|
||||
echo ""
|
||||
echo "7. Or discard review branch:"
|
||||
echo " ${GREEN}git checkout main && git branch -D $BRANCH_NAME${NC}"
|
||||
echo ""
|
||||
|
||||
# Create a review log file
|
||||
LOG_FILE="upstream-review-${UPSTREAM}-${DATE}.txt"
|
||||
echo "=== Upstream Review: $UPSTREAM @ $DATE ===" > "$LOG_FILE"
|
||||
echo "" >> "$LOG_FILE"
|
||||
echo "Commits:" >> "$LOG_FILE"
|
||||
git log --oneline "main..${REMOTE_REF}" >> "$LOG_FILE"
|
||||
echo "" >> "$LOG_FILE"
|
||||
echo "File changes:" >> "$LOG_FILE"
|
||||
git diff --stat "main..${REMOTE_REF}" >> "$LOG_FILE"
|
||||
|
||||
echo -e "${GREEN}Review log saved to: $LOG_FILE${NC}"
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup stable development code signing to reduce keychain prompts
|
||||
set -euo pipefail
|
||||
|
||||
echo "🔐 Setting up stable development code signing..."
|
||||
echo ""
|
||||
echo "This will create a self-signed certificate that stays consistent across rebuilds,"
|
||||
echo "reducing keychain permission prompts."
|
||||
echo ""
|
||||
|
||||
# Check if we already have a CodexBar development certificate
|
||||
CERT_NAME="CodexBar Development"
|
||||
if security find-certificate -c "$CERT_NAME" >/dev/null 2>&1; then
|
||||
echo "✅ Certificate '$CERT_NAME' already exists!"
|
||||
echo ""
|
||||
echo "To use it, add this to your shell profile (~/.zshrc or ~/.bashrc):"
|
||||
echo ""
|
||||
echo " export APP_IDENTITY='$CERT_NAME'"
|
||||
echo ""
|
||||
echo "Then restart your terminal and rebuild with ./Scripts/compile_and_run.sh"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Creating self-signed certificate '$CERT_NAME'..."
|
||||
echo ""
|
||||
|
||||
# Create a temporary config file for the certificate
|
||||
TEMP_CONFIG=$(mktemp)
|
||||
trap "rm -f $TEMP_CONFIG" EXIT
|
||||
|
||||
cat > "$TEMP_CONFIG" <<EOF
|
||||
[ req ]
|
||||
distinguished_name = req_distinguished_name
|
||||
x509_extensions = v3_req
|
||||
prompt = no
|
||||
|
||||
[ req_distinguished_name ]
|
||||
CN = $CERT_NAME
|
||||
O = CodexBar Development
|
||||
C = US
|
||||
|
||||
[ v3_req ]
|
||||
keyUsage = critical,digitalSignature
|
||||
extendedKeyUsage = codeSigning
|
||||
EOF
|
||||
|
||||
# Generate the certificate
|
||||
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 \
|
||||
-nodes -keyout /tmp/codexbar-dev.key -out /tmp/codexbar-dev.crt \
|
||||
-config "$TEMP_CONFIG" 2>/dev/null
|
||||
|
||||
# Convert to PKCS12 format
|
||||
openssl pkcs12 -export -out /tmp/codexbar-dev.p12 \
|
||||
-inkey /tmp/codexbar-dev.key -in /tmp/codexbar-dev.crt \
|
||||
-passout pass: 2>/dev/null
|
||||
|
||||
# Import into keychain
|
||||
security import /tmp/codexbar-dev.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security
|
||||
|
||||
# Clean up temporary files
|
||||
rm -f /tmp/codexbar-dev.{key,crt,p12}
|
||||
|
||||
echo ""
|
||||
echo "✅ Certificate created successfully!"
|
||||
echo ""
|
||||
echo "⚠️ IMPORTANT: You need to trust this certificate for code signing:"
|
||||
echo ""
|
||||
echo "1. Open Keychain Access.app"
|
||||
echo "2. Find '$CERT_NAME' in the 'login' keychain"
|
||||
echo "3. Double-click it"
|
||||
echo "4. Expand 'Trust' section"
|
||||
echo "5. Set 'Code Signing' to 'Always Trust'"
|
||||
echo "6. Close the window (enter your password when prompted)"
|
||||
echo ""
|
||||
echo "Then add this to your shell profile (~/.zshrc or ~/.bashrc):"
|
||||
echo ""
|
||||
echo " export APP_IDENTITY='$CERT_NAME'"
|
||||
echo ""
|
||||
echo "Restart your terminal and rebuild with ./Scripts/compile_and_run.sh"
|
||||
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="CodexBar"
|
||||
APP_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)"
|
||||
APP_BUNDLE="CodexBar.app"
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
source "$ROOT/version.env"
|
||||
source "$ROOT/Scripts/release_artifacts.sh"
|
||||
source "$ROOT/Scripts/package_product_paths.sh"
|
||||
source "$ROOT/Scripts/release_dsym_paths.sh"
|
||||
|
||||
verify_distribution_policy() {
|
||||
local app=$1
|
||||
if command -v syspolicy_check >/dev/null 2>&1; then
|
||||
syspolicy_check distribution "$app"
|
||||
else
|
||||
spctl -a -t exec -vv "$app"
|
||||
fi
|
||||
}
|
||||
|
||||
# Allow building a universal binary if ARCHES is provided; default to universal (arm64 + x86_64).
|
||||
ARCHES_VALUE=${ARCHES:-"arm64 x86_64"}
|
||||
ZIP_NAME=$(codexbar_app_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE")
|
||||
DSYM_ZIP=$(codexbar_dsym_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE")
|
||||
|
||||
if [[ -z "${APP_STORE_CONNECT_API_KEY_P8:-}" || -z "${APP_STORE_CONNECT_KEY_ID:-}" || -z "${APP_STORE_CONNECT_ISSUER_ID:-}" ]]; then
|
||||
echo "Missing APP_STORE_CONNECT_* env vars (API key, key id, issuer id)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NOTARIZATION_TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-notarize.XXXXXX")
|
||||
chmod 700 "$NOTARIZATION_TEMP_DIR"
|
||||
API_KEY_PATH="$NOTARIZATION_TEMP_DIR/codexbar-api-key.p8"
|
||||
NOTARIZATION_ZIP="$NOTARIZATION_TEMP_DIR/${APP_NAME}Notarize.zip"
|
||||
trap 'rm -rf "$NOTARIZATION_TEMP_DIR"' EXIT
|
||||
|
||||
(
|
||||
umask 077
|
||||
printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > "$API_KEY_PATH"
|
||||
)
|
||||
chmod 600 "$API_KEY_PATH"
|
||||
|
||||
ARCH_LIST=( ${ARCHES_VALUE} )
|
||||
ARCHES="${ARCHES_VALUE}" CODEXBAR_SIGNING=identity ./Scripts/package_app.sh release
|
||||
|
||||
ENTITLEMENTS_DIR="$ROOT/.build/entitlements"
|
||||
APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements"
|
||||
WIDGET_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBarWidget.entitlements"
|
||||
|
||||
echo "Signing with $APP_IDENTITY"
|
||||
if [[ -f "$APP_BUNDLE/Contents/Helpers/CodexBarCLI" ]]; then
|
||||
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
|
||||
"$APP_BUNDLE/Contents/Helpers/CodexBarCLI"
|
||||
fi
|
||||
if [[ -f "$APP_BUNDLE/Contents/Helpers/CodexBarClaudeWatchdog" ]]; then
|
||||
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
|
||||
"$APP_BUNDLE/Contents/Helpers/CodexBarClaudeWatchdog"
|
||||
fi
|
||||
if [[ -d "$APP_BUNDLE/Contents/PlugIns/CodexBarWidget.appex" ]]; then
|
||||
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
|
||||
--entitlements "$WIDGET_ENTITLEMENTS" \
|
||||
"$APP_BUNDLE/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget"
|
||||
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
|
||||
--entitlements "$WIDGET_ENTITLEMENTS" \
|
||||
"$APP_BUNDLE/Contents/PlugIns/CodexBarWidget.appex"
|
||||
fi
|
||||
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
|
||||
--entitlements "$APP_ENTITLEMENTS" \
|
||||
"$APP_BUNDLE"
|
||||
|
||||
DITTO_BIN=${DITTO_BIN:-/usr/bin/ditto}
|
||||
"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$NOTARIZATION_ZIP"
|
||||
|
||||
echo "Submitting for notarization"
|
||||
xcrun notarytool submit "$NOTARIZATION_ZIP" \
|
||||
--key "$API_KEY_PATH" \
|
||||
--key-id "$APP_STORE_CONNECT_KEY_ID" \
|
||||
--issuer "$APP_STORE_CONNECT_ISSUER_ID" \
|
||||
--wait
|
||||
|
||||
echo "Stapling ticket"
|
||||
xcrun stapler staple "$APP_BUNDLE"
|
||||
|
||||
# Strip any extended attributes that would create AppleDouble files when zipping
|
||||
xattr -cr "$APP_BUNDLE"
|
||||
find "$APP_BUNDLE" -name '._*' -delete
|
||||
|
||||
"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$ZIP_NAME"
|
||||
|
||||
verify_distribution_policy "$APP_BUNDLE"
|
||||
stapler validate "$APP_BUNDLE"
|
||||
|
||||
echo "Packaging dSYM"
|
||||
DSYM_STAGE_ROOT="$ROOT/.build/package-products/release"
|
||||
DSYM_PATHS=()
|
||||
for ARCH in "${ARCH_LIST[@]}"; do
|
||||
STAGED_DSYM="$DSYM_STAGE_ROOT/$ARCH/${APP_NAME}.dSYM"
|
||||
if [[ -d "$STAGED_DSYM" ]]; then
|
||||
DSYM_PATHS+=("$STAGED_DSYM")
|
||||
continue
|
||||
fi
|
||||
BIN_DIR=$(codexbar_swiftpm_bin_path release "$ARCH")
|
||||
DSYM_PATHS+=("$(codexbar_resolve_dsym_path "$DSYM_STAGE_ROOT" "$BIN_DIR" "$APP_NAME" "$ARCH")")
|
||||
done
|
||||
|
||||
DSYM_PATH="${DSYM_PATHS[0]}"
|
||||
DSYM_DWARF_PATHS=()
|
||||
for ((index = 0; index < ${#ARCH_LIST[@]}; index++)); do
|
||||
ARCH="${ARCH_LIST[$index]}"
|
||||
if ! ARCH_DSYM=$(codexbar_require_dsym_dwarf_for_arch "${DSYM_PATHS[$index]}" "$APP_NAME" "$ARCH"); then
|
||||
exit 1
|
||||
fi
|
||||
DSYM_DWARF_PATHS+=("$ARCH_DSYM")
|
||||
done
|
||||
|
||||
if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then
|
||||
MERGED_DSYM_ROOT="${DSYM_STAGE_ROOT}/${APP_NAME}.dSYM-universal"
|
||||
MERGED_DSYM="${MERGED_DSYM_ROOT}/${APP_NAME}.dSYM"
|
||||
rm -rf "$MERGED_DSYM_ROOT"
|
||||
mkdir -p "$MERGED_DSYM_ROOT"
|
||||
cp -R "$DSYM_PATH" "$MERGED_DSYM"
|
||||
DWARF_PATH="${MERGED_DSYM}/Contents/Resources/DWARF/${APP_NAME}"
|
||||
lipo -create "${DSYM_DWARF_PATHS[@]}" -output "$DWARF_PATH"
|
||||
DSYM_PATH="$MERGED_DSYM"
|
||||
fi
|
||||
if [[ ! -d "$DSYM_PATH" ]]; then
|
||||
echo "Missing dSYM at SwiftPM-reported path: $DSYM_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
codexbar_verify_dsym_matches_binary \
|
||||
"$APP_BUNDLE/Contents/MacOS/$APP_NAME" \
|
||||
"$DSYM_PATH/Contents/Resources/DWARF/$APP_NAME" \
|
||||
"${ARCH_LIST[@]}"
|
||||
"$DITTO_BIN" --norsrc -c -k --keepParent "$DSYM_PATH" "$DSYM_ZIP"
|
||||
|
||||
echo "Done: $ZIP_NAME"
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
codexbar_resolve_sparkle_version_child() {
|
||||
local versions_dir="$1"
|
||||
local candidate="$2"
|
||||
local label="$3"
|
||||
local versions_root resolved
|
||||
|
||||
versions_root=$(cd "$versions_dir" && pwd -P)
|
||||
if ! resolved=$(cd "$candidate" 2>/dev/null && pwd -P); then
|
||||
echo "ERROR: Sparkle ${label} does not resolve: ${candidate}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$(dirname "$resolved")" != "$versions_root" ]]; then
|
||||
echo "ERROR: Sparkle ${label} resolves outside the framework versions directory: ${candidate}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$resolved"
|
||||
}
|
||||
|
||||
codexbar_sparkle_version_dir() {
|
||||
local sparkle="$1"
|
||||
local versions_dir="${sparkle}/Versions"
|
||||
|
||||
if [[ -L "$sparkle" ]]; then
|
||||
echo "ERROR: Sparkle framework root must not be a symlink: ${sparkle}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -L "$versions_dir" ]]; then
|
||||
echo "ERROR: Sparkle versions directory must not be a symlink: ${versions_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -d "$versions_dir" ]]; then
|
||||
echo "ERROR: Missing Sparkle versions directory: ${versions_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -e "$versions_dir/Current" || -L "$versions_dir/Current" ]]; then
|
||||
local current
|
||||
if ! current=$(codexbar_resolve_sparkle_version_child "$versions_dir" "$versions_dir/Current" "Versions/Current"); then
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$current"
|
||||
return
|
||||
fi
|
||||
|
||||
local version_dirs=()
|
||||
local candidate
|
||||
shopt -s nullglob
|
||||
for candidate in "$versions_dir"/*; do
|
||||
if [[ -d "$candidate" ]]; then
|
||||
version_dirs+=("$candidate")
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
case "${#version_dirs[@]}" in
|
||||
0)
|
||||
echo "ERROR: Sparkle framework has no version directory under: ${versions_dir}" >&2
|
||||
return 1
|
||||
;;
|
||||
1)
|
||||
local resolved
|
||||
if ! resolved=$(codexbar_resolve_sparkle_version_child \
|
||||
"$versions_dir" "${version_dirs[0]}" "version directory"); then
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$resolved"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Sparkle framework has multiple version directories and no Versions/Current symlink: ${versions_dir}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
codexbar_require_sparkle_signing_target() {
|
||||
local path="$1"
|
||||
local label="$2"
|
||||
local trusted_root="$3"
|
||||
local resolved trusted_root_resolved
|
||||
|
||||
if [[ -L "$path" ]]; then
|
||||
echo "ERROR: Sparkle signing target must not be a symlink (${label}): ${path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -e "$path" ]]; then
|
||||
echo "ERROR: Missing Sparkle signing target (${label}): ${path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! trusted_root_resolved=$(cd "$trusted_root" 2>/dev/null && pwd -P); then
|
||||
echo "ERROR: Sparkle signing root does not resolve (${label}): ${trusted_root}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -d "$path" ]]; then
|
||||
resolved=$(cd "$path" && pwd -P)
|
||||
else
|
||||
resolved="$(cd "$(dirname "$path")" && pwd -P)/$(basename "$path")"
|
||||
fi
|
||||
if [[ "$resolved" != "$trusted_root_resolved" &&
|
||||
"${resolved#"$trusted_root_resolved"/}" == "$resolved" ]]; then
|
||||
echo "ERROR: Sparkle signing target resolves outside its trusted root (${label}): ${path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$resolved"
|
||||
}
|
||||
|
||||
codexbar_sparkle_signing_targets() {
|
||||
local sparkle="$1"
|
||||
local version_dir
|
||||
if ! version_dir=$(codexbar_sparkle_version_dir "$sparkle"); then
|
||||
return 1
|
||||
fi
|
||||
|
||||
codexbar_require_sparkle_signing_target "$sparkle" "framework root" "$sparkle" || return 1
|
||||
codexbar_require_sparkle_signing_target "$version_dir/Sparkle" "framework binary" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target "$version_dir/Autoupdate" "autoupdate tool" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target "$version_dir/Updater.app" "updater app" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target \
|
||||
"$version_dir/Updater.app/Contents/MacOS/Updater" "updater executable" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target \
|
||||
"$version_dir/XPCServices/Downloader.xpc" "downloader xpc" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target \
|
||||
"$version_dir/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" \
|
||||
"downloader executable" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target \
|
||||
"$version_dir/XPCServices/Installer.xpc" "installer xpc" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target \
|
||||
"$version_dir/XPCServices/Installer.xpc/Contents/MacOS/Installer" \
|
||||
"installer executable" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target "$version_dir" "framework version" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target "$sparkle" "framework root" "$sparkle" || return 1
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
content: ["./docs/index.html", "./docs/site.js"],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
tablet: "769px",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
|
||||
mono: ["SFMono-Regular", "SF Mono", "Menlo", "monospace"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
GROUP_SIZE="${CODEXBAR_TEST_GROUP_SIZE:-12}"
|
||||
SUITE_TIMEOUT="${CODEXBAR_TEST_SUITE_TIMEOUT:-180}"
|
||||
RETRY_NON_TIMEOUT_FAILURES="${CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES:-1}"
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
# Defense in depth: test processes also self-detect, but keep this explicit so runner changes cannot
|
||||
# expose the user's login Keychain. Deliberate isolated Keychain tests must opt in by setting the allow flag.
|
||||
if [[ "${CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS:-}" != "1" ]]; then
|
||||
export CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1
|
||||
fi
|
||||
|
||||
ARGS=(
|
||||
--group-size "${GROUP_SIZE}"
|
||||
--timeout "${SUITE_TIMEOUT}"
|
||||
)
|
||||
|
||||
case "${RETRY_NON_TIMEOUT_FAILURES}" in
|
||||
0) ARGS+=(--no-retry-non-timeout-failures) ;;
|
||||
1) ;;
|
||||
*)
|
||||
echo "CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES must be 0 or 1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n "${CODEXBAR_TEST_SHARD_INDEX:-}" || -n "${CODEXBAR_TEST_SHARD_COUNT:-}" ]]; then
|
||||
ARGS+=(
|
||||
--shard-index "${CODEXBAR_TEST_SHARD_INDEX:?CODEXBAR_TEST_SHARD_COUNT requires CODEXBAR_TEST_SHARD_INDEX}"
|
||||
--shard-count "${CODEXBAR_TEST_SHARD_COUNT:?CODEXBAR_TEST_SHARD_INDEX requires CODEXBAR_TEST_SHARD_COUNT}"
|
||||
)
|
||||
fi
|
||||
|
||||
exec python3 "${ROOT_DIR}/Scripts/ci_swift_test_by_suite.py" "${ARGS[@]}" "$@"
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
assert_gate() {
|
||||
local expected="$1"
|
||||
local name="$2"
|
||||
local paths_file="${tmp_dir}/${name}.paths"
|
||||
local output_file="${tmp_dir}/${name}.output"
|
||||
shift 2
|
||||
|
||||
printf '%s\n' "$@" > "$paths_file"
|
||||
GITHUB_OUTPUT="$output_file" "${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$paths_file" >/dev/null
|
||||
local actual
|
||||
actual="$(sed -n 's/^macos-tests=//p' "$output_file")"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
printf '%s: expected macos-tests=%s, got %s\n' "$name" "$expected" "${actual:-<empty>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local reason
|
||||
reason="$(sed -n 's/^macos-tests-reason=//p' "$output_file")"
|
||||
if [[ -z "$reason" ]]; then
|
||||
printf '%s: expected macos-tests-reason output\n' "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local path_count
|
||||
path_count="$(sed -n 's/^changed-path-count=//p' "$output_file")"
|
||||
if ! [[ "$path_count" =~ ^[0-9]+$ ]]; then
|
||||
printf '%s: expected numeric changed-path-count output, got %s\n' \
|
||||
"$name" "${path_count:-<empty>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$expected" == false && "$reason" != "docs/site-only changes covered by portable checks" ]]; then
|
||||
printf '%s: expected docs/site skip reason, got %s\n' "$name" "$reason" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_gate false docs-only $'M\tdocs/providers.md' $'M\tREADME.md'
|
||||
assert_gate true configuration-doc $'M\tdocs/configuration.md'
|
||||
assert_gate true rename-to-configuration-doc $'R100\tdocs/old.md\tdocs/configuration.md'
|
||||
assert_gate true rename-from-configuration-doc $'R100\tdocs/configuration.md\tdocs/new.md'
|
||||
assert_gate true agents-contract $'M\tAGENTS.md'
|
||||
assert_gate true rename-to-agents-contract $'R100\tdocs/old.md\tAGENTS.md'
|
||||
assert_gate true rename-from-agents-contract $'R100\tAGENTS.md\tdocs/new.md'
|
||||
assert_gate true source $'M\tSources/CodexBar/App.swift'
|
||||
assert_gate false docs-site $'M\tdocs/index.html' $'M\tdocs/site.css' $'M\tdocs/site.js' \
|
||||
$'M\tdocs/site-locales.mjs' $'M\tdocs/social.html' $'M\tdocs/social.png' \
|
||||
$'M\tdocs/CNAME' $'M\tdocs/.nojekyll' $'M\tdocs/llms.txt'
|
||||
assert_gate false docs-site-assets $'M\tdocs/icon.png' $'M\tdocs/logos/provider-logo.svg'
|
||||
assert_gate true docs-unknown-code $'M\tdocs/custom-tool.js'
|
||||
assert_gate true docs-site-with-config $'M\tdocs/site.css' $'M\tdocs/configuration.md'
|
||||
assert_gate true empty
|
||||
assert_gate true source-to-docs $'R100\tSources/CodexBar/App.swift\tdocs/App.md'
|
||||
assert_gate true docs-to-source $'R100\tdocs/App.md\tSources/CodexBar/App.swift'
|
||||
assert_gate false docs-to-site $'R100\tdocs/old.md\tdocs/site.css'
|
||||
|
||||
assert_gate_fails() {
|
||||
local name="$1"
|
||||
local paths_file="${tmp_dir}/${name}.paths"
|
||||
local output_file="${tmp_dir}/${name}.output"
|
||||
shift
|
||||
|
||||
printf '%s\n' "$@" > "$paths_file"
|
||||
if GITHUB_OUTPUT="$output_file" "${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$paths_file" >/dev/null 2>&1; then
|
||||
printf '%s: malformed gate input unexpectedly succeeded\n' "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -s "$output_file" ]]; then
|
||||
printf '%s: malformed gate input emitted an output\n' "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_gate_fails missing-rename-target $'R100\tREADME.md'
|
||||
assert_gate_fails extra-modified-path $'M\tREADME.md\tdocs/configuration.md'
|
||||
assert_gate_fails missing-rename-score $'R\tREADME.md\tdocs/README.md'
|
||||
assert_gate_fails invalid-rename-score $'Rfoo\tREADME.md\tdocs/README.md'
|
||||
assert_gate_fails out-of-range-rename-score $'R101\tREADME.md\tdocs/README.md'
|
||||
|
||||
unterminated_paths="${tmp_dir}/unterminated.paths"
|
||||
unterminated_output="${tmp_dir}/unterminated.output"
|
||||
printf '%s' $'M\tREADME.md\tdocs/configuration.md' > "$unterminated_paths"
|
||||
if GITHUB_OUTPUT="$unterminated_output" \
|
||||
"${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$unterminated_paths" >/dev/null 2>&1
|
||||
then
|
||||
printf 'unterminated malformed gate input unexpectedly succeeded\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -s "$unterminated_output" ]]; then
|
||||
printf 'unterminated malformed gate input emitted an output\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
verify="${ROOT_DIR}/Scripts/ci_verify_test_jobs.sh"
|
||||
"$verify" success success true success >/dev/null
|
||||
"$verify" success success false skipped >/dev/null
|
||||
|
||||
assert_verify_fails() {
|
||||
if "$verify" "$@" >/dev/null 2>&1; then
|
||||
printf 'unexpected aggregate success: %s\n' "$*" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_verify_fails success success true skipped
|
||||
assert_verify_fails success success false success
|
||||
assert_verify_fails success success "" skipped
|
||||
assert_verify_fails failure success true success
|
||||
assert_verify_fails success failure true success
|
||||
|
||||
printf 'CI macOS path gate tests passed.\n'
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PREV_TAG=${1:?"pass previous release tag (e.g. v0.1.0)"}
|
||||
CUR_TAG=${2:?"pass current release tag (e.g. v0.1.1)"}
|
||||
|
||||
PREV_VER=${PREV_TAG#v}
|
||||
CUR_VER=${CUR_TAG#v}
|
||||
APP_NAME="CodexBar"
|
||||
|
||||
ZIP_URL="https://github.com/steipete/CodexBar/releases/download/${PREV_TAG}/${APP_NAME}-macos-universal-${PREV_VER}.zip"
|
||||
TMP_DIR=$(mktemp -d /tmp/codexbar-live.XXXX)
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
echo "Downloading previous release $PREV_TAG from $ZIP_URL"
|
||||
curl --fail --location --output "$TMP_DIR/prev.zip" "$ZIP_URL"
|
||||
|
||||
echo "Installing previous release to /Applications/${APP_NAME}.app"
|
||||
osascript -e 'tell application "CodexBar" to quit' >/dev/null 2>&1 || true
|
||||
for _ in {1..20}; do
|
||||
pgrep -x "$APP_NAME" >/dev/null || break
|
||||
sleep 0.25
|
||||
done
|
||||
if pgrep -x "$APP_NAME" >/dev/null; then
|
||||
echo "ERROR: ${APP_NAME} did not quit before replacement." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /Applications/${APP_NAME}.app
|
||||
ditto -x -k "$TMP_DIR/prev.zip" "$TMP_DIR"
|
||||
ditto "$TMP_DIR/${APP_NAME}.app" /Applications/${APP_NAME}.app
|
||||
|
||||
echo "Launching previous build…"
|
||||
open -n /Applications/${APP_NAME}.app
|
||||
sleep 4
|
||||
|
||||
cat <<'MSG'
|
||||
Manual step: trigger "Check for Updates…" in the app and install the update.
|
||||
Expect to land on the newly released version. When done, confirm below.
|
||||
MSG
|
||||
|
||||
read -rp "Did the update succeed from ${PREV_TAG} to ${CUR_TAG}? (y/N) " answer
|
||||
if [[ ! "$answer" =~ ^[Yy]$ ]]; then
|
||||
echo "Live update test NOT confirmed; failing per RUN_SPARKLE_UPDATE_TEST." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
installed_ver=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
|
||||
"/Applications/${APP_NAME}.app/Contents/Info.plist")
|
||||
if [[ "$installed_ver" != "$CUR_VER" ]]; then
|
||||
echo "Live update reported success but installed ${installed_ver}; expected ${CUR_VER}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Live update test confirmed."
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
source "$ROOT/Scripts/package_product_paths.sh"
|
||||
|
||||
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-paths.XXXXXX")
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
NATIVE_DIR="$TEMP_DIR/.build/arm64-apple-macosx/release"
|
||||
SWIFTBUILD_DIR="$TEMP_DIR/.build/out/Products/Release"
|
||||
STAGE_ROOT="$TEMP_DIR/.build/package-products/release"
|
||||
mkdir -p "$NATIVE_DIR/CodexBar.dSYM" "$SWIFTBUILD_DIR/Sparkle.framework" "$SWIFTBUILD_DIR/CodexBar.dSYM"
|
||||
touch "$NATIVE_DIR/CodexBar" "$SWIFTBUILD_DIR/CodexBar"
|
||||
|
||||
native=$(codexbar_require_product_file "$NATIVE_DIR" CodexBar arm64)
|
||||
[[ "$native" == "$NATIVE_DIR/CodexBar" ]]
|
||||
|
||||
swiftbuild=$(codexbar_require_product_file "$SWIFTBUILD_DIR" CodexBar arm64)
|
||||
[[ "$swiftbuild" == "$SWIFTBUILD_DIR/CodexBar" ]]
|
||||
|
||||
framework=$(codexbar_require_product_directory "$SWIFTBUILD_DIR" Sparkle.framework packaging)
|
||||
[[ "$framework" == "$SWIFTBUILD_DIR/Sparkle.framework" ]]
|
||||
|
||||
dsym=$(codexbar_require_product_directory "$SWIFTBUILD_DIR" CodexBar.dSYM release)
|
||||
[[ "$dsym" == "$SWIFTBUILD_DIR/CodexBar.dSYM" ]]
|
||||
|
||||
resolved=$(codexbar_resolve_staged_or_reported_file "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64)
|
||||
[[ "$resolved" == "$SWIFTBUILD_DIR/CodexBar" ]]
|
||||
|
||||
resolved_dsym=$(codexbar_resolve_dsym_path "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64)
|
||||
[[ "$resolved_dsym" == "$SWIFTBUILD_DIR/CodexBar.dSYM" ]]
|
||||
|
||||
mkdir -p "$STAGE_ROOT/arm64/CodexBar.dSYM"
|
||||
touch "$STAGE_ROOT/arm64/CodexBar"
|
||||
staged=$(codexbar_resolve_staged_or_reported_file "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64)
|
||||
[[ "$staged" == "$STAGE_ROOT/arm64/CodexBar" ]]
|
||||
staged_dsym=$(codexbar_resolve_dsym_path "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64)
|
||||
[[ "$staged_dsym" == "$STAGE_ROOT/arm64/CodexBar.dSYM" ]]
|
||||
|
||||
rm -rf "$STAGE_ROOT"
|
||||
rm "$SWIFTBUILD_DIR/CodexBar"
|
||||
if codexbar_resolve_staged_or_reported_file "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64 \
|
||||
2>"$TEMP_DIR/missing-file.log"; then
|
||||
echo "ERROR: Missing reported product unexpectedly fell back to legacy output." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "$SWIFTBUILD_DIR/CodexBar" "$TEMP_DIR/missing-file.log"
|
||||
|
||||
rm -rf "$SWIFTBUILD_DIR/Sparkle.framework"
|
||||
if codexbar_require_product_directory "$SWIFTBUILD_DIR" Sparkle.framework packaging \
|
||||
2>"$TEMP_DIR/missing-directory.log"; then
|
||||
echo "ERROR: Missing reported framework was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "$SWIFTBUILD_DIR/Sparkle.framework" "$TEMP_DIR/missing-directory.log"
|
||||
|
||||
rm -rf "$SWIFTBUILD_DIR/CodexBar.dSYM"
|
||||
if codexbar_resolve_dsym_path "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64 \
|
||||
2>"$TEMP_DIR/missing-dsym.log"; then
|
||||
echo "ERROR: Missing reported dSYM unexpectedly fell back to legacy output." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "$SWIFTBUILD_DIR/CodexBar.dSYM" "$TEMP_DIR/missing-dsym.log"
|
||||
|
||||
swift() {
|
||||
[[ "$*" == "build --show-bin-path -c release --arch arm64" ]]
|
||||
printf '%s\n' "$SWIFTBUILD_DIR"
|
||||
}
|
||||
reported=$(codexbar_swiftpm_bin_path release arm64)
|
||||
[[ "$reported" == "$SWIFTBUILD_DIR" ]]
|
||||
|
||||
swift() {
|
||||
return 23
|
||||
}
|
||||
if codexbar_swiftpm_bin_path release arm64 2>"$TEMP_DIR/query.log"; then
|
||||
echo "ERROR: SwiftPM bin-path query failure was ignored." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "SwiftPM failed to report" "$TEMP_DIR/query.log"
|
||||
|
||||
swift() {
|
||||
return 0
|
||||
}
|
||||
if codexbar_swiftpm_bin_path release arm64 2>"$TEMP_DIR/empty.log"; then
|
||||
echo "ERROR: Empty SwiftPM bin path was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "SwiftPM reported an empty" "$TEMP_DIR/empty.log"
|
||||
|
||||
echo "Package product path tests passed."
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
PACKAGE_SCRIPT="$ROOT/Scripts/package_app.sh"
|
||||
RELEASE_SCRIPT="$ROOT/Scripts/sign-and-notarize.sh"
|
||||
FUNCTIONS_FILE=$(mktemp "${TMPDIR:-/tmp}/codexbar-package-signing-functions.XXXXXX")
|
||||
trap 'rm -f "$FUNCTIONS_FILE"' EXIT
|
||||
|
||||
python3 - "$PACKAGE_SCRIPT" "$FUNCTIONS_FILE" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
script = Path(sys.argv[1]).read_text()
|
||||
start = script.index('resolve_package_signing_mode() {')
|
||||
end = script.index('\n}\n', start) + 3
|
||||
Path(sys.argv[2]).write_text(script[start:end])
|
||||
PY
|
||||
|
||||
source "$FUNCTIONS_FILE"
|
||||
|
||||
unset CODEXBAR_SIGNING
|
||||
SIGNING_MODE=
|
||||
resolve_package_signing_mode
|
||||
[[ "$SIGNING_MODE" == "adhoc" ]]
|
||||
|
||||
CODEXBAR_SIGNING=identity
|
||||
resolve_package_signing_mode
|
||||
[[ "$SIGNING_MODE" == "identity" ]]
|
||||
|
||||
CODEXBAR_SIGNING=invalid
|
||||
if resolve_package_signing_mode 2>/dev/null; then
|
||||
echo "Invalid package signing mode unexpectedly succeeded" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -Fq 'CODEXBAR_SIGNING=identity ./Scripts/package_app.sh release' "$RELEASE_SCRIPT"
|
||||
|
||||
echo "Package signing tests passed."
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
PACKAGE_SCRIPT="$ROOT/Scripts/package_app.sh"
|
||||
FUNCTIONS_FILE=$(mktemp "${TMPDIR:-/tmp}/codexbar-package-strip-functions.XXXXXX")
|
||||
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-strip.XXXXXX")
|
||||
trap 'rm -rf "$FUNCTIONS_FILE" "$TEMP_DIR"' EXIT
|
||||
|
||||
python3 - "$PACKAGE_SCRIPT" "$FUNCTIONS_FILE" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
script = Path(sys.argv[1]).read_text()
|
||||
start = script.index('strip_release_binary() {')
|
||||
end = script.index('\n}\n', start) + 3
|
||||
Path(sys.argv[2]).write_text(script[start:end])
|
||||
PY
|
||||
|
||||
xcrun() {
|
||||
[[ "$1" == "strip" && "$2" == "-x" ]]
|
||||
printf '%s\n' "$3" >> "$STRIP_LOG"
|
||||
}
|
||||
|
||||
source "$FUNCTIONS_FILE"
|
||||
|
||||
binary="$TEMP_DIR/CodexBar"
|
||||
touch "$binary"
|
||||
|
||||
STRIP_LOG="$TEMP_DIR/release.log"
|
||||
LOWER_CONF=release
|
||||
strip_release_binary "$binary"
|
||||
grep -Fqx "$binary" "$STRIP_LOG"
|
||||
|
||||
STRIP_LOG="$TEMP_DIR/debug.log"
|
||||
LOWER_CONF=debug
|
||||
strip_release_binary "$binary"
|
||||
[[ ! -e "$STRIP_LOG" ]]
|
||||
|
||||
STRIP_LOG="$TEMP_DIR/missing.log"
|
||||
LOWER_CONF=release
|
||||
strip_release_binary "$TEMP_DIR/MissingBinary"
|
||||
[[ ! -e "$STRIP_LOG" ]]
|
||||
|
||||
echo "Package strip tests passed."
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
source "$ROOT/Scripts/release_dsym_paths.sh"
|
||||
|
||||
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-release-dsym-paths.XXXXXX")
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
make_dsym() {
|
||||
local dsym_path="$1"
|
||||
mkdir -p "$dsym_path/Contents/Resources/DWARF"
|
||||
touch "$dsym_path/Contents/Resources/DWARF/CodexBar"
|
||||
}
|
||||
|
||||
ARM_DSYM="$TEMP_DIR/CodexBar arm64.dSYM"
|
||||
UNIVERSAL_DSYM="$TEMP_DIR/CodexBar universal.dSYM"
|
||||
WRONG_ARCH_DSYM="$TEMP_DIR/CodexBar stale.dSYM"
|
||||
MISSING_DWARF_DSYM="$TEMP_DIR/CodexBar missing.dSYM"
|
||||
APP_BINARY="$TEMP_DIR/CodexBar.app"
|
||||
MATCHING_DWARF="$TEMP_DIR/CodexBar matching"
|
||||
MISMATCHED_DWARF="$TEMP_DIR/CodexBar mismatched"
|
||||
MISSING_UUID_DWARF="$TEMP_DIR/CodexBar missing UUID"
|
||||
make_dsym "$ARM_DSYM"
|
||||
make_dsym "$UNIVERSAL_DSYM"
|
||||
make_dsym "$WRONG_ARCH_DSYM"
|
||||
mkdir -p "$MISSING_DWARF_DSYM/Contents/Resources/DWARF"
|
||||
touch "$APP_BINARY" "$MATCHING_DWARF" "$MISMATCHED_DWARF" "$MISSING_UUID_DWARF"
|
||||
|
||||
lipo() {
|
||||
[[ "$1" == "-archs" ]]
|
||||
case "$2" in
|
||||
"$ARM_DSYM/Contents/Resources/DWARF/CodexBar")
|
||||
printf '%s\n' "arm64"
|
||||
;;
|
||||
"$UNIVERSAL_DSYM/Contents/Resources/DWARF/CodexBar")
|
||||
printf '%s\n' "arm64 x86_64"
|
||||
;;
|
||||
"$WRONG_ARCH_DSYM/Contents/Resources/DWARF/CodexBar")
|
||||
printf '%s\n' "x86_64"
|
||||
;;
|
||||
*)
|
||||
echo "unexpected lipo path: $2" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
dwarfdump() {
|
||||
[[ "$1" == "--uuid" ]]
|
||||
case "$2" in
|
||||
"$APP_BINARY" | "$MATCHING_DWARF")
|
||||
printf '%s\n' \
|
||||
"UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (arm64) $2" \
|
||||
"UUID: BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB (x86_64) $2"
|
||||
;;
|
||||
"$MISMATCHED_DWARF")
|
||||
printf '%s\n' \
|
||||
"UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (arm64) $2" \
|
||||
"UUID: CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC (x86_64) $2"
|
||||
;;
|
||||
"$MISSING_UUID_DWARF")
|
||||
printf '%s\n' "UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (arm64) $2"
|
||||
;;
|
||||
*)
|
||||
echo "unexpected dwarfdump path: $2" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
arm_dwarf=$(codexbar_require_dsym_dwarf_for_arch "$ARM_DSYM" CodexBar arm64)
|
||||
[[ "$arm_dwarf" == "$ARM_DSYM/Contents/Resources/DWARF/CodexBar" ]]
|
||||
|
||||
x86_dwarf=$(codexbar_require_dsym_dwarf_for_arch "$UNIVERSAL_DSYM" CodexBar x86_64)
|
||||
[[ "$x86_dwarf" == "$UNIVERSAL_DSYM/Contents/Resources/DWARF/CodexBar" ]]
|
||||
|
||||
if codexbar_require_dsym_dwarf_for_arch "$MISSING_DWARF_DSYM" CodexBar arm64 \
|
||||
2>"$TEMP_DIR/missing-dwarf.log"; then
|
||||
echo "ERROR: Missing dSYM DWARF file was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "$MISSING_DWARF_DSYM/Contents/Resources/DWARF/CodexBar" "$TEMP_DIR/missing-dwarf.log"
|
||||
|
||||
if codexbar_require_dsym_dwarf_for_arch "$WRONG_ARCH_DSYM" CodexBar arm64 \
|
||||
2>"$TEMP_DIR/wrong-arch.log"; then
|
||||
echo "ERROR: Wrong-architecture dSYM was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "required architecture: arm64" "$TEMP_DIR/wrong-arch.log"
|
||||
|
||||
codexbar_verify_dsym_matches_binary "$APP_BINARY" "$MATCHING_DWARF" arm64 x86_64
|
||||
|
||||
if codexbar_verify_dsym_matches_binary "$APP_BINARY" "$MISMATCHED_DWARF" arm64 x86_64 \
|
||||
2>"$TEMP_DIR/mismatched-uuid.log"; then
|
||||
echo "ERROR: Mismatched dSYM UUID was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "dSYM UUID mismatch for x86_64" "$TEMP_DIR/mismatched-uuid.log"
|
||||
|
||||
if codexbar_verify_dsym_matches_binary "$APP_BINARY" "$MISSING_UUID_DWARF" arm64 x86_64 \
|
||||
2>"$TEMP_DIR/missing-uuid.log"; then
|
||||
echo "ERROR: Missing dSYM UUID was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "Missing UUID for x86_64" "$TEMP_DIR/missing-uuid.log"
|
||||
|
||||
echo "Release dSYM path tests passed."
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-repository-size.XXXXXX")
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
mkdir -p "$TEMP_DIR/Scripts"
|
||||
cp "$ROOT_DIR/Scripts/check_repository_size.sh" "$TEMP_DIR/Scripts/"
|
||||
git -C "$TEMP_DIR" init --quiet
|
||||
empty_output=$("$TEMP_DIR/Scripts/check_repository_size.sh")
|
||||
grep -Fq 'repository size OK: 0 tracked files' <<<"$empty_output"
|
||||
|
||||
printf 'small source file\n' > "$TEMP_DIR/source.txt"
|
||||
git -C "$TEMP_DIR" add source.txt Scripts/check_repository_size.sh
|
||||
|
||||
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
|
||||
|
||||
dd if=/dev/zero of="$TEMP_DIR/untracked.bin" bs=1024 count=2049 2>/dev/null
|
||||
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
|
||||
|
||||
dd if=/dev/zero of="$TEMP_DIR/boundary.bin" bs=1024 count=2048 2>/dev/null
|
||||
git -C "$TEMP_DIR" add boundary.bin
|
||||
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
|
||||
printf 'x' >> "$TEMP_DIR/boundary.bin"
|
||||
git -C "$TEMP_DIR" add boundary.bin
|
||||
if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/large.log" 2>&1; then
|
||||
printf 'ERROR: staged blob one byte above the limit was accepted.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'tracked file exceeds 2097152 bytes: boundary.bin (2097153 bytes)' "$TEMP_DIR/large.log"
|
||||
|
||||
git -C "$TEMP_DIR" rm --cached --force --quiet boundary.bin
|
||||
git -C "$TEMP_DIR" add untracked.bin
|
||||
printf 'working tree is now small\n' > "$TEMP_DIR/untracked.bin"
|
||||
if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/staged-large.log" 2>&1; then
|
||||
printf 'ERROR: oversized staged blob was accepted after its working-tree file changed.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'tracked file exceeds 2097152 bytes: untracked.bin (2098176 bytes)' "$TEMP_DIR/staged-large.log"
|
||||
|
||||
git -C "$TEMP_DIR" rm --cached --force --quiet untracked.bin
|
||||
printf 'small staged blob\n' > "$TEMP_DIR/index-is-authoritative.bin"
|
||||
git -C "$TEMP_DIR" add index-is-authoritative.bin
|
||||
dd if=/dev/zero of="$TEMP_DIR/index-is-authoritative.bin" bs=1024 count=2049 2>/dev/null
|
||||
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
|
||||
|
||||
odd_path=$'odd\nname.txt'
|
||||
printf 'small source file\n' > "$TEMP_DIR/$odd_path"
|
||||
git -C "$TEMP_DIR" add "$odd_path"
|
||||
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
|
||||
|
||||
artifacts=(
|
||||
"CodexBar 2.app/Contents/MacOS/CodexBar"
|
||||
"CodexBar.dSYM/Contents/Info.plist"
|
||||
"CodexBar.xcarchive/Products/Applications/CodexBar.app/Contents/Info.plist"
|
||||
"CodexBar.xcresult/Data/data"
|
||||
"CodexBar.ipa"
|
||||
"CodexBar.zip"
|
||||
"CodexBar.delta"
|
||||
"CodexBar.dmg"
|
||||
"CodexBar.pkg"
|
||||
"CodexBar.tar.gz"
|
||||
"CodexBar.tgz"
|
||||
)
|
||||
for artifact in "${artifacts[@]}"; do
|
||||
mkdir -p "$TEMP_DIR/$(dirname "$artifact")"
|
||||
printf 'release artifact\n' > "$TEMP_DIR/$artifact"
|
||||
git -C "$TEMP_DIR" add -f "$artifact"
|
||||
done
|
||||
ln -s source.txt "$TEMP_DIR/CodexBar-latest.dmg"
|
||||
git -C "$TEMP_DIR" add -f CodexBar-latest.dmg
|
||||
rm "$TEMP_DIR/CodexBar.zip"
|
||||
if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/artifact.log" 2>&1; then
|
||||
printf 'ERROR: tracked release artifacts were accepted.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
for artifact in "${artifacts[@]}" CodexBar-latest.dmg; do
|
||||
grep -Fq "generated artifact is tracked: $artifact" "$TEMP_DIR/artifact.log"
|
||||
done
|
||||
|
||||
printf 'Repository size tests passed.\n'
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
source "$ROOT/Scripts/sparkle_signing_paths.sh"
|
||||
|
||||
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-sparkle-signing.XXXXXX")
|
||||
TEMP_DIR=$(cd "$TEMP_DIR" && pwd -P)
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
make_sparkle_version() {
|
||||
local sparkle="$1"
|
||||
local version="$2"
|
||||
local version_dir="$sparkle/Versions/$version"
|
||||
|
||||
mkdir -p \
|
||||
"$version_dir/Updater.app/Contents/MacOS" \
|
||||
"$version_dir/XPCServices/Downloader.xpc/Contents/MacOS" \
|
||||
"$version_dir/XPCServices/Installer.xpc/Contents/MacOS"
|
||||
touch \
|
||||
"$version_dir/Sparkle" \
|
||||
"$version_dir/Autoupdate" \
|
||||
"$version_dir/Updater.app/Contents/MacOS/Updater" \
|
||||
"$version_dir/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" \
|
||||
"$version_dir/XPCServices/Installer.xpc/Contents/MacOS/Installer"
|
||||
}
|
||||
|
||||
SINGLE="$TEMP_DIR/Single Sparkle.framework"
|
||||
make_sparkle_version "$SINGLE" B
|
||||
single_version=$(codexbar_sparkle_version_dir "$SINGLE")
|
||||
[[ "$single_version" == "$SINGLE/Versions/B" ]]
|
||||
|
||||
single_targets=$(codexbar_sparkle_signing_targets "$SINGLE")
|
||||
grep -Fqx "$SINGLE" <<<"$single_targets"
|
||||
grep -Fqx "$SINGLE/Versions/B/Sparkle" <<<"$single_targets"
|
||||
grep -Fqx "$SINGLE/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer" <<<"$single_targets"
|
||||
|
||||
CURRENT="$TEMP_DIR/Current Sparkle.framework"
|
||||
make_sparkle_version "$CURRENT" A
|
||||
make_sparkle_version "$CURRENT" C
|
||||
ln -s C "$CURRENT/Versions/Current"
|
||||
current_version=$(codexbar_sparkle_version_dir "$CURRENT")
|
||||
[[ "$current_version" == "$CURRENT/Versions/C" ]]
|
||||
|
||||
rm "$CURRENT/Versions/C/Autoupdate"
|
||||
if codexbar_sparkle_signing_targets "$CURRENT" >"$TEMP_DIR/missing-target.out" 2>"$TEMP_DIR/missing-target.log"; then
|
||||
echo "ERROR: Missing Sparkle signing target was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "Autoupdate" "$TEMP_DIR/missing-target.log"
|
||||
|
||||
AMBIGUOUS="$TEMP_DIR/Ambiguous Sparkle.framework"
|
||||
make_sparkle_version "$AMBIGUOUS" A
|
||||
make_sparkle_version "$AMBIGUOUS" B
|
||||
if codexbar_sparkle_version_dir "$AMBIGUOUS" 2>"$TEMP_DIR/ambiguous.log"; then
|
||||
echo "ERROR: Ambiguous Sparkle versions were accepted without Versions/Current." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "multiple version directories" "$TEMP_DIR/ambiguous.log"
|
||||
|
||||
BROKEN_CURRENT="$TEMP_DIR/Broken Current Sparkle.framework"
|
||||
make_sparkle_version "$BROKEN_CURRENT" B
|
||||
ln -s Missing "$BROKEN_CURRENT/Versions/Current"
|
||||
if codexbar_sparkle_version_dir "$BROKEN_CURRENT" 2>"$TEMP_DIR/broken-current.log"; then
|
||||
echo "ERROR: Broken Sparkle Versions/Current was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "Versions/Current does not resolve" "$TEMP_DIR/broken-current.log"
|
||||
|
||||
ESCAPING_CURRENT="$TEMP_DIR/Escaping Current Sparkle.framework"
|
||||
OUTSIDE_SPARKLE="$TEMP_DIR/Outside Sparkle.framework"
|
||||
make_sparkle_version "$ESCAPING_CURRENT" B
|
||||
make_sparkle_version "$OUTSIDE_SPARKLE" C
|
||||
ln -s "$OUTSIDE_SPARKLE/Versions/C" "$ESCAPING_CURRENT/Versions/Current"
|
||||
if codexbar_sparkle_version_dir "$ESCAPING_CURRENT" 2>"$TEMP_DIR/escaping-current.log"; then
|
||||
echo "ERROR: Escaping Sparkle Versions/Current was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "outside the framework versions directory" "$TEMP_DIR/escaping-current.log"
|
||||
|
||||
SYMLINKED_VERSIONS="$TEMP_DIR/Symlinked Versions Sparkle.framework"
|
||||
mkdir -p "$SYMLINKED_VERSIONS"
|
||||
ln -s "$OUTSIDE_SPARKLE/Versions" "$SYMLINKED_VERSIONS/Versions"
|
||||
if codexbar_sparkle_version_dir "$SYMLINKED_VERSIONS" 2>"$TEMP_DIR/symlinked-versions.log"; then
|
||||
echo "ERROR: Symlinked Sparkle Versions directory was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "versions directory must not be a symlink" "$TEMP_DIR/symlinked-versions.log"
|
||||
|
||||
SYMLINKED_FRAMEWORK="$TEMP_DIR/Symlinked Sparkle.framework"
|
||||
ln -s "$OUTSIDE_SPARKLE" "$SYMLINKED_FRAMEWORK"
|
||||
if codexbar_sparkle_version_dir "$SYMLINKED_FRAMEWORK" 2>"$TEMP_DIR/symlinked-framework.log"; then
|
||||
echo "ERROR: Symlinked Sparkle framework root was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "framework root must not be a symlink" "$TEMP_DIR/symlinked-framework.log"
|
||||
|
||||
SYMLINKED_TARGET="$TEMP_DIR/Symlinked Target Sparkle.framework"
|
||||
make_sparkle_version "$SYMLINKED_TARGET" B
|
||||
rm "$SYMLINKED_TARGET/Versions/B/Autoupdate"
|
||||
ln -s "$OUTSIDE_SPARKLE/Versions/C/Autoupdate" "$SYMLINKED_TARGET/Versions/B/Autoupdate"
|
||||
if codexbar_sparkle_signing_targets \
|
||||
"$SYMLINKED_TARGET" >"$TEMP_DIR/symlinked-target.out" 2>"$TEMP_DIR/symlinked-target.log"; then
|
||||
echo "ERROR: Symlinked Sparkle signing target was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "signing target must not be a symlink" "$TEMP_DIR/symlinked-target.log"
|
||||
|
||||
ESCAPING_TARGET_PARENT="$TEMP_DIR/Escaping Target Parent Sparkle.framework"
|
||||
make_sparkle_version "$ESCAPING_TARGET_PARENT" B
|
||||
mv "$ESCAPING_TARGET_PARENT/Versions/B/XPCServices" "$TEMP_DIR/displaced-xpc-services"
|
||||
ln -s "$OUTSIDE_SPARKLE/Versions/C/XPCServices" "$ESCAPING_TARGET_PARENT/Versions/B/XPCServices"
|
||||
if codexbar_sparkle_signing_targets \
|
||||
"$ESCAPING_TARGET_PARENT" >"$TEMP_DIR/escaping-target-parent.out" 2>"$TEMP_DIR/escaping-target-parent.log"; then
|
||||
echo "ERROR: Sparkle signing target with an escaping parent was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "signing target resolves outside its trusted root" "$TEMP_DIR/escaping-target-parent.log"
|
||||
|
||||
ESCAPING_SINGLE="$TEMP_DIR/Escaping Single Sparkle.framework"
|
||||
mkdir -p "$ESCAPING_SINGLE/Versions"
|
||||
ln -s "$OUTSIDE_SPARKLE/Versions/C" "$ESCAPING_SINGLE/Versions/B"
|
||||
if codexbar_sparkle_version_dir "$ESCAPING_SINGLE" 2>"$TEMP_DIR/escaping-single.log"; then
|
||||
echo "ERROR: Escaping single Sparkle version directory was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "outside the framework versions directory" "$TEMP_DIR/escaping-single.log"
|
||||
|
||||
echo "Sparkle signing path tests passed."
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codexbar-test-sharding.XXXXXX")"
|
||||
trap 'rm -rf "${TEMP_DIR}"' EXIT
|
||||
|
||||
IFS= read -r -d '' FAKE_SWIFT_SCRIPT <<'EOF' || true
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >> "${FAKE_SWIFT_LOG}"
|
||||
if [[ "$*" == "test list" ]]; then
|
||||
if [[ "${FAKE_SWIFT_MODE:-success}" == "list_fail" ]]; then
|
||||
sleep 0.25
|
||||
printf 'test-list stdout marker\n'
|
||||
printf 'test-list stderr marker\n' >&2
|
||||
exit 42
|
||||
fi
|
||||
printf '%s\n' \
|
||||
"CodexBarTests.Alpha/test_one()" \
|
||||
"CodexBarTests.Alpha/test_two(argument:)" \
|
||||
"CodexBarTests.Beta/test_two" \
|
||||
"CodexBarTests.Gamma/test_three" \
|
||||
"CodexBarTests.Delta/test_four" \
|
||||
"CodexBarTests.Epsilon/test_five" \
|
||||
"CodexBarTests.Zeta/test_six" \
|
||||
"CodexBarTests.Eta/test_seven" \
|
||||
"CodexBarTests.Theta/test_eight" \
|
||||
'CodexBarTests.`top level works`()' \
|
||||
'CodexBarTests.`top/level slash works`()'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
is_group=0
|
||||
if [[ "$*" == *"|"* ]]; then
|
||||
is_group=1
|
||||
fi
|
||||
|
||||
next_group_attempt() {
|
||||
local attempt=0
|
||||
if [[ -f "${FAKE_SWIFT_STATE}" ]]; then
|
||||
read -r attempt < "${FAKE_SWIFT_STATE}"
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
printf '%s\n' "${attempt}" > "${FAKE_SWIFT_STATE}"
|
||||
printf '%s\n' "${attempt}"
|
||||
}
|
||||
|
||||
case "${FAKE_SWIFT_MODE:-success}" in
|
||||
group_fail_once)
|
||||
if [[ "${is_group}" == "1" && "$(next_group_attempt)" == "1" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
group_always_fail)
|
||||
if [[ "${is_group}" == "1" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
group_timeout)
|
||||
if [[ "${is_group}" == "1" ]]; then
|
||||
sleep 2
|
||||
fi
|
||||
;;
|
||||
singleton_timeout)
|
||||
if [[ "${is_group}" == "0" ]]; then
|
||||
sleep 2
|
||||
fi
|
||||
;;
|
||||
group_fail_then_timeout)
|
||||
if [[ "${is_group}" == "1" ]]; then
|
||||
attempt="$(next_group_attempt)"
|
||||
if [[ "${attempt}" == "1" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
reset_case() {
|
||||
local name="$1"
|
||||
export FAKE_SWIFT_LOG="${TEMP_DIR}/${name}-swift.log"
|
||||
export FAKE_SWIFT_STATE="${TEMP_DIR}/${name}-state"
|
||||
export GITHUB_STEP_SUMMARY="${TEMP_DIR}/${name}-summary.md"
|
||||
rm -f "${FAKE_SWIFT_LOG}" "${FAKE_SWIFT_STATE}" "${GITHUB_STEP_SUMMARY}"
|
||||
}
|
||||
|
||||
run_harness() {
|
||||
python3 "${ROOT_DIR}/Scripts/ci_swift_test_by_suite.py" \
|
||||
"$@" \
|
||||
--swift-command /bin/bash \
|
||||
--swift-command-arg=-c \
|
||||
--swift-command-arg="${FAKE_SWIFT_SCRIPT}" \
|
||||
--swift-command-arg=fake-swift
|
||||
}
|
||||
|
||||
python3 - "${ROOT_DIR}/.github/workflows/ci.yml" <<'PY'
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
workflow = pathlib.Path(sys.argv[1]).read_text()
|
||||
job_match = re.search(r"(?ms)^ swift-test-macos:\n(?P<body>.*?)(?=^ [a-zA-Z0-9_-]+:|\Z)", workflow)
|
||||
if not job_match:
|
||||
raise SystemExit("swift-test-macos job not found in CI workflow")
|
||||
|
||||
job = job_match.group("body")
|
||||
if not re.search(r"(?m)^\s+shard-index:\s+\[0,\s*1\]\s*$", job):
|
||||
raise SystemExit("swift-test-macos must run exactly two shard indexes: [0, 1]")
|
||||
if not re.search(r"(?m)^\s+shard-count:\s+\[2\]\s*$", job):
|
||||
raise SystemExit("swift-test-macos shard-count must be [2]")
|
||||
if "CODEXBAR_TEST_SHARD_INDEX=${{ matrix.shard-index }}" not in job:
|
||||
raise SystemExit("swift-test-macos must pass matrix.shard-index to Scripts/test.sh")
|
||||
if "CODEXBAR_TEST_SHARD_COUNT=${{ matrix.shard-count }}" not in job:
|
||||
raise SystemExit("swift-test-macos must pass matrix.shard-count to Scripts/test.sh")
|
||||
PY
|
||||
|
||||
reset_case retry
|
||||
export FAKE_SWIFT_MODE=group_fail_once
|
||||
run_harness --group-size 4 --timeout 10 > "${TEMP_DIR}/retry.log"
|
||||
grep -Fq "failed with exit code 1; retrying group once" "${TEMP_DIR}/retry.log"
|
||||
grep -Fq "Swift test timing summary:" "${TEMP_DIR}/retry.log"
|
||||
grep -Fq '| Discovered selections | `10` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Selected selections | `10` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Selected groups | `3` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| First-pass successful groups | `2` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| First-pass failed groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Recovered groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
[[ "$(grep -c '^test --skip-build --no-parallel' "${FAKE_SWIFT_LOG}")" -eq 4 ]]
|
||||
grep -Fq "CodexBarTests\\.Alpha" "${FAKE_SWIFT_LOG}"
|
||||
grep -Fq "CodexBarTests\\.Beta" "${FAKE_SWIFT_LOG}"
|
||||
grep -Fq "CodexBarTests\\..*top\\ level\\ works" "${FAKE_SWIFT_LOG}"
|
||||
grep -Fq "CodexBarTests\\..*top/level\\ slash\\ works" "${FAKE_SWIFT_LOG}"
|
||||
[[ "$(wc -l < "${FAKE_SWIFT_LOG}")" -eq 5 ]]
|
||||
|
||||
reset_case strict
|
||||
export FAKE_SWIFT_MODE=group_fail_once
|
||||
set +e
|
||||
CODEXBAR_TEST_GROUP_SIZE=4 \
|
||||
CODEXBAR_TEST_SUITE_TIMEOUT=10 \
|
||||
CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES=0 \
|
||||
"${ROOT_DIR}/Scripts/test.sh" \
|
||||
--limit-groups 1 \
|
||||
--swift-command /bin/bash \
|
||||
--swift-command-arg=-c \
|
||||
--swift-command-arg="${FAKE_SWIFT_SCRIPT}" \
|
||||
--swift-command-arg=fake-swift \
|
||||
> "${TEMP_DIR}/strict.log" 2>&1
|
||||
strict_status=$?
|
||||
set -e
|
||||
[[ "${strict_status}" -eq 1 ]]
|
||||
grep -Fq '| First-pass failed groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Full-group retries | `0` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Isolated selection retries | `0` |' "${GITHUB_STEP_SUMMARY}"
|
||||
[[ "$(wc -l < "${FAKE_SWIFT_LOG}")" -eq 2 ]]
|
||||
|
||||
reset_case shard-0
|
||||
export FAKE_SWIFT_MODE=success
|
||||
run_harness --group-size 4 --timeout 10 --shard-index 0 --shard-count 2 > "${TEMP_DIR}/shard-0.log"
|
||||
grep -Fq '| Shard | `1/2` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Selected selections | `6` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Selected groups | `2` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reset_case shard-1
|
||||
run_harness --group-size 4 --timeout 10 --shard-index 1 --shard-count 2 > "${TEMP_DIR}/shard-1.log"
|
||||
grep -Fq '| Shard | `2/2` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Selected selections | `4` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Selected groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reset_case shard-list-0
|
||||
run_harness --group-size 4 --timeout 10 --shard-index 0 --shard-count 2 --list-only \
|
||||
> "${TEMP_DIR}/shard-list-0.log"
|
||||
reset_case shard-list-1
|
||||
run_harness --group-size 4 --timeout 10 --shard-index 1 --shard-count 2 --list-only \
|
||||
> "${TEMP_DIR}/shard-list-1.log"
|
||||
cat "${TEMP_DIR}/shard-list-0.log" "${TEMP_DIR}/shard-list-1.log" \
|
||||
| grep -v '^Discovered ' \
|
||||
| sort > "${TEMP_DIR}/shards-combined.log"
|
||||
reset_case shard-list-all
|
||||
run_harness --group-size 4 --timeout 10 --list-only \
|
||||
| grep -v '^Discovered ' \
|
||||
| sort > "${TEMP_DIR}/shards-expected.log"
|
||||
diff -u "${TEMP_DIR}/shards-expected.log" "${TEMP_DIR}/shards-combined.log"
|
||||
|
||||
reset_case group-timeout
|
||||
export FAKE_SWIFT_MODE=group_timeout
|
||||
run_harness --group-size 4 --limit-groups 1 --timeout 1 > "${TEMP_DIR}/group-timeout.log"
|
||||
grep -Fq "timed out; retrying selections one at a time" "${TEMP_DIR}/group-timeout.log"
|
||||
grep -Fq '| Timed out groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Recovered groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Isolated selection retries | `4` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reset_case singleton-timeout
|
||||
export FAKE_SWIFT_MODE=singleton_timeout
|
||||
set +e
|
||||
run_harness --group-size 1 --limit-groups 1 --timeout 1 > "${TEMP_DIR}/singleton-timeout.log" 2>&1
|
||||
singleton_timeout_status=$?
|
||||
set -e
|
||||
[[ "${singleton_timeout_status}" -eq 124 ]]
|
||||
grep -Fq '| Timed out groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Recovered groups | `0` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Isolated selection retries | `0` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reset_case retry-timeout
|
||||
export FAKE_SWIFT_MODE=group_fail_then_timeout
|
||||
run_harness --group-size 4 --limit-groups 1 --timeout 1 > "${TEMP_DIR}/retry-timeout.log"
|
||||
grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Timed out groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Recovered groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Isolated selection retries | `4` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reset_case repeated-failure
|
||||
export FAKE_SWIFT_MODE=group_always_fail
|
||||
set +e
|
||||
run_harness --group-size 4 --limit-groups 1 --timeout 10 > "${TEMP_DIR}/failure.log" 2>&1
|
||||
failure_status=$?
|
||||
set -e
|
||||
[[ "${failure_status}" -eq 1 ]]
|
||||
grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Recovered groups | `0` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reset_case list-failure
|
||||
export FAKE_SWIFT_MODE=list_fail
|
||||
set +e
|
||||
run_harness --group-size 1 --timeout 10 > "${TEMP_DIR}/list-failure.log" 2>&1
|
||||
list_failure_status=$?
|
||||
set -e
|
||||
[[ "${list_failure_status}" -ne 0 ]]
|
||||
grep -Fq "test-list stdout marker" "${TEMP_DIR}/list-failure.log"
|
||||
grep -Fq "test-list stderr marker" "${TEMP_DIR}/list-failure.log"
|
||||
grep -Eq -- '- Discovery seconds: 0\.[1-9]' "${TEMP_DIR}/list-failure.log"
|
||||
grep -Fq '| Discovered selections | `0` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
echo "Swift test sharding tests passed."
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
VERSION=${1:?"usage: $0 <version>"}
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
first_line=$(grep -m1 '^## ' CHANGELOG.md | sed 's/^## //')
|
||||
if [[ "$first_line" != ${VERSION}* ]]; then
|
||||
echo "ERROR: Top CHANGELOG section is '$first_line' but expected '${VERSION} — …'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -q "^## ${VERSION} " CHANGELOG.md || {
|
||||
echo "ERROR: No section for version ${VERSION} in CHANGELOG.md" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
grep -q '^## [0-9]\+\.[0-9]\+\.[0-9].*Unreleased' CHANGELOG.md && {
|
||||
echo "ERROR: Top section still labeled Unreleased; finalize changelog first." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Changelog OK for ${VERSION}"
|
||||
Executable
+262
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env bash
|
||||
# Isolated live verification for CodexBar #1844 / PR #1848.
|
||||
# Uses only synthetic credentials under a disposable HOME and keychain.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
log() { printf '[verify-1844] %s\n' "$*"; }
|
||||
|
||||
ARTIFACT="$(mktemp -d "${TMPDIR:-/tmp}/codexbar-1844-verify.XXXXXX")"
|
||||
chmod 700 "$ARTIFACT"
|
||||
HOME_FIXTURE="$ARTIFACT/home"
|
||||
KEYCHAIN="$ARTIFACT/claude-fixture.keychain-db"
|
||||
KEYCHAIN_PASSWORD="codexbar-1844-synthetic-fixture"
|
||||
CONFIG="$ARTIFACT/config.json"
|
||||
CLI="${CODEXBAR_CLI:-$ROOT/CodexBar.app/Contents/Helpers/CodexBarCLI}"
|
||||
APP="${CODEXBAR_APP_BINARY:-$ROOT/CodexBar.app/Contents/MacOS/CodexBar}"
|
||||
MCP_PAYLOAD='{"mcpOAuth":{"plugin:synthetic":{"accessToken":"synthetic-mcp-token"}}}'
|
||||
EXPIRED_PAYLOAD='{"claudeAiOauth":{"accessToken":"synthetic-expired-token","expiresAt":1000,"scopes":["user:profile"],"refreshToken":"synthetic-refresh-token"}}'
|
||||
|
||||
if [[ ! -x "$CLI" ]]; then
|
||||
log "Missing packaged CLI: $CLI"
|
||||
log "Run ./Scripts/package_app.sh, then retry."
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -x "$APP" ]]; then
|
||||
log "Missing packaged app binary: $APP"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
/usr/bin/security delete-keychain "$KEYCHAIN" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
log "Artifacts: $ARTIFACT"
|
||||
log "Phase 1: focused integration tests"
|
||||
{
|
||||
swift test --filter ClaudeOAuthTests
|
||||
swift test --filter ClaudeUsageTests
|
||||
swift test --filter ClaudeOAuthDelegatedRefreshCoordinatorTests
|
||||
swift test --filter 'expired claude CLI owner blocks background'
|
||||
swift test --filter ClaudeOAuthCredentialsStoreSecurityCLITests
|
||||
swift test --filter ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests
|
||||
swift test --filter ClaudeOAuthCredentialsStoreMCPOnlyGuardTests
|
||||
} 2>&1 | tee "$ARTIFACT/integration-tests.log"
|
||||
log "Phase 1 passed"
|
||||
|
||||
log "Phase 2: disposable HOME, keychain, credentials, config, and Claude CLI canary"
|
||||
mkdir -p "$HOME_FIXTURE/.claude" "$HOME_FIXTURE/Library/Preferences" "$ARTIFACT/bin"
|
||||
chmod 700 "$HOME_FIXTURE" "$HOME_FIXTURE/.claude" "$HOME_FIXTURE/Library" \
|
||||
"$HOME_FIXTURE/Library/Preferences" "$ARTIFACT/bin"
|
||||
printf '%s\n' "$EXPIRED_PAYLOAD" >"$HOME_FIXTURE/.claude/.credentials.json"
|
||||
chmod 600 "$HOME_FIXTURE/.claude/.credentials.json"
|
||||
printf '%s\n' '{"version":1,"providers":[{"id":"claude","enabled":true,"source":"oauth"}]}' >"$CONFIG"
|
||||
chmod 600 "$CONFIG"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'printf "args:" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \
|
||||
'printf " %q" "$@" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \
|
||||
'printf "\\n" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \
|
||||
'if [[ "$*" == "auth status --json" ]]; then printf "{\"loggedIn\":true}\\n"; exit 0; fi' \
|
||||
'if [[ "$*" == "--version" ]]; then printf "2.1.0\\n"; exit 0; fi' \
|
||||
'if IFS= read -r line; then' \
|
||||
' printf "stdin:%s\\n" "$line" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \
|
||||
' if [[ "$line" == *"/status"* ]]; then printf touched >"$CODEXBAR_CLAUDE_TOUCH_CANARY"; fi' \
|
||||
'fi' \
|
||||
'exit 99' \
|
||||
>"$ARTIFACT/bin/claude"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'printf touched >"$CODEXBAR_OPEN_TOUCH_CANARY"' \
|
||||
'exit 99' \
|
||||
>"$ARTIFACT/bin/open"
|
||||
chmod 700 "$ARTIFACT/bin/claude" "$ARTIFACT/bin/open"
|
||||
|
||||
/usr/bin/security list-keychains -d user >"$ARTIFACT/keychains-before.txt"
|
||||
/usr/bin/security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
/usr/bin/security set-keychain-settings -t 3600 "$KEYCHAIN"
|
||||
/usr/bin/security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
/usr/bin/security add-generic-password \
|
||||
-a codexbar-verify-1844 \
|
||||
-s 'Claude Code-credentials' \
|
||||
-w "$MCP_PAYLOAD" \
|
||||
-A \
|
||||
"$KEYCHAIN"
|
||||
/usr/bin/security list-keychains -d user >"$ARTIFACT/keychains-after.txt"
|
||||
if ! cmp -s "$ARTIFACT/keychains-before.txt" "$ARTIFACT/keychains-after.txt"; then
|
||||
log "Phase 2 failed: creating the disposable keychain changed the user search list"
|
||||
exit 1
|
||||
fi
|
||||
/usr/bin/security find-generic-password \
|
||||
-s 'Claude Code-credentials' \
|
||||
-w \
|
||||
"$KEYCHAIN" >"$ARTIFACT/keychain-fixture.json"
|
||||
cmp -s "$ARTIFACT/keychain-fixture.json" <(printf '%s\n' "$MCP_PAYLOAD")
|
||||
|
||||
PROC_LOG="$ARTIFACT/e2e-processes.log"
|
||||
STDOUT="$ARTIFACT/e2e-stdout.json"
|
||||
STDERR="$ARTIFACT/e2e-stderr.jsonl"
|
||||
CANARY="$ARTIFACT/claude-status-canary"
|
||||
INVOCATIONS="$ARTIFACT/claude-invocations.log"
|
||||
OPEN_CANARY="$ARTIFACT/open-touch-canary"
|
||||
: >"$PROC_LOG"
|
||||
: >"$INVOCATIONS"
|
||||
|
||||
set +e
|
||||
(
|
||||
env \
|
||||
HOME="$HOME_FIXTURE" \
|
||||
CFFIXED_USER_HOME="$HOME_FIXTURE" \
|
||||
CODEXBAR_CONFIG="$CONFIG" \
|
||||
CODEXBAR_DISABLE_KEYCHAIN_ACCESS=1 \
|
||||
CODEXBAR_CLAUDE_SECURITY_CLI_KEYCHAIN="$KEYCHAIN" \
|
||||
CODEXBAR_CLAUDE_TOUCH_CANARY="$CANARY" \
|
||||
CODEXBAR_CLAUDE_INVOCATIONS="$INVOCATIONS" \
|
||||
CODEXBAR_OPEN_TOUCH_CANARY="$OPEN_CANARY" \
|
||||
CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW=1 \
|
||||
CLAUDE_CLI_PATH="$ARTIFACT/bin/claude" \
|
||||
PATH="$ARTIFACT/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
|
||||
"$CLI" usage --provider claude --source oauth --format json --pretty --log-level debug \
|
||||
>"$STDOUT" 2>"$STDERR"
|
||||
) &
|
||||
PID=$!
|
||||
while kill -0 "$PID" 2>/dev/null; do
|
||||
{
|
||||
date -u +%H:%M:%S
|
||||
pgrep -P "$PID" -l 2>/dev/null || true
|
||||
} >>"$PROC_LOG"
|
||||
sleep 0.02
|
||||
done
|
||||
wait "$PID"
|
||||
CLI_STATUS=$?
|
||||
set -e
|
||||
|
||||
{
|
||||
echo "# CodexBar #1844 isolated E2E verification"
|
||||
echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "candidate: $(git rev-parse HEAD)"
|
||||
echo "packaged-cli: $CLI"
|
||||
echo "cli-exit: $CLI_STATUS"
|
||||
echo "default-keychain-search-list-unchanged: yes"
|
||||
echo "real-home-referenced: no"
|
||||
echo "claude-status-canary: $([[ -e "$CANARY" ]] && echo touched || echo untouched)"
|
||||
echo "open-touch-canary: $([[ -e "$OPEN_CANARY" ]] && echo touched || echo untouched)"
|
||||
echo
|
||||
echo "## stdout"
|
||||
cat "$STDOUT"
|
||||
echo
|
||||
echo "## stderr (filtered)"
|
||||
rg -i 'mcp|delegated|expired|oauth|touch|open|only prompt|user action' "$STDERR" || true
|
||||
echo
|
||||
echo "## Claude CLI invocations"
|
||||
cat "$INVOCATIONS"
|
||||
echo
|
||||
echo "## child processes"
|
||||
cat "$PROC_LOG"
|
||||
} | tee "$ARTIFACT/E2E-REPORT.md"
|
||||
|
||||
if [[ "$CLI_STATUS" -eq 0 ]]; then
|
||||
log "Phase 2 failed: the MCP-only fixture unexpectedly produced successful OAuth usage"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "$CANARY" ]]; then
|
||||
log "Phase 2 failed: delegated Claude CLI /status touch ran"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "$OPEN_CANARY" ]]; then
|
||||
log "Phase 2 failed: browser/open helper ran"
|
||||
exit 1
|
||||
fi
|
||||
if rg -q '/usr/bin/open|(^|/)open$|firefox|Google Chrome|Safari' "$PROC_LOG" 2>/dev/null; then
|
||||
log "Phase 2 failed: an open helper or browser was a probe child"
|
||||
exit 1
|
||||
fi
|
||||
if ! rg -qi 'MCP OAuth state only|mcpOAuthOnlyKeychain|MCP OAuth' "$STDERR" "$STDOUT"; then
|
||||
log "Phase 2 failed: expected MCP-only fail-closed message not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Phase 2 passed: exact packaged CLI failed closed without delegated /status touch or browser child"
|
||||
|
||||
log "Phase 3: isolated packaged app runtime smoke"
|
||||
APP_PROC_LOG="$ARTIFACT/app-processes.log"
|
||||
APP_STDOUT="$ARTIFACT/app-stdout.log"
|
||||
APP_STDERR="$ARTIFACT/app-stderr.log"
|
||||
: >"$APP_PROC_LOG"
|
||||
: >"$INVOCATIONS"
|
||||
(
|
||||
env \
|
||||
HOME="$HOME_FIXTURE" \
|
||||
CFFIXED_USER_HOME="$HOME_FIXTURE" \
|
||||
CODEXBAR_CONFIG="$CONFIG" \
|
||||
CODEXBAR_DISABLE_KEYCHAIN_ACCESS=1 \
|
||||
CODEXBAR_CLAUDE_SECURITY_CLI_KEYCHAIN="$KEYCHAIN" \
|
||||
CODEXBAR_CLAUDE_TOUCH_CANARY="$CANARY" \
|
||||
CODEXBAR_CLAUDE_INVOCATIONS="$INVOCATIONS" \
|
||||
CODEXBAR_OPEN_TOUCH_CANARY="$OPEN_CANARY" \
|
||||
CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW=1 \
|
||||
CLAUDE_CLI_PATH="$ARTIFACT/bin/claude" \
|
||||
PATH="$ARTIFACT/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
|
||||
"$APP" >"$APP_STDOUT" 2>"$APP_STDERR"
|
||||
) &
|
||||
APP_PID=$!
|
||||
APP_OBSERVED_CLI=0
|
||||
POST_DISCOVERY_TICKS=0
|
||||
for _ in $(seq 1 1000); do
|
||||
if ! kill -0 "$APP_PID" 2>/dev/null; then
|
||||
log "Phase 3 failed: packaged app exited before the isolated startup smoke completed"
|
||||
wait "$APP_PID" || true
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
date -u +%H:%M:%S
|
||||
pgrep -P "$APP_PID" -l 2>/dev/null || true
|
||||
} >>"$APP_PROC_LOG"
|
||||
if rg -q '^args: --version$' "$INVOCATIONS"; then
|
||||
APP_OBSERVED_CLI=1
|
||||
POST_DISCOVERY_TICKS=$((POST_DISCOVERY_TICKS + 1))
|
||||
if [[ "$POST_DISCOVERY_TICKS" -ge 250 ]]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 0.02
|
||||
done
|
||||
kill "$APP_PID"
|
||||
wait "$APP_PID" 2>/dev/null || true
|
||||
|
||||
if [[ "$APP_OBSERVED_CLI" -ne 1 ]]; then
|
||||
log "Phase 3 failed: packaged app never exercised the isolated Claude CLI fixture"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "$CANARY" ]]; then
|
||||
log "Phase 3 failed: packaged app invoked delegated Claude CLI /status touch"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "$OPEN_CANARY" ]]; then
|
||||
log "Phase 3 failed: packaged app invoked browser/open helper"
|
||||
exit 1
|
||||
fi
|
||||
if rg -q '/usr/bin/open|(^|/)open$|firefox|Google Chrome|Safari' "$APP_PROC_LOG" 2>/dev/null; then
|
||||
log "Phase 3 failed: an open helper or browser was an app child"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo
|
||||
echo "## packaged app runtime"
|
||||
echo "app-binary: $APP"
|
||||
echo "isolated-claude-cli-discovery-observed: yes"
|
||||
echo "post-discovery-observation-seconds: 5"
|
||||
echo "app-stayed-running: yes"
|
||||
echo "claude-status-canary: untouched"
|
||||
echo "open-touch-canary: untouched"
|
||||
echo "browser-child: none"
|
||||
echo
|
||||
echo "## packaged app Claude CLI invocations"
|
||||
cat "$INVOCATIONS"
|
||||
} | tee -a "$ARTIFACT/E2E-REPORT.md"
|
||||
|
||||
log "Phase 3 passed: packaged app exercised CLI discovery without delegated /status touch or browser child"
|
||||
log "Report: $ARTIFACT/E2E-REPORT.md"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
exec "$SCRIPT_DIR/mac-release" verify-appcast "$@"
|
||||
Reference in New Issue
Block a user