diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 42fa41452e..9c6b6b11b5 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -17,7 +17,7 @@ Create a GitHub Environment named `release`. Add required reviewers if the repos 1. Confirm the intended commit is on `main`, CI is green, and `apps/desktop/package.json` contains a version that has never been released. 2. In GitHub Actions, run `Release macOS arm64` against `main`. 3. Confirm every workflow step passes and a draft release named `v` exists. -4. Confirm the draft records the intended commit SHA and contains the DMG, its `.sha256` file, the ZIP, and `latest-mac.yml`. +4. Confirm the draft records the intended commit SHA and contains the DMG, signed and notarized CLI/TUI ZIP, both `.sha256` files, the desktop ZIP, and `latest-mac.yml`. ## Acceptance on another Apple Silicon Mac @@ -31,4 +31,15 @@ Download the DMG and its `.sha256` file through the GitHub UI. This download pat 6. Install `ripgrep` with `brew install ripgrep`, then confirm a task using `Grep` works. 7. Confirm the known limitation is accurate: Computer Use is not included. +## CLI/TUI acceptance on another Apple Silicon Mac + +Download `Maka--cli-mac-arm64.zip` and its `.sha256` file through the GitHub UI. This browser-download path applies quarantine metadata and must exercise the signed native addons. + +1. Run `shasum -a 256 -c Maka--cli-mac-arm64.zip.sha256`. +2. Extract the ZIP and add its `bin` directory to `PATH` without installing the desktop app. +3. Confirm `maka --version` matches the desktop version and `maka-agent --version` reports the same value. +4. Confirm `maka --help` lists `run`, `eval`, and `inspect`. +5. Run `maka`, complete or cancel the first-run setup, and confirm the TUI renders correctly. +6. Run one representative non-interactive command and confirm it completes without a repository checkout or system Node.js installation. + Publish the draft only after all checks pass. If acceptance fails, keep the draft unpublished, fix the issue, increment the desktop version, and run the workflow again; do not replace an existing release identity. diff --git a/.github/workflows/release-macos-arm64.yml b/.github/workflows/release-macos-arm64.yml index 2016ed2108..253cef055e 100644 --- a/.github/workflows/release-macos-arm64.yml +++ b/.github/workflows/release-macos-arm64.yml @@ -28,9 +28,15 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: '24.18.1' cache: npm + - name: Pin npm + run: | + expected="$(node -p "require('./package.json').packageManager.split('@')[1]")" + npm install --global "npm@${expected}" + test "$(npm --version)" = "$expected" + - name: Install dependencies run: npm ci @@ -55,6 +61,7 @@ jobs: dmg="apps/desktop/release/Maka-${version}-mac-arm64.dmg" zip="apps/desktop/release/Maka-${version}-mac-arm64.zip" update_yml="apps/desktop/release/latest-mac.yml" + cli="apps/desktop/release/Maka-${version}-cli-mac-arm64.zip" if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then echo "Tag ${tag} already exists." >&2 @@ -71,6 +78,7 @@ jobs: echo "dmg=${dmg}" echo "zip=${zip}" echo "update_yml=${update_yml}" + echo "cli=${cli}" } >> "$GITHUB_OUTPUT" - name: Package notarized app and signed DMG @@ -104,10 +112,27 @@ jobs: --context context:primary-signature \ --verbose=4 \ "$DMG_PATH" - - name: Verify the final DMG run: npm run verify:macos-arm64 -- "${{ steps.release.outputs.dmg }}" + - name: Package signed and notarized standalone CLI and TUI + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MAKA_CLI_RELEASE_SIGNING: '1' + run: npm run package:macos-arm64-cli + + - name: Verify the standalone CLI and TUI + env: + MAKA_CLI_REQUIRE_RELEASE_SIGNING: '1' + run: npm run verify:macos-arm64-cli -- "${{ steps.release.outputs.cli }}" + + - name: Remove the temporary notarization key + run: rm -f "${{ runner.temp }}/AuthKey_Maka.p8" + - name: Create draft GitHub Release env: GH_TOKEN: ${{ github.token }} @@ -126,6 +151,8 @@ jobs: "${DMG_PATH}.sha256" \ "${{ steps.release.outputs.zip }}" \ "${{ steps.release.outputs.update_yml }}" \ + "${{ steps.release.outputs.cli }}" \ + "${{ steps.release.outputs.cli }}.sha256" \ --draft \ --target "$GITHUB_SHA" \ --title "Maka ${RELEASE_VERSION}" \ diff --git a/package.json b/package.json index 5bc9770d9b..01a97d08ed 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "node": ">=22.19.0" }, "packageManager": "npm@11.12.1", + "releaseToolchain": { + "node": "24.18.1" + }, "type": "module", "workspaces": [ "packages/core", @@ -45,7 +48,9 @@ "check:third-party-notices": "node scripts/generate-third-party-notices.mjs --check", "check:release": "npm run check:stale && npm run check:third-party-notices && node scripts/check-dead-css.mjs --check", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", + "package:macos-arm64-cli": "node scripts/package-macos-arm64-cli.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", + "verify:macos-arm64-cli": "node scripts/verify-macos-arm64-cli.mjs", "measure:session-bundle": "node scripts/measure-session-bundle.mjs", "astryx:theme": "node scripts/build-astryx-theme.mjs", "sync:model-metadata": "node scripts/sync-model-metadata.mjs", diff --git a/scripts/macos-arm64-release.test.mjs b/scripts/macos-arm64-release.test.mjs index f2588fd9e3..b430a68031 100644 --- a/scripts/macos-arm64-release.test.mjs +++ b/scripts/macos-arm64-release.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; +import { parse as parseYaml } from 'yaml'; const signingEnvironment = { CSC_LINK: 'base64-certificate', @@ -49,6 +52,250 @@ test('release tooling fails closed on unsupported hosts, signing, and architectu ); }); +test('standalone CLI derives its workspace and pinned toolchain invariants', async () => { + const { + assertMacosArm64CliHost, + assertOfficialNodeRuntime, + assertReleaseSigningEnvironment, + collectWorkspaceDependencyClosure, + macosArm64CliInstallArgs, + packageMacosArm64Cli, + releaseToolchainFromManifest, + resolveMacosArm64CliArtifactPaths, + } = await import(new URL('package-macos-arm64-cli.mjs', import.meta.url)); + const { verifyMacosArm64Cli } = await import( + new URL('verify-macos-arm64-cli.mjs', import.meta.url) + ); + + assert.doesNotThrow(() => assertMacosArm64CliHost('darwin', 'arm64')); + assert.throws(() => assertMacosArm64CliHost('darwin', 'x64'), /Apple Silicon macOS host/); + await assert.rejects( + packageMacosArm64Cli({ platform: 'linux', arch: 'x64' }), + /Apple Silicon macOS host/, + ); + await assert.rejects( + verifyMacosArm64Cli('/missing', { platform: 'linux', arch: 'x64' }), + /Apple Silicon macOS host/, + ); + + assert.deepEqual( + releaseToolchainFromManifest({ + packageManager: 'npm@11.12.1', + releaseToolchain: { node: '24.18.1' }, + }), + { nodeVersion: '24.18.1', npmVersion: '11.12.1' }, + ); + assert.throws( + () => releaseToolchainFromManifest({ packageManager: 'npm@latest' }), + /exact releaseToolchain\.node/, + ); + + const manifests = new Map([ + ['maka-agent', { dependencies: { '@maka/core': '0.1.0', thirdParty: '1.0.0' } }], + ['@maka/core', { dependencies: { '@maka/storage': '0.1.0' } }], + ['@maka/storage', { dependencies: {} }], + ['@maka/unrelated', { dependencies: {} }], + ]); + assert.deepEqual(collectWorkspaceDependencyClosure('maka-agent', manifests), [ + '@maka/core', + '@maka/storage', + 'maka-agent', + ]); + manifests.get('@maka/core').dependencies['@maka/missing'] = '0.1.0'; + assert.throws( + () => collectWorkspaceDependencyClosure('maka-agent', manifests), + /not in workspaces/, + ); + + const installArgs = macosArm64CliInstallArgs(); + assert.equal(installArgs[0], 'ci'); + assert.equal(installArgs.includes('--prefix'), false); + assert.ok(installArgs.includes('maka-agent')); + assert.ok(resolveMacosArm64CliArtifactPaths('1.2.3').archivePath.endsWith('.zip')); + + assert.doesNotThrow(() => + assertOfficialNodeRuntime({ + actualVersion: '24.18.1', + expectedVersion: '24.18.1', + architectures: 'arm64\n', + signature: + 'flags=0x10000(runtime)\nAuthority=Developer ID Application: Node.js Foundation (HX7739G8FX)', + linkedLibraries: [ + '/System/Library/Frameworks/Security.framework/Versions/A/Security', + '/usr/lib/libSystem.B.dylib', + ], + }), + ); + assert.throws( + () => + assertOfficialNodeRuntime({ + actualVersion: '24.18.1', + expectedVersion: '24.18.1', + architectures: 'arm64\n', + signature: + 'flags=0x10000(runtime)\nAuthority=Developer ID Application: Node.js Foundation (HX7739G8FX)', + linkedLibraries: ['@rpath/libnode.147.dylib', '/opt/homebrew/opt/libuv/lib/libuv.1.dylib'], + }), + /not self-contained/, + ); + assert.throws(() => assertReleaseSigningEnvironment({}), /CSC_LINK/); + assert.doesNotThrow(() => assertReleaseSigningEnvironment(signingEnvironment)); +}); + +test('CLI release rejects unsafe archives, false TUI readiness, and crash exits', async () => { + const { assertAcceptedNotarization } = await import( + new URL('package-macos-arm64-cli.mjs', import.meta.url) + ); + const { assertExpectedTuiExit, assertSafeCliArchiveEntries, isTuiReadyOutput } = await import( + new URL('verify-macos-arm64-cli.mjs', import.meta.url) + ); + + assert.doesNotThrow(() => + assertSafeCliArchiveEntries( + ['Maka-1-cli-mac-arm64/', 'Maka-1-cli-mac-arm64/bin/maka'], + 'Maka-1-cli-mac-arm64', + ), + ); + assert.throws( + () => assertSafeCliArchiveEntries(['../escape'], 'Maka-1-cli-mac-arm64'), + /Unsafe CLI archive entry/, + ); + assert.throws( + () => + assertSafeCliArchiveEntries(['Maka-1-cli-mac-arm64/libexec/._node'], 'Maka-1-cli-mac-arm64'), + /Unsafe CLI archive entry/, + ); + + assert.equal(isTuiReadyOutput('Error loading /tmp/Maka-1-cli-mac-arm64/addon.node'), false); + assert.equal(isTuiReadyOutput('无法启动 Maka:还没有可用的模型连接。'), false); + assert.equal(isTuiReadyOutput('陪你把事做完'), true); + assert.throws( + () => + assertExpectedTuiExit({ + ready: true, + stopRequested: true, + exitCode: 1, + signal: 0, + output: '陪你把事做完\ncrash', + }), + /crashed after startup/, + ); + assert.doesNotThrow(() => + assertExpectedTuiExit({ + ready: true, + stopRequested: true, + exitCode: 0, + signal: 0, + output: '陪你把事做完', + }), + ); + assert.doesNotThrow(() => assertAcceptedNotarization('{"status":"Accepted"}')); + assert.throws(() => assertAcceptedNotarization('{"status":"Invalid"}'), /status Invalid/); +}); + +test('CLI staging removes test output and rejects dangling symlinks', async () => { + const { assertNoDanglingSymlinks, pruneTestArtifacts } = await import( + new URL('package-macos-arm64-cli.mjs', import.meta.url) + ); + const root = await mkdtemp(join(tmpdir(), 'maka-cli-unit-')); + try { + const dist = join(root, 'dist'); + await mkdir(join(dist, '__tests__'), { recursive: true }); + await Promise.all([ + writeFile(join(dist, 'index.js'), 'export {};\n'), + writeFile(join(dist, 'feature.test.js'), 'throw new Error();\n'), + writeFile(join(dist, '__tests__', 'fixture.js'), 'throw new Error();\n'), + ]); + await pruneTestArtifacts(dist); + await access(join(dist, 'index.js')); + await assert.rejects(access(join(dist, 'feature.test.js')), { code: 'ENOENT' }); + await assert.rejects(access(join(dist, '__tests__')), { code: 'ENOENT' }); + + await mkdir(join(root, 'target')); + await symlink('target', join(root, 'valid-link')); + await assertNoDanglingSymlinks(root); + await symlink('missing', join(root, 'dangling-link')); + await assert.rejects(assertNoDanglingSymlinks(root), /Dangling symlink/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('CLI staging applies repository dependency patches before relocation', async () => { + const { applyDependencyPatches, listDependencyPatchNames } = await import( + new URL('package-macos-arm64-cli.mjs', import.meta.url) + ); + const root = await mkdtemp(join(tmpdir(), 'maka-cli-patches-')); + try { + const calls = []; + const expectedPatchNames = await listDependencyPatchNames(); + assert.ok(expectedPatchNames.includes('@ai-sdk+provider-utils+5.0.11.patch')); + const appliedPatchNames = await applyDependencyPatches(root, { + patchPackageEntry: '/tools/patch-package/index.js', + run: async (command, args, options) => { + calls.push({ command, args, options }); + }, + }); + assert.deepEqual(appliedPatchNames, expectedPatchNames); + await Promise.all(expectedPatchNames.map((name) => access(join(root, 'patches', name)))); + assert.deepEqual(calls, [ + { + command: process.execPath, + args: ['/tools/patch-package/index.js', '--error-on-fail'], + options: { cwd: root, env: process.env }, + }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('packaged streaming smoke rejects provider-utils index-hole regressions', async () => { + const { assertPatchedStreamingToolCalls } = await import( + new URL('verify-macos-arm64-cli.mjs', import.meta.url) + ); + const expectedCalls = [ + { type: 'tool-call', toolCallId: 'call_1', toolName: 'read_file', input: '{"path":"a.txt"}' }, + { type: 'tool-call', toolCallId: 'call_2', toolName: 'read_file', input: '{"path":"b.txt"}' }, + { type: 'finish' }, + ]; + assert.doesNotThrow(() => assertPatchedStreamingToolCalls(expectedCalls)); + assert.throws( + () => assertPatchedStreamingToolCalls([{ type: 'error', error: new Error('index hole') }]), + /failed to finish/, + ); + assert.throws( + () => assertPatchedStreamingToolCalls([expectedCalls[1], expectedCalls[0], { type: 'finish' }]), + /reordered or dropped/, + ); +}); + +test('release workflow pins the toolchain and gates CLI publication on signing', async () => { + const workflow = parseYaml( + await readFile( + new URL('../.github/workflows/release-macos-arm64.yml', import.meta.url), + 'utf8', + ), + ); + const steps = workflow.jobs.release.steps; + const setupNode = steps.find((step) => step.name === 'Set up Node.js'); + const packageCli = steps.find( + (step) => step.name === 'Package signed and notarized standalone CLI and TUI', + ); + const verifyCli = steps.find((step) => step.name === 'Verify the standalone CLI and TUI'); + const release = steps.find((step) => step.name === 'Create draft GitHub Release'); + const cleanupIndex = steps.findIndex( + (step) => step.name === 'Remove the temporary notarization key', + ); + + assert.equal(setupNode.with['node-version'], '24.18.1'); + assert.equal(packageCli.env.MAKA_CLI_RELEASE_SIGNING, '1'); + assert.equal(verifyCli.env.MAKA_CLI_REQUIRE_RELEASE_SIGNING, '1'); + assert.match(release.run, /steps\.release\.outputs\.cli/); + assert.match(release.run, /steps\.release\.outputs\.cli \}\}\.sha256/); + assert.ok(cleanupIndex > steps.indexOf(verifyCli)); +}); + test('the packaged app is checked for every unsigned helper that could still be in a tree', async () => { // `apps/desktop/resources/bin` is gitignored, so removing a helper from the // repository does not remove it from the machine of anyone who prepared it diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs new file mode 100644 index 0000000000..d6de1f563f --- /dev/null +++ b/scripts/package-macos-arm64-cli.mjs @@ -0,0 +1,642 @@ +import { execFile, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { + access, + chmod, + copyFile, + cp, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const requireFromHere = createRequire(import.meta.url); +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const releaseDirectory = join(repoRoot, 'apps', 'desktop', 'release'); +const dependencyPatchesDirectory = join(repoRoot, 'patches'); +const cliPackageName = 'maka-agent'; +const localPackagePrefix = '@maka/'; +const requiredSigningEnvironment = [ + 'CSC_LINK', + 'CSC_KEY_PASSWORD', + 'APPLE_API_KEY', + 'APPLE_API_KEY_ID', + 'APPLE_API_ISSUER', +]; + +function runCommand(command, args, options = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd ?? repoRoot, + env: options.env ?? process.env, + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) { + resolvePromise(); + return; + } + reject( + new Error( + `${command} ${args.join(' ')} failed with ${ + signal ? `signal ${signal}` : `exit code ${code}` + }`, + ), + ); + }); + }); +} + +function inspectCommand(command, args, options = {}) { + return execFileAsync(command, args, { + cwd: options.cwd ?? repoRoot, + env: options.env ?? process.env, + maxBuffer: options.maxBuffer ?? 20 * 1024 * 1024, + timeout: options.timeout ?? 30_000, + }); +} + +export function assertMacosArm64CliHost(platform = process.platform, arch = process.arch) { + if (platform !== 'darwin' || arch !== 'arm64') { + throw new Error('CLI release packaging requires an Apple Silicon macOS host.'); + } +} + +export function resolveMacosArm64CliArtifactPaths(version) { + const archiveName = `Maka-${version}-cli-mac-arm64.zip`; + return { + archiveRootName: `Maka-${version}-cli-mac-arm64`, + archivePath: join(releaseDirectory, archiveName), + checksumPath: join(releaseDirectory, `${archiveName}.sha256`), + }; +} + +export function macosArm64CliWrapper() { + return `#!/bin/sh +set -eu +bin_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) +exec "$bin_dir/../libexec/node/bin/node" "$bin_dir/../libexec/node_modules/maka-agent/dist/cli.js" "$@" +`; +} + +export function macosArm64CliInstallArgs() { + return [ + 'ci', + '--omit=dev', + '--workspace', + cliPackageName, + '--include-workspace-root=false', + '--ignore-scripts', + '--no-audit', + '--no-fund', + ]; +} + +export function releaseToolchainFromManifest(manifest) { + const nodeVersion = manifest.releaseToolchain?.node; + const npmMatch = /^npm@(\d+\.\d+\.\d+)$/.exec(manifest.packageManager ?? ''); + if (typeof nodeVersion !== 'string' || !/^\d+\.\d+\.\d+$/.test(nodeVersion)) { + throw new Error('package.json must define an exact releaseToolchain.node version.'); + } + if (!npmMatch) { + throw new Error('package.json packageManager must pin an exact npm version.'); + } + return { nodeVersion, npmVersion: npmMatch[1] }; +} + +function manifestFromEntry(entry) { + return entry?.manifest ?? entry; +} + +export function collectWorkspaceDependencyClosure(entryName, manifestsByName) { + const closure = new Set(); + const visiting = new Set(); + + function visit(packageName) { + if (closure.has(packageName)) return; + if (visiting.has(packageName)) { + throw new Error(`Workspace dependency cycle reached ${packageName}.`); + } + const entry = manifestsByName.get(packageName); + if (!entry) { + throw new Error(`Workspace package ${packageName} is missing.`); + } + visiting.add(packageName); + const manifest = manifestFromEntry(entry); + for (const dependencyName of Object.keys(manifest.dependencies ?? {}).sort()) { + if (manifestsByName.has(dependencyName)) { + visit(dependencyName); + } else if (dependencyName.startsWith(localPackagePrefix)) { + throw new Error( + `${packageName} depends on local package ${dependencyName}, but it is not in workspaces.`, + ); + } + } + visiting.delete(packageName); + closure.add(packageName); + } + + visit(entryName); + return [...closure].sort(); +} + +function assertInsideRepo(path) { + const pathFromRepo = relative(repoRoot, path); + if (pathFromRepo === '..' || pathFromRepo.startsWith(`..${sep}`) || isAbsolute(pathFromRepo)) { + throw new Error(`Workspace path escapes the repository: ${path}`); + } +} + +export async function resolveCliWorkspacePackages() { + const rootManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + const manifestsByName = new Map(); + for (const workspacePath of rootManifest.workspaces ?? []) { + if (typeof workspacePath !== 'string' || /[*?[\]{}]/.test(workspacePath)) { + throw new Error(`CLI release requires explicit workspace paths, found ${workspacePath}.`); + } + const directory = resolve(repoRoot, workspacePath); + assertInsideRepo(directory); + const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')); + if (typeof manifest.name !== 'string' || !manifest.name) { + throw new Error(`${workspacePath}/package.json is missing a package name.`); + } + if (manifestsByName.has(manifest.name)) { + throw new Error(`Duplicate workspace package name ${manifest.name}.`); + } + manifestsByName.set(manifest.name, { directory, manifest, workspacePath }); + } + + return collectWorkspaceDependencyClosure(cliPackageName, manifestsByName).map((name) => ({ + name, + ...manifestsByName.get(name), + })); +} + +function isTestArtifactName(name) { + return /\.(?:test|spec)\.(?:[cm]?js|d\.ts|[cm]?js\.map)$/.test(name); +} + +export async function pruneTestArtifacts(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory() && entry.name === '__tests__') { + await rm(path, { recursive: true, force: true }); + } else if (entry.isDirectory()) { + await pruneTestArtifacts(path); + } else if (entry.isFile() && isTestArtifactName(entry.name)) { + await rm(path, { force: true }); + } + } +} + +async function sha256File(path) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +async function stageWorkspacePackages(installRoot, workspacePackages) { + await Promise.all([ + copyFile(join(repoRoot, 'package.json'), join(installRoot, 'package.json')), + copyFile(join(repoRoot, 'package-lock.json'), join(installRoot, 'package-lock.json')), + ...workspacePackages.map(async ({ directory, workspacePath }) => { + const targetDirectory = join(installRoot, workspacePath); + await mkdir(targetDirectory, { recursive: true }); + await Promise.all([ + copyFile(join(directory, 'package.json'), join(targetDirectory, 'package.json')), + cp(join(directory, 'dist'), join(targetDirectory, 'dist'), { recursive: true }), + ]); + await pruneTestArtifacts(join(targetDirectory, 'dist')); + }), + ]); +} + +export async function listDependencyPatchNames() { + let entries; + try { + entries = await readdir(dependencyPatchesDirectory, { withFileTypes: true }); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.patch')) + .map((entry) => entry.name) + .sort(); +} + +export async function applyDependencyPatches( + installRoot, + { env = process.env, run = runCommand, patchPackageEntry } = {}, +) { + const patchNames = await listDependencyPatchNames(); + if (patchNames.length === 0) return patchNames; + + const stagedPatchesDirectory = join(installRoot, 'patches'); + await mkdir(stagedPatchesDirectory, { recursive: true }); + await Promise.all( + patchNames.map((name) => + copyFile(join(dependencyPatchesDirectory, name), join(stagedPatchesDirectory, name)), + ), + ); + + const entry = patchPackageEntry ?? requireFromHere.resolve('patch-package/index.js'); + await run(process.execPath, [entry, '--error-on-fail'], { cwd: installRoot, env }); + return patchNames; +} + +async function retainOnlyDirectory(parent, retainedName) { + for (const entry of await readdir(parent)) { + if (entry !== retainedName) { + await rm(join(parent, entry), { recursive: true, force: true }); + } + } +} + +async function pruneNonTargetNativeBinaries(nodeModulesDirectory) { + await retainOnlyDirectory(join(nodeModulesDirectory, 'node-pty', 'prebuilds'), 'darwin-arm64'); + await retainOnlyDirectory( + join(nodeModulesDirectory, 'fs-native-extensions', 'prebuilds'), + 'darwin-arm64', + ); + await retainOnlyDirectory( + join(nodeModulesDirectory, '@earendil-works', 'pi-tui', 'native', 'darwin', 'prebuilds'), + 'darwin-arm64', + ); + await rm(join(nodeModulesDirectory, '@earendil-works', 'pi-tui', 'native', 'win32'), { + recursive: true, + force: true, + }); +} + +async function rewriteCliVersion(installRoot, workspacePackages, version) { + const cliPackage = workspacePackages.find(({ name }) => name === cliPackageName); + if (!cliPackage) throw new Error(`${cliPackageName} is missing from the workspace closure.`); + const manifestPath = join(installRoot, cliPackage.workspacePath, 'package.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + manifest.version = version; + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); +} + +function packageModulePath(nodeModulesDirectory, packageName) { + return join(nodeModulesDirectory, ...packageName.split('/')); +} + +export async function assertNoDanglingSymlinks(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) { + try { + await realpath(path); + } catch (error) { + if (error?.code === 'ENOENT') throw new Error(`Dangling symlink in CLI artifact: ${path}`); + throw error; + } + } else if (entry.isDirectory()) { + await assertNoDanglingSymlinks(path); + } + } +} + +async function assertWorkspaceLinks(archiveRoot, workspacePackages) { + const nodeModulesDirectory = join(archiveRoot, 'libexec', 'node_modules'); + for (const { name, workspacePath } of workspacePackages) { + const linkTarget = await realpath(packageModulePath(nodeModulesDirectory, name)); + const packageTarget = await realpath(join(archiveRoot, 'libexec', workspacePath)); + if (linkTarget !== packageTarget) { + throw new Error(`${name} does not resolve to its staged workspace package.`); + } + } + await assertNoDanglingSymlinks(join(archiveRoot, 'libexec')); +} + +function parseLinkedLibraries(output) { + return output + .split('\n') + .slice(1) + .map((line) => line.trim().split(/\s+/)[0]) + .filter(Boolean); +} + +export function assertOfficialNodeRuntime({ + actualVersion, + expectedVersion, + architectures, + signature, + linkedLibraries, +}) { + if (actualVersion !== expectedVersion) { + throw new Error(`CLI release requires Node ${expectedVersion}, found ${actualVersion}.`); + } + const architectureList = architectures.trim().split(/\s+/).filter(Boolean); + if (architectureList.length !== 1 || architectureList[0] !== 'arm64') { + throw new Error( + `CLI Node runtime must contain only arm64, found ${architectureList.join(', ')}.`, + ); + } + if (!signature.includes('Authority=Developer ID Application: Node.js Foundation (HX7739G8FX)')) { + throw new Error('CLI release requires the official Node.js Foundation runtime.'); + } + if (!signature.includes('flags=0x10000(runtime)')) { + throw new Error('CLI Node runtime must use the hardened runtime signature.'); + } + const nonSystemLibraries = linkedLibraries.filter( + (path) => !path.startsWith('/usr/lib/') && !path.startsWith('/System/Library/'), + ); + if (nonSystemLibraries.length > 0) { + throw new Error( + `CLI Node runtime is not self-contained; non-system libraries: ${nonSystemLibraries.join(', ')}`, + ); + } +} + +async function inspectReleaseToolchain({ execPath, env, inspect, toolchain }) { + const [nodeVersion, npmVersion, architectures, signature, dependencies] = await Promise.all([ + inspect(execPath, ['-p', 'process.versions.node'], { env }), + inspect('npm', ['--version'], { env }), + inspect('lipo', ['-archs', execPath], { env }), + inspect('codesign', ['-d', '--verbose=4', execPath], { env }), + inspect('otool', ['-L', execPath], { env }), + ]); + assertOfficialNodeRuntime({ + actualVersion: nodeVersion.stdout.trim(), + expectedVersion: toolchain.nodeVersion, + architectures: architectures.stdout, + signature: `${signature.stdout}\n${signature.stderr}`, + linkedLibraries: parseLinkedLibraries(dependencies.stdout), + }); + if (npmVersion.stdout.trim() !== toolchain.npmVersion) { + throw new Error( + `CLI release requires npm ${toolchain.npmVersion}, found ${npmVersion.stdout.trim()}.`, + ); + } +} + +async function findFiles(directory, predicate) { + const matches = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) matches.push(...(await findFiles(path, predicate))); + else if (entry.isFile() && predicate(path)) matches.push(path); + } + return matches; +} + +function isCliNativeBinary(path) { + return path.endsWith('.node') || basename(path) === 'spawn-helper'; +} + +export function assertReleaseSigningEnvironment(env) { + for (const name of requiredSigningEnvironment) { + if (!env[name]?.trim()) throw new Error(`CLI release signing requires ${name}.`); + } +} + +export function assertAcceptedNotarization(output) { + let result; + try { + result = JSON.parse(output); + } catch { + throw new Error('notarytool did not return valid JSON.'); + } + if (result.status !== 'Accepted') { + throw new Error(`CLI notarization failed with status ${result.status ?? 'unknown'}.`); + } +} + +async function signCliBinaries(archiveRoot, { env, run }) { + assertReleaseSigningEnvironment(env); + const { createKeychain, findIdentity, removeKeychain } = requireFromHere( + 'app-builder-lib/out/codeSign/macCodeSign', + ); + const { TmpDir } = requireFromHere('temp-file'); + const temporaryFiles = new TmpDir('maka-cli-signing'); + let keychainFile; + try { + ({ keychainFile } = await createKeychain({ + tmpDir: temporaryFiles, + cscLink: env.CSC_LINK, + cscKeyPassword: env.CSC_KEY_PASSWORD, + currentDir: repoRoot, + })); + const identity = await findIdentity('Developer ID Application', null, keychainFile); + if (!identity) throw new Error('Could not resolve a Developer ID Application identity.'); + if (!identity.hash) throw new Error('Developer ID Application identity is missing its hash.'); + + const nodePath = join(archiveRoot, 'libexec', 'node', 'bin', 'node'); + const nativeBinaries = await findFiles( + join(archiveRoot, 'libexec', 'node_modules'), + isCliNativeBinary, + ); + if (nativeBinaries.length === 0) throw new Error('CLI artifact contains no native binaries.'); + for (const binaryPath of [...nativeBinaries, nodePath]) { + await run( + 'codesign', + [ + '--force', + '--options', + 'runtime', + '--timestamp', + '--sign', + identity.hash, + '--keychain', + keychainFile, + binaryPath, + ], + { env }, + ); + await run('codesign', ['--verify', '--strict', '--verbose=2', binaryPath], { env }); + } + return { identityName: identity.name, nativeBinaryCount: nativeBinaries.length }; + } finally { + if (keychainFile) await removeKeychain(keychainFile, false); + await temporaryFiles.cleanup(); + } +} + +async function createCliZip(archiveRoot, archivePath, { env, run }) { + await rm(archivePath, { force: true }); + await run( + 'ditto', + [ + '-c', + '-k', + '--keepParent', + '--norsrc', + '--noextattr', + '--noqtn', + '--noacl', + archiveRoot, + archivePath, + ], + { env }, + ); +} + +async function notarizeCliZip(archivePath, { env, inspect }) { + const result = await inspect( + 'xcrun', + [ + 'notarytool', + 'submit', + archivePath, + '--key', + env.APPLE_API_KEY, + '--key-id', + env.APPLE_API_KEY_ID, + '--issuer', + env.APPLE_API_ISSUER, + '--wait', + '--output-format', + 'json', + ], + { env, timeout: 20 * 60_000 }, + ); + assertAcceptedNotarization(result.stdout); +} + +export async function packageMacosArm64Cli({ + platform = process.platform, + arch = process.arch, + execPath = process.execPath, + env = process.env, + run = runCommand, + inspect = inspectCommand, + releaseSigning = env.MAKA_CLI_RELEASE_SIGNING === '1', +} = {}) { + assertMacosArm64CliHost(platform, arch); + + const [rootManifest, desktopManifest, workspacePackages] = await Promise.all([ + readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), + readFile(join(repoRoot, 'apps', 'desktop', 'package.json'), 'utf8').then(JSON.parse), + resolveCliWorkspacePackages(), + ]); + if (typeof desktopManifest.version !== 'string' || !desktopManifest.version.trim()) { + throw new Error('Desktop release version is missing.'); + } + const toolchain = releaseToolchainFromManifest(rootManifest); + if (releaseSigning) assertReleaseSigningEnvironment(env); + await inspectReleaseToolchain({ execPath, env, inspect, toolchain }); + + const version = desktopManifest.version; + const nodeRoot = dirname(dirname(execPath)); + const nodeLicensePath = join(nodeRoot, 'LICENSE'); + await Promise.all([ + access(execPath), + access(nodeLicensePath), + access(join(repoRoot, 'LICENSE')), + access(join(repoRoot, 'NOTICE')), + ...workspacePackages.map(({ directory }) => access(join(directory, 'dist'))), + ]); + + const { archiveRootName, archivePath, checksumPath } = resolveMacosArm64CliArtifactPaths(version); + await mkdir(releaseDirectory, { recursive: true }); + const stagingRoot = await mkdtemp(join(tmpdir(), 'maka-cli-')); + let complete = false; + + try { + const installRoot = join(stagingRoot, 'install'); + await mkdir(installRoot, { recursive: true }); + await stageWorkspacePackages(installRoot, workspacePackages); + await run('npm', macosArm64CliInstallArgs(), { cwd: installRoot, env }); + const dependencyPatches = await applyDependencyPatches(installRoot, { env, run }); + + const nodeModulesDirectory = join(installRoot, 'node_modules'); + await rewriteCliVersion(installRoot, workspacePackages, version); + await pruneNonTargetNativeBinaries(nodeModulesDirectory); + await pruneTestArtifacts(nodeModulesDirectory); + + const archiveRoot = join(stagingRoot, archiveRootName); + const binDirectory = join(archiveRoot, 'bin'); + const embeddedNodeDirectory = join(archiveRoot, 'libexec', 'node'); + await Promise.all([ + mkdir(binDirectory, { recursive: true }), + mkdir(join(embeddedNodeDirectory, 'bin'), { recursive: true }), + ]); + await rename(nodeModulesDirectory, join(archiveRoot, 'libexec', 'node_modules')); + for (const { workspacePath } of workspacePackages) { + const source = join(installRoot, workspacePath); + const target = join(archiveRoot, 'libexec', workspacePath); + await mkdir(dirname(target), { recursive: true }); + await rename(source, target); + } + await Promise.all([ + copyFile(execPath, join(embeddedNodeDirectory, 'bin', 'node')), + copyFile(nodeLicensePath, join(embeddedNodeDirectory, 'LICENSE')), + copyFile(join(repoRoot, 'LICENSE'), join(archiveRoot, 'LICENSE')), + copyFile(join(repoRoot, 'NOTICE'), join(archiveRoot, 'NOTICE')), + writeFile(join(binDirectory, 'maka'), macosArm64CliWrapper(), 'utf8'), + writeFile(join(binDirectory, 'maka-agent'), macosArm64CliWrapper(), 'utf8'), + writeFile( + join(archiveRoot, 'RELEASE.json'), + `${JSON.stringify( + { + version, + nodeVersion: toolchain.nodeVersion, + npmVersion: toolchain.npmVersion, + dependencyPatches, + workspacePackages: workspacePackages.map(({ name }) => name).sort(), + signing: releaseSigning ? 'developer-id-notarized' : 'development', + }, + null, + 2, + )}\n`, + 'utf8', + ), + writeFile( + join(archiveRoot, 'README.txt'), + [ + `Maka CLI/TUI ${version} for Apple Silicon macOS`, + '', + "Add this directory's bin folder to PATH, then run:", + ' maka --help', + '', + 'The archive includes its own Node.js runtime and does not require the Maka desktop app.', + '', + ].join('\n'), + 'utf8', + ), + ]); + await Promise.all([ + chmod(join(embeddedNodeDirectory, 'bin', 'node'), 0o755), + chmod(join(binDirectory, 'maka'), 0o755), + chmod(join(binDirectory, 'maka-agent'), 0o755), + ]); + await assertWorkspaceLinks(archiveRoot, workspacePackages); + + let signing; + if (releaseSigning) signing = await signCliBinaries(archiveRoot, { env, run }); + await createCliZip(archiveRoot, archivePath, { env, run }); + if (releaseSigning) await notarizeCliZip(archivePath, { env, inspect }); + + const sha256 = await sha256File(archivePath); + await writeFile(checksumPath, `${sha256} ${basename(archivePath)}\n`, 'utf8'); + complete = true; + return { archivePath, checksumPath, dependencyPatches, sha256, signing, version }; + } finally { + await rm(stagingRoot, { recursive: true, force: true }); + if (!complete) { + const { archivePath, checksumPath } = resolveMacosArm64CliArtifactPaths(version); + await Promise.all([rm(archivePath, { force: true }), rm(checksumPath, { force: true })]); + } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const result = await packageMacosArm64Cli(); + console.log(`Created ${result.archivePath}`); + console.log(`SHA-256 ${result.sha256}`); +} diff --git a/scripts/verify-macos-arm64-cli.mjs b/scripts/verify-macos-arm64-cli.mjs new file mode 100644 index 0000000000..75d5713d67 --- /dev/null +++ b/scripts/verify-macos-arm64-cli.mjs @@ -0,0 +1,512 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { + assertMacosArm64CliHost, + assertNoDanglingSymlinks, + listDependencyPatchNames, + releaseToolchainFromManifest, + resolveCliWorkspacePackages, + resolveMacosArm64CliArtifactPaths, +} from './package-macos-arm64-cli.mjs'; + +const execFileAsync = promisify(execFile); +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const tuiReadyPattern = /陪你把事做完|配置模型提供商/; + +async function runCommand(command, args, options = {}) { + return execFileAsync(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + maxBuffer: 20 * 1024 * 1024, + timeout: options.timeout ?? 30_000, + }); +} + +async function sha256File(path) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +export function isTuiReadyOutput(output) { + return tuiReadyPattern.test(output); +} + +export function assertExpectedTuiExit({ ready, stopRequested, exitCode, signal, output }) { + if (!ready) { + throw new Error( + `TUI exited before rendering in a PTY (exit ${exitCode}, signal ${signal}). Output: ${output.slice(-1000)}`, + ); + } + if (!stopRequested || (exitCode !== 0 && exitCode !== 130)) { + throw new Error( + `TUI crashed after startup (exit ${exitCode}, signal ${signal}). Output: ${output.slice(-1000)}`, + ); + } +} + +export function assertSafeCliArchiveEntries(entries, archiveRootName) { + if (entries.length === 0) throw new Error('CLI archive is empty.'); + for (const entry of entries) { + const normalized = entry.replace(/\\/g, '/'); + const segments = normalized.split('/').filter(Boolean); + if ( + normalized.startsWith('/') || + segments.includes('..') || + segments.some((segment) => segment.startsWith('._')) || + segments[0] !== archiveRootName + ) { + throw new Error(`Unsafe CLI archive entry: ${entry}`); + } + } +} + +async function smokeTuiInPty(archiveRoot, environment) { + const cliManifestPath = join( + archiveRoot, + 'libexec', + 'node_modules', + 'maka-agent', + 'package.json', + ); + const requireFromCli = createRequire(cliManifestPath); + const pty = requireFromCli('node-pty'); + const executable = join(archiveRoot, 'bin', 'maka'); + const workspaceRoot = join( + environment.HOME, + 'Library', + 'Application Support', + 'Maka', + 'workspaces', + 'default', + ); + await mkdir(workspaceRoot, { recursive: true }); + const storageEntry = requireFromCli.resolve('@maka/storage'); + const { createConnectionStore } = await import(pathToFileURL(storageEntry).href); + await createConnectionStore(workspaceRoot).create({ + slug: 'release-smoke-local', + name: 'Release smoke local', + providerType: 'ollama', + defaultModel: 'release-smoke-model', + }); + + await new Promise((resolvePromise, reject) => { + let output = ''; + let ready = false; + let stopRequested = false; + let closeTimer; + const child = pty.spawn(executable, [], { + cols: 100, + rows: 30, + cwd: archiveRoot, + env: { ...environment, TERM: 'xterm-256color' }, + }); + const timeout = setTimeout(() => { + child.kill(); + reject(new Error(`TUI did not start in a PTY. Output: ${output.slice(-1000)}`)); + }, 10_000); + + child.onData((data) => { + output += data; + if (!ready && isTuiReadyOutput(output)) { + ready = true; + stopRequested = true; + child.write('\u0003'); + closeTimer = setTimeout(() => child.write('\u0003'), 250); + } + }); + child.onExit(({ exitCode, signal }) => { + clearTimeout(timeout); + clearTimeout(closeTimer); + try { + assertExpectedTuiExit({ ready, stopRequested, exitCode, signal, output }); + resolvePromise(); + } catch (error) { + reject(error); + } + }); + }); +} + +function parseLinkedLibraries(output) { + return output + .split('\n') + .slice(1) + .map((line) => line.trim().split(/\s+/)[0]) + .filter(Boolean); +} + +function assertSelfContainedNode(output) { + const nonSystemLibraries = parseLinkedLibraries(output).filter( + (path) => !path.startsWith('/usr/lib/') && !path.startsWith('/System/Library/'), + ); + if (nonSystemLibraries.length > 0) { + throw new Error(`Embedded Node links non-system libraries: ${nonSystemLibraries.join(', ')}`); + } +} + +function parseSignatureDetails(output) { + const authority = output.match(/^Authority=(Developer ID Application: .+)$/m)?.[1]; + const teamIdentifier = output.match(/^TeamIdentifier=(.+)$/m)?.[1]; + const hardenedRuntime = output.includes('flags=0x10000(runtime)'); + return { authority, hardenedRuntime, teamIdentifier }; +} + +async function findFiles(directory, predicate) { + const matches = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) matches.push(...(await findFiles(path, predicate))); + else if (entry.isFile() && predicate(path)) matches.push(path); + } + return matches; +} + +async function assertNoTestArtifacts(archiveRoot) { + const libexecRoot = join(archiveRoot, 'libexec'); + const forbidden = await findFiles(libexecRoot, (path) => { + const pathFromLibexec = relative(libexecRoot, path); + return ( + pathFromLibexec.split(sep).includes('__tests__') || + /\.(?:test|spec)\.(?:[cm]?js|d\.ts|[cm]?js\.map)$/.test(path) + ); + }); + if (forbidden.length > 0) { + throw new Error(`CLI artifact contains test files: ${forbidden.slice(0, 5).join(', ')}`); + } +} + +async function assertWorkspaceClosure(archiveRoot, metadata) { + const workspacePackages = await resolveCliWorkspacePackages(); + const expectedNames = workspacePackages.map(({ name }) => name).sort(); + if (JSON.stringify(metadata.workspacePackages) !== JSON.stringify(expectedNames)) { + throw new Error('CLI artifact workspace closure does not match package manifests.'); + } + for (const { name, workspacePath } of workspacePackages) { + const linkPath = join(archiveRoot, 'libexec', 'node_modules', ...name.split('/')); + const packagePath = join(archiveRoot, 'libexec', workspacePath); + const [resolvedLink, resolvedPackage] = await Promise.all([ + import('node:fs/promises').then(({ realpath }) => realpath(linkPath)), + import('node:fs/promises').then(({ realpath }) => realpath(packagePath)), + ]); + if (resolvedLink !== resolvedPackage) { + throw new Error(`${name} does not resolve to the packaged workspace directory.`); + } + } + await assertNoDanglingSymlinks(join(archiveRoot, 'libexec')); +} + +function streamingChunk(delta, finishReason = null) { + return { + id: 'chatcmpl-release-smoke', + object: 'chat.completion.chunk', + created: 0, + model: 'release-smoke-model', + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; +} + +export function assertPatchedStreamingToolCalls(parts) { + const errors = parts.filter((part) => part.type === 'error'); + if (errors.length > 0 || parts.at(-1)?.type !== 'finish') { + throw new Error('Packaged provider-utils failed to finish streamed tool calls.'); + } + const actualCalls = parts + .filter((part) => part.type === 'tool-call') + .map(({ toolCallId, toolName, input }) => ({ toolCallId, toolName, input })); + const expectedCalls = [ + { toolCallId: 'call_1', toolName: 'read_file', input: '{"path":"a.txt"}' }, + { toolCallId: 'call_2', toolName: 'read_file', input: '{"path":"b.txt"}' }, + ]; + if (JSON.stringify(actualCalls) !== JSON.stringify(expectedCalls)) { + throw new Error('Packaged provider-utils reordered or dropped streamed tool calls.'); + } +} + +async function smokePatchedStreamingToolCalls(archiveRoot) { + const cliManifestPath = join( + archiveRoot, + 'libexec', + 'node_modules', + 'maka-agent', + 'package.json', + ); + const requireFromCli = createRequire(cliManifestPath); + const runtimeEntry = requireFromCli.resolve('@maka/runtime'); + const { getAIModel } = await import(pathToFileURL(runtimeEntry).href); + const payloads = [ + streamingChunk({ role: 'assistant', content: 'Reading both files.' }), + streamingChunk({ + tool_calls: [ + { + index: 1, + id: 'call_1', + type: 'function', + function: { name: 'read_file', arguments: '' }, + }, + ], + }), + streamingChunk({ tool_calls: [{ index: 1, function: { arguments: '{"path":"a.txt"}' } }] }), + streamingChunk({ + tool_calls: [ + { + index: 2, + id: 'call_2', + type: 'function', + function: { name: 'read_file', arguments: '' }, + }, + ], + }), + streamingChunk({ tool_calls: [{ index: 2, function: { arguments: '{"path":"b.txt"}' } }] }), + streamingChunk({}, 'tool_calls'), + ]; + const body = `${payloads.map((payload) => `data: ${JSON.stringify(payload)}\n\n`).join('')}data: [DONE]\n\n`; + const model = getAIModel({ + connection: { + slug: 'release-smoke', + providerType: 'openai-compatible', + baseUrl: 'https://release-smoke.invalid/v1', + defaultModel: 'release-smoke-model', + }, + apiKey: 'release-smoke-key', + modelId: 'release-smoke-model', + fetch: async () => + new Response(body, { headers: { 'content-type': 'text/event-stream' }, status: 200 }), + }); + const { stream } = await model.doStream({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'read a.txt and b.txt' }] }], + tools: [ + { + type: 'function', + name: 'read_file', + inputSchema: { type: 'object', properties: { path: { type: 'string' } } }, + }, + ], + }); + const parts = []; + for await (const part of stream) parts.push(part); + assertPatchedStreamingToolCalls(parts); +} + +async function verifyBinarySignatures(binaryPaths, { requireReleaseSigning, run }) { + let expectedTeamIdentifier; + for (const binaryPath of binaryPaths) { + await run('codesign', ['--verify', '--strict', '--verbose=2', binaryPath]); + if (!requireReleaseSigning) continue; + const signature = await run('codesign', ['-d', '--verbose=4', binaryPath]); + const details = parseSignatureDetails(`${signature.stdout}\n${signature.stderr}`); + if (!details.authority || !details.hardenedRuntime || !details.teamIdentifier) { + throw new Error(`${binaryPath} is not signed with a hardened Developer ID identity.`); + } + expectedTeamIdentifier ??= details.teamIdentifier; + if (details.teamIdentifier !== expectedTeamIdentifier) { + throw new Error(`${binaryPath} is signed by a different Developer ID team.`); + } + } +} + +export async function verifyMacosArm64Cli( + archivePath, + { + platform = process.platform, + arch = process.arch, + run = runCommand, + smokeTui = smokeTuiInPty, + requireReleaseSigning = process.env.MAKA_CLI_REQUIRE_RELEASE_SIGNING === '1', + } = {}, +) { + assertMacosArm64CliHost(platform, arch); + const [rootManifest, desktopManifest] = await Promise.all([ + readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), + readFile(join(repoRoot, 'apps', 'desktop', 'package.json'), 'utf8').then(JSON.parse), + ]); + const toolchain = releaseToolchainFromManifest(rootManifest); + const version = desktopManifest.version; + const expectedPaths = resolveMacosArm64CliArtifactPaths(version); + const resolvedArchivePath = resolve(archivePath ?? expectedPaths.archivePath); + const checksumPath = `${resolvedArchivePath}.sha256`; + await Promise.all([access(resolvedArchivePath), access(checksumPath)]); + + const sha256 = await sha256File(resolvedArchivePath); + const expectedChecksum = `${sha256} ${basename(resolvedArchivePath)}\n`; + const actualChecksum = await readFile(checksumPath, 'utf8'); + if (actualChecksum !== expectedChecksum) { + throw new Error(`CLI checksum does not match ${basename(resolvedArchivePath)}.`); + } + + const archiveEntries = await run('unzip', ['-Z1', resolvedArchivePath]); + assertSafeCliArchiveEntries( + archiveEntries.stdout.split('\n').filter(Boolean), + expectedPaths.archiveRootName, + ); + + const extractionRoot = await mkdtemp(join(dirname(resolvedArchivePath), '.verify-cli-')); + try { + await run('ditto', ['-x', '-k', resolvedArchivePath, extractionRoot]); + const archiveRoot = join(extractionRoot, expectedPaths.archiveRootName); + const nodePath = join(archiveRoot, 'libexec', 'node', 'bin', 'node'); + const makaPath = join(archiveRoot, 'bin', 'maka'); + const makaAgentPath = join(archiveRoot, 'bin', 'maka-agent'); + const metadataPath = join(archiveRoot, 'RELEASE.json'); + const requiredPaths = [ + nodePath, + makaPath, + makaAgentPath, + metadataPath, + join(archiveRoot, 'LICENSE'), + join(archiveRoot, 'NOTICE'), + join(archiveRoot, 'libexec', 'node', 'LICENSE'), + ]; + await Promise.all(requiredPaths.map((path) => access(path))); + + const [metadata, expectedDependencyPatches] = await Promise.all([ + readFile(metadataPath, 'utf8').then(JSON.parse), + listDependencyPatchNames(), + ]); + if ( + metadata.version !== version || + metadata.nodeVersion !== toolchain.nodeVersion || + metadata.npmVersion !== toolchain.npmVersion + ) { + throw new Error('CLI release metadata does not match the pinned release toolchain.'); + } + if (JSON.stringify(metadata.dependencyPatches) !== JSON.stringify(expectedDependencyPatches)) { + throw new Error('CLI release metadata does not match the repository dependency patches.'); + } + if (requireReleaseSigning && metadata.signing !== 'developer-id-notarized') { + throw new Error('Release CLI artifact is not marked as Developer ID signed and notarized.'); + } + await Promise.all([ + assertWorkspaceClosure(archiveRoot, metadata), + assertNoTestArtifacts(archiveRoot), + ]); + + const nativeBinaries = await findFiles( + join(archiveRoot, 'libexec', 'node_modules'), + (path) => path.endsWith('.node') || basename(path) === 'spawn-helper', + ); + if (nativeBinaries.length === 0) throw new Error('CLI artifact contains no native binaries.'); + for (const binaryPath of [nodePath, ...nativeBinaries]) { + const { stdout } = await run('lipo', ['-archs', binaryPath]); + const architectures = stdout.trim().split(/\s+/).filter(Boolean); + if (architectures.length !== 1 || architectures[0] !== 'arm64') { + throw new Error(`${binaryPath} must contain only arm64.`); + } + } + const nodeDependencies = await run('otool', ['-L', nodePath]); + assertSelfContainedNode(nodeDependencies.stdout); + await verifyBinarySignatures([nodePath, ...nativeBinaries], { requireReleaseSigning, run }); + + if (requireReleaseSigning) { + await run('xattr', [ + '-w', + '-r', + 'com.apple.quarantine', + '0083;00000000;GitHub;MakaReleaseVerification', + archiveRoot, + ]); + } + + const isolatedHome = join(extractionRoot, 'home'); + const commandWorkspace = join(extractionRoot, 'workspace'); + await Promise.all([mkdir(isolatedHome), mkdir(commandWorkspace)]); + const environment = { + HOME: isolatedHome, + LANG: 'en_US.UTF-8', + MAKA_DISABLE_DEFERRED_TOOLS: '1', + PATH: '/usr/bin:/bin:/usr/sbin:/sbin', + SHELL: '/bin/zsh', + TMPDIR: extractionRoot, + }; + + const embeddedNodeVersion = await run(nodePath, ['-p', 'process.versions.node'], { + cwd: commandWorkspace, + env: environment, + }); + if (embeddedNodeVersion.stdout.trim() !== toolchain.nodeVersion) { + throw new Error('Embedded Node version does not match the pinned release toolchain.'); + } + const versionResult = await run(makaPath, ['--version'], { + cwd: commandWorkspace, + env: environment, + }); + if (versionResult.stdout.trim() !== version) { + throw new Error( + `CLI version ${versionResult.stdout.trim()} does not match desktop ${version}.`, + ); + } + const aliasVersionResult = await run(makaAgentPath, ['--version'], { + cwd: commandWorkspace, + env: environment, + }); + if (aliasVersionResult.stdout.trim() !== version) { + throw new Error('maka-agent alias reports a different version.'); + } + const helpResult = await run(makaPath, ['--help'], { + cwd: commandWorkspace, + env: environment, + }); + for (const command of ['run', 'eval', 'inspect']) { + if (!helpResult.stdout.includes(command)) { + throw new Error(`CLI help does not list ${command}.`); + } + } + await smokePatchedStreamingToolCalls(archiveRoot); + + const fixtureDirectory = join(extractionRoot, 'eval-fixture'); + const outputDirectory = join(extractionRoot, 'eval-output'); + const specPath = join(extractionRoot, 'eval-spec.json'); + await mkdir(fixtureDirectory); + await writeFile(join(fixtureDirectory, 'marker.txt'), 'ok\n', 'utf8'); + await writeFile( + specPath, + JSON.stringify({ + configs: [ + { id: 'fake-cfg', backend: 'fake', llmConnectionSlug: 'fake', model: 'fake-model' }, + ], + tasks: [ + { + id: 'portable-cli-smoke', + instruction: 'verify the portable CLI', + workspaceDir: fixtureDirectory, + verification: { command: 'test -f marker.txt', protectedPaths: [] }, + }, + ], + }), + 'utf8', + ); + const evalResult = await run(makaPath, ['eval', 'run', specPath, '--out', outputDirectory], { + cwd: commandWorkspace, + env: environment, + timeout: 60_000, + }); + if (!evalResult.stdout.includes('portable-cli-smoke')) { + throw new Error('Deterministic non-interactive evaluation did not complete.'); + } + await access(join(outputDirectory, 'comparison.md')); + await smokeTui(archiveRoot, environment); + + return { + archivePath: resolvedArchivePath, + checksumPath, + nativeBinaryCount: nativeBinaries.length, + sha256, + streamingPatchVerified: true, + version, + }; + } finally { + await rm(extractionRoot, { recursive: true, force: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const result = await verifyMacosArm64Cli(process.argv[2]); + console.log(`Verified ${result.archivePath}`); + console.log(`SHA-256 ${result.sha256}`); +}