@codyswann/lisa 4.4.8 → 4.4.10
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/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +78 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +1 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +8 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +78 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/parity-safety-net.sh +78 -1
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +78 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/parity-safety-net.sh +78 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +78 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/parity-safety-net.sh +78 -1
- package/scripts/lib/plugin-cache-resolution.mjs +325 -0
- package/scripts/plugin-parity-drift.mjs +20 -253
- package/scripts/plugin-routing-validate.mjs +32 -68
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one resolver for "what version of this plugin is installed right now".
|
|
3
|
+
*
|
|
4
|
+
* It lives here rather than in either caller because there used to be two of
|
|
5
|
+
* them, and the second one was invisible. `plugin-routing-validate.mjs`
|
|
6
|
+
* imported `compareSemver` and `isValidSemver` from `plugin-parity-drift.mjs`
|
|
7
|
+
* and then walked the cache itself — so the two scripts LOOKED like one
|
|
8
|
+
* implementation, shared a module, and disagreed about the answer.
|
|
9
|
+
*
|
|
10
|
+
* That is worse than an ordinary duplicate. When the orphan defect was fixed in
|
|
11
|
+
* the detector (CodySwannGT/lisa#3085), the fix did not reach the validator, and
|
|
12
|
+
* the next person to hit it would have found a detector whose orphan handling
|
|
13
|
+
* was correct and tested, an import linking the failing script to it, and a
|
|
14
|
+
* failure the fix was supposed to have prevented. **The remedy appears to have
|
|
15
|
+
* been applied.** A fix that is present, correct, and unreachable costs more
|
|
16
|
+
* than no fix at all, because it argues the cause is somewhere else
|
|
17
|
+
* (CodySwannGT/lisa#3093).
|
|
18
|
+
*
|
|
19
|
+
* So: one implementation, one place to fix, and the import stops being
|
|
20
|
+
* misleading. The single genuine difference between the two callers is now a
|
|
21
|
+
* NAMED option rather than an accidental divergence — see `dirNameFallback`.
|
|
22
|
+
*
|
|
23
|
+
* ## What the cache actually is
|
|
24
|
+
*
|
|
25
|
+
* Append-mostly. Uninstalling a plugin does not delete its version directories;
|
|
26
|
+
* it stamps each one with `.orphaned_at`. The directories on disk are a record
|
|
27
|
+
* of every version ever fetched, not of what is installed now, and reading them
|
|
28
|
+
* as the latter is how a pin came to be compared against ten-day-old leftovers.
|
|
29
|
+
*
|
|
30
|
+
* Determinism: Node built-ins only, no network, no `Date`, no `Math.random`.
|
|
31
|
+
* @module scripts/lib/plugin-cache-resolution
|
|
32
|
+
*/
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
|
|
36
|
+
// Literals named once — each was repeated enough times that a typo in one
|
|
37
|
+
// copy would diverge silently.
|
|
38
|
+
const NOT_INSTALLED = "not-installed";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Semver 2.0.0 grammar, one clause per name. Build metadata (`+...`) is
|
|
42
|
+
* accepted but ignored in comparison; prerelease (`-...`) is accepted and sorts
|
|
43
|
+
* below its release.
|
|
44
|
+
*
|
|
45
|
+
* Assembled from fragments rather than written as one literal because the
|
|
46
|
+
* literal was unreadable — this is the semver.org grammar verbatim, and the
|
|
47
|
+
* composed `.source` is byte-identical to the literal it replaced.
|
|
48
|
+
*/
|
|
49
|
+
const SEMVER_NUMERIC = "0|[1-9]\\d*";
|
|
50
|
+
const SEMVER_PRERELEASE_ID = `(?:${SEMVER_NUMERIC}|\\d*[A-Za-z-][0-9A-Za-z-]*)`;
|
|
51
|
+
const SEMVER_PRERELEASE = `(?:-(${SEMVER_PRERELEASE_ID}(?:\\.${SEMVER_PRERELEASE_ID})*))?`;
|
|
52
|
+
const SEMVER_BUILD = "(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?";
|
|
53
|
+
const SEMVER_RE = new RegExp(
|
|
54
|
+
`^(${SEMVER_NUMERIC})\\.(${SEMVER_NUMERIC})\\.(${SEMVER_NUMERIC})${SEMVER_PRERELEASE}${SEMVER_BUILD}$`
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** A plugin name / marketplace token: `1*(ALPHA / DIGIT / "-" / "_")`. */
|
|
58
|
+
/**
|
|
59
|
+
* A single path token: no `.`, no `/`, so it cannot escape the cache root.
|
|
60
|
+
*
|
|
61
|
+
* Exported because both callers validate the same shape before building a cache
|
|
62
|
+
* path, and two copies of a path-traversal guard is exactly the duplication
|
|
63
|
+
* this module exists to end.
|
|
64
|
+
*/
|
|
65
|
+
export const TOKEN_RE = /^[A-Za-z0-9_-]+$/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* True iff `value` is a valid semver 2.0.0 string.
|
|
69
|
+
*
|
|
70
|
+
* @param {unknown} value - candidate version string.
|
|
71
|
+
* @returns {boolean} whether `value` parses as semver.
|
|
72
|
+
*/
|
|
73
|
+
export function isValidSemver(value) {
|
|
74
|
+
if (typeof value !== "string") {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return SEMVER_RE.test(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Split a semver string into its numeric `[major, minor, patch]` core and the
|
|
82
|
+
* raw prerelease string (build metadata stripped).
|
|
83
|
+
*
|
|
84
|
+
* @param {string} version - a valid semver string.
|
|
85
|
+
* @returns {{ core: readonly number[], prerelease: string }} parsed parts.
|
|
86
|
+
*/
|
|
87
|
+
function splitSemver(version) {
|
|
88
|
+
const withoutBuild = version.split("+", 1)[0];
|
|
89
|
+
const dashIndex = withoutBuild.indexOf("-");
|
|
90
|
+
const coreStr =
|
|
91
|
+
dashIndex === -1 ? withoutBuild : withoutBuild.slice(0, dashIndex);
|
|
92
|
+
const prerelease = dashIndex === -1 ? "" : withoutBuild.slice(dashIndex + 1);
|
|
93
|
+
const core = coreStr.split(".").map(part => Number.parseInt(part, 10));
|
|
94
|
+
return { core, prerelease };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Compare two prerelease strings per semver precedence rules.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} a - first prerelease (may be empty = "is a release").
|
|
101
|
+
* @param {string} b - second prerelease (may be empty = "is a release").
|
|
102
|
+
* @returns {number} -1, 0, or 1.
|
|
103
|
+
*/
|
|
104
|
+
function comparePrerelease(a, b) {
|
|
105
|
+
if (a === b) {
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
if (a === "") {
|
|
109
|
+
return 1; // a is a full release; it outranks any prerelease b.
|
|
110
|
+
}
|
|
111
|
+
if (b === "") {
|
|
112
|
+
return -1;
|
|
113
|
+
}
|
|
114
|
+
const aIds = a.split(".");
|
|
115
|
+
const bIds = b.split(".");
|
|
116
|
+
for (let i = 0; i < Math.max(aIds.length, bIds.length); i++) {
|
|
117
|
+
const ai = aIds[i];
|
|
118
|
+
const bi = bIds[i];
|
|
119
|
+
if (ai === undefined) {
|
|
120
|
+
return -1; // shorter set of identifiers has lower precedence.
|
|
121
|
+
}
|
|
122
|
+
if (bi === undefined) {
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
const aNum = /^\d+$/.test(ai);
|
|
126
|
+
const bNum = /^\d+$/.test(bi);
|
|
127
|
+
if (aNum && bNum) {
|
|
128
|
+
const diff = Number.parseInt(ai, 10) - Number.parseInt(bi, 10);
|
|
129
|
+
if (diff !== 0) {
|
|
130
|
+
return diff < 0 ? -1 : 1;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (aNum !== bNum) {
|
|
135
|
+
return aNum ? -1 : 1; // numeric identifiers rank below alphanumeric.
|
|
136
|
+
}
|
|
137
|
+
if (ai !== bi) {
|
|
138
|
+
return ai < bi ? -1 : 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Compare two semver strings. Build metadata is ignored; a prerelease sorts
|
|
146
|
+
* below its associated release.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} a - first valid semver string.
|
|
149
|
+
* @param {string} b - second valid semver string.
|
|
150
|
+
* @returns {number} -1 if a < b, 0 if equal precedence, 1 if a > b.
|
|
151
|
+
*/
|
|
152
|
+
export function compareSemver(a, b) {
|
|
153
|
+
const pa = splitSemver(a);
|
|
154
|
+
const pb = splitSemver(b);
|
|
155
|
+
for (let i = 0; i < 3; i++) {
|
|
156
|
+
const diff = pa.core[i] - pb.core[i];
|
|
157
|
+
if (diff !== 0) {
|
|
158
|
+
return diff < 0 ? -1 : 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return comparePrerelease(pa.prerelease, pb.prerelease);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* True iff `target` is an existing directory.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} target - filesystem path.
|
|
168
|
+
* @returns {boolean} whether `target` resolves to a directory.
|
|
169
|
+
*/
|
|
170
|
+
export function isDirectory(target) {
|
|
171
|
+
try {
|
|
172
|
+
return fs.statSync(target).isDirectory();
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Read a plugin manifest's `version` field, or `null` if unreadable / invalid.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} manifestPath - path to a `.claude-plugin/plugin.json`.
|
|
182
|
+
* @returns {string | null} the manifest version string, or `null`.
|
|
183
|
+
*/
|
|
184
|
+
function readManifestVersion(manifestPath) {
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
187
|
+
return typeof parsed.version === "string" ? parsed.version : null;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Whether a cached version directory has been ORPHANED — the plugin manager's
|
|
195
|
+
* marker for a version that is no longer installed or served.
|
|
196
|
+
*
|
|
197
|
+
* The cache is append-mostly: uninstalling a plugin does not delete its version
|
|
198
|
+
* directories, it stamps each one with `.orphaned_at`. So the directories on
|
|
199
|
+
* disk are a record of every version ever fetched, not of what is installed
|
|
200
|
+
* now, and reading them as the latter is how this script came to compare a pin
|
|
201
|
+
* against ten-day-old leftovers.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} versionDir - absolute path to one cached version directory.
|
|
204
|
+
* @returns {boolean} true when the directory carries an orphan marker.
|
|
205
|
+
*/
|
|
206
|
+
function isOrphanedVersion(versionDir) {
|
|
207
|
+
return fs.existsSync(path.join(versionDir, ".orphaned_at"));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve the current upstream version of `name@marketplace` purely from the
|
|
212
|
+
* cache tree: the MAX valid semver across the immediate version subdirs that
|
|
213
|
+
* are still LIVE, read from each subdir's `.claude-plugin/plugin.json`
|
|
214
|
+
* `version` field. Non-semver dirs (`unknown`, git hashes) are skipped because
|
|
215
|
+
* the manifest version is what counts, and orphaned dirs are skipped because
|
|
216
|
+
* they are not installed.
|
|
217
|
+
*
|
|
218
|
+
* Orphans are filtered BEFORE the max, and the order is load-bearing.
|
|
219
|
+
* "Filter, then take the max" and "take the max, then check whether it is
|
|
220
|
+
* orphaned" are different functions, and they disagree exactly when the newest
|
|
221
|
+
* live version is older than an orphan:
|
|
222
|
+
*
|
|
223
|
+
* live 1.0.6 + orphaned 2.0.4
|
|
224
|
+
* filter-then-max -> 1.0.6 (correct: 1.0.6 IS installed)
|
|
225
|
+
* max-then-check -> not-installed (wrong)
|
|
226
|
+
*
|
|
227
|
+
* When nothing live remains, this reports `not-installed` — a status this
|
|
228
|
+
* script already has and already handles — rather than manufacturing a current
|
|
229
|
+
* version out of orphans. That is `core/apply-receipt`'s principle: an
|
|
230
|
+
* unresolvable state reports unresolvable, not half-understood.
|
|
231
|
+
*
|
|
232
|
+
* The failure this closes was not theoretical. Every one of the ten cached
|
|
233
|
+
* `safety-net` versions was orphaned in a single sweep, and the resulting
|
|
234
|
+
* manufactured comparison blocked every push from the checkout — in one
|
|
235
|
+
* direction, and then, after a pin was moved to satisfy it, in the other.
|
|
236
|
+
* A defect that produces two opposite plausible remedies is one where the
|
|
237
|
+
* remedy is in neither direction.
|
|
238
|
+
*
|
|
239
|
+
* ## The one difference between the two callers, named
|
|
240
|
+
*
|
|
241
|
+
* `dirNameFallback` exists because the routing validator has always accepted a
|
|
242
|
+
* semver DIRECTORY NAME when a live version's manifest carries no usable
|
|
243
|
+
* `version` — some plugins ship no manifest version but a semver-named dir —
|
|
244
|
+
* while the drift detector deliberately reads the manifest and nothing else,
|
|
245
|
+
* because a `synced-from` pin is compared against what the manifest declares.
|
|
246
|
+
*
|
|
247
|
+
* That difference is real and worth keeping. What was not worth keeping is that
|
|
248
|
+
* it lived in two separate directory walks, where nobody could see it was the
|
|
249
|
+
* ONLY difference. A named option can be read; an accidental divergence has to
|
|
250
|
+
* be discovered by diffing two functions, which is what
|
|
251
|
+
* CodySwannGT/lisa#3093 was filed about.
|
|
252
|
+
*
|
|
253
|
+
* The fallback applies only to LIVE directories. An orphaned `2.0.4` with no
|
|
254
|
+
* manifest is still not installed, whatever its name says.
|
|
255
|
+
* @param {string} cacheRoot - the installed-plugin cache root.
|
|
256
|
+
* @param {string} name - plugin name.
|
|
257
|
+
* @param {string} marketplace - marketplace id.
|
|
258
|
+
* @param {{ dirNameFallback?: boolean }} [options] - resolution options.
|
|
259
|
+
* @returns {{ status: "ok" | "not-installed" | "unresolved", version: string | null }}
|
|
260
|
+
* the resolution outcome.
|
|
261
|
+
*/
|
|
262
|
+
export function resolveCurrentVersion(
|
|
263
|
+
cacheRoot,
|
|
264
|
+
name,
|
|
265
|
+
marketplace,
|
|
266
|
+
options = {}
|
|
267
|
+
) {
|
|
268
|
+
const dirNameFallback = options.dirNameFallback === true;
|
|
269
|
+
// Defense-in-depth path-traversal guard: only single-token names/marketplaces
|
|
270
|
+
// (no `.`, `/`, `..`) can map to a cache subdir. parseSyncedFrom already
|
|
271
|
+
// enforces this, but resolveCurrentVersion is a public export that no longer
|
|
272
|
+
// co-locates with its validating caller.
|
|
273
|
+
if (!TOKEN_RE.test(name) || !TOKEN_RE.test(marketplace)) {
|
|
274
|
+
return { status: NOT_INSTALLED, version: null };
|
|
275
|
+
}
|
|
276
|
+
const dir = path.join(cacheRoot, marketplace, name);
|
|
277
|
+
if (!isDirectory(dir)) {
|
|
278
|
+
return { status: NOT_INSTALLED, version: null };
|
|
279
|
+
}
|
|
280
|
+
const versions = [];
|
|
281
|
+
// Counted separately from `versions`, because "nothing is installed" and
|
|
282
|
+
// "something is installed but I cannot read its version" are different
|
|
283
|
+
// answers and must not collapse into one. A live directory with an
|
|
284
|
+
// unparseable manifest is `unresolved`; no live directory at all is
|
|
285
|
+
// `not-installed`.
|
|
286
|
+
let liveDirs = 0;
|
|
287
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
288
|
+
if (!entry.isDirectory()) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const versionDir = path.join(dir, entry.name);
|
|
292
|
+
// Before the manifest is even read: an orphaned directory is not an
|
|
293
|
+
// installed version, whatever its manifest claims.
|
|
294
|
+
if (isOrphanedVersion(versionDir)) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
liveDirs += 1;
|
|
298
|
+
const manifest = path.join(versionDir, ".claude-plugin", "plugin.json");
|
|
299
|
+
const version = readManifestVersion(manifest);
|
|
300
|
+
if (version !== null && isValidSemver(version)) {
|
|
301
|
+
versions.push(version);
|
|
302
|
+
} else if (dirNameFallback && isValidSemver(entry.name)) {
|
|
303
|
+
versions.push(entry.name);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (versions.length === 0) {
|
|
307
|
+
// No live directory at all means the plugin is not installed — a known,
|
|
308
|
+
// answerable state, and the one the whole orphan filter exists to reach.
|
|
309
|
+
// But a live directory whose manifest would not parse is still INSTALLED
|
|
310
|
+
// and merely unreadable, which is what `unresolved` has always meant.
|
|
311
|
+
// Collapsing the two would answer "I cannot read this version" with "this
|
|
312
|
+
// is not here", and an operator would go looking for the wrong thing.
|
|
313
|
+
return liveDirs === 0
|
|
314
|
+
? { status: NOT_INSTALLED, version: null }
|
|
315
|
+
: { status: "unresolved", version: null };
|
|
316
|
+
}
|
|
317
|
+
// `versions` is non-empty (guarded above), so seeding with the first element
|
|
318
|
+
// is exactly what the no-seed form did — and it cannot throw if that guard is
|
|
319
|
+
// ever moved.
|
|
320
|
+
const max = versions.reduce(
|
|
321
|
+
(acc, v) => (compareSemver(v, acc) > 0 ? v : acc),
|
|
322
|
+
versions[0]
|
|
323
|
+
);
|
|
324
|
+
return { status: "ok", version: max };
|
|
325
|
+
}
|
|
@@ -43,6 +43,13 @@ import process from "node:process";
|
|
|
43
43
|
import { fileURLToPath } from "node:url";
|
|
44
44
|
|
|
45
45
|
import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
46
|
+
import {
|
|
47
|
+
compareSemver,
|
|
48
|
+
isDirectory,
|
|
49
|
+
isValidSemver,
|
|
50
|
+
resolveCurrentVersion,
|
|
51
|
+
TOKEN_RE,
|
|
52
|
+
} from "./lib/plugin-cache-resolution.mjs";
|
|
46
53
|
|
|
47
54
|
// Literals named once — each was repeated enough times that a typo in one
|
|
48
55
|
// copy would diverge silently.
|
|
@@ -54,128 +61,26 @@ const REPO_ROOT = path.resolve(
|
|
|
54
61
|
);
|
|
55
62
|
|
|
56
63
|
/**
|
|
57
|
-
* Semver
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
+
* Semver comparison, cache resolution, and the orphan filter all live in one
|
|
65
|
+
* module now, shared with `plugin-routing-validate.mjs`.
|
|
66
|
+
*
|
|
67
|
+
* They used to live here, and the validator walked the cache itself while
|
|
68
|
+
* importing only the semver helpers from this file — so the two looked like one
|
|
69
|
+
* implementation and were not. When the orphan defect was fixed here, the fix
|
|
70
|
+
* did not reach there (CodySwannGT/lisa#3093).
|
|
71
|
+
*
|
|
72
|
+
* Re-exported rather than merely imported: `resolveCurrentVersion`,
|
|
73
|
+
* `compareSemver` and `isValidSemver` are this module's public surface and have
|
|
74
|
+
* callers. A re-export is one implementation with two names; the thing being
|
|
75
|
+
* removed is one name with two implementations.
|
|
64
76
|
*/
|
|
65
|
-
|
|
66
|
-
const SEMVER_PRERELEASE_ID = `(?:${SEMVER_NUMERIC}|\\d*[A-Za-z-][0-9A-Za-z-]*)`;
|
|
67
|
-
const SEMVER_PRERELEASE = `(?:-(${SEMVER_PRERELEASE_ID}(?:\\.${SEMVER_PRERELEASE_ID})*))?`;
|
|
68
|
-
const SEMVER_BUILD = "(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?";
|
|
69
|
-
const SEMVER_RE = new RegExp(
|
|
70
|
-
`^(${SEMVER_NUMERIC})\\.(${SEMVER_NUMERIC})\\.(${SEMVER_NUMERIC})${SEMVER_PRERELEASE}${SEMVER_BUILD}$`
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
/** A plugin name / marketplace token: `1*(ALPHA / DIGIT / "-" / "_")`. */
|
|
74
|
-
const TOKEN_RE = /^[A-Za-z0-9_-]+$/;
|
|
75
|
-
|
|
77
|
+
export { compareSemver, isValidSemver, resolveCurrentVersion };
|
|
76
78
|
/**
|
|
77
79
|
* Usage error — thrown by `parseArgs` for an invalid invocation so `main` can
|
|
78
80
|
* distinguish it (exit 2) from a drift result (exit 1).
|
|
79
81
|
*/
|
|
80
82
|
export class UsageError extends Error {}
|
|
81
83
|
|
|
82
|
-
/**
|
|
83
|
-
* True iff `value` is a valid semver 2.0.0 string.
|
|
84
|
-
*
|
|
85
|
-
* @param {unknown} value - candidate version string.
|
|
86
|
-
* @returns {boolean} whether `value` parses as semver.
|
|
87
|
-
*/
|
|
88
|
-
export function isValidSemver(value) {
|
|
89
|
-
if (typeof value !== "string") {
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
return SEMVER_RE.test(value);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Split a semver string into its numeric `[major, minor, patch]` core and the
|
|
97
|
-
* raw prerelease string (build metadata stripped).
|
|
98
|
-
*
|
|
99
|
-
* @param {string} version - a valid semver string.
|
|
100
|
-
* @returns {{ core: readonly number[], prerelease: string }} parsed parts.
|
|
101
|
-
*/
|
|
102
|
-
function splitSemver(version) {
|
|
103
|
-
const withoutBuild = version.split("+", 1)[0];
|
|
104
|
-
const dashIndex = withoutBuild.indexOf("-");
|
|
105
|
-
const coreStr =
|
|
106
|
-
dashIndex === -1 ? withoutBuild : withoutBuild.slice(0, dashIndex);
|
|
107
|
-
const prerelease = dashIndex === -1 ? "" : withoutBuild.slice(dashIndex + 1);
|
|
108
|
-
const core = coreStr.split(".").map(part => Number.parseInt(part, 10));
|
|
109
|
-
return { core, prerelease };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Compare two prerelease strings per semver precedence rules.
|
|
114
|
-
*
|
|
115
|
-
* @param {string} a - first prerelease (may be empty = "is a release").
|
|
116
|
-
* @param {string} b - second prerelease (may be empty = "is a release").
|
|
117
|
-
* @returns {number} -1, 0, or 1.
|
|
118
|
-
*/
|
|
119
|
-
function comparePrerelease(a, b) {
|
|
120
|
-
if (a === b) {
|
|
121
|
-
return 0;
|
|
122
|
-
}
|
|
123
|
-
if (a === "") {
|
|
124
|
-
return 1; // a is a full release; it outranks any prerelease b.
|
|
125
|
-
}
|
|
126
|
-
if (b === "") {
|
|
127
|
-
return -1;
|
|
128
|
-
}
|
|
129
|
-
const aIds = a.split(".");
|
|
130
|
-
const bIds = b.split(".");
|
|
131
|
-
for (let i = 0; i < Math.max(aIds.length, bIds.length); i++) {
|
|
132
|
-
const ai = aIds[i];
|
|
133
|
-
const bi = bIds[i];
|
|
134
|
-
if (ai === undefined) {
|
|
135
|
-
return -1; // shorter set of identifiers has lower precedence.
|
|
136
|
-
}
|
|
137
|
-
if (bi === undefined) {
|
|
138
|
-
return 1;
|
|
139
|
-
}
|
|
140
|
-
const aNum = /^\d+$/.test(ai);
|
|
141
|
-
const bNum = /^\d+$/.test(bi);
|
|
142
|
-
if (aNum && bNum) {
|
|
143
|
-
const diff = Number.parseInt(ai, 10) - Number.parseInt(bi, 10);
|
|
144
|
-
if (diff !== 0) {
|
|
145
|
-
return diff < 0 ? -1 : 1;
|
|
146
|
-
}
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
if (aNum !== bNum) {
|
|
150
|
-
return aNum ? -1 : 1; // numeric identifiers rank below alphanumeric.
|
|
151
|
-
}
|
|
152
|
-
if (ai !== bi) {
|
|
153
|
-
return ai < bi ? -1 : 1;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
return 0;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Compare two semver strings. Build metadata is ignored; a prerelease sorts
|
|
161
|
-
* below its associated release.
|
|
162
|
-
*
|
|
163
|
-
* @param {string} a - first valid semver string.
|
|
164
|
-
* @param {string} b - second valid semver string.
|
|
165
|
-
* @returns {number} -1 if a < b, 0 if equal precedence, 1 if a > b.
|
|
166
|
-
*/
|
|
167
|
-
export function compareSemver(a, b) {
|
|
168
|
-
const pa = splitSemver(a);
|
|
169
|
-
const pb = splitSemver(b);
|
|
170
|
-
for (let i = 0; i < 3; i++) {
|
|
171
|
-
const diff = pa.core[i] - pb.core[i];
|
|
172
|
-
if (diff !== 0) {
|
|
173
|
-
return diff < 0 ? -1 : 1;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return comparePrerelease(pa.prerelease, pb.prerelease);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
84
|
/**
|
|
180
85
|
* Parse a `synced-from` value of the form `name@marketplace@version`.
|
|
181
86
|
*
|
|
@@ -249,144 +154,6 @@ export function parseFrontmatter(content) {
|
|
|
249
154
|
return result;
|
|
250
155
|
}
|
|
251
156
|
|
|
252
|
-
/**
|
|
253
|
-
* True iff `target` is an existing directory.
|
|
254
|
-
*
|
|
255
|
-
* @param {string} target - filesystem path.
|
|
256
|
-
* @returns {boolean} whether `target` resolves to a directory.
|
|
257
|
-
*/
|
|
258
|
-
function isDirectory(target) {
|
|
259
|
-
try {
|
|
260
|
-
return fs.statSync(target).isDirectory();
|
|
261
|
-
} catch {
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Read a plugin manifest's `version` field, or `null` if unreadable / invalid.
|
|
268
|
-
*
|
|
269
|
-
* @param {string} manifestPath - path to a `.claude-plugin/plugin.json`.
|
|
270
|
-
* @returns {string | null} the manifest version string, or `null`.
|
|
271
|
-
*/
|
|
272
|
-
function readManifestVersion(manifestPath) {
|
|
273
|
-
try {
|
|
274
|
-
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
275
|
-
return typeof parsed.version === "string" ? parsed.version : null;
|
|
276
|
-
} catch {
|
|
277
|
-
return null;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Whether a cached version directory has been ORPHANED — the plugin manager's
|
|
283
|
-
* marker for a version that is no longer installed or served.
|
|
284
|
-
*
|
|
285
|
-
* The cache is append-mostly: uninstalling a plugin does not delete its version
|
|
286
|
-
* directories, it stamps each one with `.orphaned_at`. So the directories on
|
|
287
|
-
* disk are a record of every version ever fetched, not of what is installed
|
|
288
|
-
* now, and reading them as the latter is how this script came to compare a pin
|
|
289
|
-
* against ten-day-old leftovers.
|
|
290
|
-
*
|
|
291
|
-
* @param {string} versionDir - absolute path to one cached version directory.
|
|
292
|
-
* @returns {boolean} true when the directory carries an orphan marker.
|
|
293
|
-
*/
|
|
294
|
-
function isOrphanedVersion(versionDir) {
|
|
295
|
-
return fs.existsSync(path.join(versionDir, ".orphaned_at"));
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Resolve the current upstream version of `name@marketplace` purely from the
|
|
300
|
-
* cache tree: the MAX valid semver across the immediate version subdirs that
|
|
301
|
-
* are still LIVE, read from each subdir's `.claude-plugin/plugin.json`
|
|
302
|
-
* `version` field. Non-semver dirs (`unknown`, git hashes) are skipped because
|
|
303
|
-
* the manifest version is what counts, and orphaned dirs are skipped because
|
|
304
|
-
* they are not installed.
|
|
305
|
-
*
|
|
306
|
-
* Orphans are filtered BEFORE the max, and the order is load-bearing.
|
|
307
|
-
* "Filter, then take the max" and "take the max, then check whether it is
|
|
308
|
-
* orphaned" are different functions, and they disagree exactly when the newest
|
|
309
|
-
* live version is older than an orphan:
|
|
310
|
-
*
|
|
311
|
-
* live 1.0.6 + orphaned 2.0.4
|
|
312
|
-
* filter-then-max -> 1.0.6 (correct: 1.0.6 IS installed)
|
|
313
|
-
* max-then-check -> not-installed (wrong)
|
|
314
|
-
*
|
|
315
|
-
* When nothing live remains, this reports `not-installed` — a status this
|
|
316
|
-
* script already has and already handles — rather than manufacturing a current
|
|
317
|
-
* version out of orphans. That is `core/apply-receipt`'s principle: an
|
|
318
|
-
* unresolvable state reports unresolvable, not half-understood.
|
|
319
|
-
*
|
|
320
|
-
* The failure this closes was not theoretical. Every one of the ten cached
|
|
321
|
-
* `safety-net` versions was orphaned in a single sweep, and the resulting
|
|
322
|
-
* manufactured comparison blocked every push from the checkout — in one
|
|
323
|
-
* direction, and then, after a pin was moved to satisfy it, in the other.
|
|
324
|
-
* A defect that produces two opposite plausible remedies is one where the
|
|
325
|
-
* remedy is in neither direction.
|
|
326
|
-
*
|
|
327
|
-
* @param {string} cacheRoot - the installed-plugin cache root.
|
|
328
|
-
* @param {string} name - plugin name.
|
|
329
|
-
* @param {string} marketplace - marketplace id.
|
|
330
|
-
* @returns {{ status: "ok" | "not-installed" | "unresolved", version: string | null }}
|
|
331
|
-
* the resolution outcome.
|
|
332
|
-
*/
|
|
333
|
-
export function resolveCurrentVersion(cacheRoot, name, marketplace) {
|
|
334
|
-
// Defense-in-depth path-traversal guard: only single-token names/marketplaces
|
|
335
|
-
// (no `.`, `/`, `..`) can map to a cache subdir. parseSyncedFrom already
|
|
336
|
-
// enforces this, but resolveCurrentVersion is a public export that no longer
|
|
337
|
-
// co-locates with its validating caller.
|
|
338
|
-
if (!TOKEN_RE.test(name) || !TOKEN_RE.test(marketplace)) {
|
|
339
|
-
return { status: NOT_INSTALLED, version: null };
|
|
340
|
-
}
|
|
341
|
-
const dir = path.join(cacheRoot, marketplace, name);
|
|
342
|
-
if (!isDirectory(dir)) {
|
|
343
|
-
return { status: NOT_INSTALLED, version: null };
|
|
344
|
-
}
|
|
345
|
-
const versions = [];
|
|
346
|
-
// Counted separately from `versions`, because "nothing is installed" and
|
|
347
|
-
// "something is installed but I cannot read its version" are different
|
|
348
|
-
// answers and must not collapse into one. A live directory with an
|
|
349
|
-
// unparseable manifest is `unresolved`; no live directory at all is
|
|
350
|
-
// `not-installed`.
|
|
351
|
-
let liveDirs = 0;
|
|
352
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
353
|
-
if (!entry.isDirectory()) {
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
const versionDir = path.join(dir, entry.name);
|
|
357
|
-
// Before the manifest is even read: an orphaned directory is not an
|
|
358
|
-
// installed version, whatever its manifest claims.
|
|
359
|
-
if (isOrphanedVersion(versionDir)) {
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
|
-
liveDirs += 1;
|
|
363
|
-
const manifest = path.join(versionDir, ".claude-plugin", "plugin.json");
|
|
364
|
-
const version = readManifestVersion(manifest);
|
|
365
|
-
if (version !== null && isValidSemver(version)) {
|
|
366
|
-
versions.push(version);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
if (versions.length === 0) {
|
|
370
|
-
// No live directory at all means the plugin is not installed — a known,
|
|
371
|
-
// answerable state, and the one the whole orphan filter exists to reach.
|
|
372
|
-
// But a live directory whose manifest would not parse is still INSTALLED
|
|
373
|
-
// and merely unreadable, which is what `unresolved` has always meant.
|
|
374
|
-
// Collapsing the two would answer "I cannot read this version" with "this
|
|
375
|
-
// is not here", and an operator would go looking for the wrong thing.
|
|
376
|
-
return liveDirs === 0
|
|
377
|
-
? { status: NOT_INSTALLED, version: null }
|
|
378
|
-
: { status: "unresolved", version: null };
|
|
379
|
-
}
|
|
380
|
-
// `versions` is non-empty (guarded above), so seeding with the first element
|
|
381
|
-
// is exactly what the no-seed form did — and it cannot throw if that guard is
|
|
382
|
-
// ever moved.
|
|
383
|
-
const max = versions.reduce(
|
|
384
|
-
(acc, v) => (compareSemver(v, acc) > 0 ? v : acc),
|
|
385
|
-
versions[0]
|
|
386
|
-
);
|
|
387
|
-
return { status: "ok", version: max };
|
|
388
|
-
}
|
|
389
|
-
|
|
390
157
|
/**
|
|
391
158
|
* Classify a synced skill by comparing its pinned version to the resolved
|
|
392
159
|
* current version (§3.3).
|