| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| import { execFile, spawn } from 'node:child_process'; | |
| import { createHash, randomBytes } 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'; | |
| import { resolveProductReleaseIdentity } from './product-release-identity.mjs'; | |
| import { | |
| isMakaDevelopmentArtifact, | |
| isThirdPartyDevelopmentArtifact, | |
| releaseNpmEnvironment, | |
| resolveReleaseWorkspacePackages, | |
| resolveWorkspaceReleaseFiles, | |
| workspaceReleaseManifest, | |
| } from './release-cli-file-policy.mjs'; | |
| 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 requiredSigningEnvironment = [ | |
| 'CSC_LINK', | |
| 'CSC_KEY_PASSWORD', | |
| 'APPLE_API_KEY', | |
| 'APPLE_API_KEY_ID', | |
| 'APPLE_API_ISSUER', | |
| ]; | |
| export const OFFICIAL_NODE_RUNTIME_ENTITLEMENTS = Object.freeze([ | |
| 'com.apple.security.cs.allow-dyld-environment-variables', | |
| 'com.apple.security.cs.allow-jit', | |
| 'com.apple.security.cs.allow-unsigned-executable-memory', | |
| 'com.apple.security.cs.disable-executable-page-protection', | |
| 'com.apple.security.cs.disable-library-validation', | |
| 'com.apple.security.get-task-allow', | |
| ]); | |
| // The official archive is validated verbatim above, but Apple rejects | |
| // distribution software that keeps get-task-allow. Re-sign only the Node | |
| // executable with the same runtime capabilities minus that development grant. | |
| export const DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS = Object.freeze( | |
| OFFICIAL_NODE_RUNTIME_ENTITLEMENTS.filter( | |
| (entitlement) => entitlement !== 'com.apple.security.get-task-allow', | |
| ), | |
| ); | |
| export 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} ${(options.displayArgs ?? 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 | |
| launcher=$0 | |
| while [ -L "$launcher" ]; do | |
| link_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) | |
| link_target=$(readlink "$launcher") | |
| case "$link_target" in | |
| /*) launcher=$link_target ;; | |
| *) launcher=$link_dir/$link_target ;; | |
| esac | |
| done | |
| bin_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) | |
| libexec_dir=$(CDPATH= cd -P "$bin_dir/../libexec" && pwd) | |
| MAKA_EVAL_MAKA_BUNDLE_PATH=$libexec_dir | |
| export MAKA_EVAL_MAKA_BUNDLE_PATH | |
| exec "$libexec_dir/node/bin/node" "$libexec_dir/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 standaloneInstallEnvironment(environment) { | |
| return releaseNpmEnvironment(environment, join(repoRoot, '.npmrc')); | |
| } | |
| export async function resolveCliWorkspacePackages() { | |
| return resolveReleaseWorkspacePackages(repoRoot, cliPackageName); | |
| } | |
| export async function pruneThirdPartyDevelopmentArtifacts(directory, root = directory) { | |
| for (const entry of await readdir(directory, { withFileTypes: true })) { | |
| const path = join(directory, entry.name); | |
| const relativePath = relative(root, path); | |
| if (isThirdPartyDevelopmentArtifact(relativePath)) { | |
| await rm(path, { recursive: entry.isDirectory(), force: true }); | |
| } else if (entry.isDirectory()) { | |
| await pruneThirdPartyDevelopmentArtifacts(path, root); | |
| } | |
| } | |
| } | |
| export function standaloneInstallRootManifest(rootManifest, workspacePackages) { | |
| const { allowScripts: _allowScripts, ...staged } = rootManifest; | |
| return { | |
| ...staged, | |
| workspaces: workspacePackages.map(({ workspacePath }) => workspacePath), | |
| }; | |
| } | |
| async function sha256File(path) { | |
| const hash = createHash('sha256'); | |
| for await (const chunk of createReadStream(path)) hash.update(chunk); | |
| return hash.digest('hex'); | |
| } | |
| export async function stageWorkspacePackages(installRoot, workspacePackages) { | |
| const rootManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); | |
| await Promise.all([ | |
| writeFile( | |
| join(installRoot, 'package.json'), | |
| `${JSON.stringify(standaloneInstallRootManifest(rootManifest, workspacePackages), null, 2)}\n`, | |
| 'utf8', | |
| ), | |
| copyFile(join(repoRoot, 'package-lock.json'), join(installRoot, 'package-lock.json')), | |
| ...workspacePackages.map(async ({ directory, manifest, workspacePath }) => { | |
| const targetDirectory = join(installRoot, workspacePath); | |
| await mkdir(targetDirectory, { recursive: true }); | |
| await writeFile( | |
| join(targetDirectory, 'package.json'), | |
| `${JSON.stringify(workspaceReleaseManifest(manifest), null, 2)}\n`, | |
| ); | |
| await Promise.all( | |
| resolveWorkspaceReleaseFiles(directory, manifest).map(async (releaseFile) => { | |
| const source = join(directory, ...releaseFile.split('/')); | |
| const target = join(targetDirectory, ...releaseFile.split('/')); | |
| await mkdir(dirname(target), { recursive: true }); | |
| await cp(source, target, { | |
| recursive: true, | |
| filter: (path) => !isMakaDevelopmentArtifact(relative(directory, path)), | |
| }); | |
| }), | |
| ); | |
| }), | |
| ]); | |
| } | |
| 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 dependencyPatchPackageName(patchName) { | |
| const patch = await readFile(join(dependencyPatchesDirectory, patchName), 'utf8'); | |
| const target = patch.match(/^diff --git a\/node_modules\/((?:@[^/]+\/)?[^/]+)\//m)?.[1]; | |
| if (!target) throw new Error(`Cannot resolve the target package for patch ${patchName}.`); | |
| return target; | |
| } | |
| export async function listApplicableDependencyPatchNames(nodeModulesDirectory) { | |
| const applicable = []; | |
| for (const patchName of await listDependencyPatchNames()) { | |
| const packageName = await dependencyPatchPackageName(patchName); | |
| try { | |
| await access(packageModulePath(nodeModulesDirectory, packageName)); | |
| applicable.push(patchName); | |
| } catch (error) { | |
| if (error?.code !== 'ENOENT') throw error; | |
| } | |
| } | |
| return applicable; | |
| } | |
| export async function applyDependencyPatches( | |
| installRoot, | |
| { env = process.env, run = runCommand, patchPackageEntry } = {}, | |
| ) { | |
| const patchNames = await listApplicableDependencyPatchNames(join(installRoot, 'node_modules')); | |
| 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; | |
| } | |
| function packageModulePath(nodeModulesDirectory, packageName) { | |
| return join(nodeModulesDirectory, ...packageName.split('/')); | |
| } | |
| export async function assertNoDanglingSymlinks(directory, rootDirectory = directory) { | |
| const resolvedRoot = await realpath(rootDirectory); | |
| for (const entry of await readdir(directory, { withFileTypes: true })) { | |
| const path = join(directory, entry.name); | |
| if (entry.isSymbolicLink()) { | |
| try { | |
| const target = await realpath(path); | |
| const targetFromRoot = relative(resolvedRoot, target); | |
| if ( | |
| targetFromRoot === '..' || | |
| targetFromRoot.startsWith(`..${sep}`) || | |
| isAbsolute(targetFromRoot) | |
| ) { | |
| throw new Error(`Symlink escapes the CLI artifact: ${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, rootDirectory); | |
| } | |
| } | |
| } | |
| 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(', ')}`, | |
| ); | |
| } | |
| } | |
| function extractNodeEntitlements(output, expectedEntitlements, description) { | |
| const start = output.indexOf('<?xml'); | |
| const end = output.indexOf('</plist>'); | |
| if (start < 0 || end < start) { | |
| throw new Error(`${description} has no readable entitlement plist.`); | |
| } | |
| const plist = output.slice(start, end + '</plist>'.length); | |
| const allKeys = [...plist.matchAll(/<key>([^<]+)<\/key>/gu)].map((match) => match[1]).sort(); | |
| const keys = [...plist.matchAll(/<key>([^<]+)<\/key>\s*<true\s*\/>/gu)] | |
| .map((match) => match[1]) | |
| .sort(); | |
| if ( | |
| JSON.stringify(allKeys) !== JSON.stringify(expectedEntitlements) || | |
| JSON.stringify(keys) !== JSON.stringify(expectedEntitlements) | |
| ) { | |
| throw new Error(`${description} entitlements do not match the reviewed contract.`); | |
| } | |
| return plist; | |
| } | |
| export function extractOfficialNodeEntitlements(output) { | |
| return extractNodeEntitlements( | |
| output, | |
| OFFICIAL_NODE_RUNTIME_ENTITLEMENTS, | |
| 'Official Node runtime', | |
| ); | |
| } | |
| export function extractDistributionNodeEntitlements(output) { | |
| return extractNodeEntitlements( | |
| output, | |
| DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS, | |
| 'Distribution Node runtime', | |
| ); | |
| } | |
| export function distributionNodeEntitlements(officialEntitlements) { | |
| const plist = extractOfficialNodeEntitlements(officialEntitlements); | |
| const distributionPlist = plist.replace( | |
| /\s*<key>com\.apple\.security\.get-task-allow<\/key>\s*<true\s*\/>/u, | |
| '', | |
| ); | |
| return extractDistributionNodeEntitlements(distributionPlist); | |
| } | |
| async function inspectReleaseToolchain({ execPath, env, inspect, toolchain }) { | |
| const [nodeVersion, npmVersion, architectures, signature, entitlements, 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('codesign', ['-d', '--entitlements', ':-', 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()}.`, | |
| ); | |
| } | |
| return extractOfficialNodeEntitlements(`${entitlements.stdout}\n${entitlements.stderr}`); | |
| } | |
| export async function assertOfficialNodeArchive( | |
| archivePath, | |
| toolchain, | |
| { hashFile = sha256File } = {}, | |
| ) { | |
| if (basename(archivePath) !== toolchain.nodeArchive) { | |
| throw new Error(`CLI Node archive must be named ${toolchain.nodeArchive}.`); | |
| } | |
| const actualSha256 = await hashFile(archivePath); | |
| if (actualSha256 !== toolchain.nodeArchiveSha256) { | |
| throw new Error( | |
| `CLI Node archive digest mismatch: expected ${toolchain.nodeArchiveSha256}, found ${actualSha256}.`, | |
| ); | |
| } | |
| return actualSha256; | |
| } | |
| async function extractOfficialNodeRuntime(stagingRoot, archivePath, toolchain, { env, run }) { | |
| await access(archivePath); | |
| await assertOfficialNodeArchive(archivePath, toolchain); | |
| const extractionRoot = join(stagingRoot, 'official-node'); | |
| await mkdir(extractionRoot, { recursive: true }); | |
| await run('tar', ['-xJf', archivePath, '-C', extractionRoot], { env }); | |
| const distributionRoot = join(extractionRoot, `node-v${toolchain.nodeVersion}-darwin-arm64`); | |
| const execPath = join(distributionRoot, 'bin', 'node'); | |
| const licensePath = join(distributionRoot, 'LICENSE'); | |
| await Promise.all([access(execPath), access(licensePath)]); | |
| return { execPath, licensePath }; | |
| } | |
| export async function collectPackagedProductionDependencies( | |
| nodeModulesDirectory, | |
| workspaceNames = new Set(), | |
| ) { | |
| const dependencies = new Set(); | |
| async function visitNodeModules(directory) { | |
| let entries; | |
| try { | |
| entries = await readdir(directory, { withFileTypes: true }); | |
| } catch (error) { | |
| if (error?.code === 'ENOENT') return; | |
| throw error; | |
| } | |
| const packageDirectories = []; | |
| for (const entry of entries) { | |
| if (entry.name === '.bin') continue; | |
| const path = join(directory, entry.name); | |
| if (entry.name.startsWith('@') && entry.isDirectory()) { | |
| for (const scopedEntry of await readdir(path, { withFileTypes: true })) { | |
| packageDirectories.push(join(path, scopedEntry.name)); | |
| } | |
| } else if (entry.isDirectory() || entry.isSymbolicLink()) { | |
| packageDirectories.push(path); | |
| } | |
| } | |
| for (const packageDirectory of packageDirectories) { | |
| let manifest; | |
| try { | |
| manifest = JSON.parse(await readFile(join(packageDirectory, 'package.json'), 'utf8')); | |
| } catch (error) { | |
| if (error?.code === 'ENOENT') continue; | |
| throw error; | |
| } | |
| if ( | |
| !workspaceNames.has(manifest.name) && | |
| typeof manifest.name === 'string' && | |
| typeof manifest.version === 'string' | |
| ) { | |
| dependencies.add(`${manifest.name}@${manifest.version}`); | |
| } | |
| await visitNodeModules(join(packageDirectory, 'node_modules')); | |
| } | |
| } | |
| await visitNodeModules(nodeModulesDirectory); | |
| return [...dependencies].sort(); | |
| } | |
| 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; | |
| } | |
| export async function inspectNativeArtifacts( | |
| directory, | |
| { inspect = inspectCommand, concurrency = 16 } = {}, | |
| ) { | |
| const files = await findFiles(directory, () => true); | |
| const foreignBinaries = []; | |
| const machOBinaries = []; | |
| let nextIndex = 0; | |
| const workers = Array.from( | |
| { length: Math.min(Math.max(1, concurrency), files.length) }, | |
| async () => { | |
| while (nextIndex < files.length) { | |
| const path = files[nextIndex++]; | |
| const result = await inspect('file', ['-b', path]); | |
| if (/\bMach-O\b/.test(result.stdout)) machOBinaries.push(path); | |
| else if (/^(?:ELF\b|PE32\b|MS-DOS executable\b)/.test(result.stdout)) { | |
| foreignBinaries.push(path); | |
| } | |
| } | |
| }, | |
| ); | |
| await Promise.all(workers); | |
| return { foreignBinaries: foreignBinaries.sort(), machOBinaries: machOBinaries.sort() }; | |
| } | |
| export async function findMachOBinaries(directory, options = {}) { | |
| return (await inspectNativeArtifacts(directory, options)).machOBinaries; | |
| } | |
| export function isMacosArm64MachO(architectures, buildVersion) { | |
| return architectures.trim() === 'arm64' && /^\s*platform MACOS\s*$/m.test(buildVersion); | |
| } | |
| export function macosArm64MachOAction(architectures, buildVersion) { | |
| const architectureList = architectures.trim().split(/\s+/u).filter(Boolean); | |
| if (!architectureList.includes('arm64') || !/^\s*platform MACOS\s*$/m.test(buildVersion)) { | |
| return 'remove'; | |
| } | |
| return architectureList.length === 1 ? 'keep' : 'thin'; | |
| } | |
| async function pruneNonTargetNativeBinaries(nodeModulesDirectory, { inspect, run }) { | |
| const { foreignBinaries, machOBinaries } = await inspectNativeArtifacts(nodeModulesDirectory, { | |
| inspect, | |
| }); | |
| await Promise.all(foreignBinaries.map((path) => rm(path, { force: true }))); | |
| for (const binaryPath of machOBinaries) { | |
| const [architectures, buildVersion] = await Promise.all([ | |
| inspect('lipo', ['-archs', binaryPath]), | |
| inspect('xcrun', ['vtool', '-show-build', binaryPath]), | |
| ]); | |
| const action = macosArm64MachOAction(architectures.stdout, buildVersion.stdout); | |
| if (action === 'remove') { | |
| await rm(binaryPath, { force: true }); | |
| } else if (action === 'thin') { | |
| const thinnedPath = `${binaryPath}.arm64`; | |
| try { | |
| await run('lipo', [binaryPath, '-thin', 'arm64', '-output', thinnedPath]); | |
| const [thinnedArchitectures, thinnedBuildVersion] = await Promise.all([ | |
| inspect('lipo', ['-archs', thinnedPath]), | |
| inspect('xcrun', ['vtool', '-show-build', thinnedPath]), | |
| ]); | |
| if (!isMacosArm64MachO(thinnedArchitectures.stdout, thinnedBuildVersion.stdout)) { | |
| throw new Error(`Could not thin Mach-O file to macOS arm64: ${binaryPath}`); | |
| } | |
| await rename(thinnedPath, binaryPath); | |
| } finally { | |
| await rm(thinnedPath, { force: true }); | |
| } | |
| } | |
| } | |
| } | |
| 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'}.`); | |
| } | |
| } | |
| export function decodeSigningCertificate(value) { | |
| const encoded = value?.replace(/[\t\n\r ]+/gu, ''); | |
| if (!encoded || encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) { | |
| throw new Error('CSC_LINK must contain one base64-encoded PKCS12 certificate.'); | |
| } | |
| const bytes = Buffer.from(encoded, 'base64'); | |
| if (bytes.length === 0) { | |
| throw new Error('CSC_LINK must contain one base64-encoded PKCS12 certificate.'); | |
| } | |
| return { bytes }; | |
| } | |
| export function parseDeveloperIdApplicationIdentity(output) { | |
| const identities = [...output.matchAll(/^\s*\d+\)\s+([0-9A-Fa-f]{40})\s+"([^"]+)"\s*$/gmu)] | |
| .map((match) => ({ | |
| hash: match[1].toUpperCase(), | |
| name: match[2], | |
| teamIdentifier: / \(([A-Z0-9]{10})\)$/u.exec(match[2])?.[1], | |
| })) | |
| .filter(({ name }) => name.startsWith('Developer ID Application:')); | |
| if (identities.length !== 1) { | |
| throw new Error('CLI signing keychain must contain one Developer ID Application identity.'); | |
| } | |
| if (!identities[0].teamIdentifier) { | |
| throw new Error('Developer ID Application identity has no exact Apple Team ID.'); | |
| } | |
| return identities[0]; | |
| } | |
| export function assertExpectedAppleTeam(identity, expectedTeamIdentifier) { | |
| if (identity.teamIdentifier !== expectedTeamIdentifier) { | |
| throw new Error( | |
| `CLI signing identity belongs to Apple team ${identity.teamIdentifier}, expected ${expectedTeamIdentifier}.`, | |
| ); | |
| } | |
| return identity; | |
| } | |
| export async function createSigningKeychain({ env, expectedTeamIdentifier, run, inspect }) { | |
| const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-cli-signing-')); | |
| const certificatePath = join(temporaryRoot, 'identity.p12'); | |
| const pemPath = join(temporaryRoot, 'identity.pem'); | |
| const keychainFile = join(temporaryRoot, 'signing.keychain-db'); | |
| const keychainPassword = randomBytes(32).toString('hex'); | |
| let created = false; | |
| const cleanup = async () => { | |
| let keychainDeletionError; | |
| if (created) { | |
| try { | |
| await run('security', ['delete-keychain', keychainFile], { env }); | |
| } catch (error) { | |
| keychainDeletionError = error; | |
| } finally { | |
| created = false; | |
| } | |
| } | |
| try { | |
| await rm(temporaryRoot, { recursive: true, force: true }); | |
| } catch (error) { | |
| if (keychainDeletionError) { | |
| throw new AggregateError( | |
| [keychainDeletionError, error], | |
| 'Signing keychain and temporary credential cleanup both failed', | |
| ); | |
| } | |
| throw error; | |
| } | |
| if (keychainDeletionError) throw keychainDeletionError; | |
| }; | |
| try { | |
| await writeFile(certificatePath, decodeSigningCertificate(env.CSC_LINK).bytes, { mode: 0o600 }); | |
| await run('security', ['create-keychain', '-p', keychainPassword, keychainFile], { | |
| env, | |
| displayArgs: ['create-keychain', '-p', '<redacted>', keychainFile], | |
| }); | |
| created = true; | |
| await run('security', ['unlock-keychain', '-p', keychainPassword, keychainFile], { | |
| env, | |
| displayArgs: ['unlock-keychain', '-p', '<redacted>', keychainFile], | |
| }); | |
| await run('security', ['set-keychain-settings', '-lut', '21600', keychainFile], { env }); | |
| await run( | |
| 'openssl', | |
| [ | |
| 'pkcs12', | |
| '-in', | |
| certificatePath, | |
| '-nodes', | |
| '-passin', | |
| 'env:CSC_KEY_PASSWORD', | |
| '-out', | |
| pemPath, | |
| ], | |
| { env }, | |
| ); | |
| await chmod(pemPath, 0o600); | |
| await run('security', ['import', pemPath, '-k', keychainFile, '-T', '/usr/bin/codesign'], { | |
| env, | |
| }); | |
| await run( | |
| 'security', | |
| [ | |
| 'set-key-partition-list', | |
| '-S', | |
| 'apple-tool:,apple:', | |
| '-s', | |
| '-k', | |
| keychainPassword, | |
| keychainFile, | |
| ], | |
| { | |
| env, | |
| displayArgs: [ | |
| 'set-key-partition-list', | |
| '-S', | |
| 'apple-tool:,apple:', | |
| '-s', | |
| '-k', | |
| '<redacted>', | |
| keychainFile, | |
| ], | |
| }, | |
| ); | |
| const identityOutput = await inspect( | |
| 'security', | |
| ['find-identity', '-v', '-p', 'codesigning', keychainFile], | |
| { env }, | |
| ); | |
| const identity = assertExpectedAppleTeam( | |
| parseDeveloperIdApplicationIdentity(identityOutput.stdout), | |
| expectedTeamIdentifier, | |
| ); | |
| return { | |
| cleanup, | |
| directory: temporaryRoot, | |
| identity, | |
| keychainFile, | |
| }; | |
| } catch (error) { | |
| await cleanup(); | |
| throw error; | |
| } | |
| } | |
| async function signCliBinaries( | |
| machOBinaries, | |
| { env, expectedTeamIdentifier, run, inspect, nodeEntitlements, nodePath }, | |
| ) { | |
| assertReleaseSigningEnvironment(env); | |
| const signing = await createSigningKeychain({ env, expectedTeamIdentifier, run, inspect }); | |
| try { | |
| const nodeEntitlementsPath = join(signing.directory, 'node-entitlements.plist'); | |
| await writeFile(nodeEntitlementsPath, `${nodeEntitlements}\n`, { mode: 0o600 }); | |
| for (const binaryPath of machOBinaries) { | |
| const entitlements = | |
| resolve(binaryPath) === resolve(nodePath) ? ['--entitlements', nodeEntitlementsPath] : []; | |
| await run( | |
| 'codesign', | |
| [ | |
| '--force', | |
| '--options', | |
| 'runtime', | |
| '--timestamp', | |
| ...entitlements, | |
| '--sign', | |
| signing.identity.hash, | |
| '--keychain', | |
| signing.keychainFile, | |
| binaryPath, | |
| ], | |
| { env }, | |
| ); | |
| await run('codesign', ['--verify', '--strict', '--verbose=2', binaryPath], { env }); | |
| } | |
| const signedNodeEntitlements = await inspect( | |
| 'codesign', | |
| ['-d', '--entitlements', ':-', nodePath], | |
| { env }, | |
| ); | |
| extractDistributionNodeEntitlements( | |
| `${signedNodeEntitlements.stdout}\n${signedNodeEntitlements.stderr}`, | |
| ); | |
| return { | |
| identityName: signing.identity.name, | |
| machOBinaryCount: machOBinaries.length, | |
| teamIdentifier: signing.identity.teamIdentifier, | |
| }; | |
| } finally { | |
| await signing.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, | |
| env = process.env, | |
| run = runCommand, | |
| inspect = inspectCommand, | |
| releaseSigning = env.MAKA_CLI_RELEASE_SIGNING === '1', | |
| nodeArchivePath = env.MAKA_CLI_NODE_ARCHIVE, | |
| } = {}) { | |
| assertMacosArm64CliHost(platform, arch); | |
| const [rootManifest, desktopManifest, cliManifest, workspacePackages, sourceCommitResult] = | |
| await Promise.all([ | |
| readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), | |
| readFile(join(repoRoot, 'apps', 'desktop', 'package.json'), 'utf8').then(JSON.parse), | |
| readFile(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8').then(JSON.parse), | |
| resolveCliWorkspacePackages(), | |
| inspect('git', ['rev-parse', 'HEAD']), | |
| ]); | |
| const sourceCommit = sourceCommitResult.stdout.trim(); | |
| const identity = resolveProductReleaseIdentity({ | |
| rootManifest, | |
| desktopManifest, | |
| cliManifest, | |
| sha: sourceCommit, | |
| }); | |
| if (releaseSigning) assertReleaseSigningEnvironment(env); | |
| if (!nodeArchivePath) { | |
| throw new Error(`Set MAKA_CLI_NODE_ARCHIVE to the verified ${identity.nodeArchive} path.`); | |
| } | |
| const version = identity.version; | |
| await Promise.all([ | |
| access(join(repoRoot, 'DISCLAIMER-WIP')), | |
| 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 officialNode = await extractOfficialNodeRuntime( | |
| stagingRoot, | |
| resolve(nodeArchivePath), | |
| identity, | |
| { env, run }, | |
| ); | |
| const officialNodeEntitlements = await inspectReleaseToolchain({ | |
| execPath: officialNode.execPath, | |
| env, | |
| inspect, | |
| toolchain: identity, | |
| }); | |
| const nodeEntitlements = distributionNodeEntitlements(officialNodeEntitlements); | |
| const installRoot = join(stagingRoot, 'install'); | |
| await mkdir(installRoot, { recursive: true }); | |
| await stageWorkspacePackages(installRoot, workspacePackages); | |
| await run('npm', macosArm64CliInstallArgs(), { | |
| cwd: installRoot, | |
| env: standaloneInstallEnvironment(env), | |
| }); | |
| const dependencyPatches = await applyDependencyPatches(installRoot, { env, run }); | |
| const productionDependencies = await collectPackagedProductionDependencies( | |
| join(installRoot, 'node_modules'), | |
| new Set(workspacePackages.map(({ name }) => name)), | |
| ); | |
| const nodeModulesDirectory = join(installRoot, 'node_modules'); | |
| await pruneNonTargetNativeBinaries(nodeModulesDirectory, { inspect, run }); | |
| await pruneThirdPartyDevelopmentArtifacts(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(officialNode.execPath, join(embeddedNodeDirectory, 'bin', 'node')), | |
| copyFile(officialNode.licensePath, join(embeddedNodeDirectory, 'LICENSE')), | |
| copyFile(join(repoRoot, 'DISCLAIMER-WIP'), join(archiveRoot, 'DISCLAIMER-WIP')), | |
| copyFile(join(repoRoot, 'LICENSE'), join(archiveRoot, 'LICENSE')), | |
| copyFile(join(repoRoot, 'NOTICE'), join(archiveRoot, 'NOTICE')), | |
| writeFile(join(binDirectory, 'maka'), macosArm64CliWrapper(), '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), | |
| ]); | |
| await assertWorkspaceLinks(archiveRoot, workspacePackages); | |
| const thirdPartyNoticesPath = join(archiveRoot, 'THIRD_PARTY_NOTICES.txt'); | |
| await copyFile( | |
| join(repoRoot, 'packages', 'cli', 'THIRD_PARTY_NOTICES.txt'), | |
| thirdPartyNoticesPath, | |
| ); | |
| const thirdPartyNoticesSha256 = await sha256File(thirdPartyNoticesPath); | |
| const machOBinaries = await findMachOBinaries(archiveRoot, { inspect }); | |
| if (machOBinaries.length === 0) throw new Error('CLI artifact contains no Mach-O binaries.'); | |
| const releaseMetadata = { | |
| schemaVersion: 1, | |
| product: 'Maka', | |
| version, | |
| sourceCommit, | |
| platform: 'macos', | |
| architecture: 'arm64', | |
| publicCommands: identity.publicCommands, | |
| node: { | |
| version: identity.nodeVersion, | |
| sourceUrl: identity.nodeSourceUrl, | |
| archive: identity.nodeArchive, | |
| archiveSha256: identity.nodeArchiveSha256, | |
| entitlements: releaseSigning | |
| ? DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS | |
| : OFFICIAL_NODE_RUNTIME_ENTITLEMENTS, | |
| }, | |
| npmVersion: identity.npmVersion, | |
| dependencyPatches, | |
| productionDependencies, | |
| thirdPartyNoticesSha256, | |
| workspacePackages: workspacePackages.map(({ name }) => name).sort(), | |
| machOBinaries: machOBinaries.map((path) => relative(archiveRoot, path)).sort(), | |
| signing: releaseSigning ? 'developer-id-notarized' : 'development', | |
| signingTeamIdentifier: releaseSigning ? identity.appleTeamIdentifier : null, | |
| }; | |
| await writeFile( | |
| join(archiveRoot, 'RELEASE.json'), | |
| `${JSON.stringify(releaseMetadata, null, 2)}\n`, | |
| 'utf8', | |
| ); | |
| let signing; | |
| if (releaseSigning) { | |
| signing = await signCliBinaries(machOBinaries, { | |
| env, | |
| expectedTeamIdentifier: identity.appleTeamIdentifier, | |
| run, | |
| inspect, | |
| nodeEntitlements, | |
| nodePath: join(embeddedNodeDirectory, 'bin', 'node'), | |
| }); | |
| } | |
| 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}`); | |
| } |