import { spawn } from 'node:child_process' import fs from 'node:fs' import fsp from 'node:fs/promises' import path from 'node:path' import { createInterface } from 'node:readline/promises' import { DEFAULT_BASE_URL, REPO_ROOT, readPrivateKey, readPublicKey, runSsh, writeFileAtomic } from './lib.mjs' import { ReleaseError, acquireReleaseLock, finalizeReleaseRequest, parseReleaseArgs, resolveVersionChoice, runReleaseWorkflow, toReleaseError, verifyPublishedManifest } from './release-workflow.mjs' import { readLatestViaSsh, validateReleaseBundle } from './transport.mjs' const usage = `Usage: npm run release npm run release -- --version [--notes-zh ] [--notes-en ] npm run release -- --resume --version npm run release -- --help Options: --version Stable Windows x64 version. Prompted when omitted. --notes-zh Chinese release notes. Defaults to "YCode 更新". --notes-en English release notes. Defaults to "YCode update". --resume Reuse an existing installer and signed manifest. --ssh-host SSH host, default local001. --base-url Release base URL, default ${DEFAULT_BASE_URL}. --private-key Signing key, default in the current user profile. --help Show this help. The workflow runs tests, builds, signs, deploys, and verifies latest.json. It does not create keys, roll back the server, or clean stale release locks. ` async function pathExists(filename) { try { await fsp.access(filename) return true } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false throw error } } function runExecutable(command, args, { cwd = REPO_ROOT } = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env: process.env, shell: false, stdio: 'inherit', windowsHide: true }) child.once('error', reject) child.once('exit', (code, signal) => { if (code === 0) { resolve() } else { reject(new Error(`${path.basename(command)} exited with ${signal ? `signal ${signal}` : `code ${code}`}`)) } }) }) } function resolveNpmCli() { const candidates = [ path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), process.env.npm_execpath ].filter((candidate) => candidate && path.basename(candidate).toLowerCase() === 'npm-cli.js') const npmCli = candidates.find((candidate) => fs.existsSync(candidate)) if (!npmCli) throw new Error('Unable to locate npm-cli.js; run this command through npm run release') return npmCli } async function promptForVersion() { const readline = createInterface({ input: process.stdin, output: process.stdout }) try { return await readline.question('Release version (stable x.y.z): ') } finally { readline.close() } } function productionDependencies() { const npmCli = resolveNpmCli() return { root: REPO_ROOT, pathExists, realpath: fsp.realpath, readFile: fsp.readFile, writeFileAtomic, readPrivateKey, readPublicKey, validateBundle: validateReleaseBundle, acquireReleaseLock, checkToolchain: async (paths, { resume }) => { const required = [['deploy.mjs', paths.deployScript]] if (!resume) { required.unshift( ['npm-cli.js', npmCli], ['electron-builder CLI', paths.builderCli], ['prepare-release.mjs', paths.prepareScript] ) } for (const [label, filename] of required) { if (!fs.existsSync(filename)) throw new Error(`${label} not found: ${filename}`) } }, checkSsh: async (host) => { runSsh(host, 'true\n') }, readLatest: ({ host, publicKeyPem, baseUrl }) => readLatestViaSsh(host, publicKeyPem, baseUrl), runNpm: (args) => runExecutable(process.execPath, [npmCli, ...args]), runNode: (args) => runExecutable(process.execPath, args), verifyHttp: verifyPublishedManifest, logWarning: (message) => console.warn(message) } } async function main() { let parsed try { parsed = parseReleaseArgs(process.argv.slice(2)) } catch (error) { throw toReleaseError('input', error) } if (parsed.help) { process.stdout.write(usage) return } let version try { version = await resolveVersionChoice({ version: parsed.version, prompt: promptForVersion }) } catch (error) { throw toReleaseError('prompt', error) } if (!version) { console.log('Release cancelled.') return } let request try { request = finalizeReleaseRequest(parsed, version) } catch (error) { throw toReleaseError('input', error) } const result = await runReleaseWorkflow(request, productionDependencies()) console.log(`Release ${result.version} published and verified.`) console.log(`Installer: ${result.installerPath}`) console.log(`Manifest: ${result.manifestPath}`) } main().catch((error) => { const releaseError = error instanceof ReleaseError ? error : toReleaseError('input', error) console.error(`Release failed during ${releaseError.stage}: ${releaseError.message}`) process.exitCode = 1 })