/** * Module publish command — build and publish one or more modules to the registry. * * Usage: celilo module publish ... [--token ] * [--registry ] [--revision ] * [--message "release note"] [--allow-dirty] * [--allow-stale] * * Multiple module directories may be passed; each is built and published in * order. A failure on any one stops the run (publishes are non-destructive, * safe to retry after fixing the root cause). * * Token resolution order: * 1. --token flag * 2. CELILO_PUBLISH_TOKEN env var * 3. The locally-installed `celilo-registry` module's `publish_tokens` * secret (first non-empty line). This is the path operators use after * deploying their own registry — no env-var ritual required. * * Stale-check (D6): * For each module, refuse to publish if commits touching the module dir * landed AFTER the last commit that touched its manifest.yml. The fix is * to bump manifest.yml#version (or just touch it, if the change is * build-only — auto-revision handles the +N bump). --allow-stale * overrides for one-time recovery from existing drift. * * Per CELILO_UPDATE D4 (strict-publish) the manifest's capability * versions are validated against `CAPABILITY_CONTRACT_VERSIONS` from * `@celilo/capabilities` before any build work; a mismatch refuses * the publish so a stale manifest can't ship code that doesn't match * its claimed contract. * * Per CELILO_UPDATE D5, every published .netapp carries a * `release.json` with git SHA / branch / dirty flag / publish * timestamp / CLI version / optional --message. */ import { rm } from 'node:fs/promises'; import { readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { buildModule } from '../../module/packaging/build'; import { buildReleaseMetadata, collectGitInfo, makeRealGitRunner, readInstalledCliVersion, } from '../../module/packaging/release-metadata'; import { RegistryClient } from '../../registry/client'; import { CrossModuleDataManager } from '../../services/cross-module-data-manager'; import { type ManifestForCapabilityCheck, validateCapabilityVersions, } from '../../services/module-validator/capability-versions'; import { checkModuleStale } from '../../services/module-validator/git-hygiene'; import { getFlag, hasFlag } from '../parser'; import type { CommandResult } from '../types'; interface ManifestForPublish extends ManifestForCapabilityCheck { id: string; version: string; description?: string; icon?: string; version_source?: { kind?: string }; } /** * Resolve the publish token via three-tier fallback: * 1. --token flag value passed directly (if non-empty) * 2. CELILO_PUBLISH_TOKEN env var (if non-empty) * 3. Celilo secret store: celilo-registry module's `publish_tokens` secret, * first non-empty line. Lazy DB read — if Celilo isn't installed locally * or the registry module isn't deployed, that's a graceful skip, not a * crash. * * Exported for testing. */ export async function resolveToken(flagValue: string): Promise { if (flagValue) return flagValue; const envValue = process.env.CELILO_PUBLISH_TOKEN ?? ''; if (envValue) return envValue; // Secret-store fallback. Wrap in try/catch — if the local Celilo install // isn't initialized (no DB, no master key), we just return empty and let // the caller report "no token found" with the full guidance. try { const dataManager = new CrossModuleDataManager(); await dataManager.initialize(); const raw = dataManager.getSecret('celilo-registry', 'publish_tokens'); if (!raw) return ''; const firstToken = raw .split('\n') .map((line) => line.trim()) .find((line) => line.length > 0); return firstToken ?? ''; } catch { return ''; } } interface ResolvedOpts { token: string; registryUrl: string; revisionOverride: number | null; message: string | null; allowDirty: boolean; allowStale: boolean; } interface PerModuleOutcome { moduleDir: string; status: 'published' | 'skipped' | 'failed'; message: string; /** Set when published — for the multi-publish summary. */ publishedAs?: string; } /** * Publish a single module. Errors return outcome.status='failed' rather than * throwing — the caller orchestrates the multi-module summary and decides * whether to abort the run. * * Exported for testing. */ export async function publishOneModule( moduleDir: string, opts: ResolvedOpts, ): Promise { const resolvedDir = resolve(moduleDir); // Read manifest let manifest: ManifestForPublish; try { const manifestRaw = await readFile(join(resolvedDir, 'manifest.yml'), 'utf-8'); manifest = parseYaml(manifestRaw) as ManifestForPublish; if (!manifest.id || !manifest.version) { return { moduleDir, status: 'failed', message: `${moduleDir}: manifest.yml missing id or version`, }; } } catch { return { moduleDir, status: 'failed', message: `${moduleDir}: Could not read manifest.yml in ${resolvedDir}`, }; } const name = manifest.id; const baseVersion = manifest.version; // Strict-publish: refuse on capability version mismatch (D4). const capErrors = validateCapabilityVersions(manifest); if (capErrors.length > 0) { return { moduleDir, status: 'failed', message: [ `${moduleDir}: Capability version validation failed for ${name}@${baseVersion}:`, ...capErrors.map((e) => ` • ${e}`), ].join('\n'), }; } // Stale-check: src commits past the last manifest.yml commit. Only fires // when the dir is in git history; brand-new uncommitted modules pass. // // Skipped for changeset/pin modules (ISS-0151 / openspec/changes/module-version-semantics/proposal.md): a // changeset module's version is authored via .changeset/ + `celilo module // version` and ordered by +N, so source-after-manifest is normal, not drift; // a pin module's version is checked against its upstream resolver. The gate // stays for hand-maintained (recipe/unset) modules — the default. const versionKind = manifest.version_source?.kind; const staleGateApplies = versionKind !== 'changeset' && versionKind !== 'pin'; if (!opts.allowStale && staleGateApplies) { const stale = checkModuleStale(resolvedDir); if (stale) { return { moduleDir, status: 'failed', message: [ `${moduleDir}: Stale-version drift for ${name}@${baseVersion} —`, ` src commit: ${stale.lastSrcCommit.slice(0, 12)}`, ` manifest.yml commit: ${stale.lastManifestCommit.slice(0, 12)}`, ` Files in ${moduleDir} changed after manifest.yml. Either bump`, ' manifest.yml#version (semver change), or touch it (release-only', ' change — auto-revision will pick the next +N). Then commit and retry.', ' --allow-stale skips this check (use sparingly).', ].join('\n'), }; } } // Collect git state. Refuse a dirty tree unless --allow-dirty. const gitInfo = collectGitInfo(resolvedDir, makeRealGitRunner()); if (gitInfo.dirty && !opts.allowDirty) { return { moduleDir, status: 'failed', message: [ `${moduleDir}: Working tree at ${resolvedDir} has uncommitted changes.`, ' Refusing to publish — commit (or stash) your changes, or pass --allow-dirty to override.', ].join('\n'), }; } // Determine package revision: --revision flag, or query the registry for next rev. let revision: number; if (opts.revisionOverride !== null) { revision = opts.revisionOverride; } else { const client = new RegistryClient(opts.registryUrl || undefined); try { const entries = await client.getIndex(name); const existingRevs = entries .filter((e) => e.vers.startsWith(`${baseVersion}+`)) .map((e) => Number(e.vers.split('+')[1])) .filter((n) => Number.isInteger(n)); revision = existingRevs.length > 0 ? Math.max(...existingRevs) + 1 : 1; } catch { // Registry unreachable — start at +1 revision = 1; } } const version = `${baseVersion}+${revision}`; // Skip-if-explicit-version-already-published: only relevant when the user // forced a revision number. Auto-revision always picks the next available // slot, so there's no skip case. if (opts.revisionOverride !== null) { const client = new RegistryClient(opts.registryUrl || undefined); try { const entries = await client.getIndex(name); const alreadyPublished = entries.some((e) => e.vers === version); if (alreadyPublished) { return { moduleDir, status: 'skipped', message: `${name}@${version} already on registry — skipping.`, }; } } catch { // Registry unreachable — let publish() fail at upload time with a // clearer error than "couldn't list index." } } // ISS-0104: a module's hooks resolve @celilo/* from its OWN bundled // scripts/node_modules (gitignored, prone to going stale). Refresh that // closure and refuse to ship a stale capability SDK — a stale bundle // silently runs old capability code even after a clean republish. const { refreshAndVerifyBundledDeps } = await import( '../../services/module-validator/bundled-deps' ); const depCheck = refreshAndVerifyBundledDeps(resolvedDir); if (depCheck.mismatches.length > 0 && !opts.allowStale) { return { moduleDir, status: 'failed', message: [ `${moduleDir}: bundled @celilo dependency is stale vs the workspace (ISS-0104):`, ...depCheck.mismatches.map( (m) => ` ${m.pkg}: bundled ${m.bundled}, workspace ${m.workspace}`, ), ' The deployed module would run the bundled (older) capability code.', ` Fix: cd ${join(moduleDir, 'scripts')} && bun install (then retry)`, ' --allow-stale skips this check.', ].join('\n'), }; } // Assemble release metadata for the .netapp. const releaseMetadata = buildReleaseMetadata({ moduleId: name, version, git: gitInfo, cliVersion: readInstalledCliVersion(), message: opts.message, }); // Build the .netapp into a temp dir const tmpPath = join(tmpdir(), `${name}-${version}-${Date.now()}.netapp`); console.log(`Building ${name}@${version}...`); const buildResult = await buildModule({ sourceDir: resolvedDir, outputPath: tmpPath, releaseMetadata, }); if (!buildResult.success || !buildResult.packagePath) { return { moduleDir, status: 'failed', message: `${moduleDir}: ${buildResult.error ?? 'Build failed'}`, }; } // Publish const client = new RegistryClient(opts.registryUrl || undefined); try { console.log(`Publishing ${name}@${version} to ${client.baseUrl}...`); await client.publish({ name, version, netappPath: buildResult.packagePath, token: opts.token, description: manifest.description?.trim() || undefined, icon: manifest.icon?.trim() || undefined, }); } catch (err) { return { moduleDir, status: 'failed', message: `${moduleDir}: Publish failed: ${err instanceof Error ? err.message : String(err)}`, }; } finally { await rm(tmpPath, { force: true }); } return { moduleDir, status: 'published', message: `Published ${name}@${version}${opts.message ? ` — "${opts.message}"` : ''}`, publishedAs: `${name}@${version}`, }; } export async function handleModulePublish( args: string[], flags: Record, ): Promise { if (args.length === 0) { return { success: false, error: 'Module directory required\n\nUsage: celilo module publish ... [--token ]', }; } // Validate --revision early so multi-module runs fail fast. const revisionFlag = getFlag(flags, 'revision', ''); let revisionOverride: number | null = null; if (revisionFlag) { const parsed = Number(revisionFlag); if (!Number.isInteger(parsed) || parsed < 1) { return { success: false, error: '--revision must be a positive integer' }; } revisionOverride = parsed; } if (revisionOverride !== null && args.length > 1) { return { success: false, error: '--revision cannot be combined with multiple module dirs (each module has its own revision sequence)', }; } const token = await resolveToken(getFlag(flags, 'token', '')); if (!token) { return { success: false, error: [ 'Publish token required. Resolution order:', ' 1. --token flag', ' 2. CELILO_PUBLISH_TOKEN env var', " 3. The celilo-registry module's `publish_tokens` secret", ' (set automatically when you deploy celilo-registry locally)', ].join('\n'), }; } const opts: ResolvedOpts = { token, registryUrl: getFlag(flags, 'registry', ''), revisionOverride, message: getFlag(flags, 'message', '') || null, allowDirty: hasFlag(flags, 'allow-dirty'), allowStale: hasFlag(flags, 'allow-stale'), }; const outcomes: PerModuleOutcome[] = []; for (const moduleDir of args) { const outcome = await publishOneModule(moduleDir, opts); outcomes.push(outcome); console.log(outcome.message); } // celilo#1369: every module is attempted even after a failure, and every // failure is reported together at the end. A publish is non-destructive so // re-running after fixing the underlying issue picks up where we left off // (already-published modules are skipped at the registry level when // --revision is explicit, and auto-revision just picks the next slot — // nothing duplicates). printSummary(outcomes); const failed = outcomes.filter((o) => o.status === 'failed'); if (failed.length > 0) { return { success: false, error: failed.map((o) => o.message).join('\n'), }; } if (args.length === 1) { // Backward-compat single-module shape — keep the existing message/data // contract for callers (and tests) that depend on it. By this point no // outcome is 'failed' (we'd have early-returned above), so success is // unconditional. const only = outcomes[0]; return { success: true, message: only.message, data: only.publishedAs ? { publishedAs: only.publishedAs } : undefined, }; } return { success: true, message: `Published ${outcomes.filter((o) => o.status === 'published').length} module(s).`, }; } function printSummary(outcomes: PerModuleOutcome[]): void { if (outcomes.length <= 1) return; // single-module mode already printed its line const published = outcomes.filter((o) => o.status === 'published'); const skipped = outcomes.filter((o) => o.status === 'skipped'); const failed = outcomes.filter((o) => o.status === 'failed'); console.log('\n──────────────────────────────────────────────'); console.log(' Module publish summary'); console.log('──────────────────────────────────────────────'); if (published.length > 0) { console.log('Published:'); for (const o of published) console.log(` ✓ ${o.publishedAs}`); } if (skipped.length > 0) { console.log('Skipped:'); for (const o of skipped) console.log(` - ${o.message}`); } if (failed.length > 0) { console.log('Failed:'); for (const o of failed) console.log(` ✗ ${o.message}`); } }