@commonlyai/cli 0.1.78 → 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.78",
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. */
@@ -236,8 +271,8 @@ const defaultExists = (path) => {
236
271
  export const readCurrentVersion = (home, { readFile = defaultRead } = {}) => {
237
272
  const raw = readFile(currentPointerPath(home));
238
273
  if (typeof raw !== 'string') return null;
239
- const version = raw.trim();
240
- return parseVersion(version) ? version : null;
274
+ const version = exactVersion(raw.trim());
275
+ return version || null;
241
276
  };
242
277
 
243
278
  /**
@@ -282,6 +317,9 @@ const readBinInPackageDir = (pkgDir, {
282
317
 
283
318
  /** The bin of a version dir inside the seat's home. */
284
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.
285
323
  if (!parseVersion(version)) return null;
286
324
  return readBinInPackageDir(packageDirFor(home, version), options);
287
325
  };
@@ -317,7 +355,10 @@ export const newestInstalledVersion = (home, {
317
355
  readBin = (h, v) => readBinPath(h, v),
318
356
  } = {}) => {
319
357
  const versions = readDir(home)
320
- .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)
321
362
  .filter((name) => readBin(home, name))
322
363
  .sort((a, b) => {
323
364
  const [am, an, ap] = parseVersion(a);
@@ -332,6 +373,39 @@ const isUnpinnedShippedSpec = (spec) => (
332
373
  spec === MCP_PACKAGE || spec === `${MCP_PACKAGE}@latest`
333
374
  );
334
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
+
335
409
  /**
336
410
  * What a spawn should execute, and where that decision came from.
337
411
  *
@@ -370,7 +444,23 @@ export const planMcpSpawn = (command, {
370
444
  };
371
445
  }
372
446
  }
373
- 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
+ };
374
464
  };
375
465
 
376
466
  /** The warm's cached registry answer, with its age. */
@@ -379,9 +469,9 @@ export const readRegistryCache = (home, { readFile = defaultRead } = {}) => {
379
469
  if (typeof raw !== 'string') return null;
380
470
  try {
381
471
  const parsed = JSON.parse(raw);
382
- const version = parsed && typeof parsed.version === 'string' ? parsed.version : null;
472
+ const version = exactVersion(parsed && parsed.version);
383
473
  const checkedAt = parsed && Number.isFinite(parsed.checkedAt) ? parsed.checkedAt : null;
384
- if (!parseVersion(version) || checkedAt === null) return null;
474
+ if (!version || checkedAt === null) return null;
385
475
  return { version, checkedAt };
386
476
  } catch {
387
477
  return null;
@@ -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
  } = {}) => {