/** * Umbrella planner — composes the per-phase planners into a single * `PublishPlan`. * * The planner is the value-add of openspec/changes/publilo-cli/proposal.md Phase 2: it does * all the world-reads (npm view, git log, fs walks) and produces a * typed description of every side effect the executor will perform. * Dry-run prints the plan and exits; a real publish confirms the plan * and hands it to the executor. * * Per-phase planners are imported lazily where needed so unit tests * can mock individual phases without dragging in the others' I/O. */ import { planConsumerPins } from './consumer-pins'; import { planGlobalUpdate } from './global-install'; import { buildWorkspaceVersionMap, currentGitHead, getPublishPackages } from './helpers'; import { planModulePublish } from './module-registry'; import { runPreflight } from './preflight'; import type { PublishMode, PublishOptions, PublishPlan } from './types'; import { planWorkspace } from './workspace'; /** * Which publish phases run, from the mode and the module-phase flag. * Pure so the flag combinations stay testable without npm, git or the * registry. * * `modulePhase` only moves the module sweep: `skip` plans no sweep (the * npm phases run alone), `only` plans the sweep alone (celilo#1369's * release-pipeline split — CLI delivery must not depend on the sweep, * so the sweep is run as its own invocation after delivery). */ export interface PlannedPhases { workspace: boolean; consumerPins: boolean; globalUpdate: boolean; modulePublish: boolean; } export function decidePhases( mode: PublishMode, modulePhase: PublishOptions['modulePhase'], ): PlannedPhases { if (modulePhase === 'only') { return { workspace: false, consumerPins: false, globalUpdate: false, modulePublish: true }; } const runPhase2 = mode.kind !== 'alpha'; const runPhase3 = mode.kind !== 'alpha' || mode.trackAlpha; const runModulePublish = (mode.kind === 'normal' || mode.kind === 'promote' || (mode.kind === 'alpha' && mode.alphaModules)) && modulePhase !== 'skip'; return { workspace: true, consumerPins: runPhase2, globalUpdate: runPhase3, modulePublish: runModulePublish, }; } /** * Build the full publish plan. Reads from the world (preflight, npm, * git, fs); doesn't mutate. * * Phase gating per the spec: * - normal: all four phases planned. * - alpha: workspace planned; consumer pins SKIPPED (consumers * opt into @alpha manually); global update SKIPPED unless * --track-alpha; module publish SKIPPED unless * --alpha-modules. * - promote: all four phases planned — this IS a real release. * * In alpha mode the consumer-pin / global-update / module-publish * planners are simply not invoked, so their items stay empty in the * returned plan. The executor reads the same flag combination to know * which phases to actually run; the empty-list contract means "nothing * to do here" either way. */ export function planPublish(opts: PublishOptions): PublishPlan { const preflight = runPreflight(); const phases = decidePhases(opts.mode, opts.modulePhase); const workspaceResult = phases.workspace ? planWorkspace({ mode: opts.mode, packages: getPublishPackages(), baseWorkspaceVersions: buildWorkspaceVersionMap(), gitHead: currentGitHead(), }) : { items: [] }; // PROJECTED publish list — what executeWorkspace would land on // npm if no per-package skips happen at execution time. Used to // seed the global-update plan with force-pin targets. const projectedPublishes = workspaceResult.items .filter((item) => !item.skipReason) .map((item) => ({ name: item.name, version: item.versionToPublish })); return { mode: opts.mode, options: opts, preflight, workspace: workspaceResult.items, consumerPins: phases.consumerPins ? planConsumerPins() : [], globalUpdate: phases.globalUpdate ? planGlobalUpdate({ justPublished: projectedPublishes, trackAlpha: opts.mode.kind === 'alpha' && opts.mode.trackAlpha, }) : [], modulePublish: phases.modulePublish ? planModulePublish(opts.skippedModules) : [], }; } /** * Print the plan for the operator. Called before any side effects (in * both --dry-run and real publish), so confirmation is informed. */ export function displayPlan(plan: PublishPlan): void { const mode = plan.mode; const planLabel = mode.kind === 'alpha' ? 'Alpha publish plan' : mode.kind === 'promote' ? 'Promote plan' : 'Publish plan'; console.log(`${planLabel} (in this order):\n`); for (const p of plan.workspace) { const arrow = mode.kind === 'promote' ? `${mode.target.version} → ${p.versionToPublish}` : p.versionToPublish === p.baseVersion ? p.versionToPublish : `${p.baseVersion} → ${p.versionToPublish}`; const note = p.skipReason ? ` [skip: ${p.skipReason}]` : ''; console.log(` ${p.name.padEnd(32)} ${arrow} (${p.pkg})${note}`); } console.log(); }