/** * `celilo module version []` — stamp a changeset-kind module's * payload version into manifest.yml (ISS-0151 / openspec/changes/module-version-semantics/proposal.md). * * Reads `.changeset/*.md` (keyed by the MODULE id — Option A), computes the next * `manifest.yml#version`, prepends a CHANGELOG entry, and deletes the consumed * changesets. It only edits the working tree — the caller (release.yml's version * phase) commits the result and opens the "Version Packages" PR. * * Only `version_source.kind: changeset` is handled here. `pin` (wrapper modules) * is a no-op-with-notice for now; `recipe` (and an absent block) has no * changeset-driven version and is a clean no-op. */ import { readFile, readdir, unlink, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { parseChangeset, planModuleVersion, renderChangelogSection, } from '../../module/versioning/changeset-version'; import type { CommandResult } from '../types'; /** Replace the top-level `version:` line (column 0 — not nested capability versions). */ function rewriteManifestVersion(raw: string, next: string): string { if (!/^version:[ \t]*\S.*$/m.test(raw)) { throw new Error('manifest.yml has no top-level `version:` line to rewrite'); } // Replace only the value; keep the `version:` key prefix and any trailing // comment so a targeted edit preserves the rest of the line. return raw.replace(/^(version:[ \t]*)\S+(.*)$/m, `$1${next}$2`); } /** Prepend a new section below the `# ` header (creating the file if needed). */ function updateChangelog(existing: string | null, moduleId: string, section: string): string { const headerLine = `# ${moduleId}`; if (existing?.startsWith(headerLine)) { const rest = existing.slice(headerLine.length).replace(/^\n+/, ''); return `${headerLine}\n\n${section}\n${rest}`; } const tail = existing ? `\n${existing.trimStart()}` : ''; return `${headerLine}\n\n${section}${tail}`; } export async function handleModuleVersion( args: string[], _flags: Record, ): Promise { const moduleDir = args[0] ?? '.'; const dir = resolve(moduleDir); let manifestRaw: string; let id: string; let version: string; let kind: string; try { manifestRaw = await readFile(join(dir, 'manifest.yml'), 'utf-8'); const manifest = parseYaml(manifestRaw) as { id?: string; version?: string; version_source?: { kind?: string }; }; if (!manifest.id || !manifest.version) { return { success: false, error: `${moduleDir}: manifest.yml missing id or version` }; } id = manifest.id; version = manifest.version; kind = manifest.version_source?.kind ?? 'recipe'; } catch { return { success: false, error: `${moduleDir}: could not read manifest.yml in ${dir}` }; } if (kind === 'pin') { return { success: true, message: `${id}: version_source.kind=pin — resolver-based stamping not yet implemented (ISS-0151); leaving version ${version} unchanged.`, }; } if (kind !== 'changeset') { return { success: true, message: `${id}: version_source.kind=${kind} — no changeset-driven version; ordered by the +N revision. Nothing to do.`, }; } // changeset kind: read .changeset/*.md (excluding README.md). const changesetDir = join(dir, '.changeset'); let files: string[]; try { files = (await readdir(changesetDir)).filter((f) => f.endsWith('.md') && f !== 'README.md'); } catch { files = []; } if (files.length === 0) { return { success: true, message: `${id}: no changesets — nothing to version (${version}).` }; } // Parse, remembering which file each came from so we only delete consumed ones. const parsed: { file: string; targetsModule: boolean; cs: ReturnType }[] = []; for (const file of files) { const content = await readFile(join(changesetDir, file), 'utf-8'); let cs: ReturnType; try { cs = parseChangeset(content); } catch (e) { return { success: false, error: `${id}: malformed changeset ${file}: ${e instanceof Error ? e.message : String(e)}`, }; } parsed.push({ file, cs, targetsModule: id in cs.bumps }); } const plan = planModuleVersion( version, id, parsed.map((p) => p.cs), ); if (!plan) { return { success: true, message: `${id}: ${files.length} changeset(s) present but none target "${id}" — nothing to version (${version}).`, }; } // 1. Stamp the new version into manifest.yml (targeted line edit — comments preserved). await writeFile(join(dir, 'manifest.yml'), rewriteManifestVersion(manifestRaw, plan.next)); // 2. Prepend the CHANGELOG section. const changelogPath = join(dir, 'CHANGELOG.md'); let existingChangelog: string | null = null; try { existingChangelog = await readFile(changelogPath, 'utf-8'); } catch { existingChangelog = null; } await writeFile( changelogPath, updateChangelog(existingChangelog, id, renderChangelogSection(plan)), ); // 3. Consume the changesets that targeted this module. const consumed = parsed.filter((p) => p.targetsModule); for (const p of consumed) { await unlink(join(changesetDir, p.file)); } return { success: true, message: `${id}: ${plan.current} → ${plan.next} (${plan.bump}); consumed ${consumed.length} changeset(s), wrote CHANGELOG.md.`, data: { id, from: plan.current, to: plan.next, bump: plan.bump }, }; }