@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.
- 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/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/types.d.ts +7 -0
- package/package.json +2 -1
- 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
|
-
|
|
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 {
|
|
2
|
-
export
|
|
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
|
-
|
|
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
|
|
14
|
-
*
|
|
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
|
|
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:
|
|
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 (
|
|
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
|
});
|
package/dist/core/command.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/core/command.js
CHANGED
|
@@ -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
|
-
|
|
611
|
-
if (jsonStripped.length !== rawTokens.length)
|
|
619
|
+
if (rawTokens.includes('--json'))
|
|
612
620
|
setJsonOutput(true);
|
|
613
|
-
const tokens =
|
|
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>;
|