@crouton-kit/crouter 0.3.78 → 0.3.79

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.
Files changed (72) hide show
  1. package/dist/build-root.d.ts +12 -4
  2. package/dist/build-root.js +25 -6
  3. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
  7. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
  8. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  9. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  10. package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
  11. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  12. package/dist/clients/attach/attach-cmd.js +849 -803
  13. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  14. package/dist/clients/attach/auth-pickers.js +64 -15
  15. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  16. package/dist/clients/attach/graph-overlay.js +83 -33
  17. package/dist/commands/human/queue.js +3 -4
  18. package/dist/commands/pkg/plugin-inspect.js +22 -1
  19. package/dist/commands/pkg/plugin-manage.js +31 -9
  20. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  21. package/dist/commands/sys/doctor.js +42 -4
  22. package/dist/commands/sys/setup-core.d.ts +37 -0
  23. package/dist/commands/sys/setup-core.js +138 -1
  24. package/dist/commands/sys/setup.d.ts +88 -0
  25. package/dist/commands/sys/setup.js +915 -171
  26. package/dist/commands/view-pick.d.ts +4 -0
  27. package/dist/commands/view-pick.js +17 -7
  28. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  29. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  30. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  31. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  32. package/dist/core/__tests__/command-plugins.test.js +444 -0
  33. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  34. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  35. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  36. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  37. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  38. package/dist/core/canvas/browse/app.d.ts +6 -0
  39. package/dist/core/canvas/browse/app.js +71 -41
  40. package/dist/core/command-plugins/adapter.d.ts +15 -0
  41. package/dist/core/command-plugins/adapter.js +145 -0
  42. package/dist/core/command-plugins/compose.d.ts +5 -0
  43. package/dist/core/command-plugins/compose.js +56 -0
  44. package/dist/core/command-plugins/discovery.d.ts +104 -0
  45. package/dist/core/command-plugins/discovery.js +565 -0
  46. package/dist/core/config.d.ts +2 -5
  47. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  48. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  49. package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
  50. package/dist/core/keybindings/catalog.d.ts +3 -3
  51. package/dist/core/keybindings/catalog.js +2 -1
  52. package/dist/core/profiles/select.d.ts +6 -0
  53. package/dist/core/profiles/select.js +86 -52
  54. package/dist/core/provider-management.d.ts +12 -0
  55. package/dist/core/provider-management.js +24 -0
  56. package/dist/core/runtime/broker.js +7 -6
  57. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  58. package/dist/core/runtime/pi-vendored.js +14 -0
  59. package/dist/core/runtime/recap.d.ts +1 -1
  60. package/dist/core/runtime/recap.js +50 -25
  61. package/dist/core/runtime/session-list-cache.d.ts +10 -0
  62. package/dist/core/runtime/session-list-cache.js +94 -26
  63. package/dist/core/runtime/spawn.js +1 -13
  64. package/dist/core/runtime/tmux.js +2 -1
  65. package/dist/core/scope.js +27 -4
  66. package/dist/core/subscription-state.d.ts +90 -0
  67. package/dist/core/subscription-state.js +762 -0
  68. package/dist/daemon/crtrd.js +253 -12
  69. package/dist/pi-extensions/canvas-recap.js +43 -17
  70. package/dist/types.d.ts +6 -13
  71. package/dist/types.js +0 -3
  72. package/package.json +7 -3
@@ -1,2 +1,6 @@
1
1
  import type { LeafDef } from '../core/command.js';
2
+ import { type BindingId, type BindingResolution, type RawTerminalInput } from '../core/keybindings/index.js';
3
+ declare const VIEW_PICK_ACTIONS: readonly ["crtr.view-picker.quit", "crtr.view-picker.down", "crtr.view-picker.up", "crtr.view-picker.run"];
4
+ export declare function resolveViewPickAction(bindings: BindingResolution<BindingId>, raw: RawTerminalInput): (typeof VIEW_PICK_ACTIONS)[number] | null;
2
5
  export declare const viewPickLeaf: LeafDef;
6
+ export {};
@@ -10,6 +10,16 @@ import { defineLeaf } from '../core/command.js';
10
10
  import { listViews } from '../core/view/loader.js';
11
11
  import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from '../core/tui/terminal.js';
12
12
  import { createDraw, detectColorCaps } from '../core/tui/draw.js';
13
+ import { formatBinding, matchesTerminalInput, resolveUserKeybindings, } from '../core/keybindings/index.js';
14
+ const VIEW_PICK_ACTIONS = [
15
+ 'crtr.view-picker.quit',
16
+ 'crtr.view-picker.down',
17
+ 'crtr.view-picker.up',
18
+ 'crtr.view-picker.run',
19
+ ];
20
+ export function resolveViewPickAction(bindings, raw) {
21
+ return VIEW_PICK_ACTIONS.find((id) => matchesTerminalInput(bindings, id, raw)) ?? null;
22
+ }
13
23
  export const viewPickLeaf = defineLeaf({
14
24
  name: 'pick',
15
25
  tier: 'hidden',
@@ -42,6 +52,7 @@ export const viewPickLeaf = defineLeaf({
42
52
  return;
43
53
  }
44
54
  const caps = detectColorCaps();
55
+ const bindings = resolveUserKeybindings();
45
56
  let cursor = 0;
46
57
  let scroll = 0;
47
58
  let restored = false;
@@ -58,7 +69,7 @@ export const viewPickLeaf = defineLeaf({
58
69
  const render = () => {
59
70
  const size = getTerminalSize();
60
71
  const { draw, frame } = createDraw(size, caps);
61
- draw.text(0, 0, 'crtr views — j/k move · Enter run · q quit', { bold: true });
72
+ draw.text(0, 0, `crtr views — ${formatBinding(bindings, 'crtr.view-picker.down')}/${formatBinding(bindings, 'crtr.view-picker.up')} move · ${formatBinding(bindings, 'crtr.view-picker.run')} run · ${formatBinding(bindings, 'crtr.view-picker.quit')} quit`, { bold: true });
62
73
  draw.hline(1, 0, size.cols);
63
74
  const rect = { row: 2, col: 0, width: size.cols, height: Math.max(1, size.rows - 3) };
64
75
  const items = views.map((v) => ({
@@ -97,21 +108,20 @@ export const viewPickLeaf = defineLeaf({
97
108
  return;
98
109
  }
99
110
  const { input, key } = parsed;
100
- if (key.ctrl && input === 'c')
111
+ const action = resolveViewPickAction(bindings, { input, ...key });
112
+ if (action === 'crtr.view-picker.quit')
101
113
  finish();
102
- if (input === 'q' || key.escape)
103
- finish();
104
- if (input === 'j' || key.downArrow) {
114
+ if (action === 'crtr.view-picker.down') {
105
115
  cursor = Math.min(views.length - 1, cursor + 1);
106
116
  render();
107
117
  return;
108
118
  }
109
- if (input === 'k' || key.upArrow) {
119
+ if (action === 'crtr.view-picker.up') {
110
120
  cursor = Math.max(0, cursor - 1);
111
121
  render();
112
122
  return;
113
123
  }
114
- if (key.return)
124
+ if (action === 'crtr.view-picker.run')
115
125
  launch(views[cursor].id);
116
126
  });
117
127
  });
@@ -15,12 +15,13 @@ import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher
15
15
  import { appendInbox } from '../feed/inbox.js';
16
16
  import { appendSituationalContext, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../runtime/situational-context.js';
17
17
  // Drive the watcher's injectable cadence seam (CRTR_WATCHER_TICK_MS /
18
- // CRTR_WATCHER_DEBOUNCE_MS) at a fast tempo so the test sleeps milliseconds, not
19
- // seconds. SETTLE_MS still allows a resolve+seed tick, a read tick, and the
20
- // debounce window before asserting, exactly as against the real 800/1000 cadence.
18
+ // CRTR_WATCHER_DEBOUNCE_MS) at a fast tempo so the watcher delivers in tens of
19
+ // milliseconds, not seconds exactly the resolve+seed tick, read tick, and
20
+ // debounce window it runs against the real 800/1000 cadence. Tests then poll for
21
+ // the delivery with waitFor() rather than sleeping a fixed settle window, so a
22
+ // starved interval tick on a loaded CI runner can't race the assertion.
21
23
  const TICK_MS = 20;
22
24
  const DEBOUNCE_MS = 25;
23
- const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS + 30;
24
25
  let origHome;
25
26
  let origNode;
26
27
  const homes = [];
@@ -45,6 +46,23 @@ function makeFakePi() {
45
46
  };
46
47
  }
47
48
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
49
+ // Poll `predicate` until it holds or `timeoutMs` elapses, then return either
50
+ // way. The watcher delivers on its own setInterval cadence, which competes for
51
+ // the event loop with this test's timers; a fixed sleep before asserting is
52
+ // unreliable on an oversubscribed CI runner (--test-concurrency=4), where an
53
+ // interval tick is starved past the debounce window and the assertion runs
54
+ // before the flush — the observed `0 !== 1` flake. Polling returns the instant
55
+ // delivery lands (fast in the common case) and tolerates scheduling jitter with
56
+ // a generous ceiling; returning on timeout lets the following asserts produce a
57
+ // precise message instead of a generic wait error.
58
+ async function waitFor(predicate, timeoutMs = 3000, stepMs = 10) {
59
+ const deadline = Date.now() + timeoutMs;
60
+ while (Date.now() < deadline) {
61
+ if (predicate())
62
+ return;
63
+ await wait(stepMs);
64
+ }
65
+ }
48
66
  let origTick;
49
67
  let origDebounce;
50
68
  before(() => {
@@ -92,7 +110,7 @@ describe('canvas inbox watcher — finished-node delivery', () => {
92
110
  pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
93
111
  await wait(TICK_MS + 100);
94
112
  appendInbox('node-final', { from: 'child-1', tier: 'normal', kind: 'final', label: 'all done' });
95
- await wait(SETTLE_MS);
113
+ await waitFor(() => pi.injected.length >= 1);
96
114
  assert.equal(pi.injected.length, 1, 'one coalesced injection');
97
115
  assert.equal(pi.injected[0].deliverAs, 'steer', 'a finished node steers, not follows up');
98
116
  });
@@ -103,7 +121,7 @@ describe('canvas inbox watcher — finished-node delivery', () => {
103
121
  pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
104
122
  await wait(TICK_MS + 100);
105
123
  appendInbox('node-update', { from: 'child-2', tier: 'normal', kind: 'update', label: 'still working' });
106
- await wait(SETTLE_MS);
124
+ await waitFor(() => pi.injected.length >= 1);
107
125
  assert.equal(pi.injected.length, 1);
108
126
  assert.equal(pi.injected[0].deliverAs, 'followUp', 'a normal update is not urgent → followUp');
109
127
  });
@@ -121,7 +139,10 @@ describe('canvas inbox watcher — hidden situational-context delivery', () => {
121
139
  label: '(ambient context updated)',
122
140
  data: { situational: true, situationalOnly: true },
123
141
  });
124
- await wait(SETTLE_MS);
142
+ // A situational-only flush sets sentMessages (never injected) in a single
143
+ // synchronous flush(), so waiting on the hidden send is sufficient to also
144
+ // settle the injected.length === 0 assertion below.
145
+ await waitFor(() => pi.sentMessages.length >= 1);
125
146
  assert.equal(pi.injected.length, 0, 'a situational-only entry never triggers a visible digest');
126
147
  assert.equal(pi.sentMessages.length, 1, 'exactly one hidden custom message sent');
127
148
  assert.match(pi.sentMessages[0].content, /<situational-context>/);
@@ -144,7 +165,9 @@ describe('canvas inbox watcher — hidden situational-context delivery', () => {
144
165
  label: 'visible update',
145
166
  data: { body: 'the visible message body', situational: true },
146
167
  });
147
- await wait(SETTLE_MS);
168
+ // The hidden send and the visible digest both fire in one synchronous
169
+ // flush(); the injection is the later of the two, so wait on it.
170
+ await waitFor(() => pi.injected.length >= 1 && pi.sentMessages.length >= 1);
148
171
  assert.equal(pi.injected.length, 1, 'the visible body still reaches the digest');
149
172
  assert.match(pi.injected[0].content, /the visible message body/);
150
173
  assert.doesNotMatch(pi.injected[0].content, /hidden ambient checkout state/, 'ambient text never rides the visible digest');
@@ -177,7 +200,9 @@ describe('canvas inbox watcher — hidden situational-context delivery', () => {
177
200
  label: '(ambient context updated)',
178
201
  data: { situational: true, situationalOnly: true },
179
202
  });
180
- await wait(SETTLE_MS * 3);
203
+ // First send throws (re-queued), so a later flush retries: wait for the
204
+ // successful second delivery rather than a fixed multi-settle sleep.
205
+ await waitFor(() => calls >= 2 && pi.sentMessages.length >= 1);
181
206
  assert.ok(calls >= 2, 'sendMessage retried after the first failure instead of being swallowed once');
182
207
  assert.equal(pi.injected.length, 0, 'still never reaches the visible digest');
183
208
  assert.equal(pi.sentMessages.length, 1, 'eventually delivered exactly once after the retry succeeds');
@@ -0,0 +1,298 @@
1
+ // Phase-2 surface tests: the lifecycle surfaces (pkg plugin install / show and
2
+ // sys doctor) must surface the Phase-1 validator's STABLE TYPED ISSUES to the
3
+ // caller, and must do so WITHOUT executing the plugin binary. Covers spec item
4
+ // 6 at the surface seam (the validator itself is tested in
5
+ // command-plugins.test.ts). Run with:
6
+ // node --import tsx/esm --test src/core/__tests__/command-plugins-surfaces.test.ts
7
+ //
8
+ // Every test runs against an ISOLATED home (HOME → temp) with cwd pinned to a
9
+ // dir that has no .crouter ancestor, so discovery is deterministic. Each fixture
10
+ // executable writes a sentinel file if it is ever spawned; every surface
11
+ // assertion also checks the sentinel is absent — proving validation stayed
12
+ // static.
13
+ import { test, describe, before, after, beforeEach } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync, existsSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { pluginInstall, pluginUpdate } from '../../commands/pkg/plugin-manage.js';
19
+ import { pluginShow } from '../../commands/pkg/plugin-inspect.js';
20
+ import { sysDoctorLeaf } from '../../commands/sys/doctor.js';
21
+ import { discoverCommandContributions } from '../command-plugins/discovery.js';
22
+ import { resetScopeCache } from '../scope.js';
23
+ // A real executable that records the fact it ran. No surface should ever spawn
24
+ // it — the sentinel must never appear.
25
+ const SENTINEL_EXEC = `#!/usr/bin/env node
26
+ const fs = require('fs');
27
+ if (process.env.CMDPLUGIN_SENTINEL) fs.writeFileSync(process.env.CMDPLUGIN_SENTINEL, 'executed');
28
+ process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { app_id: 'x', status: 'running' } }));
29
+ `;
30
+ function validManifest() {
31
+ return {
32
+ schemaVersion: 1,
33
+ executable: 'bin/cmd.js',
34
+ mounts: [
35
+ {
36
+ parent: [],
37
+ node: {
38
+ kind: 'branch',
39
+ name: 'app',
40
+ description: 'application lifecycle and inspection',
41
+ whenToUse: 'you need to create, deploy, inspect, or remove an application',
42
+ rootEntry: {
43
+ concept: 'applications hosted by a crouter home',
44
+ description: 'application lifecycle and inspection',
45
+ whenToUse: 'you need to create, deploy, inspect, or remove an application',
46
+ },
47
+ summary: 'application lifecycle and inspection',
48
+ children: [
49
+ {
50
+ kind: 'leaf',
51
+ name: 'show',
52
+ description: 'show one application',
53
+ whenToUse: 'inspect a single application by id',
54
+ summary: 'show one application details',
55
+ params: [{ kind: 'positional', name: 'app-id', required: true, constraint: 'the application id' }],
56
+ output: [{ name: 'app_id', type: 'string', required: true, constraint: 'the echoed application id' }],
57
+ outputKind: 'object',
58
+ effects: ['None. Read-only.'],
59
+ },
60
+ ],
61
+ },
62
+ },
63
+ ],
64
+ };
65
+ }
66
+ const CASES = [
67
+ (() => { const m = validManifest(); m['executable'] = '../../../../bin/sh'; return { name: 'escaping executable path', code: 'command_path_unsafe', manifest: m }; })(),
68
+ { name: 'non-executable file', code: 'command_not_executable', execBit: false },
69
+ (() => { const m = validManifest(); m['schemaVersion'] = 2; return { name: 'unknown schemaVersion', code: 'command_schema_version', manifest: m }; })(),
70
+ (() => { const m = validManifest(); m['requires'] = { crtr: '>=1' }; return { name: 'unknown top-level key', code: 'command_manifest_invalid', manifest: m }; })(),
71
+ (() => { const m = validManifest(); m['mounts'][0].parent = ['sys']; return { name: 'non-empty parent', code: 'command_parent_invalid', manifest: m }; })(),
72
+ ];
73
+ // ---------------------------------------------------------------------------
74
+ // Isolated home + cwd
75
+ // ---------------------------------------------------------------------------
76
+ let home;
77
+ let userRoot;
78
+ let emptyStart;
79
+ let sourceRoot; // holds source plugin dirs for install tests
80
+ let sentinel;
81
+ let realHome;
82
+ let realProfile;
83
+ let realCwd;
84
+ before(() => {
85
+ realHome = process.env['HOME'];
86
+ realProfile = process.env['CRTR_PROFILE_ID'];
87
+ realCwd = process.cwd();
88
+ delete process.env['CRTR_PROFILE_ID'];
89
+ });
90
+ after(() => {
91
+ process.chdir(realCwd);
92
+ if (realHome !== undefined)
93
+ process.env['HOME'] = realHome;
94
+ else
95
+ delete process.env['HOME'];
96
+ if (realProfile !== undefined)
97
+ process.env['CRTR_PROFILE_ID'] = realProfile;
98
+ delete process.env['CMDPLUGIN_SENTINEL'];
99
+ // Remove EVERY test's temp dirs, not just the last beforeEach's set.
100
+ for (const d of tempDirs) {
101
+ try {
102
+ rmSync(d, { recursive: true, force: true });
103
+ }
104
+ catch { /* ignore */ }
105
+ }
106
+ resetScopeCache();
107
+ });
108
+ const tempDirs = [];
109
+ function mintDir(prefix) {
110
+ const d = mkdtempSync(join(tmpdir(), prefix));
111
+ tempDirs.push(d);
112
+ return d;
113
+ }
114
+ beforeEach(() => {
115
+ home = mintDir('crtr-cmdsurf-home-');
116
+ process.env['HOME'] = home;
117
+ userRoot = join(home, '.crouter');
118
+ mkdirSync(join(userRoot, 'plugins'), { recursive: true });
119
+ emptyStart = mintDir('crtr-cmdsurf-empty-');
120
+ sourceRoot = mintDir('crtr-cmdsurf-src-');
121
+ sentinel = join(emptyStart, 'EXECUTED');
122
+ process.env['CMDPLUGIN_SENTINEL'] = sentinel;
123
+ process.chdir(emptyStart); // no .crouter ancestor → project scope is empty
124
+ resetScopeCache();
125
+ });
126
+ /** Write a plugin at `<dir>/<name>/` with a command manifest + executable. */
127
+ function writePlugin(dir, name, c) {
128
+ const root = join(dir, name);
129
+ mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
130
+ mkdirSync(join(root, 'bin'), { recursive: true });
131
+ writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name, version: '0.1.0', description: 'fixture', commands: 'commands.json' }));
132
+ writeFileSync(join(root, 'commands.json'), JSON.stringify(c.manifest ?? validManifest()));
133
+ const execPath = join(root, 'bin', 'cmd.js');
134
+ writeFileSync(execPath, SENTINEL_EXEC);
135
+ chmodSync(execPath, c.execBit === false ? 0o644 : 0o755);
136
+ return root;
137
+ }
138
+ function assertNotExecuted() {
139
+ assert.equal(existsSync(sentinel), false, 'the plugin binary must never be spawned by a validation surface');
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // pkg plugin show
143
+ // ---------------------------------------------------------------------------
144
+ describe('pkg plugin show surfaces command validation', () => {
145
+ for (const c of CASES) {
146
+ test(`${c.name} → ${c.code} in commands.issues, no execution`, async () => {
147
+ writePlugin(join(userRoot, 'plugins'), 'p', c);
148
+ resetScopeCache();
149
+ const out = await pluginShow.run({ name: 'p' });
150
+ const commands = out['commands'];
151
+ assert.ok(commands, 'show must inventory commands for a plugin declaring a manifest');
152
+ assert.ok(commands.issues.some((i) => i.code === c.code), `expected ${c.code}, got ${JSON.stringify(commands.issues.map((i) => i.code))}`);
153
+ // Typed issues render with received/expected/next.
154
+ const issue = commands.issues.find((i) => i.code === c.code);
155
+ assert.ok(typeof issue.received === 'string' && typeof issue.expected === 'string' && typeof issue.next === 'string');
156
+ assert.equal(commands.mounts.length, 0, 'a rejected manifest contributes no mounts');
157
+ assertNotExecuted();
158
+ });
159
+ }
160
+ test('valid manifest → inventory with the accepted mount and zero issues', async () => {
161
+ writePlugin(join(userRoot, 'plugins'), 'p', { name: 'valid', code: '' });
162
+ resetScopeCache();
163
+ const out = await pluginShow.run({ name: 'p' });
164
+ const commands = out['commands'];
165
+ assert.deepEqual(commands.mounts, ['app']);
166
+ assert.equal(commands.issues.length, 0);
167
+ assert.ok(typeof commands.executable === 'string', 'executable path is resolved for a valid manifest');
168
+ assertNotExecuted();
169
+ });
170
+ });
171
+ // ---------------------------------------------------------------------------
172
+ // sys doctor
173
+ // ---------------------------------------------------------------------------
174
+ describe('sys doctor surfaces command validation', () => {
175
+ for (const c of CASES) {
176
+ test(`${c.name} → fail check with human_action remediation, no execution`, async () => {
177
+ writePlugin(join(userRoot, 'plugins'), 'p', c);
178
+ resetScopeCache();
179
+ const out = await sysDoctorLeaf.run({ scope: 'user', fix: false, remote: false });
180
+ const checks = out['checks'];
181
+ const cmdCheck = checks.find((k) => k.name.startsWith('plugin:p:commands') && k.status === 'fail');
182
+ assert.ok(cmdCheck, `expected a failing command check, got ${JSON.stringify(checks.map((k) => k.name + ':' + k.status))}`);
183
+ assert.match(cmdCheck.message, new RegExp(c.code));
184
+ assert.equal(cmdCheck.remediation?.kind, 'human_action', 'command remediation is never auto-applied');
185
+ assert.equal(out['ok'], false);
186
+ assertNotExecuted();
187
+ });
188
+ }
189
+ test('--fix never touches plugin content and leaves the command issue unresolved', async () => {
190
+ writePlugin(join(userRoot, 'plugins'), 'p', { name: 'non-executable file', code: 'command_not_executable', execBit: false });
191
+ resetScopeCache();
192
+ const out = await sysDoctorLeaf.run({ scope: 'user', fix: true, remote: false });
193
+ const checks = out['checks'];
194
+ const cmdCheck = checks.find((k) => k.name.startsWith('plugin:p:commands') && k.status === 'fail');
195
+ assert.ok(cmdCheck, 'the command issue is still reported under --fix');
196
+ assert.notEqual(cmdCheck.fixed, true, '--fix must not chmod the plugin executable');
197
+ assertNotExecuted();
198
+ });
199
+ test('valid manifest → a passing command check', async () => {
200
+ writePlugin(join(userRoot, 'plugins'), 'p', { name: 'valid', code: '' });
201
+ resetScopeCache();
202
+ const out = await sysDoctorLeaf.run({ scope: 'user', fix: false, remote: false });
203
+ const checks = out['checks'];
204
+ const cmdCheck = checks.find((k) => k.name === 'plugin:p:commands');
205
+ assert.ok(cmdCheck && cmdCheck.status === 'pass', 'a valid manifest yields a pass check');
206
+ assert.match(cmdCheck.message, /app/);
207
+ assertNotExecuted();
208
+ });
209
+ });
210
+ // ---------------------------------------------------------------------------
211
+ // Cross-plugin top-level collision (drop-both + issue, visible on surfaces)
212
+ // ---------------------------------------------------------------------------
213
+ describe('cross-plugin top-level collision surfaces on doctor and show', () => {
214
+ function installTwoClaimants() {
215
+ // Two distinct plugins, both valid in isolation, both claiming `app`.
216
+ writePlugin(join(userRoot, 'plugins'), 'p-one', { name: 'valid', code: '' });
217
+ writePlugin(join(userRoot, 'plugins'), 'p-two', { name: 'valid', code: '' });
218
+ resetScopeCache();
219
+ }
220
+ test('neither claimant mounts: discovery drops the collided command from the tree', () => {
221
+ installTwoClaimants();
222
+ const snapshot = discoverCommandContributions(new Set(['sys']));
223
+ assert.equal(snapshot.contributions.length, 0, 'a collided top-level command must not be mounted by ANY claimant');
224
+ const collisions = snapshot.issues.filter((i) => i.code === 'command_collision');
225
+ assert.deepEqual(collisions.map((i) => i.plugin).sort(), ['p-one', 'p-two'], 'both claimants are flagged');
226
+ });
227
+ test('sys doctor fails BOTH claimants with the collision (no false pass)', async () => {
228
+ installTwoClaimants();
229
+ const out = await sysDoctorLeaf.run({ scope: 'user', fix: false, remote: false });
230
+ const checks = out['checks'];
231
+ for (const name of ['p-one', 'p-two']) {
232
+ const passCheck = checks.find((k) => k.name === `plugin:${name}:commands` && k.status === 'pass');
233
+ assert.equal(passCheck, undefined, `${name} must not PASS while its command is dropped by a collision`);
234
+ const failCheck = checks.find((k) => k.name.startsWith(`plugin:${name}:commands`) && k.status === 'fail' && /command_collision/.test(k.message));
235
+ assert.ok(failCheck, `${name} must fail with command_collision, got ${JSON.stringify(checks.map((k) => k.name + ':' + k.status))}`);
236
+ assert.match(failCheck.message, /claimed by multiple plugins/);
237
+ assert.match(failCheck.message, /p-one/);
238
+ assert.match(failCheck.message, /p-two/);
239
+ assert.equal(failCheck.remediation?.kind, 'human_action');
240
+ }
241
+ assert.equal(out['ok'], false);
242
+ assertNotExecuted();
243
+ });
244
+ test('pkg plugin show reports the collision on each claimant and lists no mounts', async () => {
245
+ installTwoClaimants();
246
+ for (const name of ['p-one', 'p-two']) {
247
+ const out = await pluginShow.run({ name });
248
+ const commands = out['commands'];
249
+ assert.deepEqual(commands.mounts, [], `${name} must not list the collided command as an accepted mount`);
250
+ const issue = commands.issues.find((i) => i.code === 'command_collision');
251
+ assert.ok(issue, `${name} show must carry the command_collision issue`);
252
+ assert.deepEqual(issue.received.split(', ').sort(), ['p-one', 'p-two'], 'the issue names both claimants');
253
+ }
254
+ assertNotExecuted();
255
+ });
256
+ });
257
+ // ---------------------------------------------------------------------------
258
+ // pkg plugin install / update
259
+ // ---------------------------------------------------------------------------
260
+ describe('pkg plugin install/update surface command validation', () => {
261
+ for (const c of CASES) {
262
+ test(`install ${c.name} → ${c.code} in result.commands.issues, no execution`, async () => {
263
+ const src = writePlugin(sourceRoot, 'p', c);
264
+ resetScopeCache();
265
+ const out = await pluginInstall.run({ installRef: src, scope: 'user' });
266
+ const commands = out['commands'];
267
+ assert.ok(commands, 'install reports command validation for a plugin declaring a manifest');
268
+ assert.ok(commands.issues.some((i) => i.code === c.code), `expected ${c.code}, got ${JSON.stringify(commands.issues.map((i) => i.code))}`);
269
+ assert.equal(commands.mounts.length, 0);
270
+ assertNotExecuted();
271
+ });
272
+ }
273
+ test('install valid manifest → accepted contribution, zero issues, no execution', async () => {
274
+ const src = writePlugin(sourceRoot, 'p', { name: 'valid', code: '' });
275
+ resetScopeCache();
276
+ const out = await pluginInstall.run({ installRef: src, scope: 'user' });
277
+ const commands = out['commands'];
278
+ assert.deepEqual(commands.mounts, ['app']);
279
+ assert.equal(commands.issues.length, 0);
280
+ assertNotExecuted();
281
+ });
282
+ test('update re-reports the current command validation for an edited manifest', async () => {
283
+ // Install valid, then break the on-disk manifest, then update: the report
284
+ // must reflect the CURRENT (broken) state — validation is per-call.
285
+ const src = writePlugin(sourceRoot, 'p', { name: 'valid', code: '' });
286
+ resetScopeCache();
287
+ await pluginInstall.run({ installRef: src, scope: 'user' });
288
+ // Break the installed copy's manifest in place.
289
+ writeFileSync(join(userRoot, 'plugins', 'p', 'commands.json'), JSON.stringify((() => { const m = validManifest(); m['schemaVersion'] = 2; return m; })()));
290
+ resetScopeCache();
291
+ const out = await pluginUpdate.run({ name: 'p' });
292
+ const updated = out['updated'];
293
+ const entry = updated.find((u) => u.name === 'p');
294
+ assert.ok(entry.commands, 'update reports command validation');
295
+ assert.ok(entry.commands.issues.some((i) => i.code === 'command_schema_version'));
296
+ assertNotExecuted();
297
+ });
298
+ });
@@ -0,0 +1 @@
1
+ export {};