#!/usr/bin/env bun // Release pipeline of the storebound desktop app. Builds, signs, checks and lays out one version for // storebound.dev/desktop/; nothing here installs anything, and publishing is a separate, explicit step. // // bun tools/desktop-release.ts mac [--out ] [--target-dir ] [--identity ""] // [--no-notarize] [--notes "..."] [--allow-dirty] // Universal (Apple silicon + Intel) app, updater bundle (.app.tar.gz) and DMG. With a "Developer ID Application" // identity in the keychain: hardened runtime, notarization (notarytool, App Store Connect API key) and stapling. // Without one: an ad-hoc signed build named `-unsigned`, and what the owner has to do. Must run in the owner's // local GUI session: codesign over SSH fails (the keychain is locked there), so SSH sessions are refused. // The built window is booted headless first (tools/desktop-boot-check.ts). Then, on the exact artifacts: codesign / Gatekeeper expectations, lipo (every executable universal; the x86_64 slice is // never run, Rosetta), the native self-test (sidecar handshake) from the mounted DMG and from the unpacked updater bundle, the privacy scan, minisign signatures with // the updater key (~/.storebound-keys/updater.key), SHA256SUMS, latest.json and evidence.json. // bun tools/desktop-release.ts add --out [--commit ] [--run ] [--code-signed] // Adds Windows / Linux installers built by the Forgejo workflow (.forgejo/workflows/desktop.yml) to the version // folder: privacy scan, minisign signature here (the key never goes to a runner), latest.json, SHA256SUMS and // evidence--.json (the run and commit they came from; a commit other than the one of the // folder's other evidence is refused: every artifact of a version comes from one commit). // bun tools/desktop-release.ts publish --out --host [--channel stable|beta] [--yes] // Checks the folder again, uploads it to the server's incoming folder (rsync) and runs the root-owned publish // script there (tmp -> mv, latest.json last). Without --yes it only prints the plan (dry run). // The folder's version must be package.json's: the npm CLI and the app are released together, the app first, // then `npm publish` of the same version. // bun tools/desktop-release.ts verify --out // The checks of publish without uploading. // bun tools/desktop-release.ts exec -- // Runs a build command (tauri build on a runner) with the path-free environment described at pathFreeEnv. // // Version: package.json "version", the same as the npm CLI (bun tools/desktop-version.ts); both ship together. Docs: docs/desktop-release.md. import { spawnSync, type SpawnSyncOptions } from 'node:child_process'; import { createHash } from 'node:crypto'; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; import { homedir, release as osRelease, tmpdir } from 'node:os'; import { basename, join, resolve } from 'node:path'; import { type Download, type DownloadKey, type Manifest, manifestProblems } from '../cli/lib/desktop-dist'; import { verify as verifySig } from '../cli/lib/minisign'; import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars'; import { versions } from './desktop-version'; import { scan } from './privacy-scan'; export const ROOT = resolve(import.meta.dir, '..'); const APP_DIR = join(ROOT, 'apps/desktop'); const TAURI_DIR = join(APP_DIR, 'src-tauri'); export const BASE_URL = 'https://storebound.dev/desktop'; export const KEYS_DIR = process.env.STOREBOUND_KEYS_DIR ?? join(homedir(), '.storebound-keys'); const PRODUCT = 'storebound'; const APP_NAME = 'storebound.app'; const MAIN_BIN = 'storebound-desktop'; /** Where the server takes uploads and the root-owned script that publishes them (tools/vds/). */ export const REMOTE_INCOMING = '/var/lib/storebound-desktop/incoming'; export const REMOTE_PUBLISH = '/usr/local/sbin/storebound-desktop-publish'; // --------------------------------------------------------------------------------------------------------------------- // Pure parts (tests/desktop-release.test.ts) /** Why this session cannot sign (null = a local session). codesign needs the unlocked login keychain of the GUI * session; over SSH it stops at errSecInternalComponent. */ export function remoteSessionReason(env: Record, managerName: string): string | null { if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'this is an SSH session'; if (managerName && managerName !== 'Aqua') return `this is not the logged-in GUI session (launchctl manager: ${managerName})`; return null; } /** The first "Developer ID Application" identity of `security find-identity -v -p codesigning`. */ export function developerIdIdentity(securityOutput: string): string | null { const m = /^\s*\d+\)\s+[0-9A-F]{40}\s+"(Developer ID Application: [^"]+)"/m.exec(securityOutput); return m?.[1] ?? null; } /** File names of a version. Unsigned (no Developer ID / Authenticode) builds say so in the name. */ export function artifactName(version: string, kind: 'dmg' | 'updater' | 'nsis' | 'appimage' | 'deb', codeSigned: boolean): string { const u = codeSigned ? '' : '-unsigned'; switch (kind) { case 'dmg': return `${PRODUCT}_${version}_universal${u}.dmg`; case 'updater': return `${PRODUCT}_${version}_universal${u}.app.tar.gz`; case 'nsis': return `${PRODUCT}_${version}_x64-setup${u}.exe`; case 'appimage': return `${PRODUCT}_${version}_amd64.AppImage`; case 'deb': return `${PRODUCT}_${version}_amd64.deb`; } } export const releaseUrl = (version: string, file: string): string => `${BASE_URL}/dl/${version}/${file}`; /** * Environment of a build whose binaries hold no build-machine path: rustc's --remap-path-prefix (and * -ffile-prefix-map for C compiled by build scripts) turn the home folder, the checkout, CARGO_HOME, RUSTUP_HOME and * CARGO_TARGET_DIR into ~, /src, /cargo, /rustup and /target. General roots first: rustc applies the last match. */ export function pathFreeEnv(root: string, env: Record, home = homedir(), platform: NodeJS.Platform = process.platform): Record { const win = platform === 'win32'; const sep = win ? '\\' : '/'; const trim = (p: string) => (win ? p.replace(/[\\/]+$/, '') : p.replace(/\/+$/, '') || '/'); const at = (...parts: string[]) => parts.join(sep); const roots: [string | undefined, string][] = [ [home, '~'], [root, '/src'], [env.CARGO_TARGET_DIR && (win ? env.CARGO_TARGET_DIR : resolve(env.CARGO_TARGET_DIR)), '/target'], [env.CARGO_HOME ?? at(home, '.cargo'), '/cargo'], [env.RUSTUP_HOME ?? at(home, '.rustup'), '/rustup'], ]; const maps: [string, string][] = []; for (const [f, t] of roots) { if (!f) continue; maps.push([trim(f), t]); // Windows hands some paths out in verbatim form (canonicalized by build scripts and cargo) if (win && /^[A-Za-z]:/.test(f)) maps.push([`\\\\?\\${trim(f)}`, t]); } const SEP = '\x1f'; const flags = env.CARGO_ENCODED_RUSTFLAGS ? env.CARGO_ENCODED_RUSTFLAGS.split(SEP) : (env.RUSTFLAGS ?? '').split(/\s+/).filter(Boolean); const add = (fl: string) => { if (!flags.includes(fl)) flags.push(fl); }; for (const [f, t] of maps) add(`--remap-path-prefix=${f}=${t}`); const out: Record = {}; if (win) { // MSVC: the PDB is named without its folder, and cl.exe cuts the roots off __FILE__ (longest root first) add('-Clink-arg=/PDBALTPATH:%_PDB%'); const trims = roots.map(r => r[0]).filter((f): f is string => !!f && !f.includes('"')).map(trim).sort((a, b) => b.length - a.length).map(f => `"/d1trimfile:${f}/"`); out.CL = [...new Set([env.CL, ...trims].filter(Boolean))].join(' '); out.LINK = [...new Set([env.LINK, '/PDBALTPATH:%_PDB%'].filter(Boolean))].join(' '); } else { const prefix = maps.filter(([f]) => !/\s/.test(f)).map(([f, t]) => `-ffile-prefix-map=${f}=${t}`); for (const n of ['CFLAGS', 'CXXFLAGS']) out[n] = [...(env[n] ?? '').split(/\s+/).filter(Boolean), ...prefix].join(' '); } out.CARGO_ENCODED_RUSTFLAGS = flags.join(SEP); return out; } /** latest.json of a version: an existing one of the same version keeps its other platforms. */ export function mergeManifest(existing: Manifest | null, version: string, notes: string, now: string, add: { platforms?: Manifest['platforms']; downloads?: Partial> }): Manifest { const base: Manifest = existing && existing.version === version ? existing : { version, notes, pub_date: now, platforms: {}, downloads: {} }; return { version, notes: base.notes || notes, pub_date: now, platforms: { ...base.platforms, ...add.platforms }, downloads: { ...base.downloads, ...add.downloads }, }; } /** `sha256sum -c` format, sorted. */ export function sumsText(entries: { file: string; sha256: string }[]): string { return [...entries].sort((a, b) => a.file.localeCompare(b.file)).map(e => `${e.sha256} ${e.file}`).join('\n') + '\n'; } /** Files of a version folder that are uploaded (evidence and temp files stay local). */ export const uploadable = (name: string): boolean => /^[A-Za-z0-9._-]+$/.test(name) && !name.startsWith('.') && !/^evidence/.test(name) && !name.endsWith('.part'); // --------------------------------------------------------------------------------------------------------------------- // Helpers with side effects const t0 = Date.now(); const say = (msg: string) => console.log(`[${((Date.now() - t0) / 1000).toFixed(0).padStart(4)}s] ${msg}`); function fail(msg: string): never { console.error(`\ndesktop-release: ${msg}`); process.exit(1); } function run(cmd: string[], opts: SpawnSyncOptions & { quiet?: boolean; allowFail?: boolean } = {}): { code: number; out: string; err: string } { const r = spawnSync(cmd[0]!, cmd.slice(1), { encoding: 'utf8', maxBuffer: 256 << 20, ...opts, stdio: opts.stdio ?? ['ignore', 'pipe', 'pipe'] }); const res = { code: r.status ?? 1, out: String(r.stdout ?? ''), err: String(r.stderr ?? '') }; if (res.code !== 0 && !opts.allowFail) fail(`${basename(cmd[0]!)} ${cmd.slice(1, 3).join(' ')} failed (exit ${res.code})\n${res.err.trim().split('\n').slice(-15).join('\n')}`); return res; } const sha256 = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex'); const mb = (n: number) => `${(n / 1e6).toFixed(1)} MB`; const confPubkey = (): string => JSON.parse(readFileSync(join(TAURI_DIR, 'tauri.conf.json'), 'utf8')).plugins.updater.pubkey; function updaterKey(): string { const key = process.env.STOREBOUND_UPDATER_KEY ?? join(KEYS_DIR, 'updater.key'); if (!existsSync(key)) fail(`no updater signing key at ${key.replace(homedir(), '~')}: see docs/desktop-release.md (bunx tauri signer generate)`); const pub = `${key}.pub`; if (existsSync(pub) && readFileSync(pub, 'utf8').trim() !== confPubkey()) fail(`${basename(pub)} is not the public key in tauri.conf.json: the app would refuse these updates`); return key; } /** minisign signature with the updater key (tauri signer), checked right away against tauri.conf.json's key. */ function signFile(file: string): string { const key = updaterKey(); rmSync(`${file}.sig`, { force: true }); run(['bunx', 'tauri', 'signer', 'sign', '-f', key, '-p', '', file], { cwd: APP_DIR, env: { ...process.env, TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' } }); const sig = readFileSync(`${file}.sig`, 'utf8').trim(); const v = verifySig(readFileSync(file), sig, confPubkey()); if (!v.ok) fail(`${basename(file)}: fresh signature does not verify (${v.reason})`); return sig; } function privacyGate(paths: string[]): { files: number; bytes: number; hits: number } { const r = scan(paths); for (const h of r.hits) console.error(`privacy: ${h.file} @${h.offset}${h.encoding ? ` (${h.encoding})` : ''}: ${h.what}`); for (const e of r.errors) console.error(`privacy: could not open ${e}`); if (r.hits.length || r.errors.length) fail(`privacy scan failed (${r.hits.length} hits, ${r.errors.length} errors): nothing may ship`); say(`privacy scan clean: ${r.files} files, ${mb(r.bytes)}, ${r.terms} terms${r.termsFile ? '' : ' (no forbidden-terms file: only this machine\'s names)'}`); return { files: r.files, bytes: r.bytes, hits: 0 }; } function readManifest(out: string): Manifest | null { const p = join(out, 'latest.json'); return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as Manifest : null; } function writeVersionFiles(out: string, manifest: Manifest): void { const problems = manifestProblems(manifest); if (problems.length) fail(`latest.json: ${problems.join('; ')}`); writeFileSync(join(out, 'latest.json.part'), `${JSON.stringify(manifest, null, 2)}\n`); renameSync(join(out, 'latest.json.part'), join(out, 'latest.json')); const files = readdirSync(out).filter(n => uploadable(n) && n !== 'SHA256SUMS'); writeFileSync(join(out, 'SHA256SUMS'), sumsText(files.map(file => ({ file, sha256: sha256(join(out, file)) })))); } function gitState(): { commit: string; dirty: boolean } { const commit = run(['git', 'rev-parse', 'HEAD'], { cwd: ROOT }).out.trim(); const dirty = run(['git', 'status', '--porcelain'], { cwd: ROOT }).out.trim().length > 0; return { commit, dirty }; } function args(argv: string[]) { const get = (n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; }; return { get, has: (n: string) => argv.includes(`--${n}`), rest: argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1]!.startsWith('--') && !['--allow-dirty', '--no-notarize', '--yes', '--code-signed'].includes(argv[i - 1]!))) }; } // --------------------------------------------------------------------------------------------------------------------- // mac interface SelfTest { arch: string; result: Record } /** The x86_64 slice is never run on this Mac: launching it starts Rosetta, and macOS then tells the owner that * "storebound" includes a component that will not open in the next major release. It is checked by structure only * (`lipo -archs` on every Mach-O in the bundle, each must carry arm64 and x86_64); the self-test runs natively. */ export function sliceProblems(files: { file: string; archs: string[] }[]): string[] { return files.filter(f => !(f.archs.includes('arm64') && f.archs.includes('x86_64'))).map(f => `${f.file} (${f.archs.join(' ') || 'no slices'})`); } /** Architecture guard (pure): users must never get macOS's "App Update Required ... will not open in macOS 28" * notice, which macOS shows for any app that needs Rosetta. Every Mach-O in the bundle (main binary, sidecar, * helpers, dylibs, frameworks, anything executable) must contain arm64; the Info.plist must not force Intel * (LSRequiresNativeExecution false, LSArchitecturePriority with x86_64 / i386 first). `lipo` is `lipo -archs` output. */ export function archProblems(files: { file: string; lipo: string }[], plist: Record): string[] { const out: string[] = []; for (const f of files) { const archs = f.lipo.trim().split(/\s+/).filter(Boolean); if (!archs.includes('arm64')) out.push(`${f.file}: no arm64 slice (${archs.join(' ') || 'no slices'}${archs.length && archs.every(a => /^(x86_64h?|i386)$/.test(a)) ? ', Intel-only: needs Rosetta' : ''})`); } if (plist.LSRequiresNativeExecution === false) out.push('Info.plist: LSRequiresNativeExecution is false'); const prio = plist.LSArchitecturePriority; if (Array.isArray(prio) && prio.length && /^(x86_64h?|i386)$/.test(String(prio[0]))) out.push(`Info.plist: LSArchitecturePriority puts ${prio[0]} first`); return out; } export interface ArchGuard { files: { file: string; archs: string }[]; plist: string } /** The architecture guard on one app: walks the whole bundle for Mach-O files and reads Info.plist (plutil). */ function archGuard(appPath: string, label: string): ArchGuard { const files = machOFiles(appPath).map(f => ({ file: f.slice(appPath.length + 1), lipo: run(['lipo', '-archs', f]).out.trim() })); const plist = JSON.parse(run(['plutil', '-convert', 'json', '-o', '-', join(appPath, 'Contents/Info.plist')]).out) as Record; const bad = archProblems(files, plist); if (!files.length) bad.push('no Mach-O files found'); if (bad.length) fail(`architecture guard (${label}): ${bad.join('; ')}`); const plistNote = `LSRequiresNativeExecution ${plist.LSRequiresNativeExecution ?? 'unset'}, LSArchitecturePriority ${Array.isArray(plist.LSArchitecturePriority) ? plist.LSArchitecturePriority.join(' > ') : 'unset'}`; say(`architecture guard (${label}): ${files.length} Mach-O files, each contains arm64: ${files.map(f => `${f.file} [${f.lipo}]`).join(', ')}; Info.plist ${plistNote}`); return { files: files.map(f => ({ file: f.file, archs: f.lipo })), plist: plistNote }; } function machOFiles(dir: string): string[] { const out: string[] = []; for (const e of readdirSync(dir, { withFileTypes: true })) { const f = join(dir, e.name); if (e.isDirectory()) out.push(...machOFiles(f)); else if (e.isFile() && /Mach-O/.test(run(['file', '-b', f]).out)) out.push(f); } return out; } function selfTest(appPath: string, label: string): SelfTest[] { const bin = join(appPath, 'Contents/MacOS', MAIN_BIN); const slices = machOFiles(appPath).map(f => ({ file: f.slice(appPath.length + 1), archs: run(['lipo', '-archs', f]).out.trim().split(/\s+/).filter(Boolean) })); const bad = sliceProblems(slices); if (!slices.length || bad.length) fail(`${label}: not universal: ${bad.join(', ') || 'no executables'}`); say(`${label}: every executable is universal (${slices.map(s => s.file.replace('Contents/MacOS/', '')).join(', ')}: arm64 x86_64); x86_64 checked by lipo, not run (no Rosetta)`); if (process.arch !== 'arm64') fail('the self-test runs the arm64 slice natively: build on Apple silicon'); const r = run([bin, '--self-test'], { allowFail: true, timeout: 90_000 }); if (r.code !== 0) fail(`${label}: self-test (arm64) failed: ${r.err.trim() || r.out.trim()}`); const result = JSON.parse(r.out.trim().split('\n').at(-1)!) as Record; if (result.ok !== true || result.protocol !== 'storebound-desktop/1') fail(`${label}: self-test (arm64) answered ${r.out}`); say(`self-test ${label} (arm64): sidecar ${result.sidecar}, ${result.methods} methods, ${result.ms} ms`); return [{ arch: 'arm64', result }]; } export interface Gate { codesign: string; spctl: string; stapler: string; binaries: string[] } /** Signature checks of one app, inside out: every executable in Contents/MacOS (the app and the CLI sidecar), then the * bundle. Signed: Developer ID, hardened runtime, a secure timestamp, no sandbox entitlement (Developer ID, not the * Mac App Store), Gatekeeper accepts it as notarized and the ticket is stapled. Unsigned: ad-hoc, Gatekeeper rejects. */ function gatekeeper(appPath: string, signed: boolean, notarized: boolean, label: string): Gate { run(['codesign', '--verify', '--deep', '--strict', '--verbose=2', appPath]); const binaries: string[] = []; const macos = join(appPath, 'Contents/MacOS'); for (const name of [...readdirSync(macos).sort(), '']) { const target = name ? join(macos, name) : appPath; const info = run(['codesign', '-dvv', '--verbose=4', target]).err; const flags = /flags=0x[0-9a-f]+\(([^)]*)\)/.exec(info)?.[1] ?? ''; const authority = /Authority=(Developer ID Application[^\n]*)/.exec(info)?.[1]; const ents = run(['codesign', '-d', '--entitlements', '-', '--xml', target], { allowFail: true }).out; const what = name || APP_NAME; if (signed) { if (!authority) fail(`${label}: ${what} is not signed with Developer ID`); if (!/runtime/.test(flags)) fail(`${label}: ${what} has no hardened runtime`); if (!/Timestamp=/.test(info)) fail(`${label}: ${what} has no secure timestamp`); if (/com\.apple\.security\.app-sandbox/.test(ents)) fail(`${label}: ${what} carries the App Sandbox entitlement (Mac App Store), not for Developer ID`); if (/get-task-allow/.test(ents)) fail(`${label}: ${what} is debuggable (get-task-allow)`); } else if (!/Signature=adhoc/.test(info)) fail(`${label}: unsigned build should be ad-hoc signed (${what})`); binaries.push(`${what}: ${signed ? 'Developer ID' : 'ad-hoc'}, flags ${flags || 'none'}${/Timestamp=/.test(info) ? ', timestamped' : ''}${/allow-jit/.test(ents) ? ', JIT entitlement' : ''}`); } const sp = run(['spctl', '-a', '-vvv', '-t', 'exec', appPath], { allowFail: true }); const spOut = (sp.err || sp.out).trim(); const spctl = `${sp.code === 0 ? 'accepted' : 'rejected'}: ${spOut.split('\n').slice(1, 3).join(' / ')}`; let stapler = 'not stapled (unsigned)'; if (notarized) { if (sp.code !== 0 || !/source=Notarized Developer ID/.test(spOut)) fail(`${label}: Gatekeeper does not accept the app as notarized: ${spctl}`); const st = run(['xcrun', 'stapler', 'validate', appPath], { allowFail: true }); if (st.code !== 0) fail(`${label}: stapler validate failed: ${(st.out + st.err).trim()}`); stapler = 'ticket stapled, validated'; } else if (signed) { stapler = 'signed, not notarized (--no-notarize)'; } else if (sp.code === 0) { fail(`${label}: Gatekeeper accepted an ad-hoc build: unexpected`); } const codesign = signed ? 'Developer ID Application, hardened runtime, timestamped' : 'ad-hoc'; say(`${label}: codesign ${codesign}; spctl -t exec ${spctl}; ${stapler}`); return { codesign, spctl, stapler, binaries }; } /** notarytool / Tauri credentials: APPLE_API_KEY + APPLE_API_ISSUER + APPLE_API_KEY_PATH, or storebound's own * ASC_KEY_ID + ASC_ISSUER_ID + ASC_P8 (path or PEM; default ~/.appstoreconnect/private_keys/AuthKey_.p8), or * ~/.storebound-keys/notary.json {keyId, issuerId, keyPath}. Nothing of it is printed. */ function notaryEnv(): Record | null { const env = process.env; const fromJson = (() => { const f = join(KEYS_DIR, 'notary.json'); if (!existsSync(f)) return {} as { keyId?: string; issuerId?: string; keyPath?: string }; return JSON.parse(readFileSync(f, 'utf8')) as { keyId?: string; issuerId?: string; keyPath?: string }; })(); const id = env.APPLE_API_KEY ?? env.ASC_KEY_ID ?? fromJson.keyId; const issuer = env.APPLE_API_ISSUER ?? env.ASC_ISSUER_ID ?? fromJson.issuerId; if (!id || !issuer) return null; let path = env.APPLE_API_KEY_PATH ?? env.ASC_P8 ?? fromJson.keyPath ?? join(homedir(), '.appstoreconnect/private_keys', `AuthKey_${id}.p8`); if (/BEGIN PRIVATE KEY/.test(path)) { const dir = mkdtempSync(join(tmpdir(), 'sb-notary-')); writeFileSync(join(dir, `AuthKey_${id}.p8`), path, { mode: 0o600 }); path = join(dir, `AuthKey_${id}.p8`); } path = path.replace(/^~(?=\/)/, homedir()); if (!existsSync(path)) fail(`notarization key file not found (${basename(path)}): set ASC_P8 or APPLE_API_KEY_PATH`); return { APPLE_API_KEY: id, APPLE_API_ISSUER: issuer, APPLE_API_KEY_PATH: path }; } async function mac(argv: string[]): Promise { const a = args(argv); if (process.platform !== 'darwin') fail('the macOS build runs on a Mac'); const remote = remoteSessionReason(process.env, run(['launchctl', 'managername'], { allowFail: true }).out.trim()); if (remote) fail(`${remote}. Signing and notarization must run in the owner's logged-in macOS session (Terminal on the Mac itself): codesign over SSH fails with errSecInternalComponent.`); const { version, drift } = versions(); if (!version || drift.length) fail(`desktop version drift: ${drift.map(d => d.file).join(', ')} (bun tools/desktop-version.ts)`); const git = gitState(); if (git.dirty && !a.has('allow-dirty')) fail('the checkout has uncommitted changes: a release is built from a commit (--allow-dirty for a test build)'); updaterKey(); const securityOut = run(['security', 'find-identity', '-v', '-p', 'codesigning']).out; const identity = a.get('identity') ?? developerIdIdentity(securityOut); if (a.get('identity') && !securityOut.includes(`"${a.get('identity')}"`)) fail(`identity not in the keychain: ${a.get('identity')}`); const signed = !!identity; const notary = signed && !a.has('no-notarize') ? notaryEnv() : null; if (signed && !a.has('no-notarize') && !notary) fail('Developer ID found but no notarization key: set ASC_KEY_ID + ASC_ISSUER_ID (+ ASC_P8), APPLE_API_KEY + APPLE_API_ISSUER + APPLE_API_KEY_PATH, or ~/.storebound-keys/notary.json (docs/desktop-release.md), or pass --no-notarize for a local test'); const notarized = signed && !!notary; say(`storebound desktop ${version} for macOS (universal), commit ${git.commit.slice(0, 10)}${git.dirty ? ' + uncommitted changes' : ''}: ${signed ? `signing as Developer ID${notarized ? ', notarized' : ' (not notarized)'}` : 'NO Developer ID Application identity in the keychain: ad-hoc signed preview, named -unsigned'}`); const out = resolve(a.get('out') ?? join(homedir(), '.storebound-releases', 'desktop', version)); mkdirSync(resolve(a.get('target-dir') ?? '/tmp/storebound-desktop-target'), { recursive: true }); const targetDir = realpathSync(resolve(a.get('target-dir') ?? '/tmp/storebound-desktop-target')); if (targetDir.startsWith(homedir())) fail('--target-dir must be outside the home folder (build paths end up in binaries)'); mkdirSync(out, { recursive: true }); // toolchain: rustup's (Homebrew's Rust has no x86_64 standard library for the universal build) const cargoBin = join(homedir(), '.cargo/bin'); const PATH = `${cargoBin}:${process.env.PATH}`; const installed = run(['rustup', 'target', 'list', '--installed'], { env: { ...process.env, PATH } }).out; for (const t of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) if (!installed.includes(t)) fail(`rustup target ${t} missing: rustup target add ${t}`); // sidecars: both CPU types, then one universal file (Tauri looks for binaries/storebound-universal-apple-darwin) say('sidecars: bun build --compile for darwin-arm64 and darwin-x64'); const arm = await buildOne('bun-darwin-arm64'); const x64 = await buildOne('bun-darwin-x64'); const uni = join(SIDECAR_DIR, 'storebound-universal-apple-darwin'); rmSync(uni, { force: true }); run(['lipo', '-create', '-output', uni, arm, x64]); run(['codesign', '--force', '--sign', '-', uni]); // native slices only: running the x64 sidecar would start Rosetta (see sliceProblems) if (run(['lipo', '-archs', uni]).out.trim().split(/\s+/).sort().join(' ') !== 'arm64 x86_64') fail(`${basename(uni)} is not universal`); const sideBad = archProblems([{ file: basename(uni), lipo: run(['lipo', '-archs', uni]).out }], {}); if (sideBad.length) fail(`architecture guard: ${sideBad.join('; ')}`); for (const f of [arm, uni]) { const v = run([f, 'version'], { allowFail: true, timeout: 60_000 }); if (v.code !== 0) fail(`${basename(f)} does not run: ${v.err}`); } // the app: tauri build with the path-free environment, CARGO_TARGET_DIR outside home const env: Record = { ...process.env, PATH, CARGO_TARGET_DIR: targetDir, CI: 'true' }; delete env.APPLE_API_KEY; delete env.APPLE_API_ISSUER; delete env.APPLE_API_KEY_PATH; delete env.APPLE_SIGNING_IDENTITY; delete env.APPLE_CERTIFICATE; delete env.APPLE_ID; delete env.APPLE_PASSWORD; delete env.TAURI_SIGNING_PRIVATE_KEY; Object.assign(env, pathFreeEnv(ROOT, env), notary ?? {}); const override = { bundle: { macOS: signed ? { signingIdentity: identity, hardenedRuntime: true, entitlements: './entitlements.plist' } : { signingIdentity: '-' } } }; const overrideFile = join(mkdtempSync(join(tmpdir(), 'sb-release-')), 'override.json'); writeFileSync(overrideFile, JSON.stringify(override)); // 1. build and bundle WITHOUT signing (no notarization either), 2. the architecture guard on that unsigned app, // 3. tauri bundle signs, notarizes and staples the very binaries that passed the guard (no recompile) const unsignedEnv: Record = { ...env }; delete unsignedEnv.APPLE_API_KEY; delete unsignedEnv.APPLE_API_ISSUER; delete unsignedEnv.APPLE_API_KEY_PATH; say(`tauri build --target universal-apple-darwin --bundles app --no-sign (target dir ${targetDir}; this takes a while)`); const tb = spawnSync('bunx', ['tauri', 'build', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--no-sign', '--config', overrideFile], { cwd: APP_DIR, env: unsignedEnv as Record, stdio: ['ignore', 'inherit', 'inherit'] }); if (tb.status !== 0) fail(`tauri build failed (exit ${tb.status})`); const preSignGuard = archGuard(join(targetDir, 'universal-apple-darwin/release/bundle/macos', APP_NAME), 'unsigned app (before signing)'); say(`tauri bundle --target universal-apple-darwin --bundles app (${signed ? `sign${notarized ? ', notarize, staple' : ''}` : 'ad-hoc sign'})`); const tbs = spawnSync('bunx', ['tauri', 'bundle', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile], { cwd: APP_DIR, env: env as Record, stdio: ['ignore', 'inherit', 'inherit'] }); if (tbs.status !== 0) fail(`tauri bundle (signing) failed (exit ${tbs.status})`); // the frontend tauri just embedded (apps/desktop/dist): it must draw under the release CSP and the frozen // Object.prototype with old saved states (tools/desktop-boot-check.ts); a blank window never ships again say('boot check of the built window (empty, D1 with folders gone, D3 saved state; missing bundle panel)'); const boot = spawnSync('bun', [join(ROOT, 'tools/desktop-boot-check.ts')], { cwd: ROOT, encoding: 'utf8', timeout: 600_000 }); process.stdout.write(boot.stdout ?? ''); if (boot.status !== 0) fail(`the built window does not boot cleanly (tools/desktop-boot-check.ts): ${(boot.stderr ?? '').trim().split('\n').slice(-3).join(' / ')}`); const bootCheck = (boot.stdout ?? '').trim().split('\n').at(-1) ?? 'ok'; const bundleDir = join(targetDir, 'universal-apple-darwin/release/bundle/macos'); const app = join(bundleDir, APP_NAME); if (!existsSync(app)) fail(`no ${APP_NAME} in ${bundleDir}`); const dsyms = readdirSync(bundleDir).filter(n => n.endsWith('.dSYM')); if (dsyms.length) fail(`debug symbols next to the app: ${dsyms.join(', ')}`); const arch: Record = { preSign: preSignGuard, app: archGuard(app, 'signed app') }; const checks: Record = { app: gatekeeper(app, signed, notarized, 'built app') }; const tests: Record = { app: selfTest(app, 'built app') }; // updater bundle: the app as Tauri's updater expects it (tar.gz, no macOS metadata, root-owned entries) const updaterName = artifactName(version, 'updater', signed); const updater = join(out, updaterName); rmSync(updater, { force: true }); run(['tar', '--uid', '0', '--gid', '0', '--uname', 'root', '--gname', 'wheel', '--no-mac-metadata', '-czf', updater, '-C', bundleDir, APP_NAME], { env: { ...process.env, COPYFILE_DISABLE: '1' } }); // DMG: the app and an Applications link, made with hdiutil only (no Finder scripting, nothing comes to the front) const dmgName = artifactName(version, 'dmg', signed); const dmg = join(out, dmgName); const stage = mkdtempSync(join(tmpdir(), 'sb-dmg-')); run(['ditto', app, join(stage, APP_NAME)]); symlinkSync('/Applications', join(stage, 'Applications')); rmSync(dmg, { force: true }); say(`DMG ${dmgName}`); run(['hdiutil', 'create', '-volname', PRODUCT, '-srcfolder', stage, '-fs', 'HFS+', '-format', 'UDZO', '-imagekey', 'zlib-level=9', '-ov', dmg]); rmSync(stage, { recursive: true, force: true }); if (signed) { run(['codesign', '--force', '--sign', identity!, '--timestamp', dmg]); if (notarized) { say('notarytool submit (DMG), waiting for Apple'); const n = run(['xcrun', 'notarytool', 'submit', dmg, '--key', notary!.APPLE_API_KEY_PATH!, '--key-id', notary!.APPLE_API_KEY!, '--issuer', notary!.APPLE_API_ISSUER!, '--wait', '--output-format', 'json'], { allowFail: true, timeout: 3_600_000 }); const status = (() => { try { return (JSON.parse(n.out) as { status?: string }).status; } catch { return undefined; } })(); if (status !== 'Accepted') fail(`notarization of the DMG: ${status ?? (n.err.trim() || n.out.trim() || `notarytool exited ${n.code} without an answer (killed or timed out): run again; xcrun notarytool history shows the submission`)}`); run(['xcrun', 'stapler', 'staple', dmg]); run(['xcrun', 'stapler', 'validate', dmg]); } } // the exact artifacts: mount the DMG and unpack the updater bundle, then self-test what is inside const mnt = mkdtempSync(join(tmpdir(), 'sb-mnt-')); run(['hdiutil', 'attach', '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mnt, dmg]); try { arch.dmgApp = archGuard(join(mnt, APP_NAME), 'app in the DMG'); checks.dmgApp = gatekeeper(join(mnt, APP_NAME), signed, notarized, 'app in the DMG'); tests.dmg = selfTest(join(mnt, APP_NAME), 'DMG'); } finally { run(['hdiutil', 'detach', mnt, '-force'], { allowFail: true }); } // the DMG itself, as Gatekeeper sees a downloaded disk image const dsp = run(['spctl', '-a', '-vvv', '-t', 'open', '--context', 'context:primary-signature', dmg], { allowFail: true }); const dmgSpctl = `${dsp.code === 0 ? 'accepted' : 'rejected'}: ${(dsp.err || dsp.out).trim().split('\n').slice(1, 3).join(' / ')}`; let dmgStapler = 'not stapled (unsigned)'; if (notarized) { if (dsp.code !== 0 || !/Notarized Developer ID/.test(dsp.err + dsp.out)) fail(`Gatekeeper does not accept the DMG: ${dmgSpctl}`); const st = run(['xcrun', 'stapler', 'validate', dmg], { allowFail: true }); if (st.code !== 0) fail(`DMG: stapler validate failed: ${(st.out + st.err).trim()}`); dmgStapler = 'ticket stapled, validated'; } say(`DMG: spctl -t open ${dmgSpctl}; ${dmgStapler}`); const unpacked = mkdtempSync(join(tmpdir(), 'sb-upd-')); run(['tar', '-xzf', updater, '-C', unpacked]); arch.updaterApp = archGuard(join(unpacked, APP_NAME), 'app in the updater bundle'); checks.updaterApp = gatekeeper(join(unpacked, APP_NAME), signed, notarized, 'app in the updater bundle'); tests.updater = selfTest(join(unpacked, APP_NAME), 'updater bundle'); rmSync(unpacked, { recursive: true, force: true }); // privacy: the app folder, the DMG (mounted) and the updater bundle (unpacked) const privacy = privacyGate([app, dmg, updater]); // signatures with the updater key, latest.json, SHA256SUMS const updaterSig = signFile(updater); const dmgSig = signFile(dmg); const notes = a.get('notes') ?? `storebound desktop ${version}`; const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); const manifest = mergeManifest(readManifest(out), version, notes, now, { platforms: { 'darwin-aarch64': { signature: updaterSig, url: releaseUrl(version, updaterName) }, 'darwin-x86_64': { signature: updaterSig, url: releaseUrl(version, updaterName) }, }, downloads: { 'macos-universal-dmg': { file: dmgName, url: releaseUrl(version, dmgName), sha256: sha256(dmg), size: statSync(dmg).size, signature: dmgSig, codeSigned: notarized } }, }); writeVersionFiles(out, manifest); const artifacts = [dmg, updater].map(f => ({ file: basename(f), size: statSync(f).size, sha256: sha256(f) })); const evidence = { product: 'storebound desktop', version, platform: 'macos-universal', builtAt: now, commit: git.commit, dirty: git.dirty, signing: signed ? { identity: 'Developer ID Application', notarized, hardenedRuntime: true } : { identity: 'ad-hoc', notarized: false, note: 'no Developer ID Application certificate in the keychain' }, tools: { rustc: run(['rustc', '--version'], { env: { ...process.env, PATH } }).out.trim(), bun: Bun.version, tauri: run(['bunx', 'tauri', '--version'], { cwd: APP_DIR }).out.trim(), macos: run(['sw_vers', '-productVersion']).out.trim(), darwin: osRelease(), }, checks: { ...checks, dmg: { spctl: dmgSpctl, stapler: dmgStapler }, architecture: arch, selfTest: tests, bootCheck, privacy }, artifacts, }; writeFileSync(join(out, `evidence-macos-${version}.json`), `${JSON.stringify(evidence, null, 2)}\n`); console.log(`\nstorebound desktop ${version} for macOS: ${out.replace(homedir(), '~')}`); for (const x of artifacts) console.log(` ${x.file.padEnd(48)} ${mb(x.size).padStart(9)} ${x.sha256}`); console.log(' latest.json, SHA256SUMS, *.sig, evidence-macos-*.json (evidence stays local)'); if (!signed) { console.log(` Unsigned preview (ad-hoc). To ship a signed, notarized build: 1. The Account Holder of the Apple Developer team creates a "Developer ID Application" certificate (developer.apple.com > Certificates > +; a CSR from Keychain Access on this Mac) and installs it here. 2. App Store Connect API key for notarytool (Users and Access > Integrations, role Developer): ~/.storebound-keys/notary.json {"keyId": "...", "issuerId": "...", "keyPath": "~/.storebound-keys/AuthKey_.p8"} 3. Run this again in Terminal on the Mac (not over SSH).`); } console.log(`\nnext: add Windows / Linux (bun tools/desktop-release.ts add --out ${out.replace(homedir(), '~')} ), then publish (docs/desktop-release.md).`); } // --------------------------------------------------------------------------------------------------------------------- // add (Windows / Linux installers from the Forgejo workflow) /** Every artifact of a version comes from one commit: the commit of the added files must match the commit in the * folder's other evidence files (pure; `evidence` maps an evidence file name to its commit). */ export function commitProblems(commit: string | undefined, evidence: Record): string[] { if (!commit) return []; if (!/^[0-9a-f]{40}$/.test(commit)) return [`--commit: a full 40-character commit hash, got ${commit}`]; return Object.entries(evidence).filter(([, c]) => c && c !== commit).map(([f, c]) => `${f} says commit ${c!.slice(0, 10)}, these files are from ${commit.slice(0, 10)}`); } function add(argv: string[]): void { const a = args(argv); const { version, drift } = versions(); if (!version || drift.length) fail('desktop version drift (bun tools/desktop-version.ts)'); const out = resolve(a.get('out') ?? join(homedir(), '.storebound-releases', 'desktop', version)); const files = a.rest.slice(1); if (!files.length) fail('usage: add --out '); mkdirSync(out, { recursive: true }); const commit = a.get('commit'); const otherEvidence = Object.fromEntries(readdirSync(out).filter(n => /^evidence-.*\.json$/.test(n)) .map(n => [n, (JSON.parse(readFileSync(join(out, n), 'utf8')) as { commit?: string }).commit])); const commitBad = commitProblems(commit, otherEvidence); if (commitBad.length) fail(`not one commit: ${commitBad.join('; ')}`); const codeSigned = a.has('code-signed'); const platforms: Manifest['platforms'] = {}; const downloads: Partial> = {}; const added: string[] = []; const sources: Record = {}; for (const f of files.map(x => resolve(x))) { const n = basename(f); if (!n.includes(`_${version}_`)) fail(`${n} is not version ${version}`); const kind = /-setup\.exe$/i.test(n) ? 'nsis' : /\.AppImage$/.test(n) ? 'appimage' : /\.deb$/.test(n) ? 'deb' : null; if (!kind) fail(`${n}: expected an NSIS setup .exe, an .AppImage or a .deb`); const name = artifactName(version, kind, kind === 'nsis' ? codeSigned : true); const dest = join(out, name); copyFileSync(f, dest); added.push(dest); sources[name] = n; } const privacy = privacyGate(added); for (const dest of added) { const name = basename(dest); const sig = signFile(dest); const d: Download = { file: name, url: releaseUrl(version, name), sha256: sha256(dest), size: statSync(dest).size, signature: sig, codeSigned: name.endsWith('.exe') ? codeSigned : false }; if (name.endsWith('.exe')) { downloads['windows-x86_64-nsis'] = d; platforms['windows-x86_64'] = { signature: sig, url: d.url }; } else if (name.endsWith('.AppImage')) { downloads['linux-x86_64-appimage'] = d; platforms['linux-x86_64'] = { signature: sig, url: d.url }; } else downloads['linux-x86_64-deb'] = d; say(`added ${name} (${mb(d.size)})`); } const existing = readManifest(out); const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); writeVersionFiles(out, mergeManifest(existing, version, a.get('notes') ?? existing?.notes ?? `storebound desktop ${version}`, now, { platforms, downloads })); // evidence per platform (stays local, like the macOS one): where the files came from and what was checked here for (const platform of ['linux', 'windows'] as const) { const mine = added.filter(d => (platform === 'windows') === d.endsWith('.exe')); if (!mine.length) continue; const evidence = { product: 'storebound desktop', version, platform: `${platform}-x86_64`, addedAt: now, commit: commit ?? null, run: a.get('run') ?? null, checks: { privacy, updaterSignature: 'minisign, verified against tauri.conf.json', codeSigned: platform === 'windows' ? codeSigned : false }, artifacts: mine.map(d => ({ file: basename(d), from: sources[basename(d)], size: statSync(d).size, sha256: sha256(d) })), }; writeFileSync(join(out, `evidence-${platform}-${version}.json`), `${JSON.stringify(evidence, null, 2)}\n`); } say(`latest.json now has ${Object.keys(readManifest(out)!.platforms).join(', ')}${commit ? `; evidence: commit ${commit.slice(0, 10)}` : ''}`); } // --------------------------------------------------------------------------------------------------------------------- // verify / publish function verifyFolder(out: string): { manifest: Manifest; files: string[] } { if (!existsSync(join(out, 'latest.json')) || !existsSync(join(out, 'SHA256SUMS'))) fail(`${out}: no latest.json / SHA256SUMS`); const manifest = readManifest(out)!; const problems = manifestProblems(manifest); if (problems.length) fail(`latest.json: ${problems.join('; ')}`); const sums = readFileSync(join(out, 'SHA256SUMS'), 'utf8').trim().split('\n').map(l => { const [h, f] = l.split(/\s+/); return { h: h!, f: f! }; }); for (const { h, f } of sums) if (!existsSync(join(out, f)) || sha256(join(out, f)) !== h) fail(`SHA256SUMS: ${f} does not match`); const files = readdirSync(out).filter(uploadable); for (const f of files) if (f !== 'SHA256SUMS' && !sums.some(s => s.f === f)) fail(`${f} is not in SHA256SUMS`); const pub = confPubkey(); const check = (url: string, signature: string, what: string) => { const file = url.split('/').pop()!; if (!url.startsWith(releaseUrl(manifest.version, ''))) fail(`${what}: ${url} is not in /desktop/dl/${manifest.version}/`); if (!files.includes(file)) fail(`${what}: ${file} is not in the folder`); const v = verifySig(readFileSync(join(out, file)), signature, pub); if (!v.ok) fail(`${what}: ${file}: ${v.reason}`); }; for (const [k, p] of Object.entries(manifest.platforms)) check(p.url, p.signature, `platforms.${k}`); for (const [k, d] of Object.entries(manifest.downloads ?? {})) { check(d!.url, d!.signature, `downloads.${k}`); if (sha256(join(out, d!.file)) !== d!.sha256 || statSync(join(out, d!.file)).size !== d!.size) fail(`downloads.${k}: size or SHA-256 differ from the file`); } privacyGate(files.filter(f => !/\.(sig|json)$|^SHA256SUMS$/.test(f)).map(f => join(out, f))); say(`verified ${manifest.version}: ${files.length} files, platforms ${Object.keys(manifest.platforms).join(', ')}, downloads ${Object.keys(manifest.downloads ?? {}).join(', ')}`); return { manifest, files }; } /** The npm CLI and the desktop app are one release: the folder must be the version of this checkout's package.json * (the CLI that `npx storebound desktop` installs it for). Pure. */ export function releaseTogetherProblems(folderVersion: string, pkgVersion: string | null): string[] { if (!pkgVersion) return ['package.json has no version']; return folderVersion === pkgVersion ? [] : [`the folder is desktop ${folderVersion} but package.json (the npm CLI) is ${pkgVersion}: the CLI and the app ship with one version (bun tools/desktop-version.ts --set ${folderVersion}, or build the folder from the release commit)`]; } function publish(argv: string[]): void { const a = args(argv); const out = resolve(a.get('out') ?? fail('--out is required')); const host = a.get('host') ?? process.env.STOREBOUND_PUBLISH_HOST ?? fail('--host (or STOREBOUND_PUBLISH_HOST) is required'); if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(host)) fail(`--host: user@host, got ${host}`); const channel = a.get('channel') ?? 'stable'; if (!['stable', 'beta'].includes(channel)) fail('--channel stable or beta'); const { manifest, files } = verifyFolder(out); const v = manifest.version; const together = releaseTogetherProblems(v, versions().version); if (together.length) fail(together.join('; ')); const unsigned = Object.values(manifest.downloads ?? {}).filter(d => !d!.codeSigned && /\.(dmg|exe)$/.test(d!.file)).map(d => d!.file); const steps = [ ['ssh', host, `mkdir -p ${REMOTE_INCOMING}/${v} && chmod 700 ${REMOTE_INCOMING}`], ['rsync', '-a', '--chmod=F644,D755', '--delete', ...files.map(f => join(out, f)), `${host}:${REMOTE_INCOMING}/${v}/`], ['ssh', host, `${REMOTE_PUBLISH} ${v} ${channel}`], ]; console.log(`\npublish storebound desktop ${v} to the ${channel} channel (${channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`}):`); for (const f of files) console.log(` ${f}`); if (unsigned.length) console.log(` note: unsigned preview builds (${unsigned.join(', ')}); the downloads page says so.`); console.log('\nsteps:'); for (const s of steps) console.log(` ${s[0]} ${s.slice(1).map(x => (x.includes(' ') ? `"${x}"` : x.replace(out, ''))).join(' ')}`); if (!a.has('yes')) { console.log('\ndry run: nothing uploaded. Every installed app on this channel offers the update once latest.json is live; run again with --yes to publish.'); return; } for (const s of steps) { say(`${s[0]} ...`); run(s, { stdio: ['ignore', 'inherit', 'inherit'] }); } const live = spawnSync('curl', ['-fsS', channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`], { encoding: 'utf8' }); if (live.status !== 0 || (JSON.parse(live.stdout) as Manifest).version !== v) fail('latest.json on storebound.dev is not the new version yet'); say(`live: ${BASE_URL}/ serves ${v}`); say(`next, the same release: npm publish of storebound ${v} (CONTRIBUTING.md). \`npx storebound desktop\` of ${v} installs or updates to this app.`); } if (import.meta.main) { const argv = process.argv.slice(2); const cmd = argv[0]; if (cmd === 'mac') await mac(argv.slice(1)); else if (cmd === 'add') add(argv); else if (cmd === 'verify') verifyFolder(resolve(args(argv).get('out') ?? fail('--out '))); else if (cmd === 'publish') publish(argv.slice(1)); else if (cmd === 'exec') { // `exec -- `: runs a build command with the path-free environment (the Forgejo workflow uses it) const cmdline = argv.slice(argv.indexOf('--') + 1); if (argv.indexOf('--') < 0 || !cmdline.length) fail('usage: exec -- '); const r = spawnSync(cmdline[0]!, cmdline.slice(1), { stdio: 'inherit', env: { ...process.env, ...pathFreeEnv(ROOT, process.env) }, shell: process.platform === 'win32' }); process.exit(r.status ?? 1); } else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 26).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); } }