@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.
Files changed (80) 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 +847 -801
  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/chat-view.d.ts +4 -0
  16. package/dist/clients/attach/chat-view.js +10 -0
  17. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  18. package/dist/clients/attach/graph-overlay.js +83 -33
  19. package/dist/clients/attach/input-controller.d.ts +19 -0
  20. package/dist/clients/attach/input-controller.js +55 -11
  21. package/dist/clients/attach/titled-editor.d.ts +21 -0
  22. package/dist/clients/attach/titled-editor.js +101 -0
  23. package/dist/commands/human/queue.js +3 -4
  24. package/dist/commands/pkg/plugin-inspect.js +22 -1
  25. package/dist/commands/pkg/plugin-manage.js +31 -9
  26. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  27. package/dist/commands/sys/doctor.js +42 -4
  28. package/dist/commands/sys/setup-core.d.ts +37 -0
  29. package/dist/commands/sys/setup-core.js +138 -1
  30. package/dist/commands/sys/setup.d.ts +88 -0
  31. package/dist/commands/sys/setup.js +915 -171
  32. package/dist/commands/view-pick.d.ts +4 -0
  33. package/dist/commands/view-pick.js +17 -7
  34. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  35. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  36. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  37. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  38. package/dist/core/__tests__/command-plugins.test.js +444 -0
  39. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  40. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  41. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  42. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  43. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  44. package/dist/core/canvas/browse/app.d.ts +6 -0
  45. package/dist/core/canvas/browse/app.js +71 -41
  46. package/dist/core/command-plugins/adapter.d.ts +15 -0
  47. package/dist/core/command-plugins/adapter.js +145 -0
  48. package/dist/core/command-plugins/compose.d.ts +5 -0
  49. package/dist/core/command-plugins/compose.js +56 -0
  50. package/dist/core/command-plugins/discovery.d.ts +104 -0
  51. package/dist/core/command-plugins/discovery.js +565 -0
  52. package/dist/core/config.d.ts +2 -5
  53. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  54. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  55. package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
  56. package/dist/core/keybindings/catalog.d.ts +3 -3
  57. package/dist/core/keybindings/catalog.js +2 -1
  58. package/dist/core/profiles/select.d.ts +6 -0
  59. package/dist/core/profiles/select.js +90 -59
  60. package/dist/core/provider-management.d.ts +12 -0
  61. package/dist/core/provider-management.js +24 -0
  62. package/dist/core/runtime/banner.d.ts +13 -0
  63. package/dist/core/runtime/banner.js +51 -0
  64. package/dist/core/runtime/broker.js +7 -6
  65. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  66. package/dist/core/runtime/pi-vendored.js +14 -0
  67. package/dist/core/runtime/recap.d.ts +1 -1
  68. package/dist/core/runtime/recap.js +50 -25
  69. package/dist/core/runtime/session-list-cache.d.ts +10 -0
  70. package/dist/core/runtime/session-list-cache.js +94 -26
  71. package/dist/core/runtime/spawn.js +5 -17
  72. package/dist/core/runtime/tmux.js +2 -1
  73. package/dist/core/scope.js +27 -4
  74. package/dist/core/subscription-state.d.ts +90 -0
  75. package/dist/core/subscription-state.js +762 -0
  76. package/dist/daemon/crtrd.js +253 -12
  77. package/dist/pi-extensions/canvas-recap.js +43 -17
  78. package/dist/types.d.ts +6 -13
  79. package/dist/types.js +0 -3
  80. package/package.json +7 -3
@@ -0,0 +1,444 @@
1
+ // Contract-seam tests for the external command-plugin seam (Phase 1).
2
+ // Run with: node --import tsx/esm --test src/core/__tests__/command-plugins.test.ts
3
+ //
4
+ // All tests run against an ISOLATED home (HOME → a fresh temp dir, no ambient
5
+ // user plugins) and pass an explicit no-`.crouter` startDir, so discovery is
6
+ // deterministic and cannot pick up the developer's real machine state.
7
+ //
8
+ // Covers the spec's Tests items:
9
+ // 1. Registry composition — a fixture top-level branch + leaf composes into
10
+ // real BranchDef/LeafDef whose root/branch/leaf help carries the plugin's
11
+ // representations, schema, effects, and stable order.
12
+ // 2. Core collision — a fixture claiming a SUBTREE_NAMES name is skipped +
13
+ // recorded as a command_collision issue.
14
+ // 3. Scope/shadowing — a project copy shadows a user copy of the same plugin
15
+ // name; a disabled project copy suppresses the user copy.
16
+ // 4. Protocol integration (table-driven, one fixture executable) — request
17
+ // envelope contents, rendered success, --json mirror, stderr separation,
18
+ // recoverable error propagation, and every malformed case.
19
+ // 5. Lifecycle without restart — install/disable/edit/remove all take effect
20
+ // on the next discovery call (per-invocation, no cache).
21
+ // 6/7. Typed issues for path/exec-bit/schema/unknown-key/parent violations
22
+ // without executing the fixture binary.
23
+ import { test, describe, before, after, beforeEach } from 'node:test';
24
+ import assert from 'node:assert/strict';
25
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from 'node:fs';
26
+ import { readFileSync } from 'node:fs';
27
+ import { execFileSync } from 'node:child_process';
28
+ import { tmpdir } from 'node:os';
29
+ import { join, dirname } from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, } from '../command-plugins/discovery.js';
32
+ import { composeExternalSubtrees } from '../command-plugins/compose.js';
33
+ import { defineRoot } from '../command.js';
34
+ import { renderRoot, renderBranch, renderLeafArgv } from '../help.js';
35
+ import { renderResult } from '../render.js';
36
+ import { CrtrError } from '../errors.js';
37
+ import { resetScopeCache } from '../scope.js';
38
+ import { listInstalledPluginsInRoot } from '../resolver.js';
39
+ // The set of reserved core names discovery must fail closed against. We don't
40
+ // import the real SUBTREE_NAMES (build-root.ts pulls the whole tree); a small
41
+ // explicit set is enough for the collision contract and keeps the test cheap.
42
+ const RESERVED = new Set(['push', 'node', 'canvas', 'sys', 'memory']);
43
+ // ---------------------------------------------------------------------------
44
+ // Fixture executable: reads one JSON request on stdin, writes one envelope.
45
+ // Behavior is selected by req.input.mode so one binary drives the whole table.
46
+ // ---------------------------------------------------------------------------
47
+ const FIXTURE_EXEC = `#!/usr/bin/env node
48
+ const fs = require('fs');
49
+ let data = '';
50
+ process.stdin.on('data', (c) => (data += c));
51
+ process.stdin.on('end', () => {
52
+ const req = JSON.parse(data);
53
+ const log = process.env.FIXTURE_REQUEST_LOG;
54
+ if (log) fs.writeFileSync(log, JSON.stringify(req));
55
+ const mode = (req.input && req.input.mode) || 'ok';
56
+ const env = (o, code) => { process.stdout.write(JSON.stringify(o)); process.exit(code); };
57
+ if (mode === 'invalid-json') { process.stdout.write('this is not json'); process.exit(0); }
58
+ if (mode === 'extra-stdout') { process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { app_id: 'x' } }) + '\\nEXTRA JUNK'); process.exit(0); }
59
+ if (mode === 'oversize') { process.stdout.write('x'.repeat(11 * 1024 * 1024)); process.exit(0); }
60
+ if (mode === 'signal') { process.kill(process.pid, 'SIGKILL'); return; }
61
+ if (mode === 'error') return env({ protocolVersion: 1, ok: false, error: { code: 'authentication_required', message: 'No home session is available.', field: 'session', next: 'Log in, then retry.' } }, 1);
62
+ if (mode === 'reserved-code') return env({ protocolVersion: 1, ok: false, error: { code: 'internal', message: 'boom' } }, 1);
63
+ if (mode === 'missing-field') return env({ protocolVersion: 1, ok: true, result: { status: 'running' } }, 0);
64
+ if (mode === 'exit-mismatch') return env({ protocolVersion: 1, ok: true, result: { app_id: req.input.appId, status: 'running' } }, 3);
65
+ if (mode === 'diag') { process.stderr.write('a diagnostic line on stderr\\n'); return env({ protocolVersion: 1, ok: true, result: { app_id: req.input.appId, status: 'running' } }, 0); }
66
+ return env({ protocolVersion: 1, ok: true, result: { app_id: req.input.appId, status: 'running' } }, 0);
67
+ });
68
+ `;
69
+ function commandsJson(topName = 'app') {
70
+ return {
71
+ schemaVersion: 1,
72
+ executable: 'bin/cmd.js',
73
+ mounts: [
74
+ {
75
+ parent: [],
76
+ node: {
77
+ kind: 'branch',
78
+ name: topName,
79
+ description: 'application lifecycle and inspection',
80
+ whenToUse: 'you need to create, deploy, inspect, or remove an application',
81
+ rootEntry: {
82
+ concept: 'applications hosted by a crouter home',
83
+ description: 'application lifecycle and inspection',
84
+ whenToUse: 'you need to create, deploy, inspect, or remove an application',
85
+ },
86
+ summary: 'application lifecycle and inspection',
87
+ model: 'Applications are product resources owned by the selected home.',
88
+ children: [
89
+ {
90
+ kind: 'leaf',
91
+ name: 'show',
92
+ description: 'show one application',
93
+ whenToUse: 'inspect a single application by id',
94
+ tier: 'important',
95
+ summary: 'show one application details',
96
+ params: [
97
+ { kind: 'positional', name: 'app-id', required: true, constraint: 'the application id' },
98
+ { kind: 'flag', name: 'mode', type: 'string', required: false, constraint: 'fixture behavior selector' },
99
+ ],
100
+ output: [
101
+ { name: 'app_id', type: 'string', required: true, constraint: 'the echoed application id' },
102
+ { name: 'status', type: 'string', required: false, constraint: 'current application status' },
103
+ ],
104
+ outputKind: 'object',
105
+ effects: ['None. Read-only.'],
106
+ },
107
+ ],
108
+ },
109
+ },
110
+ ],
111
+ };
112
+ }
113
+ /** Write a plugin into `scopeRoot/plugins/<name>/` with a command manifest +
114
+ * executable. `manifest` is the commands.json object (default fixture). */
115
+ function installPlugin(scopeRoot, name, opts = {}) {
116
+ const root = join(scopeRoot, 'plugins', name);
117
+ mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
118
+ mkdirSync(join(root, 'bin'), { recursive: true });
119
+ const pointer = opts.commandsPointer ?? 'commands.json';
120
+ writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name, version: '0.1.0', description: 'fixture', commands: pointer }));
121
+ writeFileSync(join(root, 'commands.json'), JSON.stringify(opts.manifest ?? commandsJson()));
122
+ const execPath = join(root, 'bin', 'cmd.js');
123
+ writeFileSync(execPath, opts.exec ?? FIXTURE_EXEC);
124
+ chmodSync(execPath, opts.execBit === false ? 0o644 : 0o755);
125
+ if (opts.enabled === false) {
126
+ writeFileSync(join(scopeRoot, 'config.json'), JSON.stringify({ plugins: { [name]: { enabled: false } } }));
127
+ }
128
+ return root;
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Isolated home + workspace
132
+ // ---------------------------------------------------------------------------
133
+ let home; // becomes HOME → user scope root = $HOME/.crouter
134
+ let userRoot;
135
+ let projectDir; // a workspace with its own .crouter (project scope)
136
+ let projectRoot;
137
+ let emptyStart; // a startDir with NO .crouter anywhere above it
138
+ let realHome;
139
+ let realProfile;
140
+ before(() => {
141
+ realHome = process.env['HOME'];
142
+ realProfile = process.env['CRTR_PROFILE_ID'];
143
+ delete process.env['CRTR_PROFILE_ID'];
144
+ });
145
+ after(() => {
146
+ if (realHome !== undefined)
147
+ process.env['HOME'] = realHome;
148
+ else
149
+ delete process.env['HOME'];
150
+ if (realProfile !== undefined)
151
+ process.env['CRTR_PROFILE_ID'] = realProfile;
152
+ });
153
+ // Every temp dir any test mints, so cleanup removes them ALL (an
154
+ // after(...[home, ...]) would only see the LAST test's set and leak the rest).
155
+ const tempDirs = [];
156
+ function mintDir(prefix) {
157
+ const d = mkdtempSync(join(tmpdir(), prefix));
158
+ tempDirs.push(d);
159
+ return d;
160
+ }
161
+ beforeEach(() => {
162
+ home = mintDir('crtr-cmdplugin-home-');
163
+ process.env['HOME'] = home;
164
+ userRoot = join(home, '.crouter');
165
+ mkdirSync(join(userRoot, 'plugins'), { recursive: true });
166
+ projectDir = mintDir('crtr-cmdplugin-proj-');
167
+ projectRoot = join(projectDir, '.crouter');
168
+ mkdirSync(join(projectRoot, 'plugins'), { recursive: true });
169
+ emptyStart = mintDir('crtr-cmdplugin-empty-');
170
+ resetScopeCache();
171
+ });
172
+ after(() => {
173
+ for (const d of tempDirs) {
174
+ try {
175
+ rmSync(d, { recursive: true, force: true });
176
+ }
177
+ catch { /* ignore */ }
178
+ }
179
+ resetScopeCache();
180
+ });
181
+ function leafOf(branch, name) {
182
+ const c = branch.children.find((x) => x.name === name);
183
+ assert.ok(c !== undefined && c.kind === 'leaf', `expected leaf ${name}`);
184
+ return c;
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // 1. Registry composition
188
+ // ---------------------------------------------------------------------------
189
+ describe('registry composition', () => {
190
+ test('a fixture top-level branch composes with plugin-owned help, schema, effects, stable order', () => {
191
+ installPlugin(userRoot, 'crouter-infra');
192
+ resetScopeCache();
193
+ const snapshot = discoverCommandContributions(RESERVED, emptyStart);
194
+ // user scope is discovered from any startDir (it's not a project ancestor).
195
+ assert.equal(snapshot.issues.length, 0, JSON.stringify(snapshot.issues));
196
+ const external = composeExternalSubtrees(snapshot);
197
+ assert.equal(external.length, 1);
198
+ const app = external[0];
199
+ assert.equal(app.kind, 'branch');
200
+ assert.equal(app.name, 'app');
201
+ // Root help carries the plugin-owned rootEntry (mapped description→desc,
202
+ // whenToUse→useWhen), no core file knows what an app is.
203
+ const root = defineRoot({ tagline: 'test', globals: [], subtrees: external });
204
+ const rootOut = renderRoot(root.help);
205
+ assert.match(rootOut, /<command name="app">/);
206
+ assert.match(rootOut, /applications hosted by a crouter home/);
207
+ assert.match(rootOut, /use when you need to create, deploy, inspect, or remove an application/);
208
+ // important child promoted into root with its shortform desc.
209
+ assert.match(rootOut, /app show {2,}show one application/);
210
+ // Branch help lists the leaf via its own description/whenToUse.
211
+ const branchOut = renderBranch(app.help);
212
+ assert.match(branchOut, /<command name="app" description="application lifecycle and inspection">/);
213
+ assert.match(branchOut, /Applications are product resources owned by the selected home\./);
214
+ assert.match(branchOut, /name="show"[^>]*whenToUse="inspect a single application by id"/);
215
+ // Leaf help carries the full input/output schema + effects.
216
+ const leaf = leafOf(app, 'show');
217
+ const leafOut = renderLeafArgv(leaf.help);
218
+ assert.match(leafOut, /APP-ID/);
219
+ assert.match(leafOut, /--mode MODE/);
220
+ assert.match(leafOut, /app_id {2,}string\. the echoed application id/);
221
+ assert.match(leafOut, /None\. Read-only\./);
222
+ });
223
+ });
224
+ // ---------------------------------------------------------------------------
225
+ // 2. Core collision
226
+ // ---------------------------------------------------------------------------
227
+ describe('core collision', () => {
228
+ test('a fixture claiming a core top-level name is skipped + recorded', () => {
229
+ installPlugin(userRoot, 'rogue', { manifest: commandsJson('push') });
230
+ resetScopeCache();
231
+ const snapshot = discoverCommandContributions(RESERVED, emptyStart);
232
+ assert.equal(composeExternalSubtrees(snapshot).length, 0, 'the colliding branch must not enter the tree');
233
+ const collision = snapshot.issues.find((i) => i.code === 'command_collision');
234
+ assert.ok(collision, 'a command_collision issue must be recorded for doctor/show');
235
+ assert.equal(collision.received, 'push');
236
+ });
237
+ });
238
+ // ---------------------------------------------------------------------------
239
+ // 3. Scope / shadowing
240
+ // ---------------------------------------------------------------------------
241
+ describe('scope shadowing', () => {
242
+ test('a project copy shadows a user copy of the same plugin name', () => {
243
+ installPlugin(userRoot, 'crouter-infra');
244
+ installPlugin(projectRoot, 'crouter-infra');
245
+ resetScopeCache();
246
+ const effective = effectiveCommandPlugins(projectDir);
247
+ assert.equal(effective.length, 1);
248
+ assert.equal(effective[0].scope, 'project');
249
+ assert.equal(effective[0].root, join(projectRoot, 'plugins', 'crouter-infra'));
250
+ });
251
+ test('a disabled project copy suppresses the user copy (no resurfacing)', () => {
252
+ installPlugin(userRoot, 'crouter-infra');
253
+ installPlugin(projectRoot, 'crouter-infra', { enabled: false });
254
+ resetScopeCache();
255
+ // sanity: the project copy really is disabled at that root.
256
+ const projPlugins = listInstalledPluginsInRoot('project', projectRoot);
257
+ assert.equal(projPlugins.find((p) => p.name === 'crouter-infra').enabled, false);
258
+ const effective = effectiveCommandPlugins(projectDir);
259
+ assert.equal(effective.length, 0, 'the disabled higher-precedence copy must suppress the user copy');
260
+ });
261
+ });
262
+ // ---------------------------------------------------------------------------
263
+ // 4. Protocol integration (table-driven)
264
+ // ---------------------------------------------------------------------------
265
+ describe('protocol integration', () => {
266
+ function showLeaf() {
267
+ installPlugin(userRoot, 'crouter-infra');
268
+ resetScopeCache();
269
+ const snapshot = discoverCommandContributions(RESERVED, emptyStart);
270
+ const app = composeExternalSubtrees(snapshot)[0];
271
+ return leafOf(app, 'show');
272
+ }
273
+ test('request envelope carries command path, camelCase input, cwd, plugin context', async () => {
274
+ const leaf = showLeaf();
275
+ const log = join(emptyStart, 'req.json');
276
+ process.env['FIXTURE_REQUEST_LOG'] = log;
277
+ try {
278
+ await leaf.run({ appId: 'app_123', mode: 'ok' });
279
+ const req = JSON.parse(readFileSync(log, 'utf8'));
280
+ assert.equal(req.protocolVersion, 1);
281
+ assert.deepEqual(req.command, ['app', 'show']);
282
+ assert.equal(req.input.appId, 'app_123');
283
+ assert.equal(req.context.cwd, process.cwd());
284
+ assert.equal(req.context.plugin.name, 'crouter-infra');
285
+ assert.equal(req.context.plugin.scope, 'user');
286
+ assert.equal(req.context.plugin.version, '0.1.0');
287
+ assert.equal(req.context.plugin.root, join(userRoot, 'plugins', 'crouter-infra'));
288
+ }
289
+ finally {
290
+ delete process.env['FIXTURE_REQUEST_LOG'];
291
+ }
292
+ });
293
+ test('ok:true → result validated + rendered; --json mirrors the same object', async () => {
294
+ const leaf = showLeaf();
295
+ const result = await leaf.run({ appId: 'app_123', mode: 'ok' });
296
+ assert.deepEqual(result, { app_id: 'app_123', status: 'running' });
297
+ // Rendered through the existing generic renderer (no custom renderer).
298
+ const rendered = renderResult(result, leaf.help);
299
+ assert.match(rendered, /app_id/);
300
+ assert.match(rendered, /app_123/);
301
+ });
302
+ test('valid envelope + wrong exit code is honored (soft diagnostic, not a discard)', async () => {
303
+ const leaf = showLeaf();
304
+ const result = await leaf.run({ appId: 'app_123', mode: 'exit-mismatch' });
305
+ assert.deepEqual(result, { app_id: 'app_123', status: 'running' });
306
+ });
307
+ test('stderr diagnostics do not pollute the stdout envelope', async () => {
308
+ const leaf = showLeaf();
309
+ const result = await leaf.run({ appId: 'app_123', mode: 'diag' });
310
+ assert.deepEqual(result, { app_id: 'app_123', status: 'running' });
311
+ });
312
+ test('ok:false → structured CrtrError with the plugin code', async () => {
313
+ const leaf = showLeaf();
314
+ await assert.rejects(() => leaf.run({ appId: 'app_123', mode: 'error' }), (e) => e instanceof CrtrError && e.code === 'authentication_required' && (e.details?.['next'] === 'Log in, then retry.'));
315
+ });
316
+ const malformed = [
317
+ ['invalid JSON', 'invalid-json'],
318
+ ['extra stdout after the envelope', 'extra-stdout'],
319
+ ['oversize stdout (>10 MiB)', 'oversize'],
320
+ ['signal termination', 'signal'],
321
+ ['missing declared output field', 'missing-field'],
322
+ ['reserved error code', 'reserved-code'],
323
+ ];
324
+ for (const [desc, mode] of malformed) {
325
+ test(`malformed: ${desc} → plugin_protocol_error`, async () => {
326
+ const leaf = showLeaf();
327
+ await assert.rejects(() => leaf.run({ appId: 'app_123', mode }), (e) => e instanceof CrtrError && e.code === 'plugin_protocol_error');
328
+ });
329
+ }
330
+ });
331
+ // ---------------------------------------------------------------------------
332
+ // 4b. True end-to-end CLI invocation (argv → parseArgv → adapter → renderer)
333
+ // ---------------------------------------------------------------------------
334
+ describe('end-to-end CLI invocation', () => {
335
+ // dist/cli.js is up 4 from src/core/__tests__/. Requires a prior `npm run
336
+ // build` (the harness/CI builds before tests). Drives the WHOLE seam: the
337
+ // declared positional `app-id` must arrive as `appId` in the request, and
338
+ // the rendered + --json outputs come through the real dispatcher.
339
+ const cli = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'dist', 'cli.js');
340
+ const runCrtr = (args) => {
341
+ installPlugin(userRoot, 'crouter-infra');
342
+ resetScopeCache();
343
+ return execFileSync(process.execPath, [cli, ...args], {
344
+ cwd: emptyStart,
345
+ env: { ...process.env, HOME: home, CRTR_NO_BOOTSTRAP: '1', CRTR_FRONT_DOOR: '' },
346
+ encoding: 'utf8',
347
+ });
348
+ };
349
+ test('crtr app show <id> renders the plugin result end to end', { timeout: 40000 }, () => {
350
+ const out = runCrtr(['app', 'show', 'app_123']);
351
+ assert.match(out, /app_123/);
352
+ assert.match(out, /running/);
353
+ });
354
+ test('--json mirrors the same result object', { timeout: 40000 }, () => {
355
+ const out = runCrtr(['app', 'show', 'app_123', '--json']);
356
+ const obj = JSON.parse(out);
357
+ assert.equal(obj.app_id, 'app_123');
358
+ assert.equal(obj.status, 'running');
359
+ });
360
+ });
361
+ // ---------------------------------------------------------------------------
362
+ // 5. Lifecycle without restart (per-invocation discovery)
363
+ // ---------------------------------------------------------------------------
364
+ describe('lifecycle without restart', () => {
365
+ test('install → appears; disable → gone; edit → visible; remove → gone', () => {
366
+ const discover = () => {
367
+ resetScopeCache();
368
+ return composeExternalSubtrees(discoverCommandContributions(RESERVED, emptyStart));
369
+ };
370
+ // install
371
+ installPlugin(userRoot, 'crouter-infra');
372
+ assert.equal(discover().length, 1);
373
+ // disable (config edit only — no restart)
374
+ writeFileSync(join(userRoot, 'config.json'), JSON.stringify({ plugins: { 'crouter-infra': { enabled: false } } }));
375
+ assert.equal(discover().length, 0);
376
+ // re-enable + edit the declaration (rename the top-level command)
377
+ writeFileSync(join(userRoot, 'config.json'), JSON.stringify({ plugins: { 'crouter-infra': { enabled: true } } }));
378
+ writeFileSync(join(userRoot, 'plugins', 'crouter-infra', 'commands.json'), JSON.stringify(commandsJson('deploy')));
379
+ const afterEdit = discover();
380
+ assert.equal(afterEdit.length, 1);
381
+ assert.equal(afterEdit[0].name, 'deploy');
382
+ // remove
383
+ rmSync(join(userRoot, 'plugins', 'crouter-infra'), { recursive: true, force: true });
384
+ assert.equal(discover().length, 0);
385
+ });
386
+ });
387
+ // ---------------------------------------------------------------------------
388
+ // 6/7. Typed issues without executing the binary
389
+ // ---------------------------------------------------------------------------
390
+ describe('typed validation issues', () => {
391
+ function validate(manifest, extra = {}) {
392
+ installPlugin(userRoot, 'p', { manifest, ...extra });
393
+ resetScopeCache();
394
+ const plugins = listInstalledPluginsInRoot('user', userRoot);
395
+ const p = plugins.find((x) => x.name === 'p');
396
+ return validatePluginCommands(p, RESERVED);
397
+ }
398
+ test('unknown schemaVersion → command_schema_version, no contribution', () => {
399
+ const m = commandsJson();
400
+ m['schemaVersion'] = 2;
401
+ const v = validate(m);
402
+ assert.equal(v.contributions.length, 0);
403
+ assert.ok(v.issues.some((i) => i.code === 'command_schema_version'));
404
+ });
405
+ test('unknown top-level key → command_manifest_invalid', () => {
406
+ const m = commandsJson();
407
+ m['requires'] = { crtr: '>=1' };
408
+ const v = validate(m);
409
+ assert.ok(v.issues.some((i) => i.code === 'command_manifest_invalid'));
410
+ });
411
+ test('non-empty parent → command_parent_invalid', () => {
412
+ const m = commandsJson();
413
+ m.mounts[0].parent = ['sys'];
414
+ const v = validate(m);
415
+ assert.ok(v.issues.some((i) => i.code === 'command_parent_invalid'));
416
+ assert.equal(v.contributions.length, 0);
417
+ });
418
+ test('non-executable file → command_not_executable', () => {
419
+ const v = validate(commandsJson(), { execBit: false });
420
+ assert.ok(v.issues.some((i) => i.code === 'command_not_executable'));
421
+ });
422
+ test('executable escaping the plugin root → command_path_unsafe', () => {
423
+ const m = commandsJson();
424
+ m['executable'] = '../../../../bin/sh';
425
+ const v = validate(m);
426
+ assert.ok(v.issues.some((i) => i.code === 'command_path_unsafe'));
427
+ });
428
+ test('a top-level leaf (no rootEntry) → command_node_invalid', () => {
429
+ const m = {
430
+ schemaVersion: 1,
431
+ executable: 'bin/cmd.js',
432
+ mounts: [{ parent: [], node: { kind: 'leaf', name: 'x', description: 'd', whenToUse: 'w', summary: 's', params: [], output: [], outputKind: 'object', effects: ['None. Read-only.'] } }],
433
+ };
434
+ const v = validate(m);
435
+ assert.ok(v.issues.some((i) => i.code === 'command_node_invalid'));
436
+ assert.equal(v.contributions.length, 0);
437
+ });
438
+ test('a valid manifest yields a contribution and zero issues', () => {
439
+ const v = validate(commandsJson());
440
+ assert.equal(v.issues.length, 0);
441
+ assert.equal(v.contributions.length, 1);
442
+ assert.equal(v.contributions[0].node.name, 'app');
443
+ });
444
+ });
@@ -17,6 +17,11 @@ export declare class SessionManager {
17
17
  static open(path: string): SessionManager;
18
18
  getSessionId(): string;
19
19
  getSessionFile(): string;
20
+ /** The directory containing the session file — the real SessionManager's
21
+ * getSessionDir(). The broker prewarms the session-list cache with it at boot
22
+ * (broker.ts runBroker), so the fixture must expose it or every broker boot
23
+ * crashes before it binds view.sock. */
24
+ getSessionDir(): string;
20
25
  private leafId;
21
26
  /** Cycling support (crtr-cycle marker) — mirrors the real SDK's getLeafId/
22
27
  * resetLeaf/appendCustomEntry just enough that `buildBrokerSession`'s cycling
@@ -124,6 +129,7 @@ declare class FakeSession {
124
129
  }>;
125
130
  getSessionId: () => string;
126
131
  getSessionFile: () => string;
132
+ getSessionDir: () => string;
127
133
  };
128
134
  get sessionId(): string;
129
135
  get sessionFile(): string | undefined;
@@ -30,7 +30,7 @@
30
30
  // <nodeDir>/fake-pi.cmd — harness writes ONE JSON command (turn|stop|dialog)
31
31
  // <nodeDir>/fake-pi.dialog.jsonl — one line per resolved unattended dialog (§5.4)
32
32
  import { writeFileSync, readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, } from 'node:fs';
33
- import { join } from 'node:path';
33
+ import { join, dirname } from 'node:path';
34
34
  import { randomUUID } from 'node:crypto';
35
35
  import { DefaultResourceLoader, getAgentDir } from '@earendil-works/pi-coding-agent';
36
36
  /** Test seam (M-1 regression): a FRESH-start kickoff prompt carrying this token
@@ -100,6 +100,13 @@ export class SessionManager {
100
100
  getSessionFile() {
101
101
  return this.sessionFile;
102
102
  }
103
+ /** The directory containing the session file — the real SessionManager's
104
+ * getSessionDir(). The broker prewarms the session-list cache with it at boot
105
+ * (broker.ts runBroker), so the fixture must expose it or every broker boot
106
+ * crashes before it binds view.sock. */
107
+ getSessionDir() {
108
+ return dirname(this.sessionFile);
109
+ }
103
110
  leafId = null;
104
111
  /** Cycling support (crtr-cycle marker) — mirrors the real SDK's getLeafId/
105
112
  * resetLeaf/appendCustomEntry just enough that `buildBrokerSession`'s cycling
@@ -285,6 +292,7 @@ class FakeSession {
285
292
  getUserMessagesForForking: () => this.tree.getUserMessagesForForking(),
286
293
  getSessionId: () => this.sm.getSessionId(),
287
294
  getSessionFile: () => this.sm.getSessionFile(),
295
+ getSessionDir: () => this.sm.getSessionDir(),
288
296
  };
289
297
  }
290
298
  get sessionId() {
@@ -10,10 +10,39 @@
10
10
  // buildRoot()-assembled tree (safe here — this is a test, not the attach
11
11
  // bundle) and asserts the set of leaves declaring `suppressOutput: true`
12
12
  // matches the registry exactly.
13
- import { test } from 'node:test';
13
+ import { test, before, after } from 'node:test';
14
14
  import assert from 'node:assert/strict';
15
+ import { mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
15
18
  import { buildRoot } from '../../build-root.js';
16
19
  import { SUPPRESS_OUTPUT_LEAVES } from '../preview-registry.js';
20
+ import { resetScopeCache } from '../scope.js';
21
+ // buildRoot() composes external command-plugin branches from the effective
22
+ // enabled plugins (user scope + project ancestors). Pin HOME + profile to an
23
+ // isolated no-plugin home so ambient machine state can't inject an external
24
+ // leaf and make this drift guard nondeterministic.
25
+ let home;
26
+ let realHome;
27
+ let realProfile;
28
+ before(() => {
29
+ realHome = process.env['HOME'];
30
+ realProfile = process.env['CRTR_PROFILE_ID'];
31
+ home = mkdtempSync(join(tmpdir(), 'crtr-preview-registry-'));
32
+ process.env['HOME'] = home;
33
+ delete process.env['CRTR_PROFILE_ID'];
34
+ resetScopeCache();
35
+ });
36
+ after(() => {
37
+ if (realHome !== undefined)
38
+ process.env['HOME'] = realHome;
39
+ else
40
+ delete process.env['HOME'];
41
+ if (realProfile !== undefined)
42
+ process.env['CRTR_PROFILE_ID'] = realProfile;
43
+ rmSync(home, { recursive: true, force: true });
44
+ resetScopeCache();
45
+ });
17
46
  test('preview-registry.ts matches every leaf declaring help.preview.suppressOutput', async () => {
18
47
  const root = await buildRoot();
19
48
  const declared = new Set();
@@ -0,0 +1,55 @@
1
+ // The scope ancestor walk (`collectAncestorScopeRoots` → `findProjectScopeRoots`)
2
+ // must never climb at or above `homedir()`. When HOME is relocated and nested
3
+ // under a directory tree that itself contains a `.crouter/` (a mock-VM home
4
+ // under the real host home, or any relocated-HOME sandbox), the unfenced walk
5
+ // climbs ABOVE homedir() and sweeps in the above-HOME `.crouter` as a bogus
6
+ // "project ancestor" — leaking that host's entire user-global memory store into
7
+ // every nested-HOME node's assembled knowledge/preference catalog. This test
8
+ // pins the fence: an ancestor `.crouter/` sitting ABOVE the relocated HOME must
9
+ // NOT be collected as a project scope root, while a legitimate project
10
+ // `.crouter/` BETWEEN the cwd and HOME still is.
11
+ //
12
+ // Mirrors src/core/__tests__/on-read-crouter-home-fence.test.ts.
13
+ //
14
+ // Run: node --import tsx/esm --test src/core/__tests__/scope-crouter-home-fence.test.ts
15
+ import { test, before, beforeEach, after } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { findProjectScopeRoots, resetScopeCache } from '../scope.js';
21
+ let sandbox;
22
+ let prevHomeEnv;
23
+ before(() => {
24
+ prevHomeEnv = process.env['HOME'];
25
+ });
26
+ beforeEach(() => {
27
+ resetScopeCache();
28
+ // A sandbox that CONTAINS the relocated HOME, so the ancestor walk from a cwd
29
+ // under HOME can (without the fence) climb above HOME into the sandbox.
30
+ sandbox = mkdtempSync(join(tmpdir(), 'crtr-scope-fence-'));
31
+ });
32
+ after(() => {
33
+ resetScopeCache();
34
+ rmSync(sandbox, { recursive: true, force: true });
35
+ if (prevHomeEnv === undefined)
36
+ delete process.env['HOME'];
37
+ else
38
+ process.env['HOME'] = prevHomeEnv;
39
+ });
40
+ test('an ancestor .crouter ABOVE a relocated HOME is not collected as a project scope root', () => {
41
+ // Simulated host user-global store, sitting ABOVE the relocated home.
42
+ const hostUserRoot = join(sandbox, '.crouter');
43
+ mkdirSync(join(hostUserRoot, 'memory'), { recursive: true });
44
+ // The relocated HOME, nested one level under the sandbox.
45
+ const home = join(sandbox, 'vm-home');
46
+ process.env['HOME'] = home;
47
+ // A legitimate project `.crouter/` between the node cwd and HOME.
48
+ const projectDir = join(home, 'work', 'proj');
49
+ const projectRoot = join(projectDir, '.crouter');
50
+ mkdirSync(join(projectRoot, 'memory'), { recursive: true });
51
+ resetScopeCache();
52
+ const roots = findProjectScopeRoots(projectDir, null);
53
+ assert.ok(!roots.includes(hostUserRoot), `above-HOME .crouter must NOT be collected as a project ancestor; got: ${JSON.stringify(roots)}`);
54
+ assert.ok(roots.includes(projectRoot), `legitimate project .crouter between cwd and HOME must still be collected; got: ${JSON.stringify(roots)}`);
55
+ });
@@ -1,6 +1,11 @@
1
1
  import { type CanvasSource } from '../source.js';
2
2
  import type { RemoteEventStream } from '../remote-event-stream.js';
3
3
  import { type CoalescedRebuilder } from './rebuild-coalescer.js';
4
+ import { type BindingId, type BindingResolution, type RawTerminalInput } from '../../keybindings/index.js';
5
+ declare const BROWSE_NAV_ACTIONS: readonly ["crtr.browse.quit", "crtr.browse.down", "crtr.browse.up", "crtr.browse.first", "crtr.browse.last", "crtr.browse.expand-or-child", "crtr.browse.collapse-or-parent", "crtr.browse.toggle-fold", "crtr.browse.tab-next", "crtr.browse.tab-previous", "crtr.browse.tab.1", "crtr.browse.tab.2", "crtr.browse.tab.3", "crtr.browse.tab.4", "crtr.browse.tab.5", "crtr.browse.close-subtree", "crtr.browse.sort", "crtr.browse.scope", "crtr.browse.residents", "crtr.browse.preview", "crtr.browse.search", "crtr.browse.revive-now", "crtr.browse.focus"];
6
+ declare const BROWSE_SEARCH_ACTIONS: readonly ["crtr.browse.search.cancel", "crtr.browse.search.commit", "crtr.browse.search.delete", "crtr.browse.search.quit"];
7
+ export declare function resolveBrowseNavAction(bindings: BindingResolution<BindingId>, raw: RawTerminalInput): (typeof BROWSE_NAV_ACTIONS)[number] | null;
8
+ export declare function resolveBrowseSearchAction(bindings: BindingResolution<BindingId>, raw: RawTerminalInput): (typeof BROWSE_SEARCH_ACTIONS)[number] | null;
4
9
  /** Wires a RemoteEventStream's `nodes`/`inbox` events through the coalescer
5
10
  * (`rebuild-coalescer.ts`) so a burst of SSE events collapses to ONE rebuild
6
11
  * round, never one N+1 remote-subprocess rebuild per event. Exported (rather
@@ -18,3 +23,4 @@ export declare function runBrowse(opts?: {
18
23
  source?: CanvasSource;
19
24
  eventStream?: RemoteEventStream;
20
25
  }): Promise<void>;
26
+ export {};