/** * `celilo publish` entry point — thin orchestration over the * planner/executor split (openspec/changes/publilo-cli/proposal.md Phase 2). * * parse args → preflight → build plan → display → confirm → execute * * Each per-phase planner and executor lives in its own module * (workspace.ts, consumer-pins.ts, global-install.ts, * module-registry.ts); this file just wires them together and owns the * top-level flag parsing + dry-run / release-touch / promote special * paths. * * Both `celilo publish ...` (via the CLI dispatcher) and * `bun run publish ...` (via the legacy shim at scripts/publish.ts) * call `main()` here with their argv slice. * * Usage: * celilo publish # publish (preflight first) * celilo publish --dry-run # preflight + plan, no changes * celilo publish --release-touch # auto-fix module stale-drift * celilo publish --allow-stale # skip stale-version safeguards * celilo publish -y / --yes # auto-confirm prompts * celilo publish --skip-module # leave a module out of the registry sweep * celilo publish --alpha # X.Y.Z-alpha.N to @alpha * celilo publish --alpha --track-alpha # also force-pin alphas globally * celilo publish --alpha --alpha-modules # also publish module +N * celilo publish --promote @ # graduate alpha to real */ import { execSync } from 'node:child_process'; import { createInterface } from 'node:readline'; import { alphaSkipDecision, isAlphaVersion, nextAlphaNumber, parsePackageSpec, stripAlphaSuffix, } from './alpha'; import { applyPendingChangesets, listPendingChangesets, printPendingChangesets, } from './changesets'; import { executePlan } from './execute'; import { REPO_ROOT, getPublishPackages, isPublished, readPkg } from './helpers'; import { displayPlan, planPublish } from './plan'; import { applyReleaseTouch, autoTouchAndCommit, printPreflightReport, runPreflight, } from './preflight'; import type { PublishMode, PublishOptions } from './types'; // ─── Confirm helper ──────────────────────────────────────────────── // Set once at main() entry from the resolved argv. Module-scoped because // confirm() is called from a dozen places and threading the flag through // each path adds noise without lifting any decisions. let autoYes = false; function prompt(question: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); return new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }), ); } /** * `-y` / `--yes` auto-confirms every prompt. Doesn't bypass the * dirty-tree check or the stale-version safeguard — those are real * gates, not just confirmations. */ async function confirm(question: string): Promise { if (autoYes) { process.stdout.write(`${question}y (auto-confirmed via -y)\n`); return true; } const reply = await prompt(question); return /^[Yy]$/.test(reply.trim()); } // ─── Flag parsing ────────────────────────────────────────────────── interface ParsedFlags { options: PublishOptions; dryRun: boolean; releaseTouch: boolean; /** * Suppresses the pending-changesets prompt at the top of normal-mode * publishes. Use when there are unapplied changesets but the * operator wants to ship what's in source right now (or pre-applied * the bumps via `bunx changeset version` themselves). The flag has * no effect in alpha or promote mode — those skip changesets * categorically. */ skipChangesets: boolean; } export function parseOptions(argv: string[]): ParsedFlags { function takeFlagValue(flag: string): string | null { const i = argv.indexOf(flag); if (i < 0) return null; const value = argv[i + 1]; if (value === undefined || value.startsWith('-')) { console.error(`✗ ${flag} requires a value (e.g. ${flag} @celilo/e2e@0.7.14-alpha.3)`); process.exit(1); } return value; } const allowStale = argv.includes('--allow-stale'); const yes = argv.includes('-y') || argv.includes('--yes'); const dryRun = argv.includes('--dry-run') || argv.includes('-n'); const releaseTouch = argv.includes('--release-touch'); const alphaFlag = argv.includes('--alpha'); const trackAlphaFlag = argv.includes('--track-alpha'); const alphaModulesFlag = argv.includes('--alpha-modules'); const skipChangesets = argv.includes('--skip-changesets'); const skipModulesFlag = argv.includes('--skip-modules'); const modulesOnlyFlag = argv.includes('--modules-only'); if (skipModulesFlag && modulesOnlyFlag) { console.error('✗ --skip-modules and --modules-only are mutually exclusive.'); process.exit(1); } const promoteArg = takeFlagValue('--promote'); const skippedModules: string[] = []; for (let i = argv.indexOf('--skip-module'); i >= 0; i = argv.indexOf('--skip-module', i + 1)) { const id = argv[i + 1]; if (id === undefined || id.startsWith('-')) { console.error('✗ --skip-module requires a module id (e.g. --skip-module homebridge)'); process.exit(1); } skippedModules.push(id); } if (alphaFlag && promoteArg) { console.error('✗ --alpha and --promote are mutually exclusive.'); process.exit(1); } if (trackAlphaFlag && !alphaFlag) { console.error('✗ --track-alpha requires --alpha.'); process.exit(1); } if (alphaModulesFlag && !alphaFlag) { console.error('✗ --alpha-modules requires --alpha.'); process.exit(1); } if (releaseTouch && (alphaFlag || promoteArg)) { console.error('✗ --release-touch cannot be combined with --alpha or --promote.'); process.exit(1); } const mode: PublishMode = promoteArg ? { kind: 'promote', target: parsePackageSpec(promoteArg) } : alphaFlag ? { kind: 'alpha', trackAlpha: trackAlphaFlag, alphaModules: alphaModulesFlag } : { kind: 'normal' }; if (mode.kind === 'promote' && !isAlphaVersion(mode.target.version)) { console.error( `✗ --promote target "${mode.target.name}@${mode.target.version}" is not an alpha (no -alpha.N suffix).`, ); process.exit(1); } return { options: { allowStale, autoYes: yes, mode, skippedModules, modulePhase: modulesOnlyFlag ? 'only' : skipModulesFlag ? 'skip' : 'run', }, dryRun, releaseTouch, skipChangesets, }; } // ─── Special-case handlers ───────────────────────────────────────── async function handleDryRun(opts: PublishOptions): Promise { const report = runPreflight(); // Match the mode-aware blocking logic the real publish uses. const skipWorkspaceStale = opts.mode.kind !== 'normal'; const skipModuleStale = opts.mode.kind === 'alpha'; printPreflightReport(report); if (opts.mode.kind === 'alpha') { console.log('\nAlpha publish plan:'); for (const pkg of getPublishPackages()) { const { name, version } = readPkg(pkg); if (!name || !version) continue; const n = nextAlphaNumber(name, version); const decision = alphaSkipDecision(pkg, name, version, n); const note = decision.skip ? ` [skip: ${decision.reason}]` : ''; console.log(` ${name.padEnd(32)} ${version} → ${version}-alpha.${n} (${pkg})${note}`); } } else if (opts.mode.kind === 'promote') { const base = stripAlphaSuffix(opts.mode.target.version); console.log( `\nPromote plan:\n ${opts.mode.target.name.padEnd(32)} ${opts.mode.target.version} → ${base}`, ); } const anyIssue = report.dirty || (!skipWorkspaceStale && report.workspaceStale.length > 0) || (!skipModuleStale && report.moduleStale.length > 0); process.exit(anyIssue ? 1 : 0); } async function handleReleaseTouch(): Promise { const report = runPreflight(); if (report.dirty) { console.error( '✗ Working tree is dirty — refusing to touch manifests on top of uncommitted work.', ); console.error(' Commit or stash first, then re-run --release-touch.\n'); console.error(execSync('git status --short', { encoding: 'utf-8', cwd: REPO_ROOT })); process.exit(1); } if (report.workspaceStale.length > 0) { console.error( "✗ Workspace package(s) need real version bumps — these can't be release-touched:", ); for (const i of report.workspaceStale) { console.error(` ${i.name}@${i.version} (bump ${i.pkg}/package.json)`); } console.error( '\n Bump those manually, commit, then re-run --release-touch (or just `bun run publish`).', ); process.exit(1); } applyReleaseTouch(report.moduleStale); process.exit(0); } // ─── Main ────────────────────────────────────────────────────────── export async function main(argv: string[]): Promise { // process.chdir matches the prior script-scope behavior: many helpers // run `git`, `bun`, `npm` as subprocesses without a cwd, relying on // the publisher being at the monorepo root. process.chdir(REPO_ROOT); const { options, dryRun, releaseTouch, skipChangesets } = parseOptions(argv); autoYes = options.autoYes; if (dryRun) return handleDryRun(options); if (releaseTouch) return handleReleaseTouch(); // Promote requires the alpha to actually exist on npm — otherwise // there's nothing to graduate. if (options.mode.kind === 'promote') { if (!isPublished(options.mode.target.name, options.mode.target.version)) { console.error( `✗ Cannot promote: ${options.mode.target.name}@${options.mode.target.version} is not on npm.`, ); process.exit(1); } } // Preflight cascade: if only module manifests drifted (the common // case), offer to auto-touch + commit + continue. For anything else, // print and exit. const preflight = runPreflight(); const skipWorkspaceStale = options.mode.kind !== 'normal'; const skipModuleStale = options.mode.kind === 'alpha'; const blocking = preflight.dirty || (!skipWorkspaceStale && preflight.workspaceStale.length > 0) || (!skipModuleStale && preflight.moduleStale.length > 0); if (blocking && !options.allowStale) { const onlyModuleDrift = !skipModuleStale && !preflight.dirty && preflight.workspaceStale.length === 0 && preflight.moduleStale.length > 0; if (onlyModuleDrift) { printPreflightReport(preflight); const proceed = await confirm( '\nAuto-touch the drifted manifests, commit, and continue with publish? [y/N] ', ); if (!proceed) { console.log( '\nAborted. Run `bun run publish --release-touch` to touch without publishing.', ); process.exit(1); } const { ok, recheck } = autoTouchAndCommit(preflight); if (!ok) process.exit(1); if ( recheck && (recheck.dirty || recheck.workspaceStale.length > 0 || recheck.moduleStale.length > 0) ) { console.error('\n✗ Preflight still has issues after auto-touch:'); printPreflightReport(recheck); process.exit(1); } console.log( `\n✓ Touched and committed ${preflight.moduleStale.length} manifest(s). Continuing with publish.\n`, ); } else { printPreflightReport(preflight); process.exit(1); } } else if (blocking && options.allowStale) { console.warn('\n⚠ Preflight issues detected; publishing anyway (--allow-stale):'); printPreflightReport(preflight); } // Changesets — Phase 4 of openspec/changes/publilo-cli/proposal.md. Only applies in normal // mode: alpha publishes are throwaway prereleases (applying changesets // would burn them on alpha publishes), and promote graduates an // existing alpha rather than producing a new version. The // --skip-changesets flag is the manual escape hatch. if (options.mode.kind === 'normal' && !skipChangesets) { const pending = listPendingChangesets(); if (pending.length > 0) { printPendingChangesets(pending); const apply = await confirm( '\nApply changesets (bump versions, update changelogs, commit), then continue with publish? [Y/n] ', ); if (!apply) { console.log( '\nAborted. Run with --skip-changesets to publish current package.json versions instead.', ); process.exit(1); } try { applyPendingChangesets(pending.length); } catch (err) { console.error(`\n✗ ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } // Re-run preflight on the post-bump tree so we don't proceed // with an unexpected state (e.g. changeset version left // something dirty that wasn't picked up by our git-add). const recheck = runPreflight(); if (recheck.dirty) { console.error('\n✗ Tree is dirty after applying changesets:'); printPreflightReport(recheck); process.exit(1); } console.log(`\n✓ Applied ${pending.length} changeset(s). Continuing with publish.\n`); } } const plan = planPublish(options); displayPlan(plan); // Alpha mode might have auto-skipped every package — bail before // prompting for confirmation we don't need. if (options.mode.kind === 'alpha' && plan.workspace.every((p) => p.skipReason !== undefined)) { console.log('All packages skipped (no source changes since last alpha). Nothing to do.'); return; } if (!(await confirm('Proceed? [y/N] '))) { console.log('Aborted.'); process.exit(0); } const result = await executePlan({ plan, confirm }); console.log('\n──────────────────────────────────────────────'); console.log(' Publish summary'); console.log('──────────────────────────────────────────────'); if (result.published.length) { console.log('Published:'); for (const p of result.published) console.log(` ✓ ${p.name}@${p.version}`); } if (result.skipped.length) { console.log('Skipped:'); for (const s of result.skipped) console.log(` - ${s}`); } if (!result.published.length && !result.skipped.length) { console.log('Nothing to do.'); } }