@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
|
@@ -106,7 +106,7 @@ export const pluginShow = defineLeaf({
|
|
|
106
106
|
{ name: 'enabled', type: 'boolean', required: true, constraint: 'Whether the plugin is active.' },
|
|
107
107
|
{ name: 'manifest', type: 'object', required: true, constraint: 'Full plugin.json contents.' },
|
|
108
108
|
{ name: 'docs', type: 'object[]', required: true, constraint: 'Each: {name, path}. Memory docs provided by the plugin (its `<pluginName>/` memory subtree).' },
|
|
109
|
-
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, mounts: string[] (accepted top-level command names), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically; command execution never occurs.' },
|
|
109
|
+
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, mounts: string[] (accepted top-level command names), extensibleBranches: string[] (mount names accepting repository fragments), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically; command execution never occurs.' },
|
|
110
110
|
{ name: 'hooks', type: 'object', required: false, constraint: 'Present for every hook declaration, including hook-only plugins. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], issues: object[] ({code, path?, message, received, expected, next}), trust: string}. Static only: no hook executable runs.' },
|
|
111
111
|
],
|
|
112
112
|
outputKind: 'object',
|
|
@@ -146,6 +146,7 @@ export const pluginShow = defineLeaf({
|
|
|
146
146
|
commands = {
|
|
147
147
|
manifestPath: v.manifestPath,
|
|
148
148
|
mounts: v.contributions.map((c) => c.node.name),
|
|
149
|
+
extensibleBranches: v.contributions.filter((c) => c.node.extensible === true).map((c) => c.node.name),
|
|
149
150
|
issues: v.issues,
|
|
150
151
|
};
|
|
151
152
|
}
|
|
@@ -1 +1,36 @@
|
|
|
1
|
+
import { type Scope } from '../../types.js';
|
|
2
|
+
type CheckStatus = 'pass' | 'fail';
|
|
3
|
+
type CheckScope = Scope | 'canvas';
|
|
4
|
+
/**
|
|
5
|
+
* Structured fix action for a failing check. Surfaced on each
|
|
6
|
+
* non-pass result so an agent reading doctor output can apply it directly
|
|
7
|
+
* when `--fix` is not used (or when --fix did not auto-apply). Every
|
|
8
|
+
* remediation includes the exact action and payload — no inference required.
|
|
9
|
+
*/
|
|
10
|
+
interface Remediation {
|
|
11
|
+
kind: 'remove_config_key' | 'rm_path' | 'human_action';
|
|
12
|
+
description: string;
|
|
13
|
+
scope?: Scope;
|
|
14
|
+
configKey?: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
command?: string;
|
|
17
|
+
}
|
|
18
|
+
interface CheckResult {
|
|
19
|
+
scope: CheckScope;
|
|
20
|
+
name: string;
|
|
21
|
+
status: CheckStatus;
|
|
22
|
+
message: string;
|
|
23
|
+
fixed?: boolean;
|
|
24
|
+
remediation?: Remediation;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Validate the command manifest + executable path of every effective command
|
|
28
|
+
* plugin (the deduped, enabled set that actually composes into the tree).
|
|
29
|
+
* Purely static — the plugin binary is NEVER executed, and no remediation ever
|
|
30
|
+
* chmods or rewrites third-party plugin content (every fix is a human_action:
|
|
31
|
+
* disable/update/remove the plugin). Only plugins whose scope is in `scopes`
|
|
32
|
+
* are reported, so `--scope` narrows these checks too.
|
|
33
|
+
*/
|
|
34
|
+
export declare function runCommandPluginChecks(scopes: Scope[]): Promise<CheckResult[]>;
|
|
1
35
|
export declare const sysDoctorLeaf: import("../../core/command.js").LeafDef;
|
|
36
|
+
export {};
|
|
@@ -14,6 +14,9 @@ import { readFault } from '../../core/runtime/fault.js';
|
|
|
14
14
|
import { detectNerdFont, nerdFontInstallCommand, NERD_FONT_SELECT_HINT } from '../../core/runtime/nerd-font.js';
|
|
15
15
|
import { detectPackageManager } from './setup-core.js';
|
|
16
16
|
import { validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
|
|
17
|
+
import { adaptPluginContributions } from '../../core/command-plugins/compose.js';
|
|
18
|
+
import { resolveCommandRegistry } from '../../core/command-manifests/registry.js';
|
|
19
|
+
import { applyCommandExtensions, orphanCommandExtensionValidations } from '../../core/command-plugins/extensions.js';
|
|
17
20
|
import { discoverHookRegistry } from '../../core/command-hooks/discovery.js';
|
|
18
21
|
import { resolveBinContributions } from '../../core/runtime/bin-contributions.js';
|
|
19
22
|
import { inspectHumanActions } from '../../core/human-actions.js';
|
|
@@ -116,7 +119,7 @@ function faultRemediation(fault, nodeId) {
|
|
|
116
119
|
* disable/update/remove the plugin). Only plugins whose scope is in `scopes`
|
|
117
120
|
* are reported, so `--scope` narrows these checks too.
|
|
118
121
|
*/
|
|
119
|
-
async function runCommandPluginChecks(scopes) {
|
|
122
|
+
export async function runCommandPluginChecks(scopes) {
|
|
120
123
|
const results = [];
|
|
121
124
|
const inScope = new Set(scopes);
|
|
122
125
|
const reserved = new Set(SUBTREE_NAMES);
|
|
@@ -124,7 +127,8 @@ async function runCommandPluginChecks(scopes) {
|
|
|
124
127
|
// collisions surface here. A collision no longer costs anyone their command:
|
|
125
128
|
// each claimant mounts under an origin-qualified name, so it is reported as a
|
|
126
129
|
// healthy mount rather than a failure demanding the user remove a plugin.
|
|
127
|
-
|
|
130
|
+
const validations = validateEffectiveCommandPlugins(reserved, undefined, undefined, await coreCommandPaths());
|
|
131
|
+
for (const v of validations) {
|
|
128
132
|
const plugin = v.plugin;
|
|
129
133
|
if (!inScope.has(plugin.scope))
|
|
130
134
|
continue;
|
|
@@ -143,6 +147,22 @@ async function runCommandPluginChecks(scopes) {
|
|
|
143
147
|
results.push(failCheck(plugin.scope, `plugin:${plugin.name}:commands[${i}]`, `${issue.code}${detail}: ${issue.message} (received ${issue.received}, expected ${issue.expected})`, remediation));
|
|
144
148
|
});
|
|
145
149
|
}
|
|
150
|
+
if (inScope.has('project')) {
|
|
151
|
+
const registry = resolveCommandRegistry(adaptPluginContributions(validations.flatMap((validation) => validation.contributions)).contributions, reserved);
|
|
152
|
+
const extensions = applyCommandExtensions(registry.contributions);
|
|
153
|
+
const allExtensionChecks = [...extensions.validations, ...orphanCommandExtensionValidations(registry.contributions)];
|
|
154
|
+
for (const extension of allExtensionChecks) {
|
|
155
|
+
if (extension.issue === undefined) {
|
|
156
|
+
results.push(pass('project', `extension:${extension.branch}`, `repository fragment valid: ${extension.fragmentPath}`));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const issue = extension.issue;
|
|
160
|
+
results.push(failCheck('project', `extension:${extension.branch}`, `${issue.code} at ${issue.path ?? extension.fragmentPath}: ${issue.message} (received ${issue.received}, expected ${issue.expected})`, {
|
|
161
|
+
kind: 'human_action',
|
|
162
|
+
description: issue.next,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
146
166
|
return results;
|
|
147
167
|
}
|
|
148
168
|
/** Statically inspect the complete effective hook registry. This shares the
|
|
@@ -29,7 +29,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
29
29
|
import { tmpdir } from 'node:os';
|
|
30
30
|
import { join, dirname } from 'node:path';
|
|
31
31
|
import { fileURLToPath } from 'node:url';
|
|
32
|
-
import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, } from '../../command-plugins/discovery.js';
|
|
32
|
+
import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, buildExternalCommandSnapshot, } from '../../command-plugins/discovery.js';
|
|
33
33
|
import { composeExternalSubtrees, adaptPluginContributions } from '../../command-plugins/compose.js';
|
|
34
34
|
import { collectHelpAddenda } from '../../command-plugins/help-addenda.js';
|
|
35
35
|
import { resolveCommandRegistry } from '../../command-manifests/registry.js';
|
|
@@ -38,9 +38,11 @@ import { renderRoot, renderBranch, renderLeafArgv } from '../../help.js';
|
|
|
38
38
|
import { renderResult } from '../../render.js';
|
|
39
39
|
import { CrtrError } from '../../errors.js';
|
|
40
40
|
import { resetScopeCache } from '../../scope.js';
|
|
41
|
+
import { createProfile } from '../../profiles/manifest.js';
|
|
41
42
|
import { listInstalledPluginsInRoot } from '../../resolver.js';
|
|
42
43
|
import { pluginShow } from '../../../commands/pkg/plugin-inspect.js';
|
|
43
|
-
import { pluginDisable, pluginEnable, pluginInstall } from '../../../commands/pkg/plugin-manage.js';
|
|
44
|
+
import { pluginDisable, pluginEnable, pluginInstall, pluginUpdate } from '../../../commands/pkg/plugin-manage.js';
|
|
45
|
+
import { runCommandPluginChecks } from '../../../commands/sys/doctor.js';
|
|
44
46
|
// The set of reserved core names discovery must fail closed against. We don't
|
|
45
47
|
// import the real SUBTREE_NAMES (build-root.ts pulls the whole tree); a small
|
|
46
48
|
// explicit set is enough for the collision contract and keeps the test cheap.
|
|
@@ -112,6 +114,45 @@ function commandsJson(topName = 'app') {
|
|
|
112
114
|
],
|
|
113
115
|
};
|
|
114
116
|
}
|
|
117
|
+
function extensibleDemoCommandsJson(extensible = true) {
|
|
118
|
+
return {
|
|
119
|
+
schemaVersion: 1,
|
|
120
|
+
mounts: [{
|
|
121
|
+
parent: [],
|
|
122
|
+
node: {
|
|
123
|
+
kind: 'branch',
|
|
124
|
+
name: 'demo',
|
|
125
|
+
description: 'fixture extensible branch',
|
|
126
|
+
whenToUse: 'testing repository command contributions',
|
|
127
|
+
...(extensible ? { extensible: true } : {}),
|
|
128
|
+
rootEntry: { concept: 'fixture command contributions', description: 'fixture extensible branch', whenToUse: 'testing repository command contributions' },
|
|
129
|
+
summary: 'fixture extensible branch',
|
|
130
|
+
children: [{ kind: 'branch', name: 'built-in', description: 'plugin-owned branch', whenToUse: 'using the plugin-owned command', summary: 'plugin-owned branch', children: [] }],
|
|
131
|
+
},
|
|
132
|
+
}],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function extensionFragment(rootName = 'repo-branch') {
|
|
136
|
+
return {
|
|
137
|
+
schemaVersion: 1,
|
|
138
|
+
transport: { kind: 'exec', executable: 'scripts/extension.js' },
|
|
139
|
+
mounts: [
|
|
140
|
+
{ parent: [], node: { kind: 'branch', name: rootName, description: 'repository branch', whenToUse: 'using repository-defined commands', summary: 'repository branch', children: [] } },
|
|
141
|
+
{ parent: [rootName], node: { kind: 'leaf', name: 'run', description: 'run repository operation', whenToUse: 'testing extension execution', summary: 'run repository operation', params: [{ kind: 'flag', name: 'thing', type: 'string', required: true, constraint: 'fixture input' }], output: [{ name: 'value', type: 'string', required: true, constraint: 'echoed fixture input' }], outputKind: 'object', effects: ['None. Read-only.'] } },
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const EXTENSION_EXEC = `#!/usr/bin/env node
|
|
146
|
+
const fs = require('fs');
|
|
147
|
+
let data = '';
|
|
148
|
+
process.stdin.on('data', (chunk) => data += chunk);
|
|
149
|
+
process.stdin.on('end', () => {
|
|
150
|
+
const request = JSON.parse(data);
|
|
151
|
+
if (process.env.EXTENSION_REQUEST_LOG) fs.writeFileSync(process.env.EXTENSION_REQUEST_LOG, JSON.stringify(request));
|
|
152
|
+
if (process.env.EXTENSION_SENTINEL) fs.writeFileSync(process.env.EXTENSION_SENTINEL, 'executed');
|
|
153
|
+
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { value: request.input.thing } }));
|
|
154
|
+
});
|
|
155
|
+
`;
|
|
115
156
|
function passthroughCommandsJson() {
|
|
116
157
|
return {
|
|
117
158
|
schemaVersion: 1,
|
|
@@ -210,6 +251,26 @@ after(() => {
|
|
|
210
251
|
}
|
|
211
252
|
resetScopeCache();
|
|
212
253
|
});
|
|
254
|
+
function installExtensionFixture(manifest = extensionFragment()) {
|
|
255
|
+
installPlugin(userRoot, 'extension-fixture', { manifest: extensibleDemoCommandsJson() });
|
|
256
|
+
const commandsDir = join(projectRoot, 'commands');
|
|
257
|
+
const scriptsDir = join(projectDir, 'scripts');
|
|
258
|
+
mkdirSync(commandsDir, { recursive: true });
|
|
259
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
260
|
+
const fragmentPath = join(commandsDir, 'demo.json');
|
|
261
|
+
const executable = join(scriptsDir, 'extension.js');
|
|
262
|
+
writeFileSync(fragmentPath, JSON.stringify(manifest));
|
|
263
|
+
writeFileSync(executable, EXTENSION_EXEC);
|
|
264
|
+
chmodSync(executable, 0o755);
|
|
265
|
+
return { fragmentPath, executable };
|
|
266
|
+
}
|
|
267
|
+
function extensionBranch(startDir = projectDir, name = 'demo') {
|
|
268
|
+
resetScopeCache();
|
|
269
|
+
const snapshot = buildExternalCommandSnapshot(RESERVED, startDir);
|
|
270
|
+
const branch = composeExternalSubtrees(snapshot).find((node) => node.name === name);
|
|
271
|
+
assert.ok(branch, `expected the extensible ${name} branch`);
|
|
272
|
+
return branch;
|
|
273
|
+
}
|
|
213
274
|
function leafOf(branch, name) {
|
|
214
275
|
const c = branch.children.find((x) => x.name === name);
|
|
215
276
|
assert.ok(c !== undefined && c.kind === 'leaf', `expected leaf ${name}`);
|
|
@@ -384,6 +445,260 @@ describe('end-to-end CLI invocation', () => {
|
|
|
384
445
|
assert.equal(obj.status, 'running');
|
|
385
446
|
});
|
|
386
447
|
});
|
|
448
|
+
// Extensible plugin command branches — repository fragments attach to a marked
|
|
449
|
+
// top-level branch as one native forest and stay entirely inside the existing
|
|
450
|
+
// parser/help/exec contract.
|
|
451
|
+
describe('extensible plugin command branches', () => {
|
|
452
|
+
test('merges native help, executes with extension context, and mirrors JSON', async () => {
|
|
453
|
+
const { fragmentPath } = installExtensionFixture();
|
|
454
|
+
const branch = extensionBranch();
|
|
455
|
+
const help = renderBranch(branch.help);
|
|
456
|
+
assert.match(help, /<command name="demo"/);
|
|
457
|
+
assert.match(help, /<subcommand name="built-in"/);
|
|
458
|
+
assert.match(help, /<subcommand name="repo-branch"[^>]*subcommands="1"/);
|
|
459
|
+
assert.ok(help.indexOf('name="built-in"') < help.indexOf('name="repo-branch"'));
|
|
460
|
+
assert.equal((help.match(/<command name=/g) ?? []).length, 1);
|
|
461
|
+
const repoBranch = branch.children.find((child) => child.name === 'repo-branch');
|
|
462
|
+
assert.ok(repoBranch !== undefined && repoBranch.kind === 'branch');
|
|
463
|
+
const leaf = leafOf(repoBranch, 'run');
|
|
464
|
+
const leafHelp = renderLeafArgv(leaf.help);
|
|
465
|
+
assert.match(leafHelp, /^demo repo-branch run: run repository operation\./);
|
|
466
|
+
assert.match(leafHelp, /Input/);
|
|
467
|
+
assert.match(leafHelp, /Output \(fields carried in the rendered result\)/);
|
|
468
|
+
assert.match(leafHelp, /Effects/);
|
|
469
|
+
const requestLog = join(projectDir, 'extension-request.json');
|
|
470
|
+
process.env['EXTENSION_REQUEST_LOG'] = requestLog;
|
|
471
|
+
const priorCwd = process.cwd();
|
|
472
|
+
process.chdir(projectDir);
|
|
473
|
+
try {
|
|
474
|
+
assert.deepEqual(await leaf.run({ thing: 'x' }), { value: 'x' });
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
process.chdir(priorCwd);
|
|
478
|
+
delete process.env['EXTENSION_REQUEST_LOG'];
|
|
479
|
+
}
|
|
480
|
+
const request = JSON.parse(readFileSync(requestLog, 'utf8'));
|
|
481
|
+
assert.deepEqual(request.command, ['demo', 'repo-branch', 'run']);
|
|
482
|
+
assert.deepEqual(request.input, { thing: 'x' });
|
|
483
|
+
assert.deepEqual(request.context.extension, { branch: 'demo', root: projectDir });
|
|
484
|
+
assert.equal(request.context.plugin, undefined);
|
|
485
|
+
const root = defineRoot({ tagline: 'test', globals: [], subtrees: [branch] });
|
|
486
|
+
assert.match(renderRoot(root.help), /\[\+2 subcommands — `crtr demo -h`\]/);
|
|
487
|
+
assert.equal(existsSync(join(projectDir, 'EXECUTED')), false);
|
|
488
|
+
assert.ok(existsSync(fragmentPath));
|
|
489
|
+
});
|
|
490
|
+
test('uses a collision-qualified branch name for fragment discovery, doctor, and exec context', async () => {
|
|
491
|
+
installPlugin(userRoot, 'first', { manifest: extensibleDemoCommandsJson() });
|
|
492
|
+
installPlugin(userRoot, 'second', { manifest: extensibleDemoCommandsJson() });
|
|
493
|
+
const commandsDir = join(projectRoot, 'commands');
|
|
494
|
+
const scriptsDir = join(projectDir, 'scripts');
|
|
495
|
+
mkdirSync(commandsDir, { recursive: true });
|
|
496
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
497
|
+
writeFileSync(join(commandsDir, 'first:demo.json'), JSON.stringify(extensionFragment()));
|
|
498
|
+
writeFileSync(join(scriptsDir, 'extension.js'), EXTENSION_EXEC);
|
|
499
|
+
chmodSync(join(scriptsDir, 'extension.js'), 0o755);
|
|
500
|
+
const first = extensionBranch(projectDir, 'first:demo');
|
|
501
|
+
const second = extensionBranch(projectDir, 'second:demo');
|
|
502
|
+
assert.ok(first.children.some((child) => child.name === 'repo-branch'));
|
|
503
|
+
assert.ok(!second.children.some((child) => child.name === 'repo-branch'));
|
|
504
|
+
const repoBranch = first.children.find((child) => child.name === 'repo-branch');
|
|
505
|
+
assert.ok(repoBranch !== undefined && repoBranch.kind === 'branch');
|
|
506
|
+
const requestLog = join(projectDir, 'collision-extension-request.json');
|
|
507
|
+
process.env['EXTENSION_REQUEST_LOG'] = requestLog;
|
|
508
|
+
const priorCwd = process.cwd();
|
|
509
|
+
process.chdir(projectDir);
|
|
510
|
+
try {
|
|
511
|
+
assert.deepEqual(await leafOf(repoBranch, 'run').run({ thing: 'x' }), { value: 'x' });
|
|
512
|
+
const checks = await runCommandPluginChecks(['project']);
|
|
513
|
+
const extension = checks.find((check) => check.name === 'extension:first:demo');
|
|
514
|
+
assert.ok(extension, JSON.stringify(checks));
|
|
515
|
+
assert.equal(extension.status, 'pass');
|
|
516
|
+
}
|
|
517
|
+
finally {
|
|
518
|
+
process.chdir(priorCwd);
|
|
519
|
+
delete process.env['EXTENSION_REQUEST_LOG'];
|
|
520
|
+
}
|
|
521
|
+
const request = JSON.parse(readFileSync(requestLog, 'utf8'));
|
|
522
|
+
assert.deepEqual(request.command, ['first:demo', 'repo-branch', 'run']);
|
|
523
|
+
assert.deepEqual(request.context.extension, { branch: 'first:demo', root: projectDir });
|
|
524
|
+
assert.equal(request.context.plugin, undefined);
|
|
525
|
+
});
|
|
526
|
+
test('runs through the real CLI with native help and --json output', { timeout: 40000 }, () => {
|
|
527
|
+
installExtensionFixture();
|
|
528
|
+
const cli = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', 'dist', 'cli.js');
|
|
529
|
+
const run = (args) => execFileSync(process.execPath, [cli, ...args], {
|
|
530
|
+
cwd: projectDir,
|
|
531
|
+
env: { ...process.env, HOME: home, CRTR_FRONT_DOOR: '' },
|
|
532
|
+
encoding: 'utf8',
|
|
533
|
+
});
|
|
534
|
+
const branchHelp = run(['demo', '-h']);
|
|
535
|
+
assert.match(branchHelp, /<command name="demo"/);
|
|
536
|
+
assert.match(branchHelp, /<subcommand name="built-in"/);
|
|
537
|
+
assert.match(branchHelp, /<subcommand name="repo-branch"/);
|
|
538
|
+
assert.deepEqual(JSON.parse(run(['demo', 'repo-branch', 'run', '--thing', 'x', '--json'])), { value: 'x' });
|
|
539
|
+
});
|
|
540
|
+
test('discovers per invocation, scopes by cwd, and nearest root wins', () => {
|
|
541
|
+
const { fragmentPath } = installExtensionFixture();
|
|
542
|
+
assert.ok(renderBranch(extensionBranch().help).includes('repo-branch'));
|
|
543
|
+
writeFileSync(fragmentPath, JSON.stringify(extensionFragment('changed')));
|
|
544
|
+
assert.ok(renderBranch(extensionBranch().help).includes('name="changed"'));
|
|
545
|
+
rmSync(fragmentPath);
|
|
546
|
+
assert.ok(!renderBranch(extensionBranch().help).includes('repo-branch'));
|
|
547
|
+
writeFileSync(fragmentPath, JSON.stringify(extensionFragment('outer')));
|
|
548
|
+
const inner = join(projectDir, 'nested');
|
|
549
|
+
const innerScope = join(inner, '.crouter', 'commands');
|
|
550
|
+
mkdirSync(innerScope, { recursive: true });
|
|
551
|
+
mkdirSync(join(inner, 'scripts'), { recursive: true });
|
|
552
|
+
writeFileSync(join(inner, 'scripts', 'extension.js'), EXTENSION_EXEC);
|
|
553
|
+
chmodSync(join(inner, 'scripts', 'extension.js'), 0o755);
|
|
554
|
+
writeFileSync(join(innerScope, 'demo.json'), JSON.stringify(extensionFragment('inner')));
|
|
555
|
+
assert.ok(renderBranch(extensionBranch(inner).help).includes('name="inner"'));
|
|
556
|
+
assert.ok(!renderBranch(extensionBranch(inner).help).includes('name="outer"'));
|
|
557
|
+
assert.ok(!renderBranch(extensionBranch(emptyStart).help).includes('name="outer"'));
|
|
558
|
+
});
|
|
559
|
+
test('does not discover fragments from selected-profile projects outside the cwd chain', () => {
|
|
560
|
+
installPlugin(userRoot, 'extension-fixture', { manifest: extensibleDemoCommandsJson() });
|
|
561
|
+
const profileProject = mintDir('crtr-cmdplugin-profile-project-');
|
|
562
|
+
const fragmentDir = join(profileProject, '.crouter', 'commands');
|
|
563
|
+
const scriptDir = join(profileProject, 'scripts');
|
|
564
|
+
mkdirSync(fragmentDir, { recursive: true });
|
|
565
|
+
mkdirSync(scriptDir, { recursive: true });
|
|
566
|
+
writeFileSync(join(fragmentDir, 'demo.json'), JSON.stringify(extensionFragment()));
|
|
567
|
+
writeFileSync(join(scriptDir, 'extension.js'), EXTENSION_EXEC);
|
|
568
|
+
chmodSync(join(scriptDir, 'extension.js'), 0o755);
|
|
569
|
+
process.env['CRTR_PROFILE_ID'] = createProfile('extension-fragment-scope', [{ path: profileProject, memory: 'content' }]).profileId;
|
|
570
|
+
resetScopeCache();
|
|
571
|
+
assert.ok(!renderBranch(extensionBranch(emptyStart).help).includes('repo-branch'));
|
|
572
|
+
});
|
|
573
|
+
test('rejects absolute executable paths and identifies the offending fragment node', async () => {
|
|
574
|
+
const { fragmentPath, executable } = installExtensionFixture();
|
|
575
|
+
const fragment = extensionFragment();
|
|
576
|
+
fragment.transport.executable = executable;
|
|
577
|
+
writeFileSync(fragmentPath, JSON.stringify(fragment));
|
|
578
|
+
const help = renderBranch(extensionBranch().help);
|
|
579
|
+
assert.match(help, /<extension-issue/);
|
|
580
|
+
assert.ok(!help.includes('repo-branch'));
|
|
581
|
+
const priorCwd = process.cwd();
|
|
582
|
+
process.chdir(projectDir);
|
|
583
|
+
try {
|
|
584
|
+
const checks = await runCommandPluginChecks(['project']);
|
|
585
|
+
const check = checks.find((candidate) => candidate.name === 'extension:demo');
|
|
586
|
+
assert.ok(check, JSON.stringify(checks));
|
|
587
|
+
assert.equal(check.status, 'fail');
|
|
588
|
+
assert.match(check.message, new RegExp(`${fragmentPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:transport\\.executable`));
|
|
589
|
+
}
|
|
590
|
+
finally {
|
|
591
|
+
process.chdir(priorCwd);
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
test('rejects the complete fragment with scoped help notice and doctor remediation', async () => {
|
|
595
|
+
const { fragmentPath } = installExtensionFixture();
|
|
596
|
+
const bad = extensionFragment();
|
|
597
|
+
bad['unknown'] = true;
|
|
598
|
+
writeFileSync(fragmentPath, JSON.stringify(bad));
|
|
599
|
+
const help = renderBranch(extensionBranch().help);
|
|
600
|
+
assert.match(help, /name="built-in"/);
|
|
601
|
+
assert.ok(!help.includes('repo-branch'));
|
|
602
|
+
assert.match(help, new RegExp(`<extension-issue fragment="${fragmentPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
|
|
603
|
+
assert.match(help, /unknown top-level keys/);
|
|
604
|
+
const sentinel = join(projectDir, 'extension-executed');
|
|
605
|
+
process.env['EXTENSION_SENTINEL'] = sentinel;
|
|
606
|
+
const priorCwd = process.cwd();
|
|
607
|
+
process.chdir(projectDir);
|
|
608
|
+
try {
|
|
609
|
+
const checks = await runCommandPluginChecks(['project']);
|
|
610
|
+
const check = checks.find((candidate) => candidate.name === 'extension:demo');
|
|
611
|
+
assert.ok(check, JSON.stringify(checks));
|
|
612
|
+
assert.equal(check.status, 'fail');
|
|
613
|
+
assert.match(check.message, /command_extension_invalid/);
|
|
614
|
+
assert.match(check.message, new RegExp(fragmentPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
615
|
+
assert.match(check.remediation.description, /regenerate/i);
|
|
616
|
+
}
|
|
617
|
+
finally {
|
|
618
|
+
process.chdir(priorCwd);
|
|
619
|
+
delete process.env['EXTENSION_SENTINEL'];
|
|
620
|
+
}
|
|
621
|
+
assert.equal(existsSync(sentinel), false);
|
|
622
|
+
});
|
|
623
|
+
test('requires the marker, rejects passthrough/rootEntry/conflicts, and leaves unmarked passthrough inert', async () => {
|
|
624
|
+
const { fragmentPath } = installExtensionFixture();
|
|
625
|
+
writeFileSync(join(userRoot, 'plugins', 'extension-fixture', 'commands.json'), JSON.stringify(extensibleDemoCommandsJson(false)));
|
|
626
|
+
assert.ok(!renderBranch(extensionBranch().help).includes('repo-branch'));
|
|
627
|
+
const priorCwd = process.cwd();
|
|
628
|
+
process.chdir(projectDir);
|
|
629
|
+
try {
|
|
630
|
+
const checks = await runCommandPluginChecks(['project']);
|
|
631
|
+
const orphan = checks.find((candidate) => candidate.name === 'extension:demo');
|
|
632
|
+
assert.ok(orphan, JSON.stringify(checks));
|
|
633
|
+
assert.equal(orphan.status, 'fail');
|
|
634
|
+
assert.match(orphan.message, /no effective plugin branch is extensible/);
|
|
635
|
+
}
|
|
636
|
+
finally {
|
|
637
|
+
process.chdir(priorCwd);
|
|
638
|
+
}
|
|
639
|
+
writeFileSync(join(userRoot, 'plugins', 'extension-fixture', 'commands.json'), JSON.stringify(extensibleDemoCommandsJson()));
|
|
640
|
+
const variants = [
|
|
641
|
+
['passthrough', (fragment) => { fragment['mounts'][0]['node']['passthrough'] = { bin: 'x', installHint: 'x' }; }],
|
|
642
|
+
['rootEntry', (fragment) => { fragment['mounts'][0]['node']['rootEntry'] = { concept: 'x', description: 'x', whenToUse: 'x' }; }],
|
|
643
|
+
];
|
|
644
|
+
for (const [name, mutate] of variants) {
|
|
645
|
+
const fragment = extensionFragment();
|
|
646
|
+
mutate(fragment);
|
|
647
|
+
writeFileSync(fragmentPath, JSON.stringify(fragment));
|
|
648
|
+
const help = renderBranch(extensionBranch().help);
|
|
649
|
+
assert.match(help, /<extension-issue/);
|
|
650
|
+
assert.ok(!help.includes('repo-branch'), name);
|
|
651
|
+
}
|
|
652
|
+
writeFileSync(fragmentPath, JSON.stringify(extensionFragment('built-in')));
|
|
653
|
+
const conflictHelp = renderBranch(extensionBranch().help);
|
|
654
|
+
assert.match(conflictHelp, /<extension-issue/);
|
|
655
|
+
assert.match(conflictHelp, /conflicts with a child/);
|
|
656
|
+
installPlugin(userRoot, 'passthrough-fixture', { manifest: passthroughCommandsJson() });
|
|
657
|
+
writeFileSync(join(projectRoot, 'commands', 'capture.json'), JSON.stringify(extensionFragment()));
|
|
658
|
+
resetScopeCache();
|
|
659
|
+
const snapshot = buildExternalCommandSnapshot(RESERVED, projectDir);
|
|
660
|
+
const capture = composeExternalSubtrees(snapshot).find((branch) => branch.name === 'capture');
|
|
661
|
+
assert.ok(capture?.passthrough);
|
|
662
|
+
assert.equal(capture.children.length, 0);
|
|
663
|
+
});
|
|
664
|
+
test('install and update validate the marker but never inspect repository fragments', async () => {
|
|
665
|
+
const source = join(mintDir('crtr-extensible-source-'), 'fixture');
|
|
666
|
+
mkdirSync(join(source, '.crouter-plugin'), { recursive: true });
|
|
667
|
+
mkdirSync(join(source, 'bin'), { recursive: true });
|
|
668
|
+
const manifest = extensibleDemoCommandsJson();
|
|
669
|
+
manifest.mounts[0].node['name'] = 'install-demo';
|
|
670
|
+
const rootEntry = manifest.mounts[0].node['rootEntry'];
|
|
671
|
+
rootEntry['concept'] = 'install fixture';
|
|
672
|
+
writeFileSync(join(source, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'install-extension-fixture', version: '0.1.0', description: 'fixture', commands: 'commands.json', transport: { kind: 'exec', executable: 'bin/cmd.js' } }));
|
|
673
|
+
writeFileSync(join(source, 'commands.json'), JSON.stringify(manifest));
|
|
674
|
+
writeFileSync(join(source, 'bin', 'cmd.js'), EXTENSION_EXEC);
|
|
675
|
+
chmodSync(join(source, 'bin', 'cmd.js'), 0o755);
|
|
676
|
+
mkdirSync(join(projectRoot, 'commands'), { recursive: true });
|
|
677
|
+
writeFileSync(join(projectRoot, 'commands', 'install-demo.json'), JSON.stringify({ unknown: true }));
|
|
678
|
+
await pluginInstall.run({ installRef: source, scope: 'user' });
|
|
679
|
+
(manifest.mounts[0].node['children'][0]['extensible'] = true);
|
|
680
|
+
writeFileSync(join(source, 'commands.json'), JSON.stringify(manifest));
|
|
681
|
+
await assert.rejects(pluginUpdate.run({ name: 'install-extension-fixture' }), /extensible is valid only on a top-level branch mount/);
|
|
682
|
+
});
|
|
683
|
+
test('plugin inspection identifies extensible branches without executing the fragment executable', async () => {
|
|
684
|
+
const { executable } = installExtensionFixture();
|
|
685
|
+
const sentinel = join(projectDir, 'inspect-executed');
|
|
686
|
+
process.env['EXTENSION_SENTINEL'] = sentinel;
|
|
687
|
+
const priorCwd = process.cwd();
|
|
688
|
+
process.chdir(projectDir);
|
|
689
|
+
try {
|
|
690
|
+
const shown = await pluginShow.run({ name: 'extension-fixture' });
|
|
691
|
+
const commands = shown['commands'];
|
|
692
|
+
assert.deepEqual(commands.extensibleBranches, ['demo']);
|
|
693
|
+
}
|
|
694
|
+
finally {
|
|
695
|
+
process.chdir(priorCwd);
|
|
696
|
+
delete process.env['EXTENSION_SENTINEL'];
|
|
697
|
+
}
|
|
698
|
+
assert.equal(existsSync(sentinel), false);
|
|
699
|
+
assert.ok(existsSync(executable));
|
|
700
|
+
});
|
|
701
|
+
});
|
|
387
702
|
// 5. Lifecycle without restart (per-invocation discovery)
|
|
388
703
|
describe('lifecycle without restart', () => {
|
|
389
704
|
test('install → appears; disable → gone; edit → visible; remove → gone', () => {
|
|
@@ -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 {};
|