@north-light/crouter 0.3.281 → 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 (32) 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/command-manifests/registry.d.ts +3 -1
  11. package/dist/core/command-manifests/schema.d.ts +2 -75
  12. package/dist/core/command-manifests/schema.js +5 -0
  13. package/dist/core/command-plugins/compose.js +1 -1
  14. package/dist/core/command-plugins/revalidate.d.ts +18 -0
  15. package/dist/core/command-plugins/revalidate.js +152 -0
  16. package/dist/core/command-plugins/transport/http-fetch.d.ts +29 -4
  17. package/dist/core/command-plugins/transport/http-fetch.js +18 -8
  18. package/dist/core/command-plugins/transport/http-invoke.js +7 -0
  19. package/dist/core/command.d.ts +8 -0
  20. package/dist/core/command.js +19 -3
  21. package/dist/core/exclusive-lock.d.ts +12 -0
  22. package/dist/core/exclusive-lock.js +29 -0
  23. package/dist/core/installed-plugins.js +31 -0
  24. package/dist/core/manifest-recovery.d.ts +15 -0
  25. package/dist/core/manifest-recovery.js +72 -0
  26. package/dist/core/manifest-stale.d.ts +18 -0
  27. package/dist/core/manifest-stale.js +39 -0
  28. package/dist/core/plugin-swap-lock.d.ts +9 -0
  29. package/dist/core/plugin-swap-lock.js +31 -0
  30. package/dist/types.d.ts +7 -0
  31. package/package.json +2 -1
  32. package/runtime.lock.json +2 -2
@@ -24,7 +24,9 @@ export type LeafAdapter = (leaf: DeclLeafBase, commandPath: readonly string[]) =
24
24
  * will produce its leaves' run implementations. */
25
25
  export interface CommandContribution {
26
26
  contributor: CommandContributorRef;
27
- node: DeclBranch<DeclLeafBase>;
27
+ /** Every leaf below this branch carries its transport dialect — a validated
28
+ * contribution is never a bare {@link DeclLeafBase}. */
29
+ node: DeclBranch;
28
30
  /** Root name from the manifest, before collision qualification. */
29
31
  manifestName: string;
30
32
  adaptLeaf: LeafAdapter;
@@ -1,77 +1,5 @@
1
- import type { InputParam, Field } from '../help.js';
2
- export interface DeclRootEntry {
3
- concept: string;
4
- description: string;
5
- whenToUse: string;
6
- }
7
- export interface DeclBranch<L = DeclLeafBase> {
8
- kind: 'branch';
9
- name: string;
10
- description: string;
11
- whenToUse: string;
12
- tier?: 'normal' | 'common' | 'important';
13
- /** Required on a top-level branch, forbidden on a nested one. */
14
- rootEntry?: DeclRootEntry;
15
- /** Allows the nearest repository fragment to contribute children below this top-level branch. */
16
- extensible?: true;
17
- summary: string;
18
- model?: string;
19
- /** Exec transport only: forward every argv token after this branch to an
20
- * external binary instead of parsing children. A passthrough branch is
21
- * childless by construction. HTTP manifests reject passthrough because an
22
- * HTTP transport must not name a local binary to execute. */
23
- passthrough?: DeclPassthrough;
24
- children: DeclNode<L>[];
25
- }
26
- export interface DeclPassthrough {
27
- bin: string;
28
- installHint: string;
29
- }
30
- export interface DeclLeafBase {
31
- kind: 'leaf';
32
- name: string;
33
- description: string;
34
- whenToUse: string;
35
- tier?: 'normal' | 'common' | 'important';
36
- summary: string;
37
- params: InputParam[];
38
- output: Field[];
39
- effects: string[];
40
- }
41
- /** Exec-transport leaf: requires outputKind: 'object'. */
42
- export interface ExecDeclLeaf extends DeclLeafBase {
43
- outputKind: 'object';
44
- }
45
- /** HTTP-transport plugin REST mapping (placeholder types; full spec in manifest.ts). */
46
- export type RestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
47
- export type RestParamPlacement = 'path' | 'query' | 'body' | 'header';
48
- export interface RestParamMapping {
49
- in: RestParamPlacement;
50
- as?: string;
51
- }
52
- export interface RestMapping {
53
- method: RestMethod;
54
- path: string;
55
- streaming?: boolean;
56
- /** Constant literal body fields merged into the request body (top-level, alongside
57
- * any bodyRoot-nested param values). Forbidden on GET. */
58
- body?: Record<string, string | number | boolean>;
59
- /** When set, all in:"body" param values nest under this key instead of the body
60
- * top level (constants from `body` stay top-level regardless). Forbidden on GET. */
61
- bodyRoot?: string;
62
- params: Record<string, RestParamMapping>;
63
- }
64
- export interface ManifestTimeouts {
65
- connectMs?: number;
66
- requestMs?: number;
67
- streamIdleMs?: number;
68
- }
69
- /** HTTP-transport leaf: requires rest (derives outputKind from streaming). */
70
- export interface HttpDeclLeaf extends DeclLeafBase {
71
- rest: RestMapping;
72
- }
73
- export type DeclLeaf = ExecDeclLeaf | HttpDeclLeaf;
74
- export type DeclNode<L = DeclLeafBase> = DeclBranch<L> | (L extends DeclLeafBase ? DeclLeaf : never);
1
+ import type { ManifestRootEntry as DeclRootEntry, ManifestPassthrough as DeclPassthrough, ManifestLeafBase as DeclLeafBase, ManifestExecLeaf as ExecDeclLeaf, ManifestHttpLeaf as HttpDeclLeaf, ManifestLeaf as DeclLeaf, ManifestBranch as DeclBranch, ManifestNode as DeclNode, RestMethod, RestParamPlacement, RestParamMapping, RestMapping, ManifestTimeouts } from '../../api/plugin-manifest-schema.js';
2
+ export type { DeclRootEntry, DeclPassthrough, DeclLeafBase, ExecDeclLeaf, HttpDeclLeaf, DeclLeaf, DeclBranch, DeclNode, RestMethod, RestParamPlacement, RestParamMapping, RestMapping, ManifestTimeouts, };
75
3
  export interface CommandManifestIssue {
76
4
  code: CommandIssueCode;
77
5
  path?: string;
@@ -92,4 +20,3 @@ export interface CommandNodeValidationOptions {
92
20
  allowExtensible?: boolean;
93
21
  }
94
22
  export declare function validateCommandNode(raw: unknown, path: string[], topLevel: boolean, transport: TransportKind, issue: IssueFn, options?: CommandNodeValidationOptions): DeclBranch<DeclLeaf> | DeclLeaf | null;
95
- export {};
@@ -1,4 +1,9 @@
1
1
  // Unified command-manifest schema. Transport is the sole leaf-dialect discriminator.
2
+ //
3
+ // The SHAPE of a manifest is declared once, in `src/api/plugin-manifest-schema.ts`,
4
+ // and published as `@north-light/crouter-api/plugin-manifest` so a server that
5
+ // serves a plugin bundle compiles against the same types validated here. This
6
+ // module owns the validators and re-exports those types under their in-tree names.
2
7
  import { isRecord } from '../../shared/predicates.js';
3
8
  // Validation helpers
4
9
  const TIERS = new Set(['normal', 'common', 'important']);
@@ -43,5 +43,5 @@ function buildBranch(contribution, node, path) {
43
43
  }
44
44
  function buildLeaf(contribution, node, path) {
45
45
  const adapted = contribution.adaptLeaf(node, path);
46
- return defineLeaf({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), help: { name: path.join(' '), summary: node.summary, params: node.params, output: node.output, outputKind: adapted.outputKind, effects: node.effects }, run: adapted.run });
46
+ return defineLeaf({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), help: { name: path.join(' '), summary: node.summary, params: node.params, output: node.output, outputKind: adapted.outputKind, effects: [...node.effects] }, run: adapted.run });
47
47
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Bring every installed bundle plugin whose last check has aged out back in
3
+ * step with its endpoint. Never throws: a plugin that cannot be revalidated
4
+ * keeps the package already unpacked on disk, which is a complete and valid
5
+ * command tree, and the failure is reported on stderr rather than replacing the
6
+ * caller's command with an error about a background check.
7
+ */
8
+ export declare function revalidateBundlePlugins(argv: readonly string[]): Promise<void>;
9
+ /**
10
+ * Force one plugin back in step with its endpoint, ignoring both the TTL and
11
+ * the stored validator. Recovery from a server that answered `manifest_stale`:
12
+ * the description the caller invoked from is out of date, so the check that
13
+ * would normally be skipped is exactly the one that has to run.
14
+ *
15
+ * Returns whether the package on disk actually changed, so a caller can tell a
16
+ * recoverable staleness from an operation that is genuinely gone.
17
+ */
18
+ export declare function forceRevalidateBundlePlugin(name: string): Promise<boolean>;
@@ -0,0 +1,152 @@
1
+ // TTL-gated revalidation of installed bundle plugins.
2
+ //
3
+ // A bundle plugin's package is fetched once, at install. Without this pass a
4
+ // long-lived machine keeps invoking the command tree it installed on day one,
5
+ // so a leaf the server renamed fails from the client side with no way back
6
+ // except a human running `crtr pkg plugin update`.
7
+ //
8
+ // The pass runs before the command tree is built, so a replacement it performs
9
+ // is what this very invocation dispatches against. Being on that path, it is
10
+ // also bound by what it may DRAG there: this module reaches only the leaf-level
11
+ // scope, config, and installed-plugin readers — never `discovery.js`, which
12
+ // pulls the manifest validators, the registry, and the composer onto every
13
+ // single crtr invocation, nor `plugin-manage.js`, which pulls the whole package
14
+ // command graph. The machinery that fetches and swaps a package is imported
15
+ // only once a plugin is found due.
16
+ import { readState } from '../config.js';
17
+ import { listInstalledPluginsInRoot } from '../installed-plugins.js';
18
+ import { scopeRoot } from '../scope.js';
19
+ import { GLOBAL_TOKENS } from '../command.js';
20
+ const DEFAULT_TTL_MS = 15 * 60 * 1000;
21
+ /** Project first, so a project-scoped package shadows a user-scoped one of the
22
+ * same name exactly as the command tree resolves it. */
23
+ const SCOPES = ['project', 'user'];
24
+ /** How long a recorded check stands before the next invocation re-probes.
25
+ * `CRTR_PLUGIN_REVALIDATE_TTL_MS=0` disables the pass outright — which is how
26
+ * a test, or any process that must not reach the network, opts out. */
27
+ function ttlMs() {
28
+ const raw = process.env['CRTR_PLUGIN_REVALIDATE_TTL_MS'];
29
+ if (raw === undefined || raw.trim() === '')
30
+ return DEFAULT_TTL_MS;
31
+ const parsed = Number(raw);
32
+ if (!Number.isFinite(parsed) || parsed < 0)
33
+ return DEFAULT_TTL_MS;
34
+ return parsed;
35
+ }
36
+ /** A check is fresh for `ttl` after it happened. A `checked_at` in the FUTURE
37
+ * did not happen: a clock that jumped back, or a file copied from another
38
+ * machine, must not be able to suppress the pass until the clock catches up. */
39
+ function isFresh(checkedAt, ttl, now) {
40
+ if (checkedAt === undefined)
41
+ return false;
42
+ const at = Date.parse(checkedAt);
43
+ if (Number.isNaN(at))
44
+ return false;
45
+ const age = now - at;
46
+ return age >= 0 && age < ttl;
47
+ }
48
+ /** A command that manages packages does its own fetching, with its own reporting
49
+ * and its own exit codes. Revalidating underneath it would race that work and
50
+ * make the leaf's result describe a package it did not install. `crtr --json
51
+ * pkg …` is that same command: the global tokens the dispatcher strips are not
52
+ * part of the command path here either. */
53
+ function managesPackages(argv) {
54
+ return argv.slice(2).find((token) => !GLOBAL_TOKENS.has(token)) === 'pkg';
55
+ }
56
+ /** Every bundle plugin whose last check has aged out, resolved from the same
57
+ * scope root whose state ledger records the check — so the plugin read and the
58
+ * `checked_at` write can never describe different roots. */
59
+ function duePlugins(ttl, now) {
60
+ const due = [];
61
+ const claimed = new Set();
62
+ for (const scope of SCOPES) {
63
+ const root = scopeRoot(scope);
64
+ if (root === null)
65
+ continue;
66
+ const state = readState(scope);
67
+ for (const plugin of listInstalledPluginsInRoot(scope, root)) {
68
+ if (claimed.has(plugin.name))
69
+ continue;
70
+ claimed.add(plugin.name);
71
+ const bundle = plugin.manifest.bundle;
72
+ if (!plugin.enabled || bundle === undefined)
73
+ continue;
74
+ if (isFresh(state.plugins[plugin.name]?.checked_at, ttl, now))
75
+ continue;
76
+ due.push({ name: plugin.name, scope, bundle, enabled: plugin.enabled });
77
+ }
78
+ }
79
+ return due;
80
+ }
81
+ /**
82
+ * Bring every installed bundle plugin whose last check has aged out back in
83
+ * step with its endpoint. Never throws: a plugin that cannot be revalidated
84
+ * keeps the package already unpacked on disk, which is a complete and valid
85
+ * command tree, and the failure is reported on stderr rather than replacing the
86
+ * caller's command with an error about a background check.
87
+ */
88
+ export async function revalidateBundlePlugins(argv) {
89
+ const ttl = ttlMs();
90
+ if (ttl === 0 || managesPackages(argv))
91
+ return;
92
+ let due;
93
+ try {
94
+ due = duePlugins(ttl, Date.now());
95
+ }
96
+ catch {
97
+ // Enumeration reads config, state, and every installed plugin.json. A scope
98
+ // this process cannot read is a scope it also cannot revalidate, and the
99
+ // command it was invoked for may not need plugins at all.
100
+ return;
101
+ }
102
+ if (due.length === 0)
103
+ return;
104
+ const { revalidateBundlePlugin } = await import('../../commands/pkg/plugin-manage.js');
105
+ for (const plugin of due) {
106
+ try {
107
+ await revalidateBundlePlugin(plugin.name, plugin.bundle, plugin.scope, {
108
+ enable: plugin.enabled,
109
+ conditional: true,
110
+ timeoutMs: 2_000,
111
+ });
112
+ }
113
+ catch (error) {
114
+ const detail = error instanceof Error ? error.message : String(error);
115
+ process.stderr.write(`crtr: plugin "${plugin.name}" could not be revalidated (${detail}); using the installed package.\n`);
116
+ }
117
+ }
118
+ }
119
+ /**
120
+ * Force one plugin back in step with its endpoint, ignoring both the TTL and
121
+ * the stored validator. Recovery from a server that answered `manifest_stale`:
122
+ * the description the caller invoked from is out of date, so the check that
123
+ * would normally be skipped is exactly the one that has to run.
124
+ *
125
+ * Returns whether the package on disk actually changed, so a caller can tell a
126
+ * recoverable staleness from an operation that is genuinely gone.
127
+ */
128
+ export async function forceRevalidateBundlePlugin(name) {
129
+ let plugin;
130
+ for (const scope of SCOPES) {
131
+ const root = scopeRoot(scope);
132
+ if (root === null)
133
+ continue;
134
+ plugin = listInstalledPluginsInRoot(scope, root).find((candidate) => candidate.name === name);
135
+ if (plugin !== undefined)
136
+ break;
137
+ }
138
+ const bundle = plugin?.manifest.bundle;
139
+ if (plugin === undefined || bundle === undefined)
140
+ return false;
141
+ const { revalidateBundlePlugin } = await import('../../commands/pkg/plugin-manage.js');
142
+ // An unconditional refetch either swaps the package or reports that the bytes
143
+ // the server served are byte-identical to the ones already unpacked. Both
144
+ // answers are the swap's own, so `undefined` here means "nothing changed" —
145
+ // the operation the caller invoked is genuinely gone, and the server's error
146
+ // stands.
147
+ const replaced = await revalidateBundlePlugin(name, bundle, plugin.scope, {
148
+ enable: plugin.enabled,
149
+ conditional: false,
150
+ });
151
+ return replaced !== undefined;
152
+ }
@@ -3,14 +3,39 @@ import type { HttpPluginFetchTarget } from '../endpoint.js';
3
3
  export interface FetchSuccess {
4
4
  status: 200;
5
5
  raw: Uint8Array;
6
+ /** The server's validator for these exact bytes, when it sent one. Storing it
7
+ * is what lets the next fetch be conditional. */
8
+ etag?: string;
6
9
  }
7
- export type FetchResult = FetchSuccess | FetchFailure;
10
+ /** The archive the caller already holds is still current. Only ever returned
11
+ * when the caller supplied `ifNoneMatch`. */
12
+ export interface FetchNotModified {
13
+ status: 304;
14
+ }
15
+ export type FetchResult = FetchSuccess | FetchNotModified | FetchFailure;
8
16
  export interface FetchFailure {
9
17
  status: 'auth_env_missing' | 'cli_unreachable' | 'cli_protocol_error';
10
18
  message: string;
11
19
  }
20
+ export interface FetchOptions {
21
+ /** Send as `If-None-Match`, inviting the server to answer 304 instead of
22
+ * resending an archive the caller already has unpacked. */
23
+ ifNoneMatch?: string;
24
+ /** Inactivity timeout. Defaults to ten seconds, which suits an install the
25
+ * caller is waiting on; a revalidation on an ordinary command's latency path
26
+ * passes something far shorter. */
27
+ timeoutMs?: number;
28
+ }
12
29
  /**
13
- * One authenticated GET for a plugin directory archive. It has one ten-second
14
- * inactivity timeout and never retries or conditionally revalidates.
30
+ * One authenticated GET for a plugin directory archive, conditional when the
31
+ * caller supplies a validator. It never retries.
32
+ *
33
+ * A caller that sends no validator cannot be answered 304 — there is nothing
34
+ * for the server to compare against — and the overloads say so, so an
35
+ * unconditional caller handles the two outcomes it can actually get instead of
36
+ * carrying an unreachable branch for the third.
15
37
  */
16
- export declare function fetchHttpPluginBundle(registration: HttpPluginFetchTarget): Promise<FetchResult>;
38
+ export declare function fetchHttpPluginBundle(registration: HttpPluginFetchTarget, options?: FetchOptions & {
39
+ ifNoneMatch?: never;
40
+ }): Promise<FetchSuccess | FetchFailure>;
41
+ export declare function fetchHttpPluginBundle(registration: HttpPluginFetchTarget, options: FetchOptions): Promise<FetchResult>;
@@ -1,11 +1,7 @@
1
1
  import { URL } from 'node:url';
2
2
  import { request as httpsRequest } from 'node:https';
3
3
  import { request as httpRequest } from 'node:http';
4
- /**
5
- * One authenticated GET for a plugin directory archive. It has one ten-second
6
- * inactivity timeout and never retries or conditionally revalidates.
7
- */
8
- export async function fetchHttpPluginBundle(registration) {
4
+ export async function fetchHttpPluginBundle(registration, options = {}) {
9
5
  let token;
10
6
  if (registration.authEnv) {
11
7
  token = process.env[registration.authEnv];
@@ -26,20 +22,34 @@ export async function fetchHttpPluginBundle(registration) {
26
22
  message: `Invalid endpoint URL: ${registration.endpoint}`,
27
23
  };
28
24
  }
25
+ const timeoutMs = options.timeoutMs ?? 10_000;
29
26
  const headers = { Accept: 'application/x-tar' };
30
27
  if (token)
31
28
  headers['Authorization'] = `Bearer ${token}`;
29
+ if (options.ifNoneMatch !== undefined)
30
+ headers['If-None-Match'] = options.ifNoneMatch;
32
31
  return new Promise((resolve) => {
33
32
  const request = url.protocol === 'https:' ? httpsRequest : httpRequest;
34
- const req = request(url, { method: 'GET', headers, timeout: 10_000 }, (res) => {
33
+ const req = request(url, { method: 'GET', headers, timeout: timeoutMs }, (res) => {
35
34
  let body = Buffer.alloc(0);
36
35
  res.on('data', (chunk) => { body = Buffer.concat([body, chunk]); });
37
36
  res.on('end', () => {
37
+ const etag = res.headers['etag'];
38
38
  if (!res.statusCode) {
39
39
  resolve({ status: 'cli_protocol_error', message: 'No HTTP status code received.' });
40
40
  }
41
+ else if (res.statusCode === 304) {
42
+ // Unsolicited: the caller has no archive this could be affirming, so
43
+ // there is nothing on disk for a bare 304 to mean.
44
+ if (options.ifNoneMatch === undefined) {
45
+ resolve({ status: 'cli_protocol_error', message: `HTTP 304 from ${registration.endpoint} without a conditional request` });
46
+ }
47
+ else {
48
+ resolve({ status: 304 });
49
+ }
50
+ }
41
51
  else if (res.statusCode >= 200 && res.statusCode < 300) {
42
- resolve({ status: 200, raw: new Uint8Array(body) });
52
+ resolve({ status: 200, raw: new Uint8Array(body), ...(typeof etag === 'string' ? { etag } : {}) });
43
53
  }
44
54
  else {
45
55
  resolve({ status: 'cli_protocol_error', message: `HTTP ${res.statusCode} from ${registration.endpoint}` });
@@ -48,7 +58,7 @@ export async function fetchHttpPluginBundle(registration) {
48
58
  });
49
59
  req.on('timeout', () => {
50
60
  req.destroy();
51
- resolve({ status: 'cli_unreachable', message: `Request timeout (10s) fetching ${registration.endpoint}` });
61
+ resolve({ status: 'cli_unreachable', message: `Request timeout (${timeoutMs}ms) fetching ${registration.endpoint}` });
52
62
  });
53
63
  req.on('error', (error) => {
54
64
  resolve({ status: 'cli_unreachable', message: `Network error fetching ${registration.endpoint}: ${error.message}` });
@@ -17,6 +17,7 @@ import { request as httpRequest } from 'node:http';
17
17
  import { request as httpsRequest } from 'node:https';
18
18
  import { validateDeclaredResult } from './exec-invoke.js';
19
19
  import { CrtrError } from '../../errors.js';
20
+ import { envelopeClaimsStale, staleErrorDetails } from '../../manifest-stale.js';
20
21
  import { diag, recordPreviewError, recordPreviewJsonLine, writeStdout } from '../../io.js';
21
22
  import { ExitCode } from '../../../types.js';
22
23
  import { isRecord } from '../../../shared/predicates.js';
@@ -479,9 +480,15 @@ function throwNon2xx(spec, status, body) {
479
480
  const field = typeof err['field'] === 'string' ? err['field'] : undefined;
480
481
  const next = typeof err['next'] === 'string' ? err['next'] : `Inspect the backend response, then retry if appropriate.`;
481
482
  const accepted = SNAKE.test(code) && !RESERVED_CODES.has(code);
483
+ // A generic recovery hint, not a backend-specific code: the server is
484
+ // saying the command description this call was parsed from is out of
485
+ // date. The dispatcher acts on it by refetching the plugin's package and
486
+ // re-running the original argv against the refreshed tree.
487
+ const manifestStale = envelopeClaimsStale(err);
482
488
  throw new CrtrError(accepted ? code : 'cli_protocol_error', message, exit, {
483
489
  ...(err['received'] !== undefined ? { received: err['received'] } : {}),
484
490
  ...(field !== undefined ? { field } : {}),
491
+ ...(manifestStale ? staleErrorDetails(spec.registration.name) : {}),
485
492
  http_status: status,
486
493
  next,
487
494
  });
@@ -173,5 +173,13 @@ export interface ParseArgvOptions {
173
173
  * Returns a plain object whose keys are camelCase parameter names.
174
174
  * Optionally tracks which parameters were explicitly provided via a callback. */
175
175
  export declare function parseArgv(params: InputParam[], tokens: string[], options?: ParseArgvOptions): Promise<Record<string, unknown>>;
176
+ /**
177
+ * Tokens handled before dispatch and stripped from what the leaf schema parses
178
+ * (root `-h` renders them as the Globals footer). They belong to no command, so
179
+ * anything reasoning about which command an argv names — the plugin
180
+ * revalidation gate, for one — has to ignore them too. One declaration, so a
181
+ * new global cannot be added here and missed there.
182
+ */
183
+ export declare const GLOBAL_TOKENS: ReadonlySet<string>;
176
184
  export declare function runCli(root: RootDef, argv: string[]): Promise<void>;
177
185
  export {};
@@ -8,6 +8,7 @@ import { renderRoot, renderBranch, renderLeafArgv } from './help.js';
8
8
  import { beginPreview, publishPreviewResult, readStdinRaw, peekStdinRaw, emit, handle, setJsonOutput, isJsonOutput } from './io.js';
9
9
  import { renderResult } from './render.js';
10
10
  import { CrtrError } from './errors.js';
11
+ import { isManifestStaleError } from './manifest-stale.js';
11
12
  import { operationIdContext } from './events/operation-id.js';
12
13
  import { ExitCode } from '../types.js';
13
14
  import { readFileSync } from 'node:fs';
@@ -594,6 +595,14 @@ export async function parseArgv(params, tokens, options) {
594
595
  }
595
596
  return result;
596
597
  }
598
+ /**
599
+ * Tokens handled before dispatch and stripped from what the leaf schema parses
600
+ * (root `-h` renders them as the Globals footer). They belong to no command, so
601
+ * anything reasoning about which command an argv names — the plugin
602
+ * revalidation gate, for one — has to ignore them too. One declaration, so a
603
+ * new global cannot be added here and missed there.
604
+ */
605
+ export const GLOBAL_TOKENS = new Set(['--json', '--no-autostart']);
597
606
  export async function runCli(root, argv) {
598
607
  // argv is process.argv — strip node binary + script path. `--json` is a
599
608
  // global: pull it out anywhere it appears so the rest of argv parses against
@@ -607,10 +616,9 @@ export async function runCli(root, argv) {
607
616
  // to every API-backed verb, so strip it here rather than declaring it on each
608
617
  // leaf schema (an undeclared flag would otherwise be rejected as unknown).
609
618
  const rawTokens = argv.slice(2);
610
- const jsonStripped = rawTokens.filter((t) => t !== '--json');
611
- if (jsonStripped.length !== rawTokens.length)
619
+ if (rawTokens.includes('--json'))
612
620
  setJsonOutput(true);
613
- const tokens = jsonStripped.filter((t) => t !== '--no-autostart');
621
+ const tokens = rawTokens.filter((token) => !GLOBAL_TOKENS.has(token));
614
622
  // Bare root invocation or -h at root
615
623
  if (tokens.length === 0 || (tokens.length === 1 && (tokens[0] === '-h' || tokens[0] === '--help'))) {
616
624
  process.stdout.write(renderRoot(root.help) + '\n');
@@ -686,6 +694,14 @@ export async function runCli(root, argv) {
686
694
  // JSONL leaves call emitLine themselves and return void
687
695
  }
688
696
  catch (e) {
697
+ // A backend that answered `manifest_stale` says this dispatch was parsed
698
+ // from an out-of-date command description. Only the caller above this one
699
+ // can act on that — refetching the package and re-walking the ORIGINAL argv
700
+ // against the refreshed tree — so it is the single error class this
701
+ // dispatcher reports by rethrowing instead of rendering. Whoever declines
702
+ // to recover renders it with the same `handle`.
703
+ if (isManifestStaleError(e))
704
+ throw e;
689
705
  handle(e);
690
706
  }
691
707
  }
@@ -4,6 +4,18 @@
4
4
  * work" apart from "my child is queued behind someone else's work" without
5
5
  * any new bookkeeping. */
6
6
  export declare function exclusiveLockOwnerPid(path: string): number | null;
7
+ /**
8
+ * Block until `path` is no longer held by a LIVE other process, or the wait
9
+ * runs out. Unlike `withExclusiveDirectoryLock*` this never CLAIMS the lock —
10
+ * it is for a reader that only needs the holder's transition to be over before
11
+ * it looks at the directory the holder is rewriting.
12
+ *
13
+ * Returns immediately when the lock is free, when its marker names this very
14
+ * process (a caller waiting on its own lock would otherwise deadlock), or when
15
+ * the named owner is gone — a dead holder's transition is already over, however
16
+ * it ended.
17
+ */
18
+ export declare function awaitExclusiveLockRelease(path: string, timeoutMs: number): void;
7
19
  export interface ExclusiveLockOptions {
8
20
  /** Grace before a lock that names no owner at all is cleared. */
9
21
  staleMs?: number;
@@ -103,6 +103,35 @@ export function exclusiveLockOwnerPid(path) {
103
103
  const pid = Number(token.split('.', 1)[0]);
104
104
  return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
105
105
  }
106
+ /**
107
+ * Block until `path` is no longer held by a LIVE other process, or the wait
108
+ * runs out. Unlike `withExclusiveDirectoryLock*` this never CLAIMS the lock —
109
+ * it is for a reader that only needs the holder's transition to be over before
110
+ * it looks at the directory the holder is rewriting.
111
+ *
112
+ * Returns immediately when the lock is free, when its marker names this very
113
+ * process (a caller waiting on its own lock would otherwise deadlock), or when
114
+ * the named owner is gone — a dead holder's transition is already over, however
115
+ * it ended.
116
+ */
117
+ export function awaitExclusiveLockRelease(path, timeoutMs) {
118
+ const deadline = Date.now() + timeoutMs;
119
+ let pollMs = POLL_MS;
120
+ for (;;) {
121
+ const token = observedMarkerToken(path);
122
+ if (token === null)
123
+ return;
124
+ if (Number(token.split('.', 1)[0]) === process.pid)
125
+ return;
126
+ if (!ownerIsAlive(token))
127
+ return;
128
+ const remaining = deadline - Date.now();
129
+ if (remaining <= 0)
130
+ return;
131
+ pause(Math.min(pollMs, remaining));
132
+ pollMs = nextPollMs(pollMs);
133
+ }
134
+ }
106
135
  function tryAcquire(path, staleMs) {
107
136
  const token = `${process.pid}.${randomUUID()}`;
108
137
  try {
@@ -11,15 +11,46 @@ import { join } from 'node:path';
11
11
  import { CONFIG_FILE } from '../types.js';
12
12
  import { listDirs, pathExists, readJsonIfExists } from './fs-utils.js';
13
13
  import { readPluginManifest } from './manifest.js';
14
+ import { awaitBundleSwap, bundleSwapInFlightElsewhere } from './plugin-swap-lock.js';
14
15
  function pluginConfigForRoot(root) {
15
16
  const cfg = readJsonIfExists(join(root, CONFIG_FILE));
16
17
  return cfg && cfg.plugins && typeof cfg.plugins === 'object' ? cfg.plugins : {};
17
18
  }
19
+ /**
20
+ * A bundle-plugin swap replaces `<plugins>/<name>` with two renames, so there
21
+ * is a window in which the config names a plugin whose directory is not there.
22
+ * A list taken inside that window would drop a plugin that is installed and
23
+ * about to be present again — the caller would build a command tree missing it.
24
+ *
25
+ * That exact signal — named in config, absent on disk — is what we re-check,
26
+ * and only when another process's swap lock says a swap is actually running: a
27
+ * genuinely deleted directory holds no lock and is reported as gone at once.
28
+ * Every normal invocation, where each config-named plugin has its directory,
29
+ * pays one Set lookup per name and nothing else.
30
+ */
31
+ function swapsToWaitOut(scopeRootPath, cfg, present) {
32
+ const names = Object.keys(cfg);
33
+ if (names.every((name) => present.has(name)))
34
+ return [];
35
+ return names.filter((name) => !present.has(name) && bundleSwapInFlightElsewhere(scopeRootPath, name));
36
+ }
18
37
  export function listInstalledPluginsInRoot(scope, scopeRootPath) {
19
38
  const dir = join(scopeRootPath, 'plugins');
20
39
  if (!pathExists(dir))
21
40
  return [];
22
41
  const cfg = pluginConfigForRoot(scopeRootPath);
42
+ const first = collectInstalledPlugins(scope, dir, cfg);
43
+ const waiting = swapsToWaitOut(scopeRootPath, cfg, new Set(first.map((plugin) => plugin.name)));
44
+ if (waiting.length === 0)
45
+ return first;
46
+ for (const name of waiting)
47
+ awaitBundleSwap(scopeRootPath, name);
48
+ // One re-list, never a loop: the second read is taken after every swap we
49
+ // observed has finished. A swap that starts after it is a package this
50
+ // invocation was never going to see anyway.
51
+ return collectInstalledPlugins(scope, dir, cfg);
52
+ }
53
+ function collectInstalledPlugins(scope, dir, cfg) {
23
54
  const out = [];
24
55
  for (const name of listDirs(dir)) {
25
56
  const root = join(dir, name);
@@ -0,0 +1,15 @@
1
+ import type { RootDef } from './command.js';
2
+ /**
3
+ * Run `argv` against `root`, and if the backend answers `manifest_stale`,
4
+ * refetch that plugin's package and run the ORIGINAL argv again against a
5
+ * freshly resolved tree — exactly once.
6
+ *
7
+ * Re-parsing the original argv, rather than re-running the leaf that failed, is
8
+ * what makes a RENAMED verb recoverable: the old leaf no longer exists in the
9
+ * refreshed tree, and only the dispatcher can answer that against the fresh
10
+ * tree's own siblings.
11
+ *
12
+ * Never throws: every outcome is rendered through the dispatcher's own
13
+ * `handle`, so a caller can mark the end of dispatch unconditionally.
14
+ */
15
+ export declare function dispatchWithStaleRecovery(root: RootDef, argv: string[]): Promise<void>;