@crouton-kit/crouter 0.3.78 → 0.3.80
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/build-root.d.ts +12 -4
- package/dist/build-root.js +25 -6
- package/dist/builtin-memory/crouter-development/plugins.md +82 -5
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
- package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
- package/dist/clients/attach/attach-cmd.d.ts +9 -1
- package/dist/clients/attach/attach-cmd.js +847 -801
- package/dist/clients/attach/auth-pickers.d.ts +0 -12
- package/dist/clients/attach/auth-pickers.js +64 -15
- package/dist/clients/attach/chat-view.d.ts +4 -0
- package/dist/clients/attach/chat-view.js +10 -0
- package/dist/clients/attach/graph-overlay.d.ts +12 -2
- package/dist/clients/attach/graph-overlay.js +83 -33
- package/dist/clients/attach/input-controller.d.ts +19 -0
- package/dist/clients/attach/input-controller.js +55 -11
- package/dist/clients/attach/titled-editor.d.ts +21 -0
- package/dist/clients/attach/titled-editor.js +101 -0
- package/dist/commands/human/queue.js +3 -4
- package/dist/commands/pkg/plugin-inspect.js +22 -1
- package/dist/commands/pkg/plugin-manage.js +31 -9
- package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
- package/dist/commands/sys/doctor.js +42 -4
- package/dist/commands/sys/setup-core.d.ts +37 -0
- package/dist/commands/sys/setup-core.js +138 -1
- package/dist/commands/sys/setup.d.ts +88 -0
- package/dist/commands/sys/setup.js +915 -171
- package/dist/commands/view-pick.d.ts +4 -0
- package/dist/commands/view-pick.js +17 -7
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
- package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
- package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins.test.js +444 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
- package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
- package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
- package/dist/core/canvas/browse/app.d.ts +6 -0
- package/dist/core/canvas/browse/app.js +71 -41
- package/dist/core/command-plugins/adapter.d.ts +15 -0
- package/dist/core/command-plugins/adapter.js +145 -0
- package/dist/core/command-plugins/compose.d.ts +5 -0
- package/dist/core/command-plugins/compose.js +56 -0
- package/dist/core/command-plugins/discovery.d.ts +104 -0
- package/dist/core/command-plugins/discovery.js +565 -0
- package/dist/core/config.d.ts +2 -5
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +3 -3
- package/dist/core/keybindings/catalog.js +2 -1
- package/dist/core/profiles/select.d.ts +6 -0
- package/dist/core/profiles/select.js +90 -59
- package/dist/core/provider-management.d.ts +12 -0
- package/dist/core/provider-management.js +24 -0
- package/dist/core/runtime/banner.d.ts +13 -0
- package/dist/core/runtime/banner.js +51 -0
- package/dist/core/runtime/broker.js +7 -6
- package/dist/core/runtime/pi-vendored.d.ts +8 -0
- package/dist/core/runtime/pi-vendored.js +14 -0
- package/dist/core/runtime/recap.d.ts +1 -1
- package/dist/core/runtime/recap.js +50 -25
- package/dist/core/runtime/session-list-cache.d.ts +10 -0
- package/dist/core/runtime/session-list-cache.js +94 -26
- package/dist/core/runtime/spawn.js +5 -17
- package/dist/core/runtime/tmux.js +2 -1
- package/dist/core/scope.js +27 -4
- package/dist/core/subscription-state.d.ts +90 -0
- package/dist/core/subscription-state.js +762 -0
- package/dist/daemon/crtrd.js +253 -12
- package/dist/pi-extensions/canvas-recap.js +43 -17
- package/dist/types.d.ts +6 -13
- package/dist/types.js +0 -3
- 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,
|
|
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
|
-
|
|
111
|
+
const action = resolveViewPickAction(bindings, { input, ...key });
|
|
112
|
+
if (action === 'crtr.view-picker.quit')
|
|
101
113
|
finish();
|
|
102
|
-
if (
|
|
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 (
|
|
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 (
|
|
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
|
|
19
|
-
// seconds
|
|
20
|
-
// debounce window
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 {};
|