@north-light/crouter 0.3.280 → 0.3.282

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.
Files changed (36) hide show
  1. package/dist/api/plugin-manifest-schema.d.ts +208 -0
  2. package/dist/api/plugin-manifest-schema.js +23 -0
  3. package/dist/cli.js +8 -2
  4. package/dist/clients/attach/viewer.js +533 -533
  5. package/dist/commands/pkg/browse/catalog.js +1 -1
  6. package/dist/commands/pkg/plugin-manage.d.ts +46 -1
  7. package/dist/commands/pkg/plugin-manage.js +170 -20
  8. package/dist/core/__tests__/integration/plugin-revalidate.test.d.ts +1 -0
  9. package/dist/core/__tests__/integration/plugin-revalidate.test.js +355 -0
  10. package/dist/core/__tests__/model-pin-durability.test.js +31 -3
  11. package/dist/core/command-manifests/registry.d.ts +3 -1
  12. package/dist/core/command-manifests/schema.d.ts +2 -75
  13. package/dist/core/command-manifests/schema.js +5 -0
  14. package/dist/core/command-plugins/compose.js +1 -1
  15. package/dist/core/command-plugins/revalidate.d.ts +18 -0
  16. package/dist/core/command-plugins/revalidate.js +152 -0
  17. package/dist/core/command-plugins/transport/http-fetch.d.ts +29 -4
  18. package/dist/core/command-plugins/transport/http-fetch.js +18 -8
  19. package/dist/core/command-plugins/transport/http-invoke.js +7 -0
  20. package/dist/core/command.d.ts +8 -0
  21. package/dist/core/command.js +19 -3
  22. package/dist/core/exclusive-lock.d.ts +12 -0
  23. package/dist/core/exclusive-lock.js +29 -0
  24. package/dist/core/installed-plugins.js +31 -0
  25. package/dist/core/manifest-recovery.d.ts +15 -0
  26. package/dist/core/manifest-recovery.js +72 -0
  27. package/dist/core/manifest-stale.d.ts +18 -0
  28. package/dist/core/manifest-stale.js +39 -0
  29. package/dist/core/plugin-swap-lock.d.ts +9 -0
  30. package/dist/core/plugin-swap-lock.js +31 -0
  31. package/dist/core/runtime/launch.d.ts +7 -2
  32. package/dist/core/runtime/launch.js +2 -1
  33. package/dist/daemon/api/__tests__/node-model-validation.test.js +15 -7
  34. package/dist/types.d.ts +7 -0
  35. package/package.json +2 -1
  36. package/runtime.lock.json +2 -2
@@ -337,7 +337,7 @@ function commandNode(node, parent = []) {
337
337
  children: [],
338
338
  params: node.params,
339
339
  output: node.output,
340
- effects: node.effects,
340
+ effects: [...node.effects],
341
341
  ...("rest" in node ? { rest: { method: node.rest.method, path: node.rest.path, streaming: node.rest.streaming === true } } : {}),
342
342
  };
343
343
  }
@@ -1,5 +1,14 @@
1
+ import { type CommandDiscoveryIssue } from '../../core/command-plugins/discovery.js';
1
2
  import { type HookPluginCandidate } from '../../core/command-hooks/discovery.js';
2
- import { type PluginManifest, type Scope } from '../../types.js';
3
+ import { type HookReport } from '../../core/command-hooks/report.js';
4
+ import { type PluginBundle, type PluginManifest, type Scope } from '../../types.js';
5
+ /** Static command-manifest report attached to install/update results. Runs
6
+ * the Phase-1 validator (no execution) so a caller sees exactly which
7
+ * top-level commands the plugin now contributes and why any were rejected. */
8
+ interface CommandReport {
9
+ mounts: string[];
10
+ issues: CommandDiscoveryIssue[];
11
+ }
3
12
  interface MarketplaceRef {
4
13
  marketplace: string;
5
14
  plugin: string;
@@ -10,6 +19,21 @@ interface InstallResult {
10
19
  path: string;
11
20
  memory?: PluginMemoryReport;
12
21
  }
22
+ interface BundleReplaceResult extends Record<string, unknown> {
23
+ name: string;
24
+ scope: Scope;
25
+ path: string;
26
+ transport: 'http';
27
+ version: string;
28
+ docs: number;
29
+ /** Count of kind-registry entries the bundle declared (0 when none). */
30
+ kinds: number;
31
+ /** Count of page-component registrations the bundle declared (0 when none). */
32
+ pageComponents: number;
33
+ commands: CommandReport;
34
+ hooks?: HookReport;
35
+ memory?: PluginMemoryReport;
36
+ }
13
37
  /** Who owns the physical memory corpus a candidate gate converges. A `package`
14
38
  * corpus is bytes this command owns — staged, or installed under the scope's
15
39
  * plugins dir — so a failed activation still rolls the package back. A `linked`
@@ -27,6 +51,27 @@ interface PluginMemoryReport extends Record<string, unknown> {
27
51
  /** Validate a package candidate before it gains an active plugin path. The
28
52
  * candidate namespace shadows any installed declaration of the same name. */
29
53
  export declare function assertPluginCandidateValid(manifest: PluginManifest, root: string, scope: Scope, scopeRootPath: string, where: string, candidates?: readonly HookPluginCandidate[], ownership?: PluginMemoryOwnership): Promise<PluginMemoryReport | undefined>;
54
+ /**
55
+ * Re-check an installed bundle plugin against its endpoint. A `conditional`
56
+ * check sends the validator stored for the package currently on disk, so an
57
+ * unchanged package is answered 304 and left untouched; an unconditional one
58
+ * asks for the archive outright, which is how recovery from a `manifest_stale`
59
+ * answer gets past a validator that is itself out of date.
60
+ *
61
+ * Returns the swap's report, or `undefined` when the package on disk is already
62
+ * the generation the server serves — whether the server said so with a 304 or
63
+ * we proved it by hashing bytes it re-sent.
64
+ *
65
+ * Every outcome, including a failure, records that the check happened, so an
66
+ * unreachable endpoint costs one timed-out probe per TTL rather than one per
67
+ * command. Failures throw; the caller decides whether a stale-but-present
68
+ * package is still good enough to run on.
69
+ */
70
+ export declare function revalidateBundlePlugin(name: string, bundleSource: PluginBundle, scope: Scope, options: {
71
+ enable: boolean;
72
+ conditional: boolean;
73
+ timeoutMs?: number;
74
+ }): Promise<BundleReplaceResult | undefined>;
30
75
  export declare function isMarketplaceRelativeSource(source: string): boolean;
31
76
  /** Install one marketplace plugin into a scope. Exported so `sys setup` can
32
77
  * install the default plugin set through the same path `pkg plugin install`
@@ -6,7 +6,9 @@ import { defineLeaf } from '../../core/command.js';
6
6
  import { notFound, usage, general, network } from '../../core/errors.js';
7
7
  import { findMarketplaceByName, findPluginByName, listAllPlugins } from '../../core/resolver.js';
8
8
  import { NEUTRAL_PROJECT_MEMORY, pluginsDir, ensureProjectScopeRoot, userScopeRoot, resolveScopeArg, projectScopeRoot, requireScopeRoot, } from '../../core/scope.js';
9
- import { updateConfig, updateState, ensureScopeInitialized, invalidPluginKindsReasons, invalidPluginBinReasons, invalidPluginRequiresReasons, normalizeRequires } from '../../core/config.js';
9
+ import { readConfig, readState, updateConfig, updateState, ensureScopeInitialized, invalidPluginKindsReasons, invalidPluginBinReasons, invalidPluginRequiresReasons, normalizeRequires } from '../../core/config.js';
10
+ import { withExclusiveDirectoryLockAsync } from '../../core/exclusive-lock.js';
11
+ import { bundleSwapLockPath } from '../../core/plugin-swap-lock.js';
10
12
  import { invalidPageComponentsReasons } from '../../core/human/page-catalog.js';
11
13
  import { pathExists, ensureDir, removePath, nowIso, linkOrCopy, isSymlink, readSymlinkTarget, atomicWriteJson, readText, realpathOrSelf, walkFiles } from '../../core/fs-utils.js';
12
14
  import { clone, deriveNameFromUrl, currentSha, gitSync, isGitRepo } from '../../core/git.js';
@@ -317,26 +319,163 @@ function existingBundleRoot(name, scope, root) {
317
319
  rejectLegacyHttpPlugin(name);
318
320
  throw general(`plugin "${name}" already exists at ${root}; run \`crtr pkg plugin remove ${name}\` before installing the archive plugin.`);
319
321
  }
320
- async function replaceBundlePlugin(name, bundleSource, scope, options) {
322
+ function resolveBundleTarget(name, bundleSource, scope, scopeRootPath) {
321
323
  const fetchTransport = validateHttpInstall(name, bundleSource.endpoint, bundleSource.authEnv);
322
324
  const bundle = { endpoint: fetchTransport.endpoint, ...(fetchTransport.authEnv !== undefined ? { authEnv: fetchTransport.authEnv } : {}) };
323
- const transport = invocationTransport(bundle);
324
- const scopeRootPath = scope === 'project' ? ensureProjectScopeRoot() : userScopeRoot();
325
325
  const root = join(scopeRootPath, 'plugins', name);
326
326
  existingBundleRoot(name, scope, root);
327
- const fetched = await fetchHttpPluginBundle({ name, endpoint: bundle.endpoint, ...(bundle.authEnv !== undefined ? { authEnv: bundle.authEnv } : {}) });
328
- if (fetched.status !== 200) {
329
- const message = `HTTP plugin bundle fetch failed for "${name}": ${fetched.message}`;
330
- if (fetched.status === 'auth_env_missing')
331
- throw usage(message);
332
- if (fetched.status === 'cli_unreachable')
333
- throw network(message);
334
- throw general(message);
335
- }
336
- const validated = await validatePluginBundle(fetched.raw, { reservedCoreNames: new Set(SUBTREE_NAMES), coreCommandPaths: await coreCommandPaths() });
327
+ return { bundle, transport: invocationTransport(bundle), scopeRootPath, root };
328
+ }
329
+ function fetchTarget(name, bundle) {
330
+ return { name, endpoint: bundle.endpoint, ...(bundle.authEnv !== undefined ? { authEnv: bundle.authEnv } : {}) };
331
+ }
332
+ /** A user-initiated install or update waits out another process's swap; the
333
+ * background pass waits a shorter time, because a wait that long there means
334
+ * the holder is wedged rather than merely slow. */
335
+ const INSTALL_SWAP_WAIT_MS = 60_000;
336
+ const REVALIDATE_SWAP_WAIT_MS = 10_000;
337
+ /**
338
+ * Serialize a plugin's package replacement against every other crtr process.
339
+ *
340
+ * The whole transition runs inside — re-reading the stored validator, the
341
+ * conditional fetch, the identity check, the directory swap, and both ledger
342
+ * writes. Holding it across the FETCH is the point: a process that queued
343
+ * behind another's swap re-reads the validator the winner just stored, so it
344
+ * sends the fresh ETag and is answered 304, instead of downloading the archive
345
+ * it waited for and swapping in a package identical to the one already there.
346
+ *
347
+ * The lock lives beside the staging and `.prev` directories the swap itself
348
+ * uses, and a holder that dies releases it immediately (its marker names a dead
349
+ * pid), so a crashed swap cannot wedge the next one.
350
+ */
351
+ async function withBundleSwapLock(name, scope, timeoutMs, run) {
352
+ const scopeRootPath = scope === 'project' ? ensureProjectScopeRoot() : userScopeRoot();
353
+ ensureDir(join(scopeRootPath, 'tmp'));
354
+ return withExclusiveDirectoryLockAsync(bundleSwapLockPath(scopeRootPath, name), () => run(scopeRootPath), {
355
+ timeoutMs,
356
+ timeoutError: () => general(`timed out waiting for another crtr process to finish replacing plugin "${name}"`, {
357
+ next: `Re-run once that process has finished, or check for a stalled crtr holding ${bundleSwapLockPath(scopeRootPath, name)}.`,
358
+ }),
359
+ });
360
+ }
361
+ async function replaceBundlePlugin(name, bundleSource, scope, options) {
362
+ return withBundleSwapLock(name, scope, INSTALL_SWAP_WAIT_MS, async (scopeRootPath) => {
363
+ const target = resolveBundleTarget(name, bundleSource, scope, scopeRootPath);
364
+ const fetched = await fetchHttpPluginBundle(fetchTarget(name, target.bundle));
365
+ if (fetched.status !== 200) {
366
+ const message = `HTTP plugin bundle fetch failed for "${name}": ${fetched.message}`;
367
+ if (fetched.status === 'auth_env_missing')
368
+ throw usage(message);
369
+ if (fetched.status === 'cli_unreachable')
370
+ throw network(message);
371
+ throw general(message);
372
+ }
373
+ return applyBundleArchive(name, scope, target, fetched.raw, fetched.etag, options);
374
+ });
375
+ }
376
+ /**
377
+ * Re-check an installed bundle plugin against its endpoint. A `conditional`
378
+ * check sends the validator stored for the package currently on disk, so an
379
+ * unchanged package is answered 304 and left untouched; an unconditional one
380
+ * asks for the archive outright, which is how recovery from a `manifest_stale`
381
+ * answer gets past a validator that is itself out of date.
382
+ *
383
+ * Returns the swap's report, or `undefined` when the package on disk is already
384
+ * the generation the server serves — whether the server said so with a 304 or
385
+ * we proved it by hashing bytes it re-sent.
386
+ *
387
+ * Every outcome, including a failure, records that the check happened, so an
388
+ * unreachable endpoint costs one timed-out probe per TTL rather than one per
389
+ * command. Failures throw; the caller decides whether a stale-but-present
390
+ * package is still good enough to run on.
391
+ */
392
+ export async function revalidateBundlePlugin(name, bundleSource, scope, options) {
393
+ return withBundleSwapLock(name, scope, REVALIDATE_SWAP_WAIT_MS, async (scopeRootPath) => {
394
+ try {
395
+ const target = resolveBundleTarget(name, bundleSource, scope, scopeRootPath);
396
+ // Read INSIDE the lock: a process that queued behind another's swap must
397
+ // send the validator that swap stored, not the one it read before waiting.
398
+ const etag = options.conditional ? readState(scope).plugins[name]?.etag : undefined;
399
+ const fetchOptions = {
400
+ ...(etag !== undefined ? { ifNoneMatch: etag } : {}),
401
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
402
+ };
403
+ const fetched = await fetchHttpPluginBundle(fetchTarget(name, target.bundle), fetchOptions);
404
+ if (fetched.status === 304)
405
+ return undefined;
406
+ if (fetched.status !== 200) {
407
+ throw general(`HTTP plugin bundle revalidation failed for "${name}": ${fetched.message}`);
408
+ }
409
+ // A server with no validator, or one whose validator moved without the
410
+ // archive changing, re-sends bytes we already have unpacked. Swapping
411
+ // them in would rewrite the package root and republish an identical
412
+ // command tree for nothing, so hash first: identical bytes are a 304 that
413
+ // cost a download, and get a 304's answer.
414
+ const version = bundleVersion(fetched.raw);
415
+ if (isInstalledGeneration(name, scope, target.root, version)) {
416
+ recordCheckedGeneration(scope, name, fetched.etag, { swapped: false });
417
+ return undefined;
418
+ }
419
+ return await applyBundleArchive(name, scope, target, fetched.raw, fetched.etag, options, version);
420
+ }
421
+ finally {
422
+ // Widened to cover `resolveBundleTarget` as well as the fetch: a plugin
423
+ // whose endpoint no longer validates throws on every invocation, and
424
+ // without a recorded check it would throw on every invocation forever.
425
+ recordPluginCheck(scope, name);
426
+ }
427
+ });
428
+ }
429
+ /** Mark that the revalidation pass reached this plugin, whatever came of it.
430
+ * Separate from the etag write, which only a served archive earns. */
431
+ function recordPluginCheck(scope, name) {
432
+ updateState(scope, (state) => {
433
+ if (state.plugins[name] === undefined)
434
+ state.plugins[name] = {};
435
+ state.plugins[name].checked_at = nowIso();
436
+ });
437
+ }
438
+ /** The ledger for a check that reached a served archive. `swapped` says whether
439
+ * that archive replaced the package on disk; the validator and the check time
440
+ * are recorded either way, because both describe the bytes now at the root. */
441
+ function recordCheckedGeneration(scope, name, etag, options) {
442
+ updateState(scope, (state) => {
443
+ if (state.plugins[name] === undefined)
444
+ state.plugins[name] = {};
445
+ if (options.swapped)
446
+ state.plugins[name].last_updated = nowIso();
447
+ state.plugins[name].checked_at = nowIso();
448
+ // The validator names the bytes now unpacked at `root`. A response that
449
+ // carried none clears it, so the next check cannot claim to hold a
450
+ // validator for a package it never received one for.
451
+ if (etag === undefined)
452
+ delete state.plugins[name].etag;
453
+ else
454
+ state.plugins[name].etag = etag;
455
+ });
456
+ }
457
+ /** The hash recorded as a bundle package's `version`: the exact archive bytes
458
+ * unpacked at its root. Same bytes, same version — which is what lets a
459
+ * re-sent archive be recognized as the generation already installed. */
460
+ function bundleVersion(raw) {
461
+ return createHash('sha256').update(raw).digest('hex').slice(0, 12);
462
+ }
463
+ /** Whether `version` is the generation already unpacked at `root`. Both halves
464
+ * matter: the recorded version says the config agrees, and `commands.json`
465
+ * says the tree that config describes is actually on disk. A swap that died
466
+ * between the rename and the config write leaves the second false, and that is
467
+ * exactly the case a no-op must not claim. */
468
+ function isInstalledGeneration(name, scope, root, version) {
469
+ if (readConfig(scope).plugins[name]?.version !== version)
470
+ return false;
471
+ return pathExists(join(root, 'commands.json'));
472
+ }
473
+ async function applyBundleArchive(name, scope, target, raw, etag, options, knownVersion) {
474
+ const { bundle, transport, scopeRootPath, root } = target;
475
+ const validated = await validatePluginBundle(raw, { reservedCoreNames: new Set(SUBTREE_NAMES), coreCommandPaths: await coreCommandPaths() });
337
476
  if (validated.bundle === undefined)
338
477
  throw invalidBundleError(`HTTP plugin bundle for "${name}" is invalid`, validated.issues);
339
- const version = createHash('sha256').update(fetched.raw).digest('hex').slice(0, 12);
478
+ const version = knownVersion ?? bundleVersion(raw);
340
479
  const manifest = {
341
480
  name,
342
481
  version,
@@ -388,15 +527,19 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
388
527
  finally {
389
528
  removePath(staging);
390
529
  }
530
+ // Directory swap, then the config's version, then the state's validator — in
531
+ // that order, and never the reverse. A process killed mid-sequence leaves the
532
+ // ledger naming an OLDER generation than the disk, which the next check
533
+ // repairs: the server 200s against a stale-or-absent validator, the hash says
534
+ // the tree on disk is already that generation, and only the ledger is
535
+ // rewritten. The forbidden state — a validator for a generation the disk does
536
+ // not have, and so a permanent 304 against a package that was never unpacked
537
+ // — is unreachable from this order.
391
538
  ensureScopeInitialized(scope, scopeRootPath);
392
539
  updateConfig(scope, (cfg) => {
393
540
  cfg.plugins[name] = { enabled: options.enable ? true : (cfg.plugins[name]?.enabled ?? true), version };
394
541
  });
395
- updateState(scope, (state) => {
396
- if (state.plugins[name] === undefined)
397
- state.plugins[name] = {};
398
- state.plugins[name].last_updated = nowIso();
399
- });
542
+ recordCheckedGeneration(scope, name, etag, { swapped: true });
400
543
  const commands = await commandReport(name, scope);
401
544
  if (commands === undefined)
402
545
  throw general(`bundle plugin "${name}" has no staged command report`);
@@ -838,6 +981,13 @@ export const pluginRemove = defineLeaf({
838
981
  updateConfig(scope, (cfg) => {
839
982
  delete cfg.plugins[name];
840
983
  });
984
+ // The state record describes a package that no longer exists. Leaving it
985
+ // would hand a later reinstall of the same name a validator for bytes it
986
+ // never fetched, and keep the revalidation pass considering a plugin that
987
+ // is gone.
988
+ updateState(scope, (state) => {
989
+ delete state.plugins[name];
990
+ });
841
991
  removedFrom.push(scope);
842
992
  }
843
993
  if (removedFrom.length === 0) {
@@ -0,0 +1,355 @@
1
+ // Contract-seam tests for bundle-plugin revalidation against a stub endpoint.
2
+ // Run with: npm run test:integration
3
+ //
4
+ // Two behaviors whose absence would be SILENT — the package on disk would still
5
+ // be a complete, valid command tree, so nothing fails loudly while the client
6
+ // dispatches against a description the server retired:
7
+ //
8
+ // 1. Round trip — an install stores the served validator; a later check sends
9
+ // it conditionally, leaves the package untouched on 304, and swaps it on a
10
+ // 200 that carries different bytes.
11
+ // 2. Refetch on miss — the forced refetch a `manifest_stale` answer triggers
12
+ // reports whether the package actually CHANGED. That boolean is the whole
13
+ // branch: false means the operation is genuinely gone and the server's own
14
+ // error stands, true means the retry has a fresher tree to parse against.
15
+ // 3. Recovery from a RENAMED op, end to end and entirely off the wire: the
16
+ // stub answers the v1 op with a real 400 error envelope carrying
17
+ // `manifest_stale`, so the flag is parsed by the transport rather than
18
+ // hand-built. `runCli` renders every other failure itself, so recovery is
19
+ // unreachable unless this one class escapes it — and from the outside a
20
+ // rendered error looks identical to a recovered one. Its three legs: the
21
+ // escape, the refetch-and-replay, and exactly-once.
22
+ //
23
+ // All run against an isolated HOME and a startDir with no `.crouter` ancestor,
24
+ // so no ambient plugin or scope of the developer's machine is reachable.
25
+ import { test, describe, before, beforeEach, afterEach, after } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+ import { mkdtempSync, writeFileSync, rmSync, readFileSync, realpathSync } from 'node:fs';
28
+ import { createServer } from 'node:http';
29
+ import { tmpdir } from 'node:os';
30
+ import { join } from 'node:path';
31
+ import * as tar from 'tar';
32
+ import { resetScopeCache, userScopeRoot } from '../../scope.js';
33
+ import { readState, readConfig } from '../../config.js';
34
+ import { revalidateBundlePlugins, forceRevalidateBundlePlugin } from '../../command-plugins/revalidate.js';
35
+ import { pluginInstall } from '../../../commands/pkg/plugin-manage.js';
36
+ import { runCli } from '../../command.js';
37
+ import { buildRoot } from '../../../build-root.js';
38
+ import { dispatchWithStaleRecovery } from '../../manifest-recovery.js';
39
+ import { CrtrError } from '../../errors.js';
40
+ const PLUGIN = 'revalidate-fixture';
41
+ const ARGV = ['node', 'crtr', 'demo', 'show'];
42
+ /** The positional the fixture leaf echoes back. */
43
+ const FIXTURE_ID = 'fixture-1';
44
+ let isolatedHome;
45
+ let isolatedCwd;
46
+ let prevHome;
47
+ let prevTtl;
48
+ let prevCwd;
49
+ let server;
50
+ let endpoint;
51
+ /** What the stub serves right now, and what every BUNDLE request asked for. */
52
+ let served;
53
+ let requests;
54
+ /** Which generations of the `demo show` op this backend has retired. A call
55
+ * against a retired one gets the 400 + `manifest_stale` envelope that drives
56
+ * client recovery; any other generation is served normally. */
57
+ let retiredOps;
58
+ /** Every generation the leaf was actually invoked at, in order — `['v1','v2']`
59
+ * is one recovery: the stale call, then the replay against the fresh tree. */
60
+ let opCalls;
61
+ before(() => {
62
+ prevHome = process.env.HOME;
63
+ prevTtl = process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS;
64
+ prevCwd = process.cwd();
65
+ });
66
+ beforeEach(async () => {
67
+ isolatedHome = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-revalidate-home-')));
68
+ isolatedCwd = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-revalidate-cwd-')));
69
+ process.env.HOME = isolatedHome;
70
+ process.chdir(isolatedCwd);
71
+ resetScopeCache();
72
+ served = { archive: makeArchive('v1'), etag: '"sha256-v1"' };
73
+ requests = [];
74
+ retiredOps = new Set();
75
+ opCalls = [];
76
+ server = createServer((req, res) => {
77
+ const path = (req.url ?? '/').split('?')[0] ?? '/';
78
+ const op = /^\/(v\d+)\/demo\/(.+)$/.exec(path);
79
+ if (op !== null) {
80
+ serveOp(res, op[1], op[2]);
81
+ return;
82
+ }
83
+ if (path !== '/v1/cli/bundle') {
84
+ res.writeHead(404, { 'Content-Type': 'application/json' });
85
+ res.end(JSON.stringify({ error: { code: 'not_found', message: `no route for ${path}` } }));
86
+ return;
87
+ }
88
+ const ifNoneMatch = typeof req.headers['if-none-match'] === 'string' ? req.headers['if-none-match'] : undefined;
89
+ if (ifNoneMatch === served.etag) {
90
+ requests.push({ ifNoneMatch, status: 304 });
91
+ res.writeHead(304, { ETag: served.etag, 'Cache-Control': 'private, no-cache' });
92
+ res.end();
93
+ return;
94
+ }
95
+ requests.push({ ifNoneMatch, status: 200 });
96
+ res.writeHead(200, { 'Content-Type': 'application/x-tar', ETag: served.etag, 'Cache-Control': 'private, no-cache' });
97
+ res.end(Buffer.from(served.archive));
98
+ });
99
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
100
+ const address = server.address();
101
+ if (address === null || typeof address === 'string')
102
+ throw new Error('stub server has no TCP address');
103
+ endpoint = `http://127.0.0.1:${address.port}/v1/cli/bundle`;
104
+ });
105
+ afterEach(async () => {
106
+ await new Promise((resolve) => server.close(() => resolve()));
107
+ process.chdir(prevCwd);
108
+ rmSync(isolatedHome, { recursive: true, force: true });
109
+ rmSync(isolatedCwd, { recursive: true, force: true });
110
+ });
111
+ after(() => {
112
+ process.chdir(prevCwd);
113
+ process.env.HOME = prevHome;
114
+ if (prevTtl === undefined)
115
+ delete process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS;
116
+ else
117
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = prevTtl;
118
+ resetScopeCache();
119
+ });
120
+ /** The `demo show` leaf, answered at whichever generation the caller's tree
121
+ * parsed the path from. A retired generation gets a real error envelope with
122
+ * `manifest_stale` set, so the flag reaches the client through the transport's
123
+ * own JSON parse rather than being constructed in the test. */
124
+ function serveOp(res, generation, id) {
125
+ opCalls.push(generation);
126
+ if (retiredOps.has(generation)) {
127
+ res.writeHead(400, { 'Content-Type': 'application/json' });
128
+ res.end(JSON.stringify({
129
+ error: {
130
+ code: 'cli_op_unknown',
131
+ message: `this backend no longer serves "demo show" at ${generation}`,
132
+ manifest_stale: true,
133
+ next: 'Refetch the plugin package and re-run.',
134
+ },
135
+ }));
136
+ return;
137
+ }
138
+ res.writeHead(200, { 'Content-Type': 'application/json' });
139
+ res.end(JSON.stringify({ id }));
140
+ }
141
+ /** One archive per generation. `marker` rides the leaf description AND the REST
142
+ * path its leaf calls, so the tree unpacked on disk says which generation
143
+ * produced it, the two generations are different bytes with different
144
+ * validators, and a call made from the OLD tree is distinguishable on the wire
145
+ * from the same call made after a swap — which is exactly what a renamed op
146
+ * looks like to a backend. */
147
+ function makeArchive(marker) {
148
+ const commands = {
149
+ schemaVersion: 1,
150
+ mounts: [
151
+ {
152
+ parent: [],
153
+ node: {
154
+ kind: 'branch',
155
+ name: 'demo',
156
+ description: `demo commands (${marker})`,
157
+ whenToUse: 'you are exercising the revalidation seam',
158
+ rootEntry: {
159
+ concept: 'a fixture command tree served by the stub endpoint',
160
+ description: `demo commands (${marker})`,
161
+ whenToUse: 'you are exercising the revalidation seam',
162
+ },
163
+ summary: `demo commands (${marker})`,
164
+ model: 'A fixture tree with one read-only leaf.',
165
+ children: [
166
+ {
167
+ kind: 'leaf',
168
+ name: 'show',
169
+ description: `show the fixture (${marker})`,
170
+ whenToUse: 'inspect the fixture record',
171
+ tier: 'important',
172
+ summary: `show the fixture record (${marker})`,
173
+ params: [{ kind: 'positional', name: 'id', required: true, constraint: 'the fixture id' }],
174
+ output: [{ name: 'id', type: 'string', required: true, constraint: 'the echoed fixture id' }],
175
+ effects: ['None. Read-only.'],
176
+ rest: { method: 'GET', path: `/${marker}/demo/{id}`, params: { id: { in: 'path' } } },
177
+ },
178
+ ],
179
+ },
180
+ },
181
+ ],
182
+ };
183
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-revalidate-tar-'));
184
+ try {
185
+ writeFileSync(join(dir, 'bundle.json'), JSON.stringify({ bundleVersion: 1 }));
186
+ writeFileSync(join(dir, 'commands.json'), JSON.stringify(commands));
187
+ const pack = tar.create({ sync: true, cwd: dir, portable: true }, ['bundle.json', 'commands.json']);
188
+ const chunks = [];
189
+ let chunk;
190
+ while ((chunk = pack.read()) !== null)
191
+ chunks.push(chunk);
192
+ return new Uint8Array(Buffer.concat(chunks));
193
+ }
194
+ finally {
195
+ rmSync(dir, { recursive: true, force: true });
196
+ }
197
+ }
198
+ async function install() {
199
+ await pluginInstall.run({ endpoint, name: PLUGIN, scope: 'user' });
200
+ }
201
+ /** The generation of the tree actually unpacked under the user scope. */
202
+ function installedMarker() {
203
+ const path = join(userScopeRoot(), 'plugins', PLUGIN, 'commands.json');
204
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
205
+ const description = parsed.mounts[0].node.description;
206
+ return description.slice(description.indexOf('(') + 1, description.indexOf(')'));
207
+ }
208
+ function pluginState() {
209
+ return readState('user').plugins[PLUGIN] ?? {};
210
+ }
211
+ function installedVersion() {
212
+ return readConfig('user').plugins[PLUGIN]?.version;
213
+ }
214
+ /** Age out the TTL without waiting one out: the pass measures freshness from
215
+ * `checked_at`, so a one-millisecond TTL plus a real pause is due. */
216
+ async function makeDue() {
217
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = '1';
218
+ await new Promise((resolve) => setTimeout(resolve, 5));
219
+ }
220
+ /** Run a dispatch and collect what the caller would actually have observed:
221
+ * what reached stdout (both rendered results and rendered errors go there) and
222
+ * whether anything escaped the frame. `process.exitCode` is restored, since a
223
+ * rendered error sets it and would otherwise fail the whole test process. */
224
+ async function captured(run) {
225
+ const chunks = [];
226
+ const realWrite = process.stdout.write.bind(process.stdout);
227
+ const realExitCode = process.exitCode;
228
+ process.stdout.write = ((chunk) => {
229
+ chunks.push(String(chunk));
230
+ return true;
231
+ });
232
+ let thrown;
233
+ try {
234
+ await run();
235
+ }
236
+ catch (error) {
237
+ thrown = error;
238
+ }
239
+ finally {
240
+ process.stdout.write = realWrite;
241
+ process.exitCode = realExitCode;
242
+ }
243
+ return { stdout: chunks.join(''), thrown };
244
+ }
245
+ describe('bundle plugin revalidation', () => {
246
+ test('an install fills the cache; an unchanged bundle 304s and a changed one swaps the package', async () => {
247
+ await install();
248
+ // The install IS the first cache fill: unconditional, and it stores what
249
+ // the server named for the bytes it unpacked.
250
+ assert.deepEqual(requests, [{ ifNoneMatch: undefined, status: 200 }]);
251
+ assert.equal(pluginState().etag, '"sha256-v1"');
252
+ assert.ok(pluginState().checked_at !== undefined);
253
+ assert.equal(installedMarker(), 'v1');
254
+ const firstVersion = installedVersion();
255
+ // Inside the TTL there is nothing to do, and nothing is asked.
256
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = '600000';
257
+ await revalidateBundlePlugins(ARGV);
258
+ assert.equal(requests.length, 1);
259
+ // Due, unchanged: the request carries the stored validator, the server
260
+ // answers 304, and the package on disk is left exactly as it was.
261
+ await makeDue();
262
+ const beforeCheck = pluginState().checked_at;
263
+ await revalidateBundlePlugins(ARGV);
264
+ assert.deepEqual(requests[1], { ifNoneMatch: '"sha256-v1"', status: 304 });
265
+ assert.equal(installedMarker(), 'v1');
266
+ assert.equal(installedVersion(), firstVersion);
267
+ assert.equal(pluginState().etag, '"sha256-v1"');
268
+ assert.notEqual(pluginState().checked_at, beforeCheck);
269
+ // Due, changed: the 200 swaps the package, and the new validator replaces
270
+ // the old one so the next check is conditional on what is now on disk.
271
+ served = { archive: makeArchive('v2'), etag: '"sha256-v2"' };
272
+ await makeDue();
273
+ await revalidateBundlePlugins(ARGV);
274
+ assert.deepEqual(requests[2], { ifNoneMatch: '"sha256-v1"', status: 200 });
275
+ assert.equal(installedMarker(), 'v2');
276
+ assert.notEqual(installedVersion(), firstVersion);
277
+ assert.equal(pluginState().etag, '"sha256-v2"');
278
+ });
279
+ test('a forced refetch reports whether the package changed, ignoring a fresh TTL', async () => {
280
+ await install();
281
+ // A TTL the ordinary pass would honor. The forced path must not.
282
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = '600000';
283
+ // Nothing newer is served: the operation the caller invoked is genuinely
284
+ // gone, so the refetch reports no change and the server's error stands.
285
+ assert.equal(await forceRevalidateBundlePlugin(PLUGIN), false);
286
+ assert.deepEqual(requests[1], { ifNoneMatch: undefined, status: 200 });
287
+ assert.equal(installedMarker(), 'v1');
288
+ // A changed package: the retry has a fresher tree to re-parse against.
289
+ served = { archive: makeArchive('v2'), etag: '"sha256-v2"' };
290
+ assert.equal(await forceRevalidateBundlePlugin(PLUGIN), true);
291
+ assert.equal(installedMarker(), 'v2');
292
+ assert.equal(pluginState().etag, '"sha256-v2"');
293
+ // A plugin that is not installed is not a recovery path.
294
+ assert.equal(await forceRevalidateBundlePlugin('absent-plugin'), false);
295
+ });
296
+ test('the dispatcher lets a manifest_stale answer off the wire escape, and renders every other failure', async () => {
297
+ await install();
298
+ // The pass must not fire underneath these legs: every fetch below is one a
299
+ // test asked for.
300
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = '600000';
301
+ retiredOps.add('v1');
302
+ const root = await buildRoot();
303
+ const stale = await captured(() => runCli(root, [...ARGV, FIXTURE_ID]));
304
+ // Rethrown, not rendered: nothing printed, and the frame above holds the
305
+ // error with the two details recovery needs.
306
+ assert.equal(stale.stdout, '');
307
+ const thrown = stale.thrown;
308
+ assert.ok(thrown instanceof CrtrError, String(thrown));
309
+ assert.equal(thrown.details?.['manifest_stale'], true);
310
+ assert.equal(thrown.details?.['plugin'], PLUGIN);
311
+ assert.deepEqual(opCalls, ['v1']);
312
+ // Every other failure stays the dispatcher's to report. The same leaf,
313
+ // missing its required positional, is rendered rather than thrown.
314
+ const usage = await captured(() => runCli(root, ARGV));
315
+ assert.equal(usage.thrown, undefined);
316
+ assert.ok(usage.stdout.includes('id'), usage.stdout);
317
+ });
318
+ test('a stale answer refetches the package and re-runs the ORIGINAL argv against the refreshed tree', async () => {
319
+ await install();
320
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = '600000';
321
+ // The backend has moved the op on: it retired the generation this client
322
+ // installed, and now serves a package whose leaf calls the new path.
323
+ retiredOps.add('v1');
324
+ served = { archive: makeArchive('v2'), etag: '"sha256-v2"' };
325
+ const root = await buildRoot();
326
+ const run = await captured(() => dispatchWithStaleRecovery(root, [...ARGV, FIXTURE_ID]));
327
+ assert.equal(run.thrown, undefined);
328
+ // The stale call, one forced bundle fetch, then the replay at the NEW
329
+ // generation — which only the refreshed tree could have addressed.
330
+ assert.deepEqual(opCalls, ['v1', 'v2']);
331
+ assert.deepEqual(requests, [
332
+ { ifNoneMatch: undefined, status: 200 }, // the install
333
+ { ifNoneMatch: undefined, status: 200 }, // the forced refetch
334
+ ]);
335
+ assert.equal(installedMarker(), 'v2');
336
+ assert.ok(run.stdout.includes(FIXTURE_ID), run.stdout);
337
+ });
338
+ test('a second stale answer is rendered rather than refetched again', async () => {
339
+ await install();
340
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = '600000';
341
+ // A backend that keeps answering `manifest_stale` however fresh the package
342
+ // is. The refetch genuinely changes the tree, so the retry does happen —
343
+ // and its own stale answer must end the cycle, not restart it.
344
+ retiredOps = new Set(['v1', 'v2']);
345
+ served = { archive: makeArchive('v2'), etag: '"sha256-v2"' };
346
+ const root = await buildRoot();
347
+ const run = await captured(() => dispatchWithStaleRecovery(root, [...ARGV, FIXTURE_ID]));
348
+ assert.equal(run.thrown, undefined);
349
+ assert.deepEqual(opCalls, ['v1', 'v2']);
350
+ assert.equal(requests.length, 2, 'the install plus exactly ONE forced refetch');
351
+ assert.equal(installedMarker(), 'v2');
352
+ // The backend's own second complaint is the answer the caller gets.
353
+ assert.ok(run.stdout.includes('no longer serves'), run.stdout);
354
+ });
355
+ });