#!/usr/bin/env bun // gstack-version-bump — deterministic version-state classifier + writer for /ship. // // Extracted from ship Step 12 prose (v2 plan T9, hybrid CLI extraction). The // idempotency classification and the dual-write to VERSION + package.json are // pure deterministic logic; running them as tested code removes the single // worst /ship footgun — re-bumping an already-shipped branch — from prose the // agent could skip or misread when the step lives in a lazy-loaded section. // // What STAYS agent judgment (NOT here): the bump-LEVEL decision (micro/patch vs // minor/major, which may AskUserQuestion on feature signals) and the queue // collision prompt. The slot pick itself is bin/gstack-next-version. This CLI // only answers "what state am I in?" and "write this exact version". // // Subcommands: // classify --base [--version-path

] // Compares VERSION vs origin/:VERSION vs package.json.version. // Emits JSON: { state, baseVersion, currentVersion, pkgVersion, pkgExists } // state ∈ FRESH | ALREADY_BUMPED | DRIFT_STALE_PKG | DRIFT_UNEXPECTED // Exit 0 on a decidable state (incl. DRIFT_UNEXPECTED — it's a real state // the caller must handle), exit 2 on bad args / unresolvable base. // // write --version [--version-path

] // Validates the 4-digit pattern, writes VERSION + package.json.version. // Use for the FRESH bump (or an approved queue rebump). Exit 3 on a // half-write (VERSION written, package.json failed) so the caller knows // drift exists; the next classify() will report DRIFT_STALE_PKG. // // repair [--version-path

] // DRIFT_STALE_PKG path: sync package.json.version to the current VERSION // file. No bump. Validates the VERSION pattern first. // // Contract: classify NEVER writes. write/repair mutate VERSION + the manifest // (+ its lockfile) only. No git mutation, no network. Mirrors // gstack-next-version's reader/writer split so /ship composes them. // // Manifest resolution (all three subcommands accept --package-json-path): // --package-json-path

→ .gstack/package-json-path → ./package.json // A repo whose only Node package lives in a subdirectory (web/, app/, // frontend/) has no ROOT package.json. The tool used to report // pkgExists:false there and write VERSION alone, leaving the manifest to be // bumped by hand — the drift this tool exists to prevent, in the one layout // where it silently did nothing. // // npm semver: VERSION is 4-digit MAJOR.MINOR.PATCH.MICRO; npm rejects a // fourth component. When a package-lock.json sits beside the manifest — proof // npm actually manages it — the MICRO is dropped and the lockfile's two // version fields are mirrored too. Without a lockfile nothing validates the // field and the historical 1:1 mirror is preserved, so gstack's own // package.json keeps carrying 1.60.1.0. import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { dirname, join, relative } from "node:path"; const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/; const DEFAULT = "0.0.0.0"; type State = "FRESH" | "ALREADY_BUMPED" | "DRIFT_STALE_PKG" | "DRIFT_UNEXPECTED"; function fail(msg: string, code = 2): never { process.stderr.write(`gstack-version-bump: ${msg}\n`); process.exit(code); } function argVal(args: string[], flag: string): string | undefined { const i = args.indexOf(flag); return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined; } /** Resolve the VERSION file path: --version-path, else .gstack/version-path, else "VERSION". */ function resolveVersionPath(cwd: string, explicit?: string): string { if (explicit) return join(cwd, explicit); const pin = join(cwd, ".gstack", "version-path"); if (existsSync(pin)) { const p = readFileSync(pin, "utf-8").trim(); if (p) return join(cwd, p); } return join(cwd, "VERSION"); } function readVersionFile(p: string): string { try { const v = readFileSync(p, "utf-8").replace(/[\r\n\s]/g, ""); return v || DEFAULT; } catch { return DEFAULT; } } /** * Resolve the package.json path: --package-json-path, else * .gstack/package-json-path, else "package.json". * * Mirrors resolveVersionPath. A repo whose only Node package lives in a * subdirectory (web/, app/, frontend/) has no ROOT package.json, so the * old join(cwd, "package.json") reported pkgExists:false and every bump * silently wrote VERSION alone — leaving the manifest to be edited by * hand, which is exactly the drift this tool exists to prevent. */ function resolvePkgPath(cwd: string, explicit?: string): string { if (explicit) return join(cwd, explicit); const pin = join(cwd, ".gstack", "package-json-path"); if (existsSync(pin)) { const p = readFileSync(pin, "utf-8").trim(); if (p) return join(cwd, p); } return join(cwd, "package.json"); } /** The npm lockfile beside a manifest, or "" when there is none. */ function lockPathFor(pkgPath: string): string { const lock = join(dirname(pkgPath), "package-lock.json"); return existsSync(lock) ? lock : ""; } /** * The version string to write INTO a manifest. * * VERSION is 4-digit MAJOR.MINOR.PATCH.MICRO; npm's semver is 3-component * and rejects a fourth. A package-lock.json beside the manifest is proof * that npm actually manages it, so the MICRO is dropped there. Without a * lockfile nothing validates the field and the historical 1:1 mirror is * preserved — gstack's own package.json carries 1.60.1.0 and must keep * doing so. */ function manifestVersion(version: string, npmManaged: boolean): string { return npmManaged ? version.split(".").slice(0, 3).join(".") : version; } /** package.json version + existence, parsed without spawning node. */ function readPkgVersion(pkgPath: string): { exists: boolean; version: string } { if (!existsSync(pkgPath)) return { exists: false, version: "" }; let raw: string; try { raw = readFileSync(pkgPath, "utf-8"); } catch { return { exists: true, version: "" }; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { fail(`${pkgPath} is not valid JSON. Fix the file before re-running /ship.`, 2); } const version = (parsed as { version?: unknown })?.version; return { exists: true, version: typeof version === "string" ? version : "" }; } function writePkgVersion(pkgPath: string, version: string): void { const raw = readFileSync(pkgPath, "utf-8"); const parsed = JSON.parse(raw) as Record; parsed.version = version; writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n"); } /** * Mirror the manifest version into package-lock.json. * * npm records it twice — at the document root and again under * `packages[""]`, the entry describing the root package itself — and * `npm install` keeps both in step. Nothing else in a release does, so a * lockfile left behind drifts one field per bump until someone runs npm. * Pure JSON edit: no npm spawn, no dependency-tree churn. */ function writeLockVersion(lockPath: string, version: string): void { const parsed = JSON.parse(readFileSync(lockPath, "utf-8")) as Record; parsed.version = version; const packages = parsed.packages as Record | undefined; if (packages && packages[""]) packages[""].version = version; writeFileSync(lockPath, JSON.stringify(parsed, null, 2) + "\n"); } function baseVersion(cwd: string, base: string, versionRel: string): string { // Verify the base ref resolves, mirroring the Step 12 guard. try { execFileSync("git", ["rev-parse", "--verify", `origin/${base}`], { cwd, stdio: "ignore" }); } catch { fail(`Unable to resolve origin/${base}. Run 'git fetch origin' or verify the base branch exists.`, 2); } try { const out = execFileSync("git", ["show", `origin/${base}:${versionRel}`], { cwd }).toString(); const v = out.replace(/[\r\n\s]/g, ""); return v || DEFAULT; } catch { // VERSION absent on base (new repo / new file) → treat as 0.0.0.0. return DEFAULT; } } /** * `expectedPkg` is what the manifest SHOULD hold for the current VERSION. * It defaults to VERSION itself (the historical 1:1 mirror), but for an * npm-managed manifest it is the 3-component truncation — otherwise a * correctly-synced `0.1.27` would be read as drift against `0.1.27.0` * forever, and every classify would return DRIFT. */ function classifyState( current: string, base: string, pkgExists: boolean, pkgVersion: string, expectedPkg: string = current, ): State { if (current === base) { // VERSION unchanged vs base. A diverging package.json means someone hand-edited // package.json bypassing /ship — unsafe to guess which is authoritative. if (pkgExists && pkgVersion && pkgVersion !== expectedPkg) return "DRIFT_UNEXPECTED"; return "FRESH"; } // VERSION already moved past base. if (pkgExists && pkgVersion && pkgVersion !== expectedPkg) return "DRIFT_STALE_PKG"; return "ALREADY_BUMPED"; } function cmdClassify(args: string[], cwd: string): void { const base = argVal(args, "--base"); if (!base) fail("classify requires --base ", 2); const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path")); const versionRel = argVal(args, "--version-path") ?? "VERSION"; const current = readVersionFile(versionPath); const baseV = baseVersion(cwd, base!, versionRel); const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path")); const pkg = readPkgVersion(pkgPath); const lockPath = pkg.exists ? lockPathFor(pkgPath) : ""; const expectedPkg = manifestVersion(current, Boolean(lockPath)); const state = classifyState(current, baseV, pkg.exists, pkg.version, expectedPkg); process.stdout.write( JSON.stringify({ state, baseVersion: baseV, currentVersion: current, pkgVersion: pkg.version || null, pkgExists: pkg.exists, pkgPath: pkg.exists ? relative(cwd, pkgPath) : null, expectedPkgVersion: pkg.exists ? expectedPkg : null, lockfile: lockPath ? relative(cwd, lockPath) : null, }) + "\n", ); // DRIFT_UNEXPECTED is a real, decidable state — the caller stops on it, but the // classification itself succeeded, so exit 0. (Bad args / unresolvable base are // the only exit-2 cases.) } function cmdWrite(args: string[], cwd: string): void { const version = argVal(args, "--version"); if (!version) fail("write requires --version ", 2); if (!VERSION_RE.test(version!)) { fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH.MICRO. Aborting.`, 2); } const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path")); const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path")); const hasPkg = existsSync(pkgPath); const lockPath = hasPkg ? lockPathFor(pkgPath) : ""; writeFileSync(versionPath, version + "\n"); if (hasPkg) { const manifestV = manifestVersion(version!, Boolean(lockPath)); try { writePkgVersion(pkgPath, manifestV); } catch { fail( `failed to update ${relative(cwd, pkgPath)}. VERSION was written but the manifest is now stale. ` + "Re-run — classify will report DRIFT_STALE_PKG and repair will sync it.", 3, ); } if (lockPath) { try { writeLockVersion(lockPath, manifestV); } catch { fail( `failed to update ${relative(cwd, lockPath)}. VERSION and the manifest were written but ` + "the lockfile is now stale. Run `npm install --package-lock-only` in its directory.", 3, ); } } } process.stdout.write( JSON.stringify({ wrote: version, packageJson: hasPkg, packageJsonPath: hasPkg ? relative(cwd, pkgPath) : null, packageJsonVersion: hasPkg ? manifestVersion(version!, Boolean(lockPath)) : null, lockfile: lockPath ? relative(cwd, lockPath) : null, }) + "\n", ); } function cmdRepair(args: string[], cwd: string): void { const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path")); const current = readVersionFile(versionPath); if (!VERSION_RE.test(current)) { fail( `VERSION file contents (${current}) do not match MAJOR.MINOR.PATCH.MICRO. ` + "Refusing to propagate invalid semver into package.json. Fix VERSION, then re-run /ship.", 2, ); } const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path")); if (!existsSync(pkgPath)) { fail(`repair: no package.json to sync (looked at ${relative(cwd, pkgPath)}).`, 2); } const lockPath = lockPathFor(pkgPath); const manifestV = manifestVersion(current, Boolean(lockPath)); try { writePkgVersion(pkgPath, manifestV); } catch { fail(`drift repair failed — could not update ${relative(cwd, pkgPath)}.`, 3); } if (lockPath) { try { writeLockVersion(lockPath, manifestV); } catch { fail(`drift repair failed — could not update ${relative(cwd, lockPath)}.`, 3); } } process.stdout.write( JSON.stringify({ repaired: current, packageJsonPath: relative(cwd, pkgPath), packageJsonVersion: manifestV, lockfile: lockPath ? relative(cwd, lockPath) : null, }) + "\n", ); } // Exported for unit tests (pure logic, no I/O). export { classifyState, VERSION_RE, type State }; if (import.meta.main) { const [sub, ...rest] = process.argv.slice(2); const cwd = process.cwd(); switch (sub) { case "classify": cmdClassify(rest, cwd); break; case "write": cmdWrite(rest, cwd); break; case "repair": cmdRepair(rest, cwd); break; default: fail("usage: gstack-version-bump [flags]", 2); } }