import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const workspaceRoot = path.resolve(__dirname, '..'); const defaultEnvPath = path.join(workspaceRoot, '.env.desktop-signing.local'); const secretKeys = new Set([ 'APPLE_APP_SPECIFIC_PASSWORD', 'CSC_KEY_PASSWORD', 'WIN_CSC_KEY_PASSWORD', ]); function expandHome(filePath) { if (!filePath) return filePath; const cleanPath = filePath.startsWith('file://') ? filePath.slice('file://'.length) : filePath; if (cleanPath === '~') { return process.env.HOME || cleanPath; } if (cleanPath.startsWith('~/')) { return path.join(process.env.HOME || '', cleanPath.slice(2)); } return cleanPath; } function parseEnvValue(rawValue) { let value = rawValue.trim(); const quote = value[0]; if ( value.length >= 2 && (quote === '"' || quote === "'") && value[value.length - 1] === quote ) { value = value.slice(1, -1); if (quote === '"') { value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\'); } } return value; } function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) { return { loaded: false, values: {} }; } const values = {}; const content = fs.readFileSync(filePath, 'utf8'); for (const [index, line] of content.split(/\r?\n/).entries()) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); if (!match) { throw new Error( `Invalid env line in ${path.relative(workspaceRoot, filePath)}:${index + 1}` ); } values[match[1]] = parseEnvValue(match[2]); } return { loaded: true, values }; } function mergeEnv(envFilePath) { const { loaded, values } = loadEnvFile(envFilePath); for (const [key, value] of Object.entries(values)) { if (process.env[key] == null || process.env[key] === '') { process.env[key] = value; } } return loaded; } function readArgValue(args, name, fallback) { const prefix = `${name}=`; const match = args.find((arg) => arg.startsWith(prefix)); return match ? match.slice(prefix.length) : fallback; } function flag(args, name) { return args.includes(name); } function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: workspaceRoot, encoding: 'utf8', env: process.env, stdio: options.stdio || 'pipe', timeout: options.timeout || 0, }); if (result.error) { throw result.error; } return result; } function runRequired(command, args) { const result = spawnSync(command, args, { cwd: workspaceRoot, env: process.env, stdio: 'inherit', }); if (result.error) { throw result.error; } if (result.status !== 0) { throw new Error(`Command failed: ${command} ${args.join(' ')}`); } } function maskEnvValue(key, value) { if (!value) return ''; return secretKeys.has(key) ? '' : value; } function certificateSubjectsFromP12(p12Path) { const result = run('openssl', [ 'pkcs12', '-in', p12Path, '-nokeys', '-clcerts', '-passin', 'env:CSC_KEY_PASSWORD', '-info', ]); return { ok: result.status === 0, output: [result.stdout, result.stderr].filter(Boolean).join('\n'), }; } function codesigningIdentities() { const result = run('security', ['find-identity', '-v', '-p', 'codesigning']); return { ok: result.status === 0, output: [result.stdout, result.stderr].filter(Boolean).join('\n'), }; } function checkTool(command, args) { try { const result = run(command, args, { timeout: 5000 }); return result.status === 0; } catch { return false; } } function findXcodeDeveloperDir() { try { const result = run('xcode-select', ['-p'], { timeout: 5000 }); if (result.status === 0) { const developerDir = result.stdout.trim(); return developerDir || ''; } } catch { return ''; } return ''; } function hasXcodeTool(toolName) { const developerDir = findXcodeDeveloperDir(); if (developerDir) { const directTool = path.join(developerDir, 'usr/bin', toolName); if (fs.existsSync(directTool)) { return true; } } return checkTool('xcrun', ['--find', toolName]); } function hasCompleteApiKeyLogin() { return Boolean( process.env.APPLE_API_KEY && process.env.APPLE_API_KEY_ID && process.env.APPLE_API_ISSUER ); } function hasPartialApiKeyLogin() { return Boolean( process.env.APPLE_API_KEY || process.env.APPLE_API_KEY_ID || process.env.APPLE_API_ISSUER ); } function hasCompleteAppleIdLogin() { return Boolean( process.env.APPLE_ID && process.env.APPLE_APP_SPECIFIC_PASSWORD && process.env.APPLE_TEAM_ID ); } function hasPartialAppleIdLogin() { return Boolean( process.env.APPLE_ID || process.env.APPLE_APP_SPECIFIC_PASSWORD || process.env.APPLE_TEAM_ID ); } function checkSigning({ requireNotarization = true, quiet = false } = {}) { const errors = []; const warnings = []; const details = []; if (process.platform !== 'darwin') { errors.push('macOS signed builds must run on macOS.'); } const cscLink = process.env.CSC_LINK ? expandHome(process.env.CSC_LINK) : ''; const cscName = process.env.CSC_NAME || ''; if (cscLink) { process.env.CSC_LINK = cscLink; if (!fs.existsSync(cscLink)) { errors.push(`CSC_LINK does not exist: ${cscLink}`); } else { details.push(`CSC_LINK=${cscLink}`); if (!process.env.CSC_KEY_PASSWORD) { errors.push('CSC_KEY_PASSWORD is required when CSC_LINK points to a .p12 file.'); } else { const certInfo = certificateSubjectsFromP12(cscLink); if (!certInfo.ok) { errors.push('Unable to read CSC_LINK .p12. Check the certificate password.'); } else if (!/Developer ID Application:/i.test(certInfo.output)) { errors.push( 'CSC_LINK is not a "Developer ID Application" certificate. It cannot be used for direct customer distribution outside the Mac App Store.' ); } else { const subject = certInfo.output.match(/subject=.*Developer ID Application:[^\n]+/i)?.[0] || 'Developer ID Application certificate found in CSC_LINK'; details.push(subject); } } } } else { const identities = codesigningIdentities(); if (!identities.ok) { errors.push('Unable to inspect macOS code signing identities with security.'); } else if (!/Developer ID Application:/i.test(identities.output)) { errors.push( 'No valid "Developer ID Application" identity is available. Set CSC_LINK to a Developer ID .p12 or import one into Keychain.' ); } else { details.push('Developer ID Application identity found in Keychain.'); } if (cscName) { details.push(`CSC_NAME=${cscName}`); } } const appleApiKey = process.env.APPLE_API_KEY ? expandHome(process.env.APPLE_API_KEY) : ''; if (appleApiKey) { process.env.APPLE_API_KEY = appleApiKey; } if (appleApiKey && !fs.existsSync(appleApiKey)) { errors.push(`APPLE_API_KEY does not exist: ${appleApiKey}`); } if (requireNotarization) { if (hasCompleteApiKeyLogin()) { details.push(`APPLE_API_KEY=${appleApiKey}`); details.push(`APPLE_API_KEY_ID=${process.env.APPLE_API_KEY_ID}`); if (process.env.APPLE_TEAM_ID) { details.push(`APPLE_TEAM_ID=${process.env.APPLE_TEAM_ID}`); } } else if (hasCompleteAppleIdLogin()) { warnings.push( 'Apple ID notarization is configured. The App Store Connect API key method is recommended for repeatable production builds.' ); details.push(`APPLE_ID=${process.env.APPLE_ID}`); details.push(`APPLE_TEAM_ID=${process.env.APPLE_TEAM_ID}`); } else { if (hasPartialApiKeyLogin()) { errors.push( 'Notarization API key config is incomplete. Set APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER.' ); } else if (hasPartialAppleIdLogin()) { errors.push( 'Apple ID notarization config is incomplete. Set APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID.' ); } else { errors.push( 'Notarization credentials are missing. Use APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER for production macOS builds.' ); } } if (!hasXcodeTool('notarytool')) { errors.push('xcrun notarytool is unavailable. Install or select Xcode command line tools.'); } if (!hasXcodeTool('stapler')) { warnings.push('xcrun stapler is unavailable. Notarization may succeed, but ticket validation/stapling checks will be limited.'); } } else { warnings.push('TRUEGROWTH_NOTARIZE=false: this build can be signed, but it is not a full commercial macOS release.'); } if (!quiet) { console.log('[desktop:signing] macOS signing inputs'); for (const key of [ 'CSC_LINK', 'CSC_NAME', 'APPLE_API_KEY', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER', 'APPLE_ID', 'APPLE_TEAM_ID', 'TRUEGROWTH_MAC_TARGETS', 'TRUEGROWTH_NOTARIZE', ]) { if (process.env[key]) { console.log(` ${key}=${maskEnvValue(key, process.env[key])}`); } } for (const detail of details) { console.log(` ok: ${detail}`); } for (const warning of warnings) { console.warn(` warn: ${warning}`); } for (const error of errors) { console.error(` error: ${error}`); } } return { ok: errors.length === 0, errors, warnings, details }; } function resolveMacTargets(args) { const fromArg = readArgValue(args, '--targets', ''); const fromEnv = process.env.TRUEGROWTH_MAC_TARGETS || ''; const raw = fromArg || fromEnv || 'zip'; return raw .split(/[,\s]+/) .map((target) => target.trim()) .filter(Boolean); } function buildMac(args) { const envFile = readArgValue(args, '--env', defaultEnvPath); const envLoaded = mergeEnv(envFile); const requireNotarization = process.env.TRUEGROWTH_NOTARIZE !== 'false'; const check = checkSigning({ requireNotarization }); if (!check.ok) { throw new Error('macOS signing preflight failed; fix the errors above before building a customer release.'); } const targets = resolveMacTargets(args); const builderArgs = [ 'exec', 'electron-builder', '--config', 'electron-builder.yml', '--mac', ...targets, '--arm64', '-c.mac.forceCodeSigning=true', ]; if (process.env.APPLE_TEAM_ID) { builderArgs.push(`-c.mac.notarize.teamId=${process.env.APPLE_TEAM_ID}`); } console.log( `[desktop:signing] Loaded ${envLoaded ? envFile : 'process environment'}` ); console.log(`[desktop:signing] Building macOS targets: ${targets.join(', ')}`); runRequired('pnpm', ['build:web']); runRequired('pnpm', ['desktop:prepare']); runRequired('pnpm', builderArgs); runRequired('pnpm', ['desktop:release-manifest', '--', '--platform=mac']); } function printHelp() { console.log(`Usage: node scripts/desktop-signing.mjs check [--env=.env.desktop-signing.local] [--no-notarize] node scripts/desktop-signing.mjs build-mac [--env=.env.desktop-signing.local] [--targets=zip|dmg,zip] `); } function main() { const [command = 'help', ...args] = process.argv.slice(2); const envFile = readArgValue(args, '--env', defaultEnvPath); if (command === 'help' || flag(args, '--help')) { printHelp(); return; } if (command === 'check') { const loaded = mergeEnv(envFile); const requireNotarization = !flag(args, '--no-notarize') && process.env.TRUEGROWTH_NOTARIZE !== 'false'; if (!loaded && !process.env.CSC_LINK && !process.env.CSC_NAME) { console.warn( `[desktop:signing] ${path.relative(workspaceRoot, envFile)} was not found; checking process environment only.` ); } const result = checkSigning({ requireNotarization }); if (!result.ok) { process.exitCode = 1; } return; } if (command === 'build-mac') { buildMac(args); return; } throw new Error(`Unknown command: ${command}`); } main();