mirror of https://github.com/garrytan/gstack.git
fix(ship): version-bump skipped subdirectory manifests and wrote npm-invalid versions
Two ways `gstack-version-bump` left a release half-done, both silent. 1. It only ever looked at ./package.json. A repo whose only Node package lives in a subdirectory (web/, app/, frontend/) has no root manifest, so classify reported pkgExists:false and every bump wrote VERSION alone -- leaving the manifest to be edited by hand, which is the exact drift this tool exists to prevent, in the one layout where it silently did nothing. The path now resolves --package-json-path -> .gstack/package-json-path -> ./package.json, so a subdirectory package is covered by a one-line pin. 2. VERSION is 4-digit MAJOR.MINOR.PATCH.MICRO and npm's semver is 3-component: npm rejects the fourth. Mirroring VERSION 1:1 into an npm-managed manifest writes a version npm will not install. When a package-lock.json sits beside the manifest -- proof npm actually manages it -- the MICRO is now dropped (0.1.26.0 -> 0.1.26) and the lockfile's two version fields are mirrored too (root and packages[""], which npm keeps in step and 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. Without a lockfile nothing validates the field, so the historical 1:1 mirror is preserved -- gstack's own package.json carries 1.60.1.0 and keeps doing so. classifyState needed an expectedPkg parameter for (2): it defaults to VERSION, but for an npm-managed manifest it is the 3-component truncation. Otherwise a correctly-synced 0.1.27 reads as drift against 0.1.27.0 forever and every classify returns DRIFT. Step 12 of the ship skill documents the new behaviour; the three ship-skill golden fixtures are updated to match the regenerated hosts. 7 new tests covering both sides, including the negatives: the pin, the override, both lockfile fields, the correctly-synced-is-not-drift case, repair, and the no-lockfile 4-digit path that must not change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
94993f7401
commit
665f979d57
|
|
@ -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 <p> → .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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
parsed.version = version;
|
||||
const packages = parsed.packages as Record<string, { version?: string }> | 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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue