@north-light/crouter 0.3.259 → 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/clients/attach/viewer.js +405 -405
- package/dist/commands/pkg/plugin-inspect.js +2 -1
- 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/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/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -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
|
}
|
package/dist/core/help.d.ts
CHANGED
|
@@ -198,6 +198,11 @@ export interface BranchHelp {
|
|
|
198
198
|
/** Parent-level listing assembled by defineBranch from the actual child defs.
|
|
199
199
|
* renderBranch reads this; never author it by hand. */
|
|
200
200
|
listing?: ListingChild[];
|
|
201
|
+
/** A rejected repository fragment is visible only on its owning extensible branch. */
|
|
202
|
+
extensionIssue?: {
|
|
203
|
+
fragment: string;
|
|
204
|
+
message: string;
|
|
205
|
+
};
|
|
201
206
|
}
|
|
202
207
|
/** Viewer-only hint about how a leaf's result should preview in the attach
|
|
203
208
|
* viewer's chat — never part of `-h` output and never reaches agent-facing
|
package/dist/core/help.js
CHANGED
|
@@ -161,6 +161,9 @@ export function renderBranch(h) {
|
|
|
161
161
|
lines.push('');
|
|
162
162
|
lines.push(`<subcommand name="${c.name}" description="${attr(c.description)}" whenToUse="${attr(c.whenToUse)}"${subs}/>`);
|
|
163
163
|
}
|
|
164
|
+
if (h.extensionIssue !== undefined) {
|
|
165
|
+
lines.push(`<extension-issue fragment="${attr(h.extensionIssue.fragment)}">This repository's contributed commands were rejected: ${h.extensionIssue.message}. Run \`crtr sys doctor\` for the full report.</extension-issue>`);
|
|
166
|
+
}
|
|
164
167
|
lines.push('</command>');
|
|
165
168
|
return lines.join('\n');
|
|
166
169
|
}
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.260",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.260",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|