@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.
- 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 +849 -803
- package/dist/clients/attach/auth-pickers.d.ts +0 -12
- package/dist/clients/attach/auth-pickers.js +64 -15
- package/dist/clients/attach/graph-overlay.d.ts +12 -2
- package/dist/clients/attach/graph-overlay.js +83 -33
- 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 +86 -52
- package/dist/core/provider-management.d.ts +12 -0
- package/dist/core/provider-management.js +24 -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 +1 -13
- 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
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 {};
|