| /* |
| * 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, |
| } 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 copyFile(join(directory, 'package.json'), join(targetDirectory, 'package.json')); |
| 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 }); |
| }), |
| ); |
| await pruneMakaDevelopmentArtifacts(targetDirectory); |
| }), |
| ]); |
| } |
| |
| async function pruneMakaDevelopmentArtifacts(directory, root = directory) { |
| for (const entry of await readdir(directory, { withFileTypes: true })) { |
| const path = join(directory, entry.name); |
| if (isMakaDevelopmentArtifact(relative(root, path))) { |
| await rm(path, { recursive: entry.isDirectory(), force: true }); |
| } else if (entry.isDirectory()) { |
| await pruneMakaDevelopmentArtifacts(path, root); |
| } |
| } |
| } |
| |
| 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}`); |
| } |