@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.77",
3
+ "version": "0.1.79",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -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
- export const versionDirFor = (home, version) => join(home, version);
117
- export const packageDirFor = (home, version) => join(home, version, 'node_modules', MCP_PACKAGE);
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. Best-effort by construction — a home
177
- * that cannot be written to costs the seat nothing but its claim.
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) return false;
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
- return false;
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 parseVersion(version) ? version : null;
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
- .filter((name) => parseVersion(name))
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
- return { command, source: 'declared', version: null, reason: 'home-empty' };
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 && typeof parsed.version === 'string' ? parsed.version : null;
472
+ const version = exactVersion(parsed && parsed.version);
366
473
  const checkedAt = parsed && Number.isFinite(parsed.checkedAt) ? parsed.checkedAt : null;
367
- if (!parseVersion(version) || checkedAt === null) return null;
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
- if (plan.source === 'home') claimInUse({ home: resolvedHome, version: plan.version });
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 plan.command;
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
- if (parsed && Number.isFinite(parsed.checkedAt) && now - parsed.checkedAt < ttlMs
151
- && parseVersion(parsed.version)) {
152
- return { version: parsed.version, source: 'cache' };
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
- if (version && parseVersion(version)) {
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
- if (parseVersion(parsed.version)) return { version: parsed.version, source: 'stale-cache' };
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
- const versions = readDir(home).filter((name) => parseVersion(name));
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
- const trimmed = raw.trim();
313
- return parseVersion(trimmed) ? trimmed : null;
321
+ return exactVersion(raw.trim()) || null;
314
322
  },
315
323
  keep = KEEP_VERSION_DIRS,
316
324
  } = {}) => {