diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump
index 298fab17d..e8118031f 100755
--- a/bin/gstack-version-bump
+++ b/bin/gstack-version-bump
@@ -30,13 +30,28 @@
// 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 + package.json
-// only. No git mutation, no network. Mirrors gstack-next-version's reader/writer
-// split so /ship composes them.
+// 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 { join } from "node:path";
+import { dirname, join, relative } from "node:path";
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/;
const DEFAULT = "0.0.0.0";
@@ -73,9 +88,48 @@ function readVersionFile(p: string): string {
}
}
+/**
+ * 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(cwd: string): { exists: boolean; version: string } {
- const pkgPath = join(cwd, "package.json");
+function readPkgVersion(pkgPath: string): { exists: boolean; version: string } {
if (!existsSync(pkgPath)) return { exists: false, version: "" };
let raw: string;
try {
@@ -87,20 +141,36 @@ function readPkgVersion(cwd: string): { exists: boolean; version: string } {
try {
parsed = JSON.parse(raw);
} catch {
- fail("package.json is not valid JSON. Fix the file before re-running /ship.", 2);
+ 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(cwd: string, version: string): void {
- const pkgPath = join(cwd, "package.json");
+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 {
@@ -118,15 +188,28 @@ function baseVersion(cwd: string, base: string, versionRel: string): string {
}
}
-function classifyState(current: string, base: string, pkgExists: boolean, pkgVersion: string): State {
+/**
+ * `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 !== current) return "DRIFT_UNEXPECTED";
+ if (pkgExists && pkgVersion && pkgVersion !== expectedPkg) return "DRIFT_UNEXPECTED";
return "FRESH";
}
// VERSION already moved past base.
- if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_STALE_PKG";
+ if (pkgExists && pkgVersion && pkgVersion !== expectedPkg) return "DRIFT_STALE_PKG";
return "ALREADY_BUMPED";
}
@@ -137,8 +220,11 @@ function cmdClassify(args: string[], cwd: string): void {
const versionRel = argVal(args, "--version-path") ?? "VERSION";
const current = readVersionFile(versionPath);
const baseV = baseVersion(cwd, base!, versionRel);
- const pkg = readPkgVersion(cwd);
- const state = classifyState(current, baseV, pkg.exists, pkg.version);
+ 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,
@@ -146,6 +232,9 @@ function cmdClassify(args: string[], cwd: string): void {
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
@@ -160,19 +249,42 @@ function cmdWrite(args: string[], cwd: string): void {
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 (existsSync(join(cwd, "package.json"))) {
+ if (hasPkg) {
+ const manifestV = manifestVersion(version!, Boolean(lockPath));
try {
- writePkgVersion(cwd, version!);
+ writePkgVersion(pkgPath, manifestV);
} catch {
fail(
- "failed to update package.json. VERSION was written but package.json is now stale. " +
+ `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: existsSync(join(cwd, "package.json")) }) + "\n");
+ 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 {
@@ -185,15 +297,32 @@ function cmdRepair(args: string[], cwd: string): void {
2,
);
}
- if (!existsSync(join(cwd, "package.json"))) {
- fail("repair: no package.json to sync.", 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(cwd, current);
+ writePkgVersion(pkgPath, manifestV);
} catch {
- fail("drift repair failed — could not update package.json.", 3);
+ fail(`drift repair failed — could not update ${relative(cwd, pkgPath)}.`, 3);
}
- process.stdout.write(JSON.stringify({ repaired: current }) + "\n");
+ 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).
diff --git a/ship/SKILL.md b/ship/SKILL.md
index 3062cda60..b6c512470 100644
--- a/ship/SKILL.md
+++ b/ship/SKILL.md
@@ -1065,7 +1065,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION"
```
- The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
+ The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes VERSION, the manifest, and the manifest's `package-lock.json` when one exists. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so when a lockfile proves npm manages the manifest the MICRO is dropped there (`0.1.26.0` → `0.1.26`) and both of the lockfile's version fields are mirrored. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash
diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl
index 068ac4fe5..0556b5332 100644
--- a/ship/SKILL.md.tmpl
+++ b/ship/SKILL.md.tmpl
@@ -187,7 +187,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION"
```
- The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
+ The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes VERSION, the manifest, and the manifest's `package-lock.json` when one exists. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so when a lockfile proves npm manages the manifest the MICRO is dropped there (`0.1.26.0` → `0.1.26`) and both of the lockfile's version fields are mirrored. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash
diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md
index 3062cda60..b6c512470 100644
--- a/test/fixtures/golden/claude-ship-SKILL.md
+++ b/test/fixtures/golden/claude-ship-SKILL.md
@@ -1065,7 +1065,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION"
```
- The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
+ The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes VERSION, the manifest, and the manifest's `package-lock.json` when one exists. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so when a lockfile proves npm manages the manifest the MICRO is dropped there (`0.1.26.0` → `0.1.26`) and both of the lockfile's version fields are mirrored. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash
diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md
index 1d9b707bf..ae1e4cf16 100644
--- a/test/fixtures/golden/codex-ship-SKILL.md
+++ b/test/fixtures/golden/codex-ship-SKILL.md
@@ -2207,7 +2207,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION"
```
- The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
+ The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes VERSION, the manifest, and the manifest's `package-lock.json` when one exists. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so when a lockfile proves npm manages the manifest the MICRO is dropped there (`0.1.26.0` → `0.1.26`) and both of the lockfile's version fields are mirrored. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash
diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md
index 40ad2e3f3..e4a2b5d35 100644
--- a/test/fixtures/golden/factory-ship-SKILL.md
+++ b/test/fixtures/golden/factory-ship-SKILL.md
@@ -2613,7 +2613,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION"
```
- The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
+ The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes VERSION, the manifest, and the manifest's `package-lock.json` when one exists. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so when a lockfile proves npm manages the manifest the MICRO is dropped there (`0.1.26.0` → `0.1.26`) and both of the lockfile's version fields are mirrored. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash
diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts
index ffcecd1a7..ee22ae945 100644
--- a/test/gstack-version-bump.test.ts
+++ b/test/gstack-version-bump.test.ts
@@ -54,7 +54,10 @@ describe('write (FRESH bump)', () => {
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0.0', scripts: { t: 'y' } }, null, 2) + '\n');
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString();
- expect(JSON.parse(out)).toEqual({ wrote: '1.1.0.0', packageJson: true });
+ expect(JSON.parse(out)).toEqual({
+ wrote: '1.1.0.0', packageJson: true, packageJsonPath: 'package.json',
+ packageJsonVersion: '1.1.0.0', lockfile: null,
+ });
expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf-8').trim()).toBe('1.1.0.0');
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
expect(pkg.version).toBe('1.1.0.0');
@@ -72,7 +75,10 @@ describe('write (FRESH bump)', () => {
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-noPkg-'));
fs.writeFileSync(path.join(d2, 'VERSION'), '0.1.0.0\n');
const out = execFileSync('bun', [BIN, 'write', '--version', '0.2.0.0'], { cwd: d2 }).toString();
- expect(JSON.parse(out)).toEqual({ wrote: '0.2.0.0', packageJson: false });
+ expect(JSON.parse(out)).toEqual({
+ wrote: '0.2.0.0', packageJson: false, packageJsonPath: null,
+ packageJsonVersion: null, lockfile: null,
+ });
expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.2.0.0');
fs.rmSync(d2, { recursive: true, force: true });
});
@@ -86,7 +92,10 @@ describe('repair (DRIFT_STALE_PKG)', () => {
fs.writeFileSync(path.join(dir, 'VERSION'), '2.0.0.0\n');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.9.0.0' }, null, 2) + '\n');
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
- expect(JSON.parse(out)).toEqual({ repaired: '2.0.0.0' });
+ expect(JSON.parse(out)).toEqual({
+ repaired: '2.0.0.0', packageJsonPath: 'package.json',
+ packageJsonVersion: '2.0.0.0', lockfile: null,
+ });
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('2.0.0.0');
expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf-8').trim()).toBe('2.0.0.0'); // unchanged
});
@@ -131,3 +140,113 @@ describe('classify (idempotency over a real git base)', () => {
expect(parsed.currentVersion).toBe('1.1.0.0');
});
});
+
+describe('subdirectory manifest (no root package.json)', () => {
+ /**
+ * The layout this tool used to silently no-op on: the only Node package
+ * lives in web/, so join(cwd, "package.json") missed it, classify said
+ * pkgExists:false, and write touched VERSION alone — leaving the manifest
+ * to be bumped by hand every release.
+ */
+ const mk = (): string => {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-subdir-'));
+ fs.mkdirSync(path.join(d, 'web'));
+ fs.mkdirSync(path.join(d, '.gstack'));
+ fs.writeFileSync(path.join(d, '.gstack', 'package-json-path'), 'web/package.json\n');
+ fs.writeFileSync(path.join(d, 'VERSION'), '0.1.0.0\n');
+ return d;
+ };
+
+ test('write finds a pinned manifest and bumps it', () => {
+ const d = mk();
+ fs.writeFileSync(path.join(d, 'web', 'package.json'),
+ JSON.stringify({ name: 'w', version: '0.1.0.0' }, null, 2) + '\n');
+ const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.2.0.0'], { cwd: d }).toString());
+ expect(out.packageJson).toBe(true);
+ expect(out.packageJsonPath).toBe('web/package.json');
+ expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.2.0.0');
+ fs.rmSync(d, { recursive: true, force: true });
+ });
+
+ test('--package-json-path overrides the pin', () => {
+ const d = mk();
+ fs.mkdirSync(path.join(d, 'app'));
+ fs.writeFileSync(path.join(d, 'web', 'package.json'), JSON.stringify({ version: '0.1.0.0' }, null, 2) + '\n');
+ fs.writeFileSync(path.join(d, 'app', 'package.json'), JSON.stringify({ version: '0.1.0.0' }, null, 2) + '\n');
+ const out = JSON.parse(execFileSync('bun',
+ [BIN, 'write', '--version', '0.3.0.0', '--package-json-path', 'app/package.json'], { cwd: d }).toString());
+ expect(out.packageJsonPath).toBe('app/package.json');
+ expect(JSON.parse(fs.readFileSync(path.join(d, 'app', 'package.json'), 'utf-8')).version).toBe('0.3.0.0');
+ // the pinned one is untouched
+ expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.1.0.0');
+ fs.rmSync(d, { recursive: true, force: true });
+ });
+});
+
+describe('npm-managed manifest (lockfile present)', () => {
+ /**
+ * VERSION is 4-digit; npm semver is 3-component and rejects a fourth. A
+ * package-lock.json beside the manifest proves npm actually manages it, so
+ * the MICRO is dropped there and the lockfile's TWO version fields are
+ * mirrored. Writing 4 digits into a real npm package breaks `npm ci`.
+ */
+ const mk = (pkgV: string, lockV: string): string => {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-npm-'));
+ fs.mkdirSync(path.join(d, 'web'));
+ fs.mkdirSync(path.join(d, '.gstack'));
+ fs.writeFileSync(path.join(d, '.gstack', 'package-json-path'), 'web/package.json\n');
+ fs.writeFileSync(path.join(d, 'VERSION'), '0.1.25.0\n');
+ fs.writeFileSync(path.join(d, 'web', 'package.json'),
+ JSON.stringify({ name: 'w', version: pkgV }, null, 2) + '\n');
+ fs.writeFileSync(path.join(d, 'web', 'package-lock.json'),
+ JSON.stringify({ name: 'w', version: lockV, lockfileVersion: 3,
+ packages: { '': { name: 'w', version: lockV }, 'node_modules/x': { version: '1.0.0' } } }, null, 2) + '\n');
+ return d;
+ };
+
+ test('write drops the MICRO and syncs BOTH lockfile version fields', () => {
+ const d = mk('0.1.25', '0.1.25');
+ const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.1.26.0'], { cwd: d }).toString());
+ expect(out.wrote).toBe('0.1.26.0');
+ expect(out.packageJsonVersion).toBe('0.1.26');
+ expect(out.lockfile).toBe('web/package-lock.json');
+ expect(fs.readFileSync(path.join(d, 'VERSION'), 'utf-8').trim()).toBe('0.1.26.0');
+ expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.1.26');
+ const lock = JSON.parse(fs.readFileSync(path.join(d, 'web', 'package-lock.json'), 'utf-8'));
+ expect(lock.version).toBe('0.1.26');
+ expect(lock.packages[''].version).toBe('0.1.26');
+ expect(lock.packages['node_modules/x'].version).toBe('1.0.0'); // deps untouched
+ fs.rmSync(d, { recursive: true, force: true });
+ });
+
+ test('a correctly-synced 3-component manifest is NOT read as drift', () => {
+ // Without the truncation-aware comparison, 0.1.25 vs 0.1.25.0 reads as
+ // DRIFT forever and every classify returns a false positive.
+ const d = mk('0.1.25', '0.1.25');
+ expect(classifyState('0.1.25.0', '0.1.24.0', true, '0.1.25', '0.1.25')).toBe('ALREADY_BUMPED');
+ fs.rmSync(d, { recursive: true, force: true });
+ });
+
+ test('repair syncs manifest + lockfile to the 3-component form', () => {
+ const d = mk('0.1.19', '0.1.19');
+ const out = JSON.parse(execFileSync('bun', [BIN, 'repair'], { cwd: d }).toString());
+ expect(out.repaired).toBe('0.1.25.0');
+ expect(out.packageJsonVersion).toBe('0.1.25');
+ expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.1.25');
+ const lock = JSON.parse(fs.readFileSync(path.join(d, 'web', 'package-lock.json'), 'utf-8'));
+ expect(lock.version).toBe('0.1.25');
+ expect(lock.packages[''].version).toBe('0.1.25');
+ fs.rmSync(d, { recursive: true, force: true });
+ });
+
+ test('no lockfile keeps the historical 4-digit mirror (gstack itself)', () => {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-nolock-'));
+ fs.writeFileSync(path.join(d, 'VERSION'), '1.60.0.0\n');
+ fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ version: '1.60.0.0' }, null, 2) + '\n');
+ const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '1.60.1.0'], { cwd: d }).toString());
+ expect(out.packageJsonVersion).toBe('1.60.1.0');
+ expect(out.lockfile).toBeNull();
+ expect(JSON.parse(fs.readFileSync(path.join(d, 'package.json'), 'utf-8')).version).toBe('1.60.1.0');
+ fs.rmSync(d, { recursive: true, force: true });
+ });
+});