Files
2026-07-13 12:38:34 +08:00

231 lines
8.0 KiB
TypeScript
Executable File

#!/usr/bin/env bun
import { $ } from 'bun';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const packageRoot = path.resolve(scriptDir, '..');
const repoRoot = path.resolve(packageRoot, '../../..');
const submodulePath = path.join(packageRoot, 'vendor/platform-imessage');
const submoduleRelativePath = path.relative(repoRoot, submodulePath);
const outputRoot = path.join(packageRoot, 'local-tools-binaries/beeper-imessage');
const upstreamRepository = 'https://github.com/ComposioHQ/platform-imessage';
const targets = [
{
platform: 'darwin-arm64',
swiftArch: 'arm64',
buildPath: '.build/arm64-apple-macosx/release/imessage-cli',
},
] as const;
type Target = (typeof targets)[number];
const exists = async (filePath: string): Promise<boolean> =>
fs
.access(filePath)
.then(() => true)
.catch(() => false);
const readJson = async <T>(filePath: string): Promise<T> =>
JSON.parse(await fs.readFile(filePath, 'utf8')) as T;
// BetterSwiftAX 0.1.0 references Accessibility constants that are not
// consistently exported by the macOS SDK on GitHub runners. Swift exposes AX
// constants as Strings whose values are the constant name without the `k` prefix
// and category suffix, so patch the resolved package checkout to literals before
// compiling sidecar binaries.
const betterSwiftAxFallbackFiles = [
'Accessibility+Action.swift',
'Accessibility+AttributeKey.swift',
'Accessibility+Notification.swift',
'Accessibility+ParameterizedAttributeKey.swift',
'Accessibility+Role.swift',
'Accessibility+Subrole.swift',
'Accessibility+Value.swift',
] as const;
const betterSwiftAxConstantReferencePattern =
/\bkAX[A-Za-z0-9]+(?:Action|ParameterizedAttribute|Attribute|Notification|Subrole|Role|Value)\b/g;
const betterSwiftAxConstantPattern =
/^kAX(.+?)(Action|ParameterizedAttribute|Attribute|Notification|Subrole|Role|Value)$/;
const betterSwiftAxConstantLiteral = (constantName: string): string => {
const match = betterSwiftAxConstantPattern.exec(constantName);
if (!match) {
throw new Error(`Unsupported BetterSwiftAX constant fallback: ${constantName}`);
}
return `"AX${match[1]}"`;
};
const ensurePlatform = () => {
if (process.platform !== 'darwin') {
throw new Error(
'Building Beeper iMessage local-tool binaries requires macOS and the Swift toolchain.'
);
}
};
const ensureSubmodule = async () => {
if (await exists(path.join(submodulePath, 'Package.swift'))) return;
console.log(`Initializing ${submoduleRelativePath} submodule...`);
await $`git submodule update --init --recursive -- ${submoduleRelativePath}`.cwd(repoRoot);
if (!(await exists(path.join(submodulePath, 'Package.swift')))) {
throw new Error(
`Missing platform-imessage submodule at ${submoduleRelativePath}. Run: git submodule update --init --recursive -- ${submoduleRelativePath}`
);
}
};
const getSubmoduleCommit = async (): Promise<string> =>
(await $`git rev-parse HEAD`.cwd(submodulePath).text()).trim();
const getSubmoduleVersion = async (): Promise<string> => {
const packageJson = await readJson<{ version?: string }>(
path.join(submodulePath, 'package.json')
);
return packageJson.version ?? 'unknown';
};
const copyLicense = async () => {
const licenseCandidates = ['license.txt', 'LICENSE.txt', 'LICENSE', 'LICENSE.md'];
for (const candidate of licenseCandidates) {
const sourcePath = path.join(submodulePath, candidate);
if (await exists(sourcePath)) {
await fs.copyFile(sourcePath, path.join(outputRoot, 'LICENSE.txt'));
return;
}
}
throw new Error('No upstream license file found in platform-imessage submodule.');
};
const patchBetterSwiftAxForCurrentSdk = async () => {
await $`swift package resolve`.cwd(submodulePath);
const checkoutRoot = path.join(
submodulePath,
'.build/checkouts/BetterSwiftAX/Sources/AccessibilityControl'
);
if (!(await exists(checkoutRoot))) {
throw new Error(
'Swift package resolution completed but the BetterSwiftAX AccessibilityControl checkout was not found.'
);
}
let replacementCount = 0;
for (const fileName of betterSwiftAxFallbackFiles) {
const sourcePath = path.join(checkoutRoot, fileName);
if (!(await exists(sourcePath))) {
throw new Error(
`Swift package resolution completed but BetterSwiftAX ${fileName} was not found.`
);
}
const source = await fs.readFile(sourcePath, 'utf8');
let fileReplacementCount = 0;
const patched = source.replace(betterSwiftAxConstantReferencePattern, constantName => {
fileReplacementCount += 1;
return betterSwiftAxConstantLiteral(constantName);
});
if (patched !== source) {
await fs.chmod(sourcePath, 0o644).catch(() => undefined);
await fs.writeFile(sourcePath, patched, 'utf8');
replacementCount += fileReplacementCount;
}
}
if (replacementCount > 0) {
console.log(
`Patched ${replacementCount} BetterSwiftAX Accessibility constants for the current macOS SDK.`
);
}
};
const buildTarget = async (target: Target) => {
console.log(`Building imessage-cli for ${target.platform} (${target.swiftArch})...`);
await patchBetterSwiftAxForCurrentSdk();
await $`swift build -c release --product imessage-cli --arch ${target.swiftArch}`.cwd(
submodulePath
);
const builtBinary = path.join(submodulePath, target.buildPath);
if (!(await exists(builtBinary))) {
throw new Error(`Swift build completed but ${target.buildPath} was not produced.`);
}
const targetDir = path.join(outputRoot, target.platform);
const outputBinary = path.join(targetDir, 'imessage-cli');
await fs.mkdir(targetDir, { recursive: true });
await fs.copyFile(builtBinary, outputBinary);
await fs.chmod(outputBinary, 0o755);
console.log(`Wrote ${path.relative(repoRoot, outputBinary)}`);
};
const writeNotice = async (params: { version: string; commit: string }) => {
const notice = `# Beeper platform-imessage CLI binary
These \`imessage-cli\` binaries are built from the Composio fork of Beeper platform-imessage.
- Upstream fork: \`${upstreamRepository}\`
- Upstream version: \`${params.version}\`
- Upstream submodule commit: \`${params.commit}\`
- License: MIT (\`license.txt\` in the upstream repository)
- Build command: \`pnpm --filter @composio/cli-local-tools build:beeper-imessage -- --target <darwin-arm64>\`
- Underlying Swift build commands:
- \`swift build -c release --product imessage-cli --arch arm64\`
The binaries are stripped release builds for macOS arm64. They require local macOS Messages data and may prompt for Messages Data, Accessibility, Contacts, and Automation permissions depending on the command.
`;
await fs.writeFile(path.join(outputRoot, 'NOTICE.md'), notice, 'utf8');
};
const parseTargets = (): ReadonlyArray<Target> => {
const rawArgs = process.argv.slice(2).filter(arg => arg !== '--');
const targetIndex = rawArgs.indexOf('--target');
const args =
targetIndex >= 0 && rawArgs[targetIndex + 1]
? [rawArgs[targetIndex + 1]!]
: rawArgs.filter(arg => !arg.startsWith('--'));
if (args.length === 0 || args.includes('all')) return targets;
const selected = targets.filter(target => args.includes(target.platform));
const unknown = args.filter(arg => !targets.some(target => target.platform === arg));
if (unknown.length > 0) {
throw new Error(
`Unknown target(s): ${unknown.join(', ')}. Expected one of: all, ${targets
.map(target => target.platform)
.join(', ')}`
);
}
return selected;
};
const main = async () => {
ensurePlatform();
await ensureSubmodule();
await fs.mkdir(outputRoot, { recursive: true });
const selectedTargets = parseTargets();
for (const target of selectedTargets) {
await buildTarget(target);
}
await copyLicense();
await writeNotice({
version: await getSubmoduleVersion(),
commit: await getSubmoduleCommit(),
});
};
main().catch(error => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});