@commonlyai/cli 0.1.77 → 0.1.79
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/package.json +1 -1
- package/src/lib/mcp-home.js +138 -17
- package/src/lib/mcp-server-version.js +22 -0
- package/src/lib/mcp-warm-child.mjs +19 -11
package/package.json
CHANGED
package/src/lib/mcp-home.js
CHANGED
|
@@ -27,6 +27,15 @@
|
|
|
27
27
|
* operator saying which build to run, and the npx cache already gives a pinned
|
|
28
28
|
* spec its own dir.
|
|
29
29
|
*
|
|
30
|
+
* The cold path is pinned too (TASK-174's second half). A spawn with no usable
|
|
31
|
+
* home still runs the declaration, but not with the shared `@latest` spec: the
|
|
32
|
+
* spec this spawn executes is the version the registry already answered with,
|
|
33
|
+
* read from the warm's own cache, so `@latest`'s one shared npx dir is not on
|
|
34
|
+
* the spawn path even once. The fallback is the declaration as written, and it
|
|
35
|
+
* is used exactly when nothing has ever resolved a version on this host — a
|
|
36
|
+
* fresh machine's first spawn, where `@latest` is also the only spec that could
|
|
37
|
+
* work — or when the cache is unreadable. Nothing here throws.
|
|
38
|
+
*
|
|
30
39
|
* Every filesystem answer here is injected, because the two states that matter
|
|
31
40
|
* (a home that has never been warmed, and a home whose pointed-at version dir was
|
|
32
41
|
* deleted under it) cannot both be produced on one host.
|
|
@@ -39,7 +48,7 @@ import { homedir } from 'node:os';
|
|
|
39
48
|
import { dirname, join, sep } from 'node:path';
|
|
40
49
|
import { fileURLToPath } from 'node:url';
|
|
41
50
|
import { isShippedCommonlyMcpCommand } from './declared-mcp-guard.js';
|
|
42
|
-
import { MCP_PACKAGE, parseVersion } from './mcp-server-version.js';
|
|
51
|
+
import { MCP_PACKAGE, exactVersion, parseVersion } from './mcp-server-version.js';
|
|
43
52
|
|
|
44
53
|
/** The env override, so tests (and an operator) can point the home elsewhere. */
|
|
45
54
|
export const MCP_HOME_ENV = 'COMMONLY_MCP_HOME';
|
|
@@ -113,8 +122,34 @@ export const isUsableWarmHome = (home) => (
|
|
|
113
122
|
export const resolveHomeDir = (home) => (isUsableWarmHome(home) ? home : mcpHomeDir());
|
|
114
123
|
|
|
115
124
|
export const currentPointerPath = (home) => join(home, 'current');
|
|
116
|
-
|
|
117
|
-
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Where a version dir name that is not a triple lands: inside the home, and
|
|
128
|
+
* never a version (a version starts with a digit), so every read of it misses
|
|
129
|
+
* and the spawn falls back to its declaration. The alternative — building the
|
|
130
|
+
* path from whatever string arrived — is how `0.3.13/../../../../tmp/x` resolved
|
|
131
|
+
* outside the home, and #1970's `startsWith(pkgDir)` confinement is computed
|
|
132
|
+
* FROM that dir, so it would have confined to the wrong place (Vera, 74938).
|
|
133
|
+
*/
|
|
134
|
+
const UNUSABLE_VERSION_DIR = '.unusable-version';
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A version dir, built from the parsed triple — and only when the string given
|
|
138
|
+
* IS that triple.
|
|
139
|
+
*
|
|
140
|
+
* Refusing beats rebuilding here: this result is what the warm RENAMES into and
|
|
141
|
+
* what the pruner REMOVES, so silently rewriting `0.3.14-rc.1` to `0.3.14` would
|
|
142
|
+
* aim both at a different build. Normalising belongs at the producer of a
|
|
143
|
+
* version value (`resolveRegistryLatest`, `readCurrentVersion`,
|
|
144
|
+
* `readRegistryCache`, `pinShippedSpec`), where the choice is deliberate and
|
|
145
|
+
* visible; a producer that names something else gets a name no version can have.
|
|
146
|
+
*/
|
|
147
|
+
export const versionDirFor = (home, version) => {
|
|
148
|
+
const triple = exactVersion(version);
|
|
149
|
+
const name = triple && triple === String(version).trim() ? triple : UNUSABLE_VERSION_DIR;
|
|
150
|
+
return join(home, name);
|
|
151
|
+
};
|
|
152
|
+
export const packageDirFor = (home, version) => join(versionDirFor(home, version), 'node_modules', MCP_PACKAGE);
|
|
118
153
|
export const lockPathFor = (home) => join(home, WARM_LOCK_NAME);
|
|
119
154
|
|
|
120
155
|
/** `<home>/<version>/.inuse/` — one empty file per claiming pid. */
|
|
@@ -173,8 +208,10 @@ export const liveInUsePids = (home, version, {
|
|
|
173
208
|
*
|
|
174
209
|
* Called by `prepareMcpSpawn`, keyed by THIS process: the MCP server runs as a
|
|
175
210
|
* descendant of the process that materialised the command, so while that process
|
|
176
|
-
* lives its version dir must not be pruned.
|
|
177
|
-
*
|
|
211
|
+
* lives its version dir must not be pruned. A claim that cannot be written is
|
|
212
|
+
* reported, never swallowed: `prepareMcpSpawn` refuses to exec from a dir it
|
|
213
|
+
* could not claim (Vera 74903), because an unclaimed dir is one the pruner is
|
|
214
|
+
* free to `rm -rf` while this seat is running out of it.
|
|
178
215
|
*/
|
|
179
216
|
export const claimInUse = ({
|
|
180
217
|
home,
|
|
@@ -184,15 +221,30 @@ export const claimInUse = ({
|
|
|
184
221
|
writeFile = writeFileSync,
|
|
185
222
|
} = {}) => {
|
|
186
223
|
try {
|
|
187
|
-
if (!isUsableWarmHome(home) || !parseVersion(version) || parsePid(pid) === null)
|
|
224
|
+
if (!isUsableWarmHome(home) || !parseVersion(version) || parsePid(pid) === null) {
|
|
225
|
+
return { claimed: false, reason: 'not_claimable' };
|
|
226
|
+
}
|
|
188
227
|
mkdir(inUseDirFor(home, version), { recursive: true });
|
|
189
228
|
writeFile(inUseMarkerPath(home, version, pid), '');
|
|
190
|
-
return true;
|
|
191
|
-
} catch {
|
|
192
|
-
|
|
229
|
+
return { claimed: true, reason: null };
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// The errno is the difference between a home nobody can write to and a
|
|
232
|
+
// `.inuse` path that is a file, and only one of those is worth a log line
|
|
233
|
+
// that says which.
|
|
234
|
+
return { claimed: false, reason: (error && error.code) || 'claim_failed' };
|
|
193
235
|
}
|
|
194
236
|
};
|
|
195
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Say it out loud. A seat whose claim failed is still a seat, but it must not be
|
|
240
|
+
* a silent one: warn() is injectable so a test can hold the sentence.
|
|
241
|
+
*/
|
|
242
|
+
const defaultWarn = (line) => {
|
|
243
|
+
try {
|
|
244
|
+
process.stderr.write(`${line}\n`);
|
|
245
|
+
} catch { /* a seat that cannot warn still runs */ }
|
|
246
|
+
};
|
|
247
|
+
|
|
196
248
|
const defaultRead = (path) => {
|
|
197
249
|
try {
|
|
198
250
|
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
@@ -219,8 +271,8 @@ const defaultExists = (path) => {
|
|
|
219
271
|
export const readCurrentVersion = (home, { readFile = defaultRead } = {}) => {
|
|
220
272
|
const raw = readFile(currentPointerPath(home));
|
|
221
273
|
if (typeof raw !== 'string') return null;
|
|
222
|
-
const version = raw.trim();
|
|
223
|
-
return
|
|
274
|
+
const version = exactVersion(raw.trim());
|
|
275
|
+
return version || null;
|
|
224
276
|
};
|
|
225
277
|
|
|
226
278
|
/**
|
|
@@ -265,6 +317,9 @@ const readBinInPackageDir = (pkgDir, {
|
|
|
265
317
|
|
|
266
318
|
/** The bin of a version dir inside the seat's home. */
|
|
267
319
|
export const readBinPath = (home, version, options = {}) => {
|
|
320
|
+
// Truthiness of `parseVersion` is truthiness of `exactVersion`; the check that
|
|
321
|
+
// matters here is `versionDirFor` refusing a name that is not a triple, so a
|
|
322
|
+
// reader naming `0.3.14-rc.1` gets nothing rather than 0.3.14's build.
|
|
268
323
|
if (!parseVersion(version)) return null;
|
|
269
324
|
return readBinInPackageDir(packageDirFor(home, version), options);
|
|
270
325
|
};
|
|
@@ -300,7 +355,10 @@ export const newestInstalledVersion = (home, {
|
|
|
300
355
|
readBin = (h, v) => readBinPath(h, v),
|
|
301
356
|
} = {}) => {
|
|
302
357
|
const versions = readDir(home)
|
|
303
|
-
|
|
358
|
+
// An exact triple, not a prefix match: a dir literally named `0.3.13-rc.1`
|
|
359
|
+
// is not `0.3.13`, and normalising the name would point this read at a dir
|
|
360
|
+
// the caller never mentioned.
|
|
361
|
+
.filter((name) => exactVersion(name) === name)
|
|
304
362
|
.filter((name) => readBin(home, name))
|
|
305
363
|
.sort((a, b) => {
|
|
306
364
|
const [am, an, ap] = parseVersion(a);
|
|
@@ -315,6 +373,39 @@ const isUnpinnedShippedSpec = (spec) => (
|
|
|
315
373
|
spec === MCP_PACKAGE || spec === `${MCP_PACKAGE}@latest`
|
|
316
374
|
);
|
|
317
375
|
|
|
376
|
+
/**
|
|
377
|
+
* The shipped command with its spec pinned to `version`, or the command
|
|
378
|
+
* unchanged when there is no version to pin to.
|
|
379
|
+
*
|
|
380
|
+
* Only slot 2 is touched, and only after `isShippedCommonlyMcpCommand` has
|
|
381
|
+
* proven the slot is the package spec — so `npx`, its `-y` and every other
|
|
382
|
+
* entry survive byte-for-byte, and a command that is not the shipped 3-arg form
|
|
383
|
+
* cannot be rewritten by construction. npx keys its cache dir by the spec
|
|
384
|
+
* string, which is the whole point: `@latest` is one dir every seat shares and a
|
|
385
|
+
* publish rewrites in place, while `@0.3.13` is a dir only this version ever
|
|
386
|
+
* uses.
|
|
387
|
+
*/
|
|
388
|
+
export const pinShippedSpec = (command, version) => {
|
|
389
|
+
// The triple, not the string: this spec is an argv entry we execute, so a
|
|
390
|
+
// version carrying a suffix must not reach it.
|
|
391
|
+
const safe = exactVersion(version);
|
|
392
|
+
if (!Array.isArray(command) || !safe) return command;
|
|
393
|
+
let changed = false;
|
|
394
|
+
const pinned = command.map((part, index) => {
|
|
395
|
+
// Slot 2 only. `isShippedCommonlyMcpCommand` already proves the package sits
|
|
396
|
+
// in slot 2 of a 3-arg command, so this bound is not reachable today — it is
|
|
397
|
+
// here so the function is safe to read on its own, and it is armed in the
|
|
398
|
+
// tests rather than argued from the shape check (Vera, 74940).
|
|
399
|
+
if (index !== 2 || !isUnpinnedShippedSpec(String(part))) return part;
|
|
400
|
+
changed = true;
|
|
401
|
+
return `${MCP_PACKAGE}@${safe}`;
|
|
402
|
+
});
|
|
403
|
+
// The same reference when nothing changed, so a caller can use identity as the
|
|
404
|
+
// "did this spawn's spec move" check — the convention the rest of this module
|
|
405
|
+
// already keeps (`ensureCommonlyMcpServer`, `withholdGrantBroker`).
|
|
406
|
+
return changed ? pinned : command;
|
|
407
|
+
};
|
|
408
|
+
|
|
318
409
|
/**
|
|
319
410
|
* What a spawn should execute, and where that decision came from.
|
|
320
411
|
*
|
|
@@ -353,7 +444,23 @@ export const planMcpSpawn = (command, {
|
|
|
353
444
|
};
|
|
354
445
|
}
|
|
355
446
|
}
|
|
356
|
-
|
|
447
|
+
// No usable home, so the declaration is what runs — and the one thing worth
|
|
448
|
+
// changing about it is the spec string. The cached registry answer is used
|
|
449
|
+
// rather than a fresh lookup because this function is synchronous and on the
|
|
450
|
+
// spawn's critical path, and the warm refreshes that same cache on the same
|
|
451
|
+
// TTL (`REGISTRY_TTL_MS`). A stale-but-real version still gives this run its
|
|
452
|
+
// own npx dir, which `@latest` does not, and a stale cache means the registry
|
|
453
|
+
// has been answering nothing for a while — the case where `@latest` is least
|
|
454
|
+
// usable. `pinned` is reported so the caller can log which spec it ran.
|
|
455
|
+
const cached = readRegistryCache(target, { readFile });
|
|
456
|
+
const pinned = cached ? pinShippedSpec(command, cached.version) : command;
|
|
457
|
+
return {
|
|
458
|
+
command: pinned,
|
|
459
|
+
source: 'declared',
|
|
460
|
+
version: null,
|
|
461
|
+
reason: 'home-empty',
|
|
462
|
+
pinned: cached ? cached.version : null,
|
|
463
|
+
};
|
|
357
464
|
};
|
|
358
465
|
|
|
359
466
|
/** The warm's cached registry answer, with its age. */
|
|
@@ -362,9 +469,9 @@ export const readRegistryCache = (home, { readFile = defaultRead } = {}) => {
|
|
|
362
469
|
if (typeof raw !== 'string') return null;
|
|
363
470
|
try {
|
|
364
471
|
const parsed = JSON.parse(raw);
|
|
365
|
-
const version = parsed &&
|
|
472
|
+
const version = exactVersion(parsed && parsed.version);
|
|
366
473
|
const checkedAt = parsed && Number.isFinite(parsed.checkedAt) ? parsed.checkedAt : null;
|
|
367
|
-
if (!
|
|
474
|
+
if (!version || checkedAt === null) return null;
|
|
368
475
|
return { version, checkedAt };
|
|
369
476
|
} catch {
|
|
370
477
|
return null;
|
|
@@ -435,17 +542,31 @@ export const prepareMcpSpawn = (command, {
|
|
|
435
542
|
now = Date.now(),
|
|
436
543
|
readFile = defaultRead,
|
|
437
544
|
exists = defaultExists,
|
|
545
|
+
warn = defaultWarn,
|
|
438
546
|
} = {}) => {
|
|
439
547
|
const resolvedHome = resolveHomeDir(home);
|
|
440
548
|
try {
|
|
441
549
|
const plan = planMcpSpawn(command, { home: resolvedHome, readFile, exists });
|
|
442
550
|
// Claim the version this spawn is about to execute from, BEFORE returning the
|
|
443
551
|
// command, so a prune that runs while the seat is starting cannot take it.
|
|
444
|
-
|
|
552
|
+
let resolved = plan.command;
|
|
553
|
+
if (plan.source === 'home') {
|
|
554
|
+
const claim = claimInUse({ home: resolvedHome, version: plan.version });
|
|
555
|
+
// A claim that could not be written means the pruner may take this very
|
|
556
|
+
// directory out from under the running seat — the hazard the claim exists
|
|
557
|
+
// for. The declared command is what the guard approved and what no pruner
|
|
558
|
+
// owns, so the spawn falls back to it rather than executing unprotected,
|
|
559
|
+
// and says why. The warm still runs: with the seat off the dir, a prune or
|
|
560
|
+
// a repair is safe.
|
|
561
|
+
if (!claim.claimed) {
|
|
562
|
+
warn(`commonly: cannot claim ${versionDirFor(resolvedHome, plan.version)} for pid ${process.pid} (${claim.reason}) — running the declared command instead, because a prune could delete the directory this seat would run from`);
|
|
563
|
+
resolved = command;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
445
566
|
const due = !lockPresent(resolvedHome, { exists })
|
|
446
567
|
&& warmLooksWanted(resolvedHome, plan.version, { now, readFile });
|
|
447
568
|
if (due) kickWarm({ home: resolvedHome, apiUrl, spawnImpl, childPath });
|
|
448
|
-
return
|
|
569
|
+
return resolved;
|
|
449
570
|
} catch {
|
|
450
571
|
// A rewrite that throws must not cost the seat its tools: the declaration
|
|
451
572
|
// is what the guard approved, and it still runs.
|
|
@@ -23,6 +23,28 @@ export const parseVersion = (spec) => {
|
|
|
23
23
|
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The version as a bare triple — or null when there is none to build.
|
|
28
|
+
*
|
|
29
|
+
* `parseVersion` is a PREFIX test, which is right for ordering two versions and
|
|
30
|
+
* wrong for building anything out of one: it accepts `0.3.13/../../../../tmp/x`,
|
|
31
|
+
* so a version read out of a file (`~/.commonly/mcp/.registry.json`, the
|
|
32
|
+
* `current` pointer) or out of `npm view` could become a path or an npm SPEC
|
|
33
|
+
* ARGUMENT that leaves the seat's home. Everything that turns a version into a
|
|
34
|
+
* STRING used outside this module's own comparisons goes through here instead,
|
|
35
|
+
* and the string it builds is the parsed triple, so no suffix can travel.
|
|
36
|
+
*
|
|
37
|
+
* The cost is deliberate and one-directional: a prerelease (`0.3.13-rc.1`)
|
|
38
|
+
* becomes `0.3.13` rather than falling back to `@latest` — the stable release of
|
|
39
|
+
* the same triple, which is a real published version, and strictly better than
|
|
40
|
+
* the shared `@latest` dir this whole change exists to get off. Anchoring the
|
|
41
|
+
* regex at the end instead would reject the prerelease and leave it unpinned.
|
|
42
|
+
*/
|
|
43
|
+
export const exactVersion = (spec) => {
|
|
44
|
+
const triple = parseVersion(spec);
|
|
45
|
+
return triple ? triple.join('.') : null;
|
|
46
|
+
};
|
|
47
|
+
|
|
26
48
|
/**
|
|
27
49
|
* True when `version` is older than `target`; null when there is no version to
|
|
28
50
|
* judge (an unpinned `@latest` tracks the published release, so it is never
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
WARM_RESULTS, currentPointerPath, isUsableWarmHome, liveInUsePids, lockPathFor,
|
|
35
35
|
readBinInPrefix, versionDirFor,
|
|
36
36
|
} from './mcp-home.js';
|
|
37
|
-
import { MCP_PACKAGE, parseVersion } from './mcp-server-version.js';
|
|
37
|
+
import { MCP_PACKAGE, exactVersion, parseVersion } from './mcp-server-version.js';
|
|
38
38
|
|
|
39
39
|
export const PROBE_API_URL_PLACEHOLDER = 'http://127.0.0.1:1';
|
|
40
40
|
export const PROBE_TOKEN_PLACEHOLDER = 'cm_agent_warm_probe';
|
|
@@ -147,9 +147,9 @@ export const resolveRegistryLatest = async (home, {
|
|
|
147
147
|
if (cached) {
|
|
148
148
|
try {
|
|
149
149
|
const parsed = JSON.parse(cached);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return { version:
|
|
150
|
+
const cachedVersion = parsed ? exactVersion(parsed.version) : null;
|
|
151
|
+
if (cachedVersion && Number.isFinite(parsed.checkedAt) && now - parsed.checkedAt < ttlMs) {
|
|
152
|
+
return { version: cachedVersion, source: 'cache' };
|
|
153
153
|
}
|
|
154
154
|
} catch { /* unreadable cache is just a miss */ }
|
|
155
155
|
}
|
|
@@ -164,18 +164,24 @@ export const resolveRegistryLatest = async (home, {
|
|
|
164
164
|
version = raw.replace(/^"|"$/g, '');
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
-
|
|
167
|
+
// This answer is both persisted and, downstream, an argv entry
|
|
168
|
+
// (`npm install <pkg>@<version>`) and a path (`<home>/<version>`, the temp
|
|
169
|
+
// prefix). Rebuild it as a triple here, at the one place it enters, so the
|
|
170
|
+
// rest of the warm can keep treating a version as a name.
|
|
171
|
+
const safe = exactVersion(version);
|
|
172
|
+
if (safe) {
|
|
168
173
|
try {
|
|
169
|
-
writeFile(cachePath, JSON.stringify({ version, checkedAt: now }));
|
|
174
|
+
writeFile(cachePath, JSON.stringify({ version: safe, checkedAt: now }));
|
|
170
175
|
} catch { /* a cache we cannot write is not a warm we should fail */ }
|
|
171
|
-
return { version, source: 'registry' };
|
|
176
|
+
return { version: safe, source: 'registry' };
|
|
172
177
|
}
|
|
173
178
|
// Registry down: the last known answer is better than none, and better than
|
|
174
179
|
// moving the pointer backwards.
|
|
175
180
|
if (cached) {
|
|
176
181
|
try {
|
|
177
182
|
const parsed = JSON.parse(cached);
|
|
178
|
-
|
|
183
|
+
const stale = parsed ? exactVersion(parsed.version) : null;
|
|
184
|
+
if (stale) return { version: stale, source: 'stale-cache' };
|
|
179
185
|
} catch { /* fall through */ }
|
|
180
186
|
}
|
|
181
187
|
return { version: null, source: 'unavailable' };
|
|
@@ -267,7 +273,10 @@ export const pruneVersionDirs = (home, current, {
|
|
|
267
273
|
},
|
|
268
274
|
livePids = (version) => liveInUsePids(home, version),
|
|
269
275
|
} = {}) => {
|
|
270
|
-
|
|
276
|
+
// Exact triples only, for the same reason `newestInstalledVersion` uses them:
|
|
277
|
+
// a dir named `0.3.13-rc.1` is not the dir `0.3.13`, and this loop REMOVES what
|
|
278
|
+
// it names.
|
|
279
|
+
const versions = readDir(home).filter((name) => exactVersion(name) === name);
|
|
271
280
|
versions.sort((a, b) => {
|
|
272
281
|
const [am, an, ap] = parseVersion(a);
|
|
273
282
|
const [bm, bn, bp] = parseVersion(b);
|
|
@@ -309,8 +318,7 @@ export const warmMcpHome = async (home, {
|
|
|
309
318
|
readCurrent = (h) => {
|
|
310
319
|
const raw = readIfAny(currentPointerPath(h));
|
|
311
320
|
if (typeof raw !== 'string') return null;
|
|
312
|
-
|
|
313
|
-
return parseVersion(trimmed) ? trimmed : null;
|
|
321
|
+
return exactVersion(raw.trim()) || null;
|
|
314
322
|
},
|
|
315
323
|
keep = KEEP_VERSION_DIRS,
|
|
316
324
|
} = {}) => {
|