@lifeaitools/rdc-skills 0.37.0 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdc",
3
- "version": "0.37.0",
3
+ "version": "0.37.2",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code — plan, build, review, overnight unattended builds with work-item tracking and TDD enforcement.",
5
5
  "author": {
6
6
  "name": "LIFEAI",
@@ -53,8 +53,19 @@ jobs:
53
53
  - name: Ensure npm supports trusted publishing
54
54
  run: npm install -g npm@latest && npm --version
55
55
 
56
+ # Every tag from v0.33.0 to v0.37.0 failed here with a bare E404 on the PUT and no
57
+ # reason, and no published version carries provenance, so the OIDC exchange has not
58
+ # succeeded once since this workflow moved to it. npm logs that exchange only at
59
+ # verbose level. The next step pulls those lines out, so the reason is printed
60
+ # rather than inferred.
56
61
  - name: Publish
57
- run: npm publish --access public
62
+ run: |
63
+ set -o pipefail
64
+ npm publish --access public --loglevel verbose 2>&1 | tee publish.log
65
+
66
+ - name: OIDC exchange result
67
+ if: always()
68
+ run: grep -iE 'oidc|provenance|trusted|npm error' publish.log || echo "no oidc lines logged"
58
69
 
59
70
  - name: Notify clauth channel
60
71
  if: always()
package/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## v0.37.2 — the release doc says what actually happens
11
+
12
+ ### Fixed
13
+ - RELEASE.md step 3 is one command, `work-ship --publish`, which waits for the tag's CI publish, falls back to the token route with npm's reason printed, and installs the exact version once npm serves it (lifeai-env d4ff3f6).
14
+ - RELEASE.md step 4 records the measured CI state: npm's OIDC exchange answers "package not found" for @lifeaitools/rdc-skills, so no trusted publisher matches LIFEAI/rdc-skills publish.yml, and names what corrects it.
15
+
16
+ ---
17
+
18
+ ## v0.37.1 — one version in every manifest
19
+
20
+ ### Fixed
21
+ - `clauth-plugin.json` declared 0.35.10 while the package shipped 0.37.0, and
22
+ `package.json` `claude.version` still said 0.10.0. Both are now 0.37.1, with
23
+ `.claude-plugin/plugin.json`, `package.json` and `package-lock.json`. Nothing had
24
+ failed on either: the only check, `rdc-skills-self-test`, compared `plugin.json`
25
+ alone.
26
+
27
+ ### Added
28
+ - `scripts/lib/manifest-versions.mjs` is one list of every place the package
29
+ declares its version, built from a repository search for `"version"`. Three
30
+ things read it:
31
+ - `tests/manifest-version-parity.test.mjs` (in `test:acceptance`) fails on any
32
+ declaration that differs from `package.json`. It also scans the repository and
33
+ fails on any JSON file with a top-level `version` that the list does not name.
34
+ - `rdc-skills-self-test` now checks every packaged declaration, not only
35
+ `plugin.json`.
36
+ - The npm `version` lifecycle hook, `scripts/sync-manifest-versions.mjs --stage`,
37
+ means `npm version patch|minor|major` sets and stages every manifest in the
38
+ same step. `--check` changes nothing and exits 1 on drift.
39
+
40
+ ### Changed
41
+ - `RELEASE.md` step 2 drops the hand edit of `plugin.json`: `npm version` now sets
42
+ every manifest, and `node scripts/sync-manifest-versions.mjs --check` confirms it.
43
+
44
+ ---
45
+
10
46
  ## v0.37.0 — review gates run the deterministic validators, in 1–2 minutes
11
47
 
12
48
  ### Changed
package/RELEASE.md CHANGED
@@ -27,25 +27,38 @@ git checkout master
27
27
  git pull --ff-only origin master
28
28
  git merge <feature-branch> --no-edit
29
29
 
30
- # 2. Bump the version — package.json AND the plugin manifest, they must match.
31
- # rdc-skills-self-test fails the whole run (plugin manifest FAIL) if they drift,
32
- # and .claude-plugin/plugin.json ships inside the published npm package, so a
33
- # stale plugin.json version is a real defect in the release, not a lint nit.
30
+ # 2. Bump the version. `npm version` bumps package.json and package-lock.json, then
31
+ # its `version` lifecycle hook (scripts/sync-manifest-versions.mjs --stage) sets
32
+ # every other declaration to match and stages it: .claude-plugin/plugin.json,
33
+ # clauth-plugin.json, and package.json `claude.version`. The list lives in
34
+ # scripts/lib/manifest-versions.mjs. Never hand-edit a manifest version.
34
35
  npm version patch|minor|major --no-git-tag-version
35
- # then hand-edit .claude-plugin/plugin.json's top-level "version" field to match —
36
- # there is no script for this yet, only the self-test catches drift after the fact.
37
-
38
- # 3. Commit, tag, and push
39
- git add package.json .claude-plugin/plugin.json
40
- git commit -m "release: v$(node -e \"console.log(require('./package.json').version)\")"
41
- git tag "v$(node -e \"console.log(require('./package.json').version)\")"
42
- git push origin master --tags
43
-
44
- # 4. Publish through the tag-triggered GitHub workflow
45
-
46
- # 5. Verify the published package
47
- npm view @lifeaitools/rdc-skills version
48
- npm install -g @lifeaitools/rdc-skills@latest
36
+ node scripts/sync-manifest-versions.mjs --check # exit 1 names any declaration that drifted
37
+ # tests/manifest-version-parity.test.mjs and rdc-skills-self-test (plugin
38
+ # manifest FAIL) both fail on drift. 0.37.0 shipped clauth-plugin.json at 0.35.10
39
+ # because the self-test then checked plugin.json alone.
40
+
41
+ # 3. Ship it — ONE command. Write the release commit message to a file first.
42
+ # work-ship checks every version field, commits, runs test:acceptance, pushes
43
+ # master, pushes the tag, WAITS for the tag's publish.yml run, and:
44
+ # - CI published -> reports the run id (the version carries provenance)
45
+ # - CI refused -> prints npm's own reason as `warn ci-publish`, then
46
+ # publishes with the vault's npm token instead
47
+ # Then it waits until npm SERVES that exact version (npm processes a publish
48
+ # first; 0.37.1 took 239 s) and installs that version, failing on a mismatch.
49
+ node "$LIFEAI_ENV/sessions/work-ship.mjs" --repo rdc-skills --lane <this worktree> -F <msgfile> --publish
50
+
51
+ # 4. CI's publish, as measured 2026-09-21: every tag since v0.33.0 was refused at
52
+ # npm's OIDC exchange with "OIDC token exchange error - package not found" --
53
+ # npm holds no trusted publisher for @lifeaitools/rdc-skills that matches
54
+ # LIFEAI / rdc-skills / publish.yml. publish.yml prints that line on every run
55
+ # (step "OIDC exchange result"). Correcting it takes the npm owner's 2FA:
56
+ # npmjs.com -> @lifeaitools/rdc-skills -> Settings -> Trusted Publisher, or
57
+ # `npm trust github @lifeaitools/rdc-skills --file publish.yml --repository LIFEAI/rdc-skills --allow-publish`.
58
+ # Until then step 3 finishes every release through the token route and says so.
59
+
60
+ # 5. Refresh the local plugin cache and read the verdict
61
+ rdc-skills-install --profile lifeai
49
62
  npm list -g @lifeaitools/rdc-skills --depth=0
50
63
  rdc-skills-self-test
51
64
  # ^ run this LAST and read the verdict line, not just the section pass counts —
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": "lifeai.plugin.v1",
3
3
  "id": "rdc-skills",
4
- "version": "0.35.10",
4
+ "version": "0.37.2",
5
5
  "publisher": "LIFEAI",
6
6
  "core": false,
7
7
  "documentation": {
package/git-sha.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "sha": "15c0b76b2fa48b6ab335478041940337a84da588"
2
+ "sha": "c22b3e63abdb2c8628348bd436077688b1243b41"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.37.0",
3
+ "version": "0.37.2",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -19,7 +19,7 @@
19
19
  "type": "plugin",
20
20
  "skills": "skills/",
21
21
  "guides": "guides/",
22
- "version": "0.10.0",
22
+ "version": "0.37.2",
23
23
  "commands": "commands/"
24
24
  },
25
25
  "bin": {
@@ -45,7 +45,7 @@
45
45
  "rdc-design": "node scripts/rdc-design-cli.mjs",
46
46
  "test:hooks": "node scripts/test-rdc-hooks.mjs",
47
47
  "test:truth-gate": "node tests/run-evidence-gate.test.mjs && node tests/work-item-exit-gate-l2.test.mjs && node tests/work-item-exit-gate-l3.test.mjs && node tests/require-work-item-on-commit.test.mjs && node tests/harness-gates.test.mjs && node tests/foreground-process-gate.test.mjs",
48
- "test:acceptance": "node tests/acceptance.test.mjs && node tests/install-rdc-skills.test.mjs && node tests/help-surface.test.mjs && node tests/manifest-contract-fields.test.mjs && node tests/plugin-namespace-names.test.mjs && node tests/skill-test-matrix.test.mjs && node tests/completion-gate-supervisor-admission.test.mjs && node tests/curl-surface.test.mjs && node tests/clauth-plugin-postinstall.test.mjs && node tests/install-duplicate-purge.test.mjs",
48
+ "test:acceptance": "node tests/acceptance.test.mjs && node tests/install-rdc-skills.test.mjs && node tests/help-surface.test.mjs && node tests/manifest-contract-fields.test.mjs && node --test tests/manifest-version-parity.test.mjs && node tests/plugin-namespace-names.test.mjs && node tests/skill-test-matrix.test.mjs && node tests/completion-gate-supervisor-admission.test.mjs && node tests/curl-surface.test.mjs && node tests/clauth-plugin-postinstall.test.mjs && node tests/install-duplicate-purge.test.mjs",
49
49
  "acceptance": "node scripts/acceptance.mjs --changed",
50
50
  "test:mcp": "node tests/mcp.test.mjs",
51
51
  "test:mcp:remote": "node tests/mcp.test.mjs --remote",
@@ -56,6 +56,7 @@
56
56
  "test:brochure": "node tests/rdc-brochure.test.mjs",
57
57
  "mcp": "node bin/rdc-skills-mcp.mjs",
58
58
  "prepack": "node scripts/prepack.mjs",
59
+ "version": "node scripts/sync-manifest-versions.mjs --stage",
59
60
  "test:deploy": "node tests/deploy-updater.test.mjs"
60
61
  },
61
62
  "dependencies": {
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Every place this package states its own version, and one way to keep them equal.
3
+ *
4
+ * package.json `version` is the source. Every entry in VERSION_DECLARATIONS must equal
5
+ * it. The list was built from a repository search for `"version"`, not from memory:
6
+ * 0.37.0 shipped with clauth-plugin.json at 0.35.10 and package.json `claude.version`
7
+ * at 0.10.0, because the only check (self-test) compared plugin.json alone.
8
+ *
9
+ * Three readers share this list, so none can drift from the others:
10
+ * - tests/manifest-version-parity.test.mjs fails on any mismatch, and on any JSON
11
+ * file with a top-level `version` that is not listed here
12
+ * - scripts/self-test.mjs the packaged declarations, post-install
13
+ * - scripts/sync-manifest-versions.mjs the `version` lifecycle hook, so
14
+ * `npm version <x>` moves every declaration and the test never has to catch it
15
+ */
16
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { isDeepStrictEqual } from 'node:util';
18
+ import path from 'node:path';
19
+
20
+ export const VERSION_SOURCE = Object.freeze({ file: 'package.json', keyPath: Object.freeze(['version']) });
21
+
22
+ /** `packaged: false` — npm never ships the file, so an installed copy cannot check it. */
23
+ export const VERSION_DECLARATIONS = Object.freeze([
24
+ { file: 'package.json', keyPath: ['claude', 'version'], packaged: true },
25
+ { file: '.claude-plugin/plugin.json', keyPath: ['version'], packaged: true },
26
+ { file: 'clauth-plugin.json', keyPath: ['version'], packaged: true },
27
+ { file: 'package-lock.json', keyPath: ['version'], packaged: false },
28
+ { file: 'package-lock.json', keyPath: ['packages', '', 'version'], packaged: false },
29
+ ].map((d) => Object.freeze({ ...d, keyPath: Object.freeze([...d.keyPath]) })));
30
+
31
+ export function label({ file, keyPath }) {
32
+ return `${file} ${keyPath.map((k) => (k === '' ? '""' : k)).join('.')}`;
33
+ }
34
+
35
+ function getPath(obj, keyPath) {
36
+ let cur = obj;
37
+ for (const key of keyPath) {
38
+ if (cur === null || typeof cur !== 'object' || !(key in cur)) return undefined;
39
+ cur = cur[key];
40
+ }
41
+ return cur;
42
+ }
43
+
44
+ function setPath(obj, keyPath, value) {
45
+ const copy = structuredClone(obj);
46
+ let cur = copy;
47
+ for (const key of keyPath.slice(0, -1)) cur = cur[key];
48
+ cur[keyPath[keyPath.length - 1]] = value;
49
+ return copy;
50
+ }
51
+
52
+ /** `{ exists, text, json, error }` — an unreadable file is reported, never thrown past the caller. */
53
+ function readJson(root, file) {
54
+ const full = path.join(root, file);
55
+ if (!existsSync(full)) return { exists: false, text: null, json: null, error: null };
56
+ try {
57
+ const text = readFileSync(full, 'utf8');
58
+ return { exists: true, text, json: JSON.parse(text), error: null };
59
+ } catch (error) {
60
+ return { exists: true, text: null, json: null, error: error.message };
61
+ }
62
+ }
63
+
64
+ /** The source version: package.json `version`. Throws when package.json cannot say. */
65
+ export function sourceVersion(root) {
66
+ const { json, error } = readJson(root, VERSION_SOURCE.file);
67
+ if (error) throw new Error(`${VERSION_SOURCE.file} is unreadable: ${error}`);
68
+ const version = json ? getPath(json, VERSION_SOURCE.keyPath) : undefined;
69
+ if (typeof version !== 'string' || !version) throw new Error(`${VERSION_SOURCE.file} has no version`);
70
+ return version;
71
+ }
72
+
73
+ /**
74
+ * Every declaration with its current value. Each is read on its own: one unreadable
75
+ * file is that declaration's `error`, and every other declaration is still checked.
76
+ * @returns {{ source: string, declarations: Array<{file, keyPath, packaged, label, exists, value, error}> }}
77
+ */
78
+ export function readDeclarations(root, { packagedOnly = false } = {}) {
79
+ const source = sourceVersion(root);
80
+ const cache = new Map();
81
+ const declarations = VERSION_DECLARATIONS
82
+ .filter((d) => !packagedOnly || d.packaged)
83
+ .map((d) => {
84
+ if (!cache.has(d.file)) cache.set(d.file, readJson(root, d.file));
85
+ const { exists, json, error } = cache.get(d.file);
86
+ return { ...d, label: label(d), exists, error, value: json ? getPath(json, d.keyPath) : undefined };
87
+ });
88
+ return { source, declarations };
89
+ }
90
+
91
+ /** The declarations that do not equal package.json's version (missing or unreadable counts). */
92
+ export function mismatches(root, options = {}) {
93
+ const { source, declarations } = readDeclarations(root, options);
94
+ return declarations.filter((d) => d.error || d.value !== source).map((d) => ({ ...d, expected: source }));
95
+ }
96
+
97
+ /** What a declaration currently says, for a message: the value, or why there is none. */
98
+ export function describeValue(d) {
99
+ if (!d.exists) return '(file missing)';
100
+ if (d.error) return `(unreadable: ${d.error})`;
101
+ if (d.value === undefined) return '(key missing)';
102
+ return JSON.stringify(d.value);
103
+ }
104
+
105
+ /**
106
+ * Replace one string value in JSON text without reformatting the file. Each
107
+ * `"<key>": "<old>"` occurrence is tried in turn and kept only when the result parses
108
+ * to exactly the original document with that one value changed — so a same-named key
109
+ * elsewhere (package-lock.json has hundreds of `version`s) is never the one edited.
110
+ */
111
+ export function replaceJsonString(text, keyPath, oldValue, newValue) {
112
+ const original = JSON.parse(text);
113
+ const want = setPath(original, keyPath, newValue);
114
+ const key = keyPath[keyPath.length - 1];
115
+ const literal = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ const pattern = new RegExp(`("${literal(key)}"\\s*:\\s*)"${literal(oldValue)}"`, 'g');
117
+ for (const match of text.matchAll(pattern)) {
118
+ const at = match.index;
119
+ const candidate = `${text.slice(0, at)}${match[1]}${JSON.stringify(newValue)}${text.slice(at + match[0].length)}`;
120
+ let parsed;
121
+ try { parsed = JSON.parse(candidate); } catch { continue; }
122
+ if (isDeepStrictEqual(parsed, want)) return candidate;
123
+ }
124
+ throw new Error(`could not locate ${keyPath.join('.')} = "${oldValue}" as a plain string to rewrite`);
125
+ }
126
+
127
+ /**
128
+ * Set every declaration to package.json's version, formatting untouched. All or
129
+ * nothing: every new file text is computed first and nothing is written unless every
130
+ * declaration could be set, so a failure never leaves some manifests bumped. A missing
131
+ * file or a non-string value is an error, never skipped.
132
+ * @returns {Array<{label, from, to}>} what changed
133
+ */
134
+ export function syncDeclarations(root) {
135
+ const source = sourceVersion(root);
136
+ const texts = new Map();
137
+ const changed = [];
138
+ for (const d of VERSION_DECLARATIONS) {
139
+ const full = path.join(root, d.file);
140
+ if (!texts.has(full)) {
141
+ if (!existsSync(full)) throw new Error(`${d.file} is listed as a version declaration but does not exist`);
142
+ texts.set(full, { original: readFileSync(full, 'utf8'), text: null });
143
+ }
144
+ const entry = texts.get(full);
145
+ const text = entry.text ?? entry.original;
146
+ const current = getPath(JSON.parse(text), d.keyPath);
147
+ if (current === source) continue;
148
+ if (typeof current !== 'string') throw new Error(`${label(d)} is ${JSON.stringify(current)}, not a version string`);
149
+ entry.text = replaceJsonString(text, d.keyPath, current, source);
150
+ changed.push({ label: label(d), from: current, to: source });
151
+ }
152
+ for (const [full, { text }] of texts) if (text !== null) writeFileSync(full, text);
153
+ return changed;
154
+ }
@@ -33,6 +33,7 @@ import { join, basename, dirname, resolve } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { spawnSync } from "node:child_process";
35
35
  import { loadAllManifests } from "./lib/manifest-schema.mjs";
36
+ import { describeValue as describeVersionValue, mismatches as versionMismatches } from "./lib/manifest-versions.mjs";
36
37
  import { runManifest } from "./lib/runner.mjs";
37
38
  import {
38
39
  generateRunId,
@@ -48,7 +49,6 @@ const GUIDES_DIR = join(REPO_ROOT, "guides");
48
49
  const AGENT_GUIDES_DIR = join(GUIDES_DIR, "agents");
49
50
  const HOOKS_DIR = join(REPO_ROOT, "hooks");
50
51
  const PLUGIN_MANIFEST = join(REPO_ROOT, ".claude-plugin", "plugin.json");
51
- const PACKAGE_JSON = join(REPO_ROOT, "package.json");
52
52
 
53
53
  // ─── Guide-content validator constants ───────────────────────────────────────
54
54
  // Path to the regen-root project (sibling of rdc-skills).
@@ -600,19 +600,24 @@ function auditPluginManifest() {
600
600
  });
601
601
  }
602
602
  try {
603
- const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf8"));
604
- if (manifest.version && pkg.version && manifest.version !== pkg.version) {
603
+ // Every declaration an installed copy carries, from the one list the parity test and
604
+ // the `npm version` hook also read (scripts/lib/manifest-versions.mjs). This checked
605
+ // plugin.json alone, and clauth-plugin.json sat at 0.35.10 through 0.37.0. Each
606
+ // declaration is read on its own, so an unreadable file is its own error and never
607
+ // hides the others.
608
+ for (const d of versionMismatches(REPO_ROOT, { packagedOnly: true })) {
605
609
  findings.push({
606
610
  level: "error",
607
611
  code: "manifest-version-mismatch",
608
- message: `plugin.json version "${manifest.version}" ≠ package.json version "${pkg.version}"`,
612
+ message: `${d.label} ${describeVersionValue(d)} ≠ package.json version "${d.expected}"`,
609
613
  });
610
614
  }
611
615
  } catch (e) {
616
+ // Only package.json itself lands here: without it no version can be verified.
612
617
  findings.push({
613
- level: "warn",
618
+ level: "error",
614
619
  code: "package-json-unreadable",
615
- message: `could not read package.json for version cross-check: ${e.message}`,
620
+ message: `could not read package.json for the version cross-check: ${e.message}`,
616
621
  });
617
622
  }
618
623
  const ok = findings.every((f) => f.level !== "error");
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sync-manifest-versions.mjs — copy package.json's version into every other place the
4
+ * package declares it (scripts/lib/manifest-versions.mjs holds the list).
5
+ *
6
+ * Wired as the npm `version` lifecycle script (with --stage), so
7
+ * `npm version patch|minor|major` bumps package.json and then every manifest in the
8
+ * same step, and stages them for the version commit. Before this, plugin.json was a
9
+ * hand edit and clauth-plugin.json was never bumped at all (0.35.10 through 0.37.0).
10
+ *
11
+ * node scripts/sync-manifest-versions.mjs rewrite what differs, print it
12
+ * node scripts/sync-manifest-versions.mjs --stage the same, then `git add` every
13
+ * declaration file (npm version)
14
+ * node scripts/sync-manifest-versions.mjs --check change nothing; exit 1 on drift
15
+ */
16
+ import { execFileSync } from 'node:child_process';
17
+ import { realpathSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { VERSION_DECLARATIONS, describeValue, mismatches, syncDeclarations } from './lib/manifest-versions.mjs';
21
+
22
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const log = (line) => console.log(`[sync-manifest-versions] ${line}`);
24
+
25
+ /** One spelling per directory: long names (not 8.3), and case-folded on Windows. */
26
+ function canonical(dir) {
27
+ const real = realpathSync.native(path.resolve(dir));
28
+ return process.platform === 'win32' ? real.toLowerCase() : real;
29
+ }
30
+
31
+ /**
32
+ * True only when this package root IS the top of a git work tree. A package unpacked
33
+ * inside some other repository must not stage into that repository's index.
34
+ */
35
+ function ownsGitWorkTree() {
36
+ try {
37
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'], {
38
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
39
+ }).trim();
40
+ return canonical(top) === canonical(root);
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ if (process.argv.includes('--check')) {
47
+ const drift = mismatches(root);
48
+ for (const d of drift) {
49
+ console.error(`[sync-manifest-versions] ${d.label} is ${describeValue(d)}, package.json is ${d.expected}`);
50
+ }
51
+ if (drift.length) process.exit(1);
52
+ log('every declaration matches package.json');
53
+ } else {
54
+ const changed = syncDeclarations(root);
55
+ for (const c of changed) log(`${c.label}: ${c.from} -> ${c.to}`);
56
+ if (!changed.length) log('nothing to change');
57
+ if (process.argv.includes('--stage')) {
58
+ // npm version skips its own git steps outside a work tree; so does this.
59
+ if (!ownsGitWorkTree()) {
60
+ log('this package is not the top of a git work tree — nothing staged');
61
+ } else {
62
+ const files = [...new Set(VERSION_DECLARATIONS.map((d) => d.file))];
63
+ execFileSync('git', ['add', '--', ...files], { cwd: root, stdio: 'inherit' });
64
+ log(`staged ${files.join(', ')}`);
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,155 @@
1
+ // Every place the package declares its version equals package.json.
2
+ //
3
+ // 0.37.0 shipped with clauth-plugin.json at 0.35.10 and package.json `claude.version`
4
+ // at 0.10.0. The only check, self-test, compared plugin.json alone, so both drifted for
5
+ // releases without a single red. This test reads the shared declaration list, and it
6
+ // also scans the repository, so a new manifest with a `version` cannot slip past the list.
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import {
15
+ VERSION_DECLARATIONS, VERSION_SOURCE, describeValue, label, mismatches, readDeclarations, replaceJsonString,
16
+ syncDeclarations,
17
+ } from '../scripts/lib/manifest-versions.mjs';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+
21
+ /**
22
+ * The JSON files this repository holds: tracked, plus new files git does not ignore.
23
+ * Read from git, not the disk, so local reports, stamps and editor JSONC that git
24
+ * ignores can neither fail this test on one machine nor pass it on another.
25
+ */
26
+ function jsonFilesWithTopLevelVersion() {
27
+ const listed = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.json'], {
28
+ cwd: ROOT, encoding: 'utf8',
29
+ }).split('\0').filter(Boolean);
30
+ return [...new Set(listed)].filter((file) => {
31
+ const json = JSON.parse(readFileSync(path.join(ROOT, file), 'utf8'));
32
+ return json && typeof json === 'object' && !Array.isArray(json) && typeof json.version === 'string';
33
+ });
34
+ }
35
+
36
+ test('every version declaration equals package.json', () => {
37
+ const drift = mismatches(ROOT);
38
+ assert.deepEqual(
39
+ drift.map((d) => `${d.label} = ${describeValue(d)}`),
40
+ [],
41
+ `package.json is ${readDeclarations(ROOT).source}; run \`node scripts/sync-manifest-versions.mjs\``,
42
+ );
43
+ });
44
+
45
+ test('no JSON file declares a top-level version the list does not know about', () => {
46
+ const known = new Set([VERSION_SOURCE.file, ...VERSION_DECLARATIONS.filter((d) => d.keyPath.length === 1).map((d) => d.file)]);
47
+ const found = jsonFilesWithTopLevelVersion();
48
+ // Positive controls: a root file, and one inside a dot-directory.
49
+ assert.ok(found.includes('package.json'), 'the scan must find package.json');
50
+ assert.ok(found.includes('.claude-plugin/plugin.json'), 'the scan must find .claude-plugin/plugin.json');
51
+ assert.deepEqual(found.filter((f) => !known.has(f)), [], 'add each to VERSION_DECLARATIONS in scripts/lib/manifest-versions.mjs');
52
+ });
53
+
54
+ test('the declaration list names every file the fleet reads a version from', () => {
55
+ const labels = VERSION_DECLARATIONS.map(label);
56
+ for (const expected of ['.claude-plugin/plugin.json version', 'clauth-plugin.json version', 'package.json claude.version']) {
57
+ assert.ok(labels.includes(expected), `${expected} must be a declaration`);
58
+ }
59
+ });
60
+
61
+ test('npm version runs the sync hook, which stages every declaration file', () => {
62
+ const pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
63
+ assert.equal(pkg.scripts?.version, 'node scripts/sync-manifest-versions.mjs --stage');
64
+ });
65
+
66
+ test('replaceJsonString edits only the named key and keeps the formatting', () => {
67
+ const text = '{\n "name": "x",\n "version": "1.0.0",\n "packages": {\n "": { "version": "1.0.0" },\n "node_modules/a": {\n "version": "1.0.0"\n }\n }\n}\n';
68
+ const nested = replaceJsonString(text, ['packages', '', 'version'], '1.0.0', '2.0.0');
69
+ assert.equal(nested, text.replace('{ "version": "1.0.0" }', '{ "version": "2.0.0" }'));
70
+ const top = replaceJsonString(text, ['version'], '1.0.0', '2.0.0');
71
+ assert.equal(top, text.replace('"version": "1.0.0",', '"version": "2.0.0",'));
72
+ assert.throws(() => replaceJsonString(text, ['version'], '9.9.9', '2.0.0'), /could not locate/);
73
+ });
74
+
75
+ function copyManifests(tmp) {
76
+ for (const file of new Set([VERSION_SOURCE.file, ...VERSION_DECLARATIONS.map((d) => d.file)])) {
77
+ mkdirSync(path.dirname(path.join(tmp, file)), { recursive: true });
78
+ cpSync(path.join(ROOT, file), path.join(tmp, file));
79
+ }
80
+ }
81
+
82
+ test('an unreadable declaration is reported on its own and hides no other', () => {
83
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'rdc-version-read-'));
84
+ try {
85
+ copyManifests(tmp);
86
+ writeFileSync(path.join(tmp, 'clauth-plugin.json'), '{ "version": ');
87
+ const drift = mismatches(tmp);
88
+ assert.deepEqual(drift.map((d) => d.label), ['clauth-plugin.json version']);
89
+ assert.match(describeValue(drift[0]), /^\(unreadable: /);
90
+ } finally {
91
+ rmSync(tmp, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ test('sync writes nothing when any declaration cannot be set', () => {
96
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'rdc-version-sync-'));
97
+ try {
98
+ copyManifests(tmp);
99
+ const pkgFile = path.join(tmp, 'package.json');
100
+ writeFileSync(pkgFile, readFileSync(pkgFile, 'utf8').replace(/"version": "[^"]+"/, '"version": "9.8.7"'));
101
+ const clauth = path.join(tmp, 'clauth-plugin.json');
102
+ writeFileSync(clauth, readFileSync(clauth, 'utf8').replace(/"version": "[^"]+"/, '"version": 1'));
103
+ const before = readFileSync(path.join(tmp, '.claude-plugin', 'plugin.json'), 'utf8');
104
+ assert.throws(() => syncDeclarations(tmp), /clauth-plugin\.json version is 1, not a version string/);
105
+ assert.equal(readFileSync(path.join(tmp, '.claude-plugin', 'plugin.json'), 'utf8'), before, 'plugin.json must be untouched');
106
+ } finally {
107
+ rmSync(tmp, { recursive: true, force: true });
108
+ }
109
+ });
110
+
111
+ test('the hook stages nothing into a repository the package is merely inside', () => {
112
+ const parent = mkdtempSync(path.join(os.tmpdir(), 'rdc-version-parent-'));
113
+ try {
114
+ const pkg = path.join(parent, 'vendor', 'rdc-skills');
115
+ copyManifests(pkg);
116
+ for (const file of ['scripts/sync-manifest-versions.mjs', 'scripts/lib/manifest-versions.mjs']) {
117
+ mkdirSync(path.dirname(path.join(pkg, file)), { recursive: true });
118
+ cpSync(path.join(ROOT, file), path.join(pkg, file));
119
+ }
120
+ execFileSync('git', ['init', '-q'], { cwd: parent });
121
+ const out = execFileSync(process.execPath, ['scripts/sync-manifest-versions.mjs', '--stage'], { cwd: pkg, encoding: 'utf8' });
122
+ assert.match(out, /nothing staged/);
123
+ assert.equal(execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: parent, encoding: 'utf8' }), '');
124
+ } finally {
125
+ rmSync(parent, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test('npm version in a throwaway copy moves every declaration and stages it', () => {
130
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'rdc-version-hook-'));
131
+ try {
132
+ copyManifests(tmp);
133
+ for (const file of ['scripts/sync-manifest-versions.mjs', 'scripts/lib/manifest-versions.mjs']) {
134
+ mkdirSync(path.dirname(path.join(tmp, file)), { recursive: true });
135
+ cpSync(path.join(ROOT, file), path.join(tmp, file));
136
+ }
137
+ execFileSync('git', ['init', '-q'], { cwd: tmp });
138
+ // npm is a .cmd shim on Windows; run it through cmd.exe rather than `shell: true`,
139
+ // which newer Node deprecates for argument arrays (DEP0190).
140
+ const npmArgs = ['version', '9.8.7', '--no-git-tag-version', '--ignore-scripts=false'];
141
+ const [cmd, args] = process.platform === 'win32'
142
+ ? ['cmd.exe', ['/d', '/s', '/c', 'npm', ...npmArgs]]
143
+ : ['npm', npmArgs];
144
+ execFileSync(cmd, args, { cwd: tmp, stdio: 'pipe' });
145
+ const { source, declarations } = readDeclarations(tmp);
146
+ assert.equal(source, '9.8.7');
147
+ assert.deepEqual(declarations.filter((d) => d.value !== '9.8.7').map((d) => `${d.label} = ${d.value}`), []);
148
+ const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: tmp, encoding: 'utf8' }).split('\n').filter(Boolean);
149
+ for (const file of new Set(VERSION_DECLARATIONS.map((d) => d.file))) {
150
+ assert.ok(staged.includes(file), `${file} must be staged for the version commit (staged: ${staged.join(', ')})`);
151
+ }
152
+ } finally {
153
+ rmSync(tmp, { recursive: true, force: true });
154
+ }
155
+ });