@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.
@@ -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
- for (const v of validateEffectiveCommandPlugins(reserved, undefined, undefined, await coreCommandPaths())) {
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, TransportKind, DeclBranch, ManifestTimeouts, CommandManifestIssue } from './schema.js';
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, each ready to mount at parent []. */
7
- roots: DeclBranch<DeclLeaf>[];
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.transport === 'http'
42
- ? new Set(['schemaVersion', 'baseUrl', 'timeouts', 'mounts', 'helpAddenda'])
43
- : new Set(['schemaVersion', 'mounts', 'helpAddenda']);
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
- // Top-level mount must be a branch with rootEntry
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
- kind: 'branch',
242
- name: m.node.name,
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
- const child = root;
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 declare function validateCommandNode(raw: unknown, path: string[], topLevel: boolean, transport: TransportKind, issue: IssueFn): DeclBranch<DeclLeaf> | DeclLeaf | null;
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 {};