@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/publish.yml +12 -1
- package/CHANGELOG.md +36 -0
- package/RELEASE.md +31 -18
- package/clauth-plugin.json +1 -1
- package/git-sha.json +1 -1
- package/package.json +4 -3
- package/scripts/lib/manifest-versions.mjs +154 -0
- package/scripts/self-test.mjs +11 -6
- package/scripts/sync-manifest-versions.mjs +67 -0
- package/tests/manifest-version-parity.test.mjs +155 -0
|
@@ -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:
|
|
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
|
|
31
|
-
#
|
|
32
|
-
# and .claude-plugin/plugin.json
|
|
33
|
-
#
|
|
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
|
-
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
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 —
|
package/clauth-plugin.json
CHANGED
package/git-sha.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lifeaitools/rdc-skills",
|
|
3
|
-
"version": "0.37.
|
|
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.
|
|
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
|
+
}
|
package/scripts/self-test.mjs
CHANGED
|
@@ -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
|
-
|
|
604
|
-
|
|
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:
|
|
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: "
|
|
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
|
+
});
|