@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.
- package/dist/api/plugin-manifest-schema.d.ts +208 -0
- package/dist/api/plugin-manifest-schema.js +23 -0
- package/dist/cli.js +8 -2
- package/dist/clients/attach/viewer.js +533 -533
- package/dist/commands/pkg/browse/catalog.js +1 -1
- package/dist/commands/pkg/plugin-manage.d.ts +46 -1
- package/dist/commands/pkg/plugin-manage.js +170 -20
- package/dist/core/__tests__/integration/plugin-revalidate.test.d.ts +1 -0
- package/dist/core/__tests__/integration/plugin-revalidate.test.js +355 -0
- package/dist/core/__tests__/model-pin-durability.test.js +31 -3
- package/dist/core/command-manifests/registry.d.ts +3 -1
- package/dist/core/command-manifests/schema.d.ts +2 -75
- package/dist/core/command-manifests/schema.js +5 -0
- package/dist/core/command-plugins/compose.js +1 -1
- package/dist/core/command-plugins/revalidate.d.ts +18 -0
- package/dist/core/command-plugins/revalidate.js +152 -0
- package/dist/core/command-plugins/transport/http-fetch.d.ts +29 -4
- package/dist/core/command-plugins/transport/http-fetch.js +18 -8
- package/dist/core/command-plugins/transport/http-invoke.js +7 -0
- package/dist/core/command.d.ts +8 -0
- package/dist/core/command.js +19 -3
- package/dist/core/exclusive-lock.d.ts +12 -0
- package/dist/core/exclusive-lock.js +29 -0
- package/dist/core/installed-plugins.js +31 -0
- package/dist/core/manifest-recovery.d.ts +15 -0
- package/dist/core/manifest-recovery.js +72 -0
- package/dist/core/manifest-stale.d.ts +18 -0
- package/dist/core/manifest-stale.js +39 -0
- package/dist/core/plugin-swap-lock.d.ts +9 -0
- package/dist/core/plugin-swap-lock.js +31 -0
- package/dist/core/runtime/launch.d.ts +7 -2
- package/dist/core/runtime/launch.js +2 -1
- package/dist/daemon/api/__tests__/node-model-validation.test.js +15 -7
- package/dist/types.d.ts +7 -0
- package/package.json +2 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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 =
|
|
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
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|