@north-light/crouter 0.3.258 → 0.3.260
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/dto/attach.d.ts +6 -0
- package/dist/api/dto/nodes.d.ts +15 -0
- package/dist/clients/attach/command.js +3 -1
- package/dist/clients/attach/input/capabilities.d.ts +3 -0
- package/dist/clients/attach/input/capabilities.js +7 -3
- package/dist/clients/attach/input/controller.d.ts +1 -1
- package/dist/clients/attach/input/controller.js +31 -17
- package/dist/clients/attach/session/connection.d.ts +12 -3
- package/dist/clients/attach/session/connection.js +40 -25
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +6 -2
- package/dist/clients/attach/session/reconnect.d.ts +7 -3
- package/dist/clients/attach/session/reconnect.js +13 -2
- package/dist/clients/attach/slash/dispatch.js +1 -1
- package/dist/clients/attach/viewer.d.ts +1 -1
- package/dist/clients/attach/viewer.js +742 -742
- package/dist/commands/pkg/plugin-inspect.js +2 -1
- package/dist/commands/surface/node/cycle.js +2 -2
- package/dist/commands/surface/node/focus.js +11 -11
- package/dist/commands/surface/node/placement.js +14 -5
- package/dist/commands/sys/doctor.d.ts +35 -0
- package/dist/commands/sys/doctor.js +22 -2
- package/dist/core/__tests__/integration/command-plugins.test.js +317 -2
- package/dist/core/broker-client/client.d.ts +4 -5
- package/dist/core/broker-client/client.js +9 -5
- package/dist/core/canvas/browse/model.d.ts +3 -3
- package/dist/core/canvas/browse/model.js +3 -3
- package/dist/core/canvas/browse/render.js +3 -3
- package/dist/core/command-manifests/manifest.d.ts +5 -3
- package/dist/core/command-manifests/manifest.js +20 -31
- package/dist/core/command-manifests/registry.d.ts +3 -0
- package/dist/core/command-manifests/schema.d.ts +12 -2
- package/dist/core/command-manifests/schema.js +24 -11
- package/dist/core/command-plugins/compose.js +1 -1
- package/dist/core/command-plugins/discovery.d.ts +1 -1
- package/dist/core/command-plugins/discovery.js +10 -4
- package/dist/core/command-plugins/extensions.d.ts +20 -0
- package/dist/core/command-plugins/extensions.js +166 -0
- package/dist/core/command-plugins/transport/exec-invoke.d.ts +15 -2
- package/dist/core/command-plugins/transport/exec-invoke.js +11 -7
- package/dist/core/help.d.ts +5 -0
- package/dist/core/help.js +3 -0
- package/dist/core/runtime/revive.d.ts +19 -6
- package/dist/core/runtime/revive.js +34 -9
- package/dist/daemon/api/handlers/attach.js +24 -20
- package/dist/daemon/api/handlers/nodes.js +7 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -12,11 +12,11 @@ export declare const SORTS: readonly SortMode[];
|
|
|
12
12
|
/** Does a node belong to this tab's slice?
|
|
13
13
|
* All — every node.
|
|
14
14
|
* Live — what you can pick up right now: active | idle, PLUS a resident
|
|
15
|
-
* agent that stopped without finishing (
|
|
16
|
-
*
|
|
15
|
+
* agent that stopped without finishing (Enter opens its saved
|
|
16
|
+
* conversation; submitted input wakes it — see `isFinishedWork`).
|
|
17
17
|
* Finished work never appears here.
|
|
18
18
|
* Dormant — done | dead | canceled: literally "no engine running". A stopped
|
|
19
|
-
* resident agent is deliberately on BOTH tabs — it is
|
|
19
|
+
* resident agent is deliberately on BOTH tabs — it is openable
|
|
20
20
|
* (Live) and it is not running (Dormant); both are true and each
|
|
21
21
|
* tab is the one a different search starts from.
|
|
22
22
|
* Attention — needs a human: has pending asks OR is parked on a fault.
|
|
@@ -11,11 +11,11 @@ export const SORTS = ['tree', 'attention', 'relevance', 'recency'];
|
|
|
11
11
|
/** Does a node belong to this tab's slice?
|
|
12
12
|
* All — every node.
|
|
13
13
|
* Live — what you can pick up right now: active | idle, PLUS a resident
|
|
14
|
-
* agent that stopped without finishing (
|
|
15
|
-
*
|
|
14
|
+
* agent that stopped without finishing (Enter opens its saved
|
|
15
|
+
* conversation; submitted input wakes it — see `isFinishedWork`).
|
|
16
16
|
* Finished work never appears here.
|
|
17
17
|
* Dormant — done | dead | canceled: literally "no engine running". A stopped
|
|
18
|
-
* resident agent is deliberately on BOTH tabs — it is
|
|
18
|
+
* resident agent is deliberately on BOTH tabs — it is openable
|
|
19
19
|
* (Live) and it is not running (Dormant); both are true and each
|
|
20
20
|
* tab is the one a different search starts from.
|
|
21
21
|
* Attention — needs a human: has pending asks OR is parked on a fault.
|
|
@@ -435,8 +435,8 @@ const HELP_ROWS = [
|
|
|
435
435
|
['Tabs', 'Tab / 1-5', 'All · Live · Dormant · Attention · Pinned'],
|
|
436
436
|
['Find', '/', 'fuzzy search (name · kind · id · prompts)'],
|
|
437
437
|
['Pin', 'f', 'star / unstar — floats to top, Pinned tab'],
|
|
438
|
-
['Open', '⏎ o', '
|
|
439
|
-
['', 'O', '
|
|
438
|
+
['Open', '⏎ o', 'open conversation (closes navigator)'],
|
|
439
|
+
['', 'O', 'open, keep browsing (background)'],
|
|
440
440
|
['Act', 'y', 'copy the node id to the clipboard'],
|
|
441
441
|
['', 'm', 'message the node (no resume)'],
|
|
442
442
|
['', 'R', 'rename the node'],
|
|
@@ -557,7 +557,7 @@ export function renderFrame(state, size, caps = { color: false, color256: false
|
|
|
557
557
|
? '⏎ commit Esc cancel ⌫ delete'
|
|
558
558
|
: state.readOnly === true
|
|
559
559
|
? '↑↓ move →/← tree / search f pin Tab tabs ? more q quit — read-only remote canvas'
|
|
560
|
-
: `${hangingHint}↑↓ move ⏎
|
|
560
|
+
: `${hangingHint}↑↓ move ⏎ open O bg / search f pin m msg x close R rename ? more q quit`;
|
|
561
561
|
lines.push(`${DIM}${clip(footer, width)}${RESET}`);
|
|
562
562
|
}
|
|
563
563
|
// Assemble: home, each line cleared to EOL, then clear below.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { DeclLeaf,
|
|
1
|
+
import type { DeclLeaf, DeclNode, TransportKind, ManifestTimeouts, CommandManifestIssue } from './schema.js';
|
|
2
2
|
export interface ValidatedCommandManifest {
|
|
3
3
|
schemaVersion: 1;
|
|
4
4
|
baseUrl?: string;
|
|
5
5
|
timeouts?: ManifestTimeouts;
|
|
6
|
-
/** Top-level branches,
|
|
7
|
-
roots:
|
|
6
|
+
/** Top-level plugin branches or repository-fragment nodes, ready to mount. */
|
|
7
|
+
roots: DeclNode<DeclLeaf>[];
|
|
8
8
|
/** Core command path (space-joined, e.g. "cron add") → attributed addendum
|
|
9
9
|
* text appended beneath that core command's help. Append-only product
|
|
10
10
|
* guidance — a plugin can never alter core contract text. */
|
|
@@ -32,4 +32,6 @@ export declare function validateCommandManifest(raw: unknown, options: {
|
|
|
32
32
|
* and ingress report can never disagree. Omitted only on the tolerant
|
|
33
33
|
* compose path, where mounting ignores addenda entirely. */
|
|
34
34
|
coreCommandPaths?: ReadonlySet<string>;
|
|
35
|
+
/** A repository fragment attaches roots below an existing extensible branch. */
|
|
36
|
+
extensionFragment?: boolean;
|
|
35
37
|
}): CommandManifestValidation;
|
|
@@ -33,17 +33,19 @@ export function validateCommandManifest(raw, options) {
|
|
|
33
33
|
};
|
|
34
34
|
// Top-level structure check
|
|
35
35
|
if (!isRecord(raw)) {
|
|
36
|
-
issue('command_manifest_invalid', 'manifest must be an object', typeName(raw), options.transport === 'http' ? '{ schemaVersion, baseUrl?, timeouts?, mounts, helpAddenda? }' : '{ schemaVersion, mounts, helpAddenda? }', 'Provide a valid JSON manifest object.');
|
|
36
|
+
issue('command_manifest_invalid', 'manifest must be an object', typeName(raw), options.extensionFragment === true ? '{ schemaVersion, transport, mounts }' : options.transport === 'http' ? '{ schemaVersion, baseUrl?, timeouts?, mounts, helpAddenda? }' : '{ schemaVersion, mounts, helpAddenda? }', 'Provide a valid JSON manifest object.');
|
|
37
37
|
return { issues };
|
|
38
38
|
}
|
|
39
39
|
// Check for unknown top-level keys
|
|
40
40
|
const topKeys = Object.keys(raw);
|
|
41
|
-
const allowedTopKeys = options.
|
|
42
|
-
? new Set(['schemaVersion', '
|
|
43
|
-
:
|
|
41
|
+
const allowedTopKeys = options.extensionFragment === true
|
|
42
|
+
? new Set(['schemaVersion', 'transport', 'mounts'])
|
|
43
|
+
: options.transport === 'http'
|
|
44
|
+
? new Set(['schemaVersion', 'baseUrl', 'timeouts', 'mounts', 'helpAddenda'])
|
|
45
|
+
: new Set(['schemaVersion', 'mounts', 'helpAddenda']);
|
|
44
46
|
const unknownKeys = topKeys.filter((k) => !allowedTopKeys.has(k));
|
|
45
47
|
if (unknownKeys.length > 0) {
|
|
46
|
-
issue('command_manifest_invalid', `unknown top-level keys`, unknownKeys.join(', '), options.transport === 'http' ? 'only: schemaVersion, baseUrl, timeouts, mounts, helpAddenda' : 'only: schemaVersion, mounts, helpAddenda', 'Remove the unknown keys.');
|
|
48
|
+
issue('command_manifest_invalid', `unknown top-level keys`, unknownKeys.join(', '), options.extensionFragment === true ? 'only: schemaVersion, transport, mounts' : options.transport === 'http' ? 'only: schemaVersion, baseUrl, timeouts, mounts, helpAddenda' : 'only: schemaVersion, mounts, helpAddenda', 'Remove the unknown keys.');
|
|
47
49
|
return { issues };
|
|
48
50
|
}
|
|
49
51
|
// Validate schemaVersion
|
|
@@ -101,7 +103,7 @@ export function validateCommandManifest(raw, options) {
|
|
|
101
103
|
// Validate and collect all mounts
|
|
102
104
|
const validatedMounts = [];
|
|
103
105
|
for (let i = 0; i < mounts.length; i++) {
|
|
104
|
-
const m = validateMount(mounts[i], i, options.transport, issue);
|
|
106
|
+
const m = validateMount(mounts[i], i, options.transport, issue, options.extensionFragment === true ? { topLevelRootEntry: 'forbidden', allowPassthrough: false, allowExtensible: false } : undefined);
|
|
105
107
|
if (m === null)
|
|
106
108
|
return { issues };
|
|
107
109
|
validatedMounts.push(m);
|
|
@@ -179,7 +181,7 @@ function validateHelpAddenda(raw, coreCommandPaths, issue) {
|
|
|
179
181
|
}
|
|
180
182
|
return out;
|
|
181
183
|
}
|
|
182
|
-
function validateMount(raw, index, transport, issue) {
|
|
184
|
+
function validateMount(raw, index, transport, issue, nodeOptions) {
|
|
183
185
|
const path = `mounts[${index}]`;
|
|
184
186
|
if (!isRecord(raw)) {
|
|
185
187
|
issue('command_manifest_invalid', 'mount must be an object', typeName(raw), '{ parent, node }', 'Fix the mount.', path);
|
|
@@ -208,17 +210,10 @@ function validateMount(raw, index, transport, issue) {
|
|
|
208
210
|
// Validate node
|
|
209
211
|
const nodeRaw = raw['node'];
|
|
210
212
|
const topLevel = parent.length === 0;
|
|
211
|
-
const node = validateCommandNode(nodeRaw, [`${path}.node`], topLevel, transport, issue);
|
|
213
|
+
const node = validateCommandNode(nodeRaw, [`${path}.node`], topLevel, transport, issue, nodeOptions);
|
|
212
214
|
if (node === null)
|
|
213
215
|
return null;
|
|
214
|
-
|
|
215
|
-
if (topLevel && node.kind !== 'branch') {
|
|
216
|
-
issue('command_node_invalid', 'top-level mount node must be a branch', node.kind, 'a branch with a rootEntry', 'Wrap the command in a top-level branch.', `${path}.node.kind`);
|
|
217
|
-
return null;
|
|
218
|
-
}
|
|
219
|
-
// Ensure the node is properly typed (should only be DeclLeaf at this point)
|
|
220
|
-
const typedNode = node;
|
|
221
|
-
return { parent, node: typedNode };
|
|
216
|
+
return { parent, node };
|
|
222
217
|
}
|
|
223
218
|
// Forest materialization (order-independent)
|
|
224
219
|
/**
|
|
@@ -236,19 +231,10 @@ function materializeForest(mounts, reservedCoreNames, issue) {
|
|
|
236
231
|
return null;
|
|
237
232
|
}
|
|
238
233
|
}
|
|
239
|
-
// Build the forest: all top-level nodes with inline children, then attach nested mounts
|
|
240
|
-
const roots = topLevel.map((m) =>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
description: m.node.description,
|
|
244
|
-
whenToUse: m.node.whenToUse,
|
|
245
|
-
...(m.node.tier !== undefined ? { tier: m.node.tier } : {}),
|
|
246
|
-
...(m.node.rootEntry !== undefined ? { rootEntry: m.node.rootEntry } : {}),
|
|
247
|
-
summary: m.node.summary,
|
|
248
|
-
...(m.node.model !== undefined ? { model: m.node.model } : {}),
|
|
249
|
-
...(m.node.passthrough !== undefined ? { passthrough: m.node.passthrough } : {}),
|
|
250
|
-
children: [...m.node.children],
|
|
251
|
-
}));
|
|
234
|
+
// Build the forest: all top-level nodes with inline children, then attach nested mounts.
|
|
235
|
+
const roots = topLevel.map((m) => m.node.kind === 'branch'
|
|
236
|
+
? { ...m.node, children: [...m.node.children] }
|
|
237
|
+
: { ...m.node });
|
|
252
238
|
// Attach nested mounts
|
|
253
239
|
for (const mount of nested) {
|
|
254
240
|
const resolved = resolveAndAttachMount(roots, mount, issue);
|
|
@@ -270,8 +256,7 @@ function materializeForest(mounts, reservedCoreNames, issue) {
|
|
|
270
256
|
return true;
|
|
271
257
|
};
|
|
272
258
|
for (const root of roots) {
|
|
273
|
-
|
|
274
|
-
if (!indexPaths(child, [root.name]))
|
|
259
|
+
if (!indexPaths(root, [root.name]))
|
|
275
260
|
return null;
|
|
276
261
|
}
|
|
277
262
|
return roots;
|
|
@@ -287,6 +272,10 @@ function resolveAndAttachMount(roots, mount, issue) {
|
|
|
287
272
|
issue('command_parent_invalid', `parent path starts with unknown branch`, first, 'a branch this manifest contributes', 'Fix the parent path or add the parent branch.', mount.parent.join('.'));
|
|
288
273
|
return false;
|
|
289
274
|
}
|
|
275
|
+
if (root.kind === 'leaf') {
|
|
276
|
+
issue('command_parent_invalid', 'parent path resolves to a leaf', first, 'a branch', 'Change the parent to point to a branch.', mount.parent.join('.'));
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
290
279
|
let current = root;
|
|
291
280
|
for (const token of rest) {
|
|
292
281
|
const child = current.children.find((c) => c.name === token);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LeafDef } from '../command.js';
|
|
2
2
|
import type { DeclLeafBase, DeclBranch, CommandManifestIssue } from './schema.js';
|
|
3
|
+
import type { ExtensionIssueNotice } from '../command-plugins/extensions.js';
|
|
3
4
|
/** Reference to an external contributor (plugin or HTTP-transport plugin). */
|
|
4
5
|
export type CommandContributorRef = {
|
|
5
6
|
name: string;
|
|
@@ -27,6 +28,8 @@ export interface CommandContribution {
|
|
|
27
28
|
/** Root name from the manifest, before collision qualification. */
|
|
28
29
|
manifestName: string;
|
|
29
30
|
adaptLeaf: LeafAdapter;
|
|
31
|
+
/** Present only on an extensible branch whose nearest repository fragment was rejected. */
|
|
32
|
+
extensionIssue?: ExtensionIssueNotice;
|
|
30
33
|
}
|
|
31
34
|
/** Per-contributor collision issue. */
|
|
32
35
|
export interface ContributorIssue extends CommandManifestIssue {
|
|
@@ -12,6 +12,8 @@ export interface DeclBranch<L = DeclLeafBase> {
|
|
|
12
12
|
tier?: 'normal' | 'common' | 'important';
|
|
13
13
|
/** Required on a top-level branch, forbidden on a nested one. */
|
|
14
14
|
rootEntry?: DeclRootEntry;
|
|
15
|
+
/** Allows the nearest repository fragment to contribute children below this top-level branch. */
|
|
16
|
+
extensible?: true;
|
|
15
17
|
summary: string;
|
|
16
18
|
model?: string;
|
|
17
19
|
/** Exec transport only: forward every argv token after this branch to an
|
|
@@ -78,8 +80,16 @@ export interface CommandManifestIssue {
|
|
|
78
80
|
expected: string;
|
|
79
81
|
next: string;
|
|
80
82
|
}
|
|
81
|
-
export type CommandIssueCode = 'command_manifest_unreadable' | 'command_manifest_invalid' | 'command_schema_version' | 'command_path_unsafe' | 'command_not_executable' | 'command_parent_invalid' | 'command_node_invalid' | 'command_collision' | 'command_help_addendum_invalid' | 'command_rest_invalid';
|
|
83
|
+
export type CommandIssueCode = 'command_manifest_unreadable' | 'command_manifest_invalid' | 'command_schema_version' | 'command_path_unsafe' | 'command_not_executable' | 'command_parent_invalid' | 'command_node_invalid' | 'command_collision' | 'command_help_addendum_invalid' | 'command_rest_invalid' | 'command_extension_invalid';
|
|
82
84
|
type IssueFn = (code: CommandIssueCode, message: string, received: string, expected: string, next: string, path?: string) => void;
|
|
83
85
|
export type TransportKind = 'exec' | 'http';
|
|
84
|
-
export
|
|
86
|
+
export interface CommandNodeValidationOptions {
|
|
87
|
+
/** Plugin roots need rootEntry; repository-fragment roots attach under an existing branch and forbid it. */
|
|
88
|
+
topLevelRootEntry?: 'required' | 'forbidden';
|
|
89
|
+
/** Repository fragments have no passthrough escape hatch. */
|
|
90
|
+
allowPassthrough?: boolean;
|
|
91
|
+
/** Only plugin manifests may mark their own top-level branch extensible. */
|
|
92
|
+
allowExtensible?: boolean;
|
|
93
|
+
}
|
|
94
|
+
export declare function validateCommandNode(raw: unknown, path: string[], topLevel: boolean, transport: TransportKind, issue: IssueFn, options?: CommandNodeValidationOptions): DeclBranch<DeclLeaf> | DeclLeaf | null;
|
|
85
95
|
export {};
|
|
@@ -5,7 +5,7 @@ const TIERS = new Set(['normal', 'common', 'important']);
|
|
|
5
5
|
const FLAG_TYPES = new Set(['string', 'int', 'bool', 'path', 'enum']);
|
|
6
6
|
const KEBAB = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
7
7
|
const BRANCH_KEYS = new Set([
|
|
8
|
-
'kind', 'name', 'description', 'whenToUse', 'tier', 'rootEntry', 'summary', 'model', 'children',
|
|
8
|
+
'kind', 'name', 'description', 'whenToUse', 'tier', 'rootEntry', 'extensible', 'summary', 'model', 'children',
|
|
9
9
|
]);
|
|
10
10
|
/** Only exec transport may declare `passthrough`; HTTP manifests keep
|
|
11
11
|
* BRANCH_KEYS, so a declared passthrough fails as an unknown key. */
|
|
@@ -331,7 +331,7 @@ function checkCommon(raw, path, issue) {
|
|
|
331
331
|
}
|
|
332
332
|
return true;
|
|
333
333
|
}
|
|
334
|
-
export function validateCommandNode(raw, path, topLevel, transport, issue) {
|
|
334
|
+
export function validateCommandNode(raw, path, topLevel, transport, issue, options = {}) {
|
|
335
335
|
const pathString = path.join('.');
|
|
336
336
|
if (!isRecord(raw)) {
|
|
337
337
|
issue('command_node_invalid', 'node must be an object', typeName(raw), 'a branch or leaf node object', 'Fix the node.', pathString);
|
|
@@ -339,9 +339,9 @@ export function validateCommandNode(raw, path, topLevel, transport, issue) {
|
|
|
339
339
|
}
|
|
340
340
|
const kind = raw['kind'];
|
|
341
341
|
if (kind === 'branch')
|
|
342
|
-
return validateBranch(raw, pathString, topLevel, transport, issue);
|
|
342
|
+
return validateBranch(raw, pathString, topLevel, transport, issue, options);
|
|
343
343
|
if (kind === 'leaf') {
|
|
344
|
-
if (topLevel) {
|
|
344
|
+
if (topLevel && options.topLevelRootEntry !== 'forbidden') {
|
|
345
345
|
issue('command_node_invalid', 'top-level node must be a branch', 'leaf', 'a branch node with a rootEntry', 'Wrap the command in a top-level branch (noun) with a rootEntry.', `${pathString}.kind`);
|
|
346
346
|
return null;
|
|
347
347
|
}
|
|
@@ -350,18 +350,26 @@ export function validateCommandNode(raw, path, topLevel, transport, issue) {
|
|
|
350
350
|
issue('command_node_invalid', 'node kind must be "branch" or "leaf"', String(kind), 'branch | leaf', 'Set kind to branch or leaf.', `${pathString}.kind`);
|
|
351
351
|
return null;
|
|
352
352
|
}
|
|
353
|
-
function validateBranch(raw, path, topLevel, transport, issue) {
|
|
354
|
-
|
|
353
|
+
function validateBranch(raw, path, topLevel, transport, issue, options) {
|
|
354
|
+
const allowsPassthrough = options.allowPassthrough !== false;
|
|
355
|
+
const allowedKeys = transport === 'exec' && allowsPassthrough ? EXEC_BRANCH_KEYS : BRANCH_KEYS;
|
|
356
|
+
if (!checkKeys(raw, allowedKeys, path, issue))
|
|
355
357
|
return null;
|
|
356
358
|
if (!checkCommon(raw, path, issue))
|
|
357
359
|
return null;
|
|
358
360
|
const hasRoot = raw['rootEntry'] !== undefined;
|
|
359
|
-
|
|
361
|
+
const rootEntryPolicy = options.topLevelRootEntry ?? 'required';
|
|
362
|
+
if (topLevel && rootEntryPolicy === 'required' && !hasRoot) {
|
|
360
363
|
issue('command_node_invalid', 'top-level branch requires a rootEntry', '(missing)', 'a rootEntry { concept, description, whenToUse }', 'Add a rootEntry to the top-level branch.', `${path}.rootEntry`);
|
|
361
364
|
return null;
|
|
362
365
|
}
|
|
363
|
-
if (!topLevel && hasRoot) {
|
|
364
|
-
issue('command_node_invalid', 'nested branch must not declare a rootEntry', 'rootEntry', 'no rootEntry (only top-level branches carry one)', 'Remove rootEntry from the nested branch.', `${path}.rootEntry`);
|
|
366
|
+
if ((!topLevel || rootEntryPolicy === 'forbidden') && hasRoot) {
|
|
367
|
+
issue('command_node_invalid', topLevel ? 'repository fragment root must not declare a rootEntry' : 'nested branch must not declare a rootEntry', 'rootEntry', topLevel ? 'no rootEntry (the owning plugin branch already has one)' : 'no rootEntry (only top-level branches carry one)', topLevel ? 'Remove rootEntry from the repository fragment root.' : 'Remove rootEntry from the nested branch.', `${path}.rootEntry`);
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
const hasExtensible = raw['extensible'] !== undefined;
|
|
371
|
+
if (hasExtensible && (!topLevel || options.allowExtensible === false || raw['extensible'] !== true)) {
|
|
372
|
+
issue('command_node_invalid', !topLevel ? 'extensible is valid only on a top-level branch mount' : 'extensible must be exactly true on a plugin top-level branch', String(raw['extensible']), !topLevel ? 'no extensible marker on nested branches' : 'extensible: true', !topLevel ? 'Move the marker to the plugin top-level branch or remove it.' : 'Set extensible to true or remove it.', `${path}.extensible`);
|
|
365
373
|
return null;
|
|
366
374
|
}
|
|
367
375
|
let rootEntry;
|
|
@@ -386,6 +394,10 @@ function validateBranch(raw, path, topLevel, transport, issue) {
|
|
|
386
394
|
return null;
|
|
387
395
|
passthrough = validated;
|
|
388
396
|
}
|
|
397
|
+
if (hasExtensible && passthrough !== undefined) {
|
|
398
|
+
issue('command_node_invalid', 'an extensible branch cannot declare passthrough', 'extensible and passthrough', 'one branch mode: extensible or passthrough', 'Remove passthrough or remove extensible.', path);
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
389
401
|
const children = raw['children'];
|
|
390
402
|
if (passthrough !== undefined) {
|
|
391
403
|
if (!Array.isArray(children) || children.length !== 0) {
|
|
@@ -400,7 +412,7 @@ function validateBranch(raw, path, topLevel, transport, issue) {
|
|
|
400
412
|
const validated = [];
|
|
401
413
|
const childNames = new Set();
|
|
402
414
|
for (let i = 0; i < children.length; i++) {
|
|
403
|
-
const child = validateCommandNode(children[i], [...path.split('.'), `children[${i}]`], false, transport, issue);
|
|
415
|
+
const child = validateCommandNode(children[i], [...path.split('.'), `children[${i}]`], false, transport, issue, options);
|
|
404
416
|
if (child === null)
|
|
405
417
|
return null;
|
|
406
418
|
if (childNames.has(child.name)) {
|
|
@@ -413,7 +425,8 @@ function validateBranch(raw, path, topLevel, transport, issue) {
|
|
|
413
425
|
return {
|
|
414
426
|
kind: 'branch', name: raw['name'], description: raw['description'], whenToUse: raw['whenToUse'],
|
|
415
427
|
...(raw['tier'] !== undefined ? { tier: raw['tier'] } : {}),
|
|
416
|
-
...(rootEntry !== undefined ? { rootEntry } : {}),
|
|
428
|
+
...(rootEntry !== undefined ? { rootEntry } : {}),
|
|
429
|
+
...(hasExtensible ? { extensible: true } : {}), summary: raw['summary'],
|
|
417
430
|
...(raw['model'] !== undefined ? { model: raw['model'] } : {}),
|
|
418
431
|
...(passthrough !== undefined ? { passthrough } : {}), children: validated,
|
|
419
432
|
};
|
|
@@ -39,7 +39,7 @@ export function composeExternalSubtrees(snapshot) {
|
|
|
39
39
|
}
|
|
40
40
|
function buildBranch(contribution, node, path) {
|
|
41
41
|
const rootEntry = node.rootEntry === undefined ? undefined : { concept: node.rootEntry.concept, desc: node.rootEntry.description, useWhen: node.rootEntry.whenToUse };
|
|
42
|
-
return defineBranch({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), ...(rootEntry !== undefined ? { rootEntry } : {}), help: { name: path.join(' '), summary: node.summary, ...(node.model !== undefined ? { model: node.model } : {}) }, ...(node.passthrough !== undefined ? { passthrough: node.passthrough } : {}), children: node.children.map((child) => child.kind === 'branch' ? buildBranch(contribution, child, [...path, child.name]) : buildLeaf(contribution, child, [...path, child.name])) });
|
|
42
|
+
return defineBranch({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), ...(rootEntry !== undefined ? { rootEntry } : {}), help: { name: path.join(' '), summary: node.summary, ...(node.model !== undefined ? { model: node.model } : {}), ...(path.length === 1 && contribution.extensionIssue !== undefined ? { extensionIssue: contribution.extensionIssue } : {}) }, ...(node.passthrough !== undefined ? { passthrough: node.passthrough } : {}), children: node.children.map((child) => child.kind === 'branch' ? buildBranch(contribution, child, [...path, child.name]) : buildLeaf(contribution, child, [...path, child.name])) });
|
|
43
43
|
}
|
|
44
44
|
function buildLeaf(contribution, node, path) {
|
|
45
45
|
const adapted = contribution.adaptLeaf(node, path);
|
|
@@ -34,4 +34,4 @@ export declare function discoverCommandContributions(reservedNames: ReadonlySet<
|
|
|
34
34
|
contributions: ValidatedContribution[];
|
|
35
35
|
issues: CommandDiscoveryIssue[];
|
|
36
36
|
};
|
|
37
|
-
export declare function buildExternalCommandSnapshot(reservedCoreNames: ReadonlySet<string
|
|
37
|
+
export declare function buildExternalCommandSnapshot(reservedCoreNames: ReadonlySet<string>, startDir?: string, profileId?: string | null): CommandRegistrySnapshot;
|
|
@@ -6,6 +6,7 @@ import { validateCommandManifest } from '../command-manifests/manifest.js';
|
|
|
6
6
|
import { qualifiedTopLevelCommandName, resolveCommandRegistry } from '../command-manifests/registry.js';
|
|
7
7
|
import { adaptPluginContributions } from './compose.js';
|
|
8
8
|
import { validateHttpPluginTransport } from './endpoint.js';
|
|
9
|
+
import { applyCommandExtensions } from './extensions.js';
|
|
9
10
|
export function effectiveCommandPlugins(startDir = process.cwd(), profileId) {
|
|
10
11
|
const seen = new Set();
|
|
11
12
|
const result = [];
|
|
@@ -130,7 +131,11 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
|
|
|
130
131
|
: transport;
|
|
131
132
|
if (resolvedTransport === undefined)
|
|
132
133
|
return { plugin, manifestPath, transport, contributions: [], issues };
|
|
133
|
-
|
|
134
|
+
const roots = validation.manifest.roots;
|
|
135
|
+
if (!roots.every((node) => node.kind === 'branch')) {
|
|
136
|
+
throw new Error('plugin manifest validation returned a top-level leaf');
|
|
137
|
+
}
|
|
138
|
+
return { plugin, manifestPath, transport: resolvedTransport, manifest: validation.manifest, contributions: roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest, manifestName: node.name })), issues };
|
|
134
139
|
}
|
|
135
140
|
export function discoverPluginCommandCandidates(startDir = process.cwd(), profileId) {
|
|
136
141
|
return effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin));
|
|
@@ -164,9 +169,10 @@ export function discoverCommandContributions(reservedNames, startDir = process.c
|
|
|
164
169
|
const validations = validateEffectiveCommandPlugins(reservedNames, startDir, profileId);
|
|
165
170
|
return { contributions: validations.flatMap((validation) => validation.contributions), issues: validations.flatMap((validation) => validation.issues) };
|
|
166
171
|
}
|
|
167
|
-
export function buildExternalCommandSnapshot(reservedCoreNames) {
|
|
168
|
-
const validations = discoverPluginCommandCandidates();
|
|
172
|
+
export function buildExternalCommandSnapshot(reservedCoreNames, startDir = process.cwd(), profileId) {
|
|
173
|
+
const validations = discoverPluginCommandCandidates(startDir, profileId);
|
|
169
174
|
const candidates = adaptPluginContributions(validations.flatMap((validation) => validation.contributions)).contributions;
|
|
170
175
|
const resolved = resolveCommandRegistry(candidates, reservedCoreNames);
|
|
171
|
-
|
|
176
|
+
const extensions = applyCommandExtensions(resolved.contributions, startDir);
|
|
177
|
+
return { contributions: extensions.contributions, issues: [...validations.flatMap((validation) => validation.issues.map((issue) => contributorIssue(validation.plugin, issue))), ...resolved.issues] };
|
|
172
178
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CommandManifestIssue } from '../command-manifests/schema.js';
|
|
2
|
+
import type { CommandContribution } from '../command-manifests/registry.js';
|
|
3
|
+
export interface ExtensionIssueNotice {
|
|
4
|
+
fragment: string;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CommandExtensionValidation {
|
|
8
|
+
branch: string;
|
|
9
|
+
fragmentPath: string;
|
|
10
|
+
projectRoot: string;
|
|
11
|
+
issue?: CommandManifestIssue;
|
|
12
|
+
}
|
|
13
|
+
/** Attach the nearest repository-owned fragment to each marked plugin branch.
|
|
14
|
+
* Discovery reads only; execution remains in the leaf adapter. */
|
|
15
|
+
export declare function applyCommandExtensions(contributions: readonly CommandContribution[], startDir?: string): {
|
|
16
|
+
contributions: CommandContribution[];
|
|
17
|
+
validations: CommandExtensionValidation[];
|
|
18
|
+
};
|
|
19
|
+
/** Doctor's only directory scan: fragments with no matching effective marked branch are otherwise inert by design. */
|
|
20
|
+
export declare function orphanCommandExtensionValidations(contributions: readonly CommandContribution[], startDir?: string): CommandExtensionValidation[];
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { existsSync, realpathSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { projectScopeRoots } from '../scope.js';
|
|
4
|
+
import { validateCommandManifest } from '../command-manifests/manifest.js';
|
|
5
|
+
import { executeExternalLeaf } from './transport/exec-invoke.js';
|
|
6
|
+
import { isRecord } from '../../shared/predicates.js';
|
|
7
|
+
function issue(fragmentPath, message, received, expected, next, nodePath) {
|
|
8
|
+
return {
|
|
9
|
+
code: 'command_extension_invalid',
|
|
10
|
+
message,
|
|
11
|
+
received,
|
|
12
|
+
expected,
|
|
13
|
+
next,
|
|
14
|
+
path: nodePath === undefined ? fragmentPath : `${fragmentPath}:${nodePath}`,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function regenerate(fragmentPath) {
|
|
18
|
+
return `Fix the repository CLI definition and regenerate ${fragmentPath}.`;
|
|
19
|
+
}
|
|
20
|
+
function safeExecutable(fragmentPath, root, executable) {
|
|
21
|
+
if (isAbsolute(executable)) {
|
|
22
|
+
return { issue: issue(fragmentPath, 'fragment transport executable must be project-relative', executable, 'a project-root-relative regular file', 'Fix transport.executable in the repository CLI definition and regenerate the fragment.', 'transport.executable') };
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const rootReal = realpathSync(root);
|
|
26
|
+
const resolved = realpathSync(resolve(root, executable));
|
|
27
|
+
if ((resolved !== rootReal && !resolved.startsWith(rootReal + sep)) || !statSync(resolved).isFile()) {
|
|
28
|
+
return { issue: issue(fragmentPath, 'fragment transport executable escapes the project root or is not a regular file', executable, 'a project-root-relative regular file', 'Fix transport.executable in the repository CLI definition and regenerate the fragment.', 'transport.executable') };
|
|
29
|
+
}
|
|
30
|
+
if ((statSync(resolved).mode & 0o111) === 0) {
|
|
31
|
+
return { issue: issue(fragmentPath, 'fragment transport executable lacks the POSIX exec bit', executable, 'a file with an executable permission bit', 'chmod +x the repository command executable, then regenerate the fragment.', 'transport.executable') };
|
|
32
|
+
}
|
|
33
|
+
return { executable: resolved };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { issue: issue(fragmentPath, 'fragment transport executable escapes the project root or is not a regular file', executable, 'a project-root-relative regular file', 'Fix transport.executable in the repository CLI definition and regenerate the fragment.', 'transport.executable') };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function validateFragment(fragmentPath, projectRoot, branch, ownChildren) {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = JSON.parse(readFileSync(fragmentPath, 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { issue: issue(fragmentPath, 'extension fragment is missing, unreadable, or not valid JSON', fragmentPath, 'a readable generated JSON fragment', `Regenerate ${fragmentPath}.`) };
|
|
46
|
+
}
|
|
47
|
+
if (!isRecord(raw)) {
|
|
48
|
+
return { issue: issue(fragmentPath, 'extension fragment must be an object', raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw, '{ schemaVersion, transport, mounts }', `Regenerate ${fragmentPath}.`) };
|
|
49
|
+
}
|
|
50
|
+
const unknown = Object.keys(raw).filter((key) => !['schemaVersion', 'transport', 'mounts'].includes(key));
|
|
51
|
+
if (unknown.length > 0) {
|
|
52
|
+
return { issue: issue(fragmentPath, 'extension fragment has unknown top-level keys', unknown.join(', '), 'only: schemaVersion, transport, mounts', `Regenerate ${fragmentPath}.`) };
|
|
53
|
+
}
|
|
54
|
+
const transport = raw['transport'];
|
|
55
|
+
if (!isRecord(transport) || Object.keys(transport).some((key) => !['kind', 'executable'].includes(key)) || transport['kind'] !== 'exec' || typeof transport['executable'] !== 'string' || transport['executable'].length === 0) {
|
|
56
|
+
return { issue: issue(fragmentPath, 'extension fragment transport must be { kind: "exec", executable: "project-relative path" }', JSON.stringify(transport), 'an exec transport with an executable', `Regenerate ${fragmentPath}.`, 'transport') };
|
|
57
|
+
}
|
|
58
|
+
const executableResult = safeExecutable(fragmentPath, projectRoot, transport['executable']);
|
|
59
|
+
if (executableResult.issue !== undefined)
|
|
60
|
+
return { issue: executableResult.issue };
|
|
61
|
+
const validation = validateCommandManifest(raw, {
|
|
62
|
+
transport: 'exec',
|
|
63
|
+
reservedCoreNames: new Set(),
|
|
64
|
+
extensionFragment: true,
|
|
65
|
+
});
|
|
66
|
+
if (validation.manifest === undefined) {
|
|
67
|
+
const source = validation.issues[0];
|
|
68
|
+
return {
|
|
69
|
+
issue: issue(fragmentPath, source.message, source.received, source.expected, regenerate(fragmentPath), source.path),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const conflict = validation.manifest.roots.find((root) => ownChildren.includes(root.name));
|
|
73
|
+
if (conflict !== undefined) {
|
|
74
|
+
return {
|
|
75
|
+
issue: issue(fragmentPath, `contributed command "${conflict.name}" conflicts with a child the extensible branch already owns`, conflict.name, 'a name not already owned by the plugin branch', `Rename "${conflict.name}" in the repository CLI tree, then regenerate ${fragmentPath}.`, conflict.name),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { fragment: { roots: validation.manifest.roots, executable: executableResult.executable } };
|
|
79
|
+
}
|
|
80
|
+
function nearestFragment(branch, startDir) {
|
|
81
|
+
for (const scopeRoot of projectScopeRoots(startDir, null)) {
|
|
82
|
+
const projectRoot = dirname(scopeRoot);
|
|
83
|
+
const fragmentPath = join(scopeRoot, 'commands', `${branch}.json`);
|
|
84
|
+
if (existsSync(fragmentPath))
|
|
85
|
+
return { fragmentPath, projectRoot };
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
function markLeaves(node, leaves) {
|
|
90
|
+
if (node.kind === 'leaf') {
|
|
91
|
+
leaves.add(node);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
node.children.forEach((child) => markLeaves(child, leaves));
|
|
95
|
+
}
|
|
96
|
+
/** Attach the nearest repository-owned fragment to each marked plugin branch.
|
|
97
|
+
* Discovery reads only; execution remains in the leaf adapter. */
|
|
98
|
+
export function applyCommandExtensions(contributions, startDir = process.cwd()) {
|
|
99
|
+
const validations = [];
|
|
100
|
+
const attached = contributions.map((contribution) => {
|
|
101
|
+
if (contribution.node.extensible !== true)
|
|
102
|
+
return contribution;
|
|
103
|
+
const branch = contribution.node.name;
|
|
104
|
+
const located = nearestFragment(branch, startDir);
|
|
105
|
+
if (located === undefined)
|
|
106
|
+
return contribution;
|
|
107
|
+
const result = validateFragment(located.fragmentPath, located.projectRoot, branch, contribution.node.children.map((child) => child.name));
|
|
108
|
+
if (result.issue !== undefined) {
|
|
109
|
+
validations.push({ branch, fragmentPath: located.fragmentPath, projectRoot: located.projectRoot, issue: result.issue });
|
|
110
|
+
return {
|
|
111
|
+
...contribution,
|
|
112
|
+
extensionIssue: { fragment: located.fragmentPath, message: result.issue.message },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const fragment = result.fragment;
|
|
116
|
+
validations.push({ branch, fragmentPath: located.fragmentPath, projectRoot: located.projectRoot });
|
|
117
|
+
const leaves = new WeakSet();
|
|
118
|
+
fragment.roots.forEach((root) => markLeaves(root, leaves));
|
|
119
|
+
return {
|
|
120
|
+
...contribution,
|
|
121
|
+
node: { ...contribution.node, children: [...contribution.node.children, ...fragment.roots] },
|
|
122
|
+
adaptLeaf: (leaf, path) => {
|
|
123
|
+
if (!leaves.has(leaf))
|
|
124
|
+
return contribution.adaptLeaf(leaf, path);
|
|
125
|
+
const commandPath = [...path];
|
|
126
|
+
return {
|
|
127
|
+
outputKind: 'object',
|
|
128
|
+
run: async (input) => executeExternalLeaf({
|
|
129
|
+
extension: { branch, root: located.projectRoot },
|
|
130
|
+
executable: fragment.executable,
|
|
131
|
+
commandPath,
|
|
132
|
+
output: leaf.output,
|
|
133
|
+
}, input),
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
return { contributions: attached, validations };
|
|
139
|
+
}
|
|
140
|
+
/** Doctor's only directory scan: fragments with no matching effective marked branch are otherwise inert by design. */
|
|
141
|
+
export function orphanCommandExtensionValidations(contributions, startDir = process.cwd()) {
|
|
142
|
+
const scopeRoot = projectScopeRoots(startDir, null)[0];
|
|
143
|
+
if (scopeRoot === undefined)
|
|
144
|
+
return [];
|
|
145
|
+
const commandsDir = join(scopeRoot, 'commands');
|
|
146
|
+
let names;
|
|
147
|
+
try {
|
|
148
|
+
names = readdirSync(commandsDir);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const extensible = new Set(contributions.filter((contribution) => contribution.node.extensible === true).map((contribution) => contribution.node.name));
|
|
154
|
+
return names.filter((name) => name.endsWith('.json')).flatMap((name) => {
|
|
155
|
+
const branch = name.slice(0, -'.json'.length);
|
|
156
|
+
if (extensible.has(branch))
|
|
157
|
+
return [];
|
|
158
|
+
const fragmentPath = join(commandsDir, name);
|
|
159
|
+
return [{
|
|
160
|
+
branch,
|
|
161
|
+
fragmentPath,
|
|
162
|
+
projectRoot: dirname(scopeRoot),
|
|
163
|
+
issue: issue(fragmentPath, `fragment names branch "${branch}" but no effective plugin branch is extensible`, branch, 'the name of an effective extensible plugin branch', `Mark the plugin branch extensible or rename/remove ${fragmentPath}.`),
|
|
164
|
+
}];
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
import type { InstalledPlugin } from '../../../types.js';
|
|
2
2
|
import type { Field } from '../../help.js';
|
|
3
|
-
export
|
|
3
|
+
export type ExternalLeafSpec = {
|
|
4
4
|
plugin: InstalledPlugin;
|
|
5
|
+
extension?: never;
|
|
5
6
|
/** Absolute, resolved executable path. */
|
|
6
7
|
executable: string;
|
|
7
8
|
/** Full canonical command path, e.g. ['app', 'show']. */
|
|
8
9
|
commandPath: string[];
|
|
9
10
|
/** Declared leaf output fields — validated against the result before render. */
|
|
10
11
|
output: Field[];
|
|
11
|
-
}
|
|
12
|
+
} | {
|
|
13
|
+
plugin?: never;
|
|
14
|
+
extension: {
|
|
15
|
+
branch: string;
|
|
16
|
+
root: string;
|
|
17
|
+
};
|
|
18
|
+
/** Absolute, resolved executable path. */
|
|
19
|
+
executable: string;
|
|
20
|
+
/** Full canonical command path, e.g. ['app', 'show']. */
|
|
21
|
+
commandPath: string[];
|
|
22
|
+
/** Declared leaf output fields — validated against the result before render. */
|
|
23
|
+
output: Field[];
|
|
24
|
+
};
|
|
12
25
|
/** Validates a result object against declared output field descriptors.
|
|
13
26
|
* Returns null if valid; otherwise returns a { field, receivedType } error.
|
|
14
27
|
* Transport-neutral: used by exec and HTTP transport adapters. */
|
|
@@ -52,12 +52,14 @@ export function executeExternalLeaf(spec, input) {
|
|
|
52
52
|
input,
|
|
53
53
|
context: {
|
|
54
54
|
cwd: process.cwd(),
|
|
55
|
-
plugin
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
...(spec.plugin !== undefined
|
|
56
|
+
? { plugin: {
|
|
57
|
+
name: spec.plugin.name,
|
|
58
|
+
version: spec.plugin.version ?? spec.plugin.manifest.version ?? null,
|
|
59
|
+
scope: spec.plugin.scope,
|
|
60
|
+
root: spec.plugin.root,
|
|
61
|
+
} }
|
|
62
|
+
: { extension: spec.extension }),
|
|
61
63
|
},
|
|
62
64
|
};
|
|
63
65
|
const res = spawnSync(spec.executable, ['--crtr-command-protocol', '1'], {
|
|
@@ -207,5 +209,7 @@ function protocolError(spec, received, expected) {
|
|
|
207
209
|
return new CrtrError('plugin_protocol_error', `${label(spec)}: ${received}. Expected ${expected}.`, ExitCode.GENERAL, { received, next: NEXT });
|
|
208
210
|
}
|
|
209
211
|
function label(spec) {
|
|
210
|
-
return
|
|
212
|
+
return spec.plugin !== undefined
|
|
213
|
+
? `plugin "${spec.plugin.name}" command \`${spec.commandPath.join(' ')}\``
|
|
214
|
+
: `extension branch "${spec.extension.branch}" command \`${spec.commandPath.join(' ')}\``;
|
|
211
215
|
}
|