@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.81
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/README.md +11 -2
- package/dist/node/index.cjs +76 -38
- package/dist/node/index.d.cts +946 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +564 -51
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +76 -38
- package/dist/node/index.js.map +1 -1
- package/package.json +34 -16
- package/src/advisor/advisor-command-module.ts +47 -0
- package/src/advisor/advisor-command.test.ts +102 -0
- package/src/advisor/advisor-command.ts +55 -0
- package/src/advisor/advisor-cost-functional.test.ts +92 -0
- package/src/advisor/index.ts +6 -0
- package/src/agent/__tests__/agent-command.test.ts +39 -8
- package/src/agent/agent-command-module.ts +14 -7
- package/src/agent/agent-command-parser.ts +1 -1
- package/src/agent/agent-command.ts +3 -3
- package/src/background/__tests__/background-command-module.test.ts +59 -52
- package/src/background/background-command-module.ts +2 -1
- package/src/background/background-command.ts +3 -3
- package/src/command-module-utils.ts +33 -0
- package/src/compact/__tests__/compact-command-module.test.ts +31 -23
- package/src/compact/compact-command-module.ts +10 -1
- package/src/compact/compact-command.ts +6 -3
- package/src/context/__tests__/context-command-module.test.ts +214 -63
- package/src/context/auto-compact-format.ts +25 -0
- package/src/context/context-breakdown.ts +295 -0
- package/src/context/context-command-module.ts +45 -14
- package/src/context/context-command.ts +34 -257
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
- package/src/default/__tests__/default-command-modules.test.ts +59 -7
- package/src/default/__tests__/model-exposure.test.ts +241 -0
- package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
- package/src/default/default-command-modules.ts +130 -18
- package/src/default/index.ts +4 -1
- package/src/devices/__tests__/devices-command-module.test.ts +212 -0
- package/src/devices/devices-command-module.ts +224 -0
- package/src/devices/devices-command-port.ts +82 -0
- package/src/devices/index.ts +11 -0
- package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
- package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
- package/src/doctor/__tests__/doctor-fixture.ts +145 -0
- package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
- package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
- package/src/doctor/doctor-command-module.ts +132 -0
- package/src/doctor/doctor-extensions-probe.ts +299 -0
- package/src/doctor/doctor-node-deps.ts +89 -0
- package/src/doctor/doctor-redaction.ts +99 -0
- package/src/doctor/doctor-render.ts +70 -0
- package/src/doctor/doctor-repair.ts +136 -0
- package/src/doctor/doctor-runner.ts +122 -0
- package/src/doctor/doctor-settings-probe.ts +239 -0
- package/src/doctor/doctor-storage-probe.ts +187 -0
- package/src/doctor/doctor-types.ts +109 -0
- package/src/doctor/index.ts +32 -0
- package/src/editor/__tests__/editor-command-functional.test.ts +49 -16
- package/src/editor/editor-command-module.ts +6 -5
- package/src/editor/editor-command.ts +10 -5
- package/src/effort/effort-command-module.ts +51 -0
- package/src/effort/effort-command.test.ts +140 -0
- package/src/effort/effort-command.ts +122 -0
- package/src/effort/index.ts +6 -0
- package/src/exit/__tests__/exit-command-module.test.ts +16 -9
- package/src/exit/exit-command-module.ts +2 -1
- package/src/exit/exit-command.ts +6 -6
- package/src/fork/__tests__/fork-command.test.ts +184 -0
- package/src/fork/fork-command-module.ts +61 -0
- package/src/fork/fork-command.ts +162 -0
- package/src/fork/index.ts +8 -0
- package/src/git/__tests__/fake-git-port.ts +28 -0
- package/src/git/__tests__/git-command-module.test.ts +103 -0
- package/src/git/__tests__/git-commit.test.ts +185 -0
- package/src/git/__tests__/git-diff.test.ts +125 -0
- package/src/git/__tests__/git-process.test.ts +221 -0
- package/src/git/__tests__/git-status.test.ts +117 -0
- package/src/git/git-command-module.ts +139 -0
- package/src/git/git-commit.ts +251 -0
- package/src/git/git-diff.ts +129 -0
- package/src/git/git-process.ts +169 -0
- package/src/git/git-status.ts +134 -0
- package/src/git/index.ts +17 -0
- package/src/goal/__tests__/goal-command.test.ts +9 -4
- package/src/goal/goal-command-module.ts +2 -1
- package/src/goal/goal-command.ts +6 -9
- package/src/handoff/__tests__/handoff-command.test.ts +218 -0
- package/src/handoff/handoff-command-module.ts +48 -0
- package/src/handoff/handoff-command.ts +187 -0
- package/src/handoff/index.ts +6 -0
- package/src/help/__tests__/help-command-module.test.ts +63 -40
- package/src/help/__tests__/help-command.test.ts +46 -37
- package/src/help/help-command-module.ts +2 -1
- package/src/help/help-command.ts +3 -3
- package/src/index.ts +124 -2
- package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
- package/src/keybindings/index.ts +6 -0
- package/src/keybindings/keybindings-command-module.ts +74 -0
- package/src/language/__tests__/language-command-module.test.ts +33 -28
- package/src/language/language-command-module.ts +2 -1
- package/src/language/language-command.ts +8 -6
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
- package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
- package/src/mcp-activation/index.ts +12 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
- package/src/mcp-activation/mcp-activation-command.ts +448 -0
- package/src/mcp-activation/mcp-model-notice.ts +85 -0
- package/src/mcp-activation/mcp-model-status.ts +99 -0
- package/src/memory/__tests__/memory-command-module.test.ts +167 -74
- package/src/memory/memory-command-module.ts +18 -1
- package/src/memory/memory-command.ts +38 -38
- package/src/mode/__tests__/mode-command-module.test.ts +51 -38
- package/src/mode/mode-command-module.ts +2 -1
- package/src/mode/mode-command.ts +15 -7
- package/src/output-style/__tests__/output-style-command.test.ts +96 -0
- package/src/output-style/index.ts +6 -0
- package/src/output-style/output-style-command-module.ts +143 -0
- package/src/peers/__tests__/peers-command.test.ts +241 -0
- package/src/peers/index.ts +9 -0
- package/src/peers/peers-command-module.ts +47 -0
- package/src/peers/peers-command.ts +142 -0
- package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
- package/src/permissions/permissions-command-module.ts +2 -1
- package/src/permissions/permissions-command.ts +46 -4
- package/src/plan/__tests__/plan-command.test.ts +129 -0
- package/src/plan/index.ts +9 -0
- package/src/plan/plan-command-module.ts +49 -0
- package/src/plan/plan-command.ts +92 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
- package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
- package/src/plugin/plugin-command-module.ts +3 -1
- package/src/plugin/plugin-command.ts +17 -16
- package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
- package/src/preset/__tests__/preset-command-module.test.ts +102 -56
- package/src/preset/preset-command-module.ts +26 -8
- package/src/preset/preset-command.ts +85 -27
- package/src/provider/__tests__/org-policy.test.ts +4 -5
- package/src/provider/__tests__/provider-command-module.test.ts +11 -14
- package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
- package/src/provider/__tests__/scripted-interaction.ts +7 -5
- package/src/provider/provider-command-execution.ts +4 -4
- package/src/provider/provider-command-module.ts +2 -1
- package/src/provider/provider-command-profile-lifecycle.ts +4 -4
- package/src/provider/provider-command-profile-operations.ts +3 -3
- package/src/provider/provider-command-profile.ts +1 -1
- package/src/provider/provider-command-setup.ts +4 -4
- package/src/provider/provider-startup.ts +62 -35
- package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
- package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
- package/src/remote-control/index.ts +8 -0
- package/src/remote-control/remote-control-command-module.ts +61 -0
- package/src/remote-control/remote-control-command.ts +138 -0
- package/src/reset/__tests__/reset-command-module.test.ts +5 -5
- package/src/reset/reset-command-module.ts +2 -1
- package/src/reset/reset-command.ts +3 -4
- package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
- package/src/rewind/rewind-command-module.ts +2 -1
- package/src/rewind/rewind-command.ts +73 -6
- package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/sandbox-command-module.ts +56 -0
- package/src/sandbox/sandbox-command.ts +94 -0
- package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
- package/src/schedule/__tests__/loop-command.test.ts +360 -0
- package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
- package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
- package/src/schedule/__tests__/schedule-command.test.ts +143 -3
- package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
- package/src/schedule/index.ts +3 -0
- package/src/schedule/loop-command.ts +333 -0
- package/src/schedule/loop-jitter.ts +41 -0
- package/src/schedule/schedule-command-module.ts +92 -14
- package/src/schedule/schedule-command.ts +98 -9
- package/src/schedule/schedule-spec-parser.ts +5 -1
- package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
- package/src/session/__tests__/session-command-module.test.ts +210 -45
- package/src/session/session-command-module.ts +68 -37
- package/src/session/session-command.ts +147 -80
- package/src/settings/settings-command-module.ts +3 -2
- package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
- package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
- package/src/shell/resolve-shell.ts +2 -2
- package/src/shell/shell-command-module.ts +6 -5
- package/src/shell/shell-command.ts +9 -5
- package/src/skills/__tests__/skills-command-module.test.ts +69 -28
- package/src/skills/index.ts +1 -0
- package/src/skills/skills-command-module.ts +22 -5
- package/src/skills/skills-command.ts +14 -13
- package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
- package/src/statusline/statusline-command-module.ts +2 -1
- package/src/statusline/statusline-command.ts +4 -4
- package/src/theme/__tests__/theme-command.test.ts +204 -0
- package/src/theme/index.ts +12 -0
- package/src/theme/theme-command-module.ts +211 -0
- package/src/user-local/__tests__/user-local-command.test.ts +145 -110
- package/src/user-local/user-local-command-constants.ts +1 -1
- package/src/user-local/user-local-command-module.ts +6 -5
- package/src/user-local/user-local-command.ts +12 -6
- package/src/user-local/user-local-memory-command.ts +17 -7
- package/src/plugins/default-plugin-command-adapter.ts +0 -164
- package/src/plugins/default-plugin-command-source-loader.ts +0 -31
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { scriptedSession, type ScriptedSessionHarness } from '@robota-sdk/agent-framework/testing';
|
|
4
|
+
|
|
5
|
+
import { createDevicesCommandModule } from '../devices-command-module.js';
|
|
6
|
+
|
|
7
|
+
import type { IDevicesCommandPort } from '../devices-command-port.js';
|
|
8
|
+
import type { ITerminalHandoff } from '@robota-sdk/agent-interface-session';
|
|
9
|
+
|
|
10
|
+
const DEVICE_A = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
|
11
|
+
const DEVICE_B = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
|
|
12
|
+
const USER = 'UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU';
|
|
13
|
+
const SIGNING = 'SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS';
|
|
14
|
+
|
|
15
|
+
function fakePort(): IDevicesCommandPort & { calls: string[] } {
|
|
16
|
+
const calls: string[] = [];
|
|
17
|
+
return {
|
|
18
|
+
calls,
|
|
19
|
+
list: vi.fn(async () => {
|
|
20
|
+
calls.push('list');
|
|
21
|
+
return {
|
|
22
|
+
userId: USER,
|
|
23
|
+
devices: [
|
|
24
|
+
{
|
|
25
|
+
deviceId: DEVICE_A,
|
|
26
|
+
name: 'laptop',
|
|
27
|
+
thisDevice: true,
|
|
28
|
+
holdsSigningKey: true,
|
|
29
|
+
certificateExpiresAt: Date.UTC(2026, 11, 25),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
deviceId: DEVICE_B,
|
|
33
|
+
name: 'desktop',
|
|
34
|
+
thisDevice: false,
|
|
35
|
+
holdsSigningKey: false,
|
|
36
|
+
certificateExpiresAt: Date.UTC(2026, 11, 20),
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
revokedDeviceCount: 1,
|
|
40
|
+
listsExpireAt: Date.UTC(2026, 8, 27),
|
|
41
|
+
};
|
|
42
|
+
}),
|
|
43
|
+
init: vi.fn(async (options) => {
|
|
44
|
+
calls.push(`init:${options.name ?? ''}`);
|
|
45
|
+
return {
|
|
46
|
+
ok: true as const,
|
|
47
|
+
value: { userId: USER, deviceId: DEVICE_A, signingKeyId: SIGNING, keyStorage: 'OS keychain' },
|
|
48
|
+
};
|
|
49
|
+
}),
|
|
50
|
+
recover: vi.fn(async () => {
|
|
51
|
+
calls.push('recover');
|
|
52
|
+
return {
|
|
53
|
+
ok: true as const,
|
|
54
|
+
value: {
|
|
55
|
+
deviceId: DEVICE_A,
|
|
56
|
+
signingKeyId: SIGNING,
|
|
57
|
+
revokedSigningKeyCount: 1,
|
|
58
|
+
droppedDeviceCount: 1,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}),
|
|
62
|
+
revoke: vi.fn(async (target) => {
|
|
63
|
+
calls.push(`revoke:${target}`);
|
|
64
|
+
return { ok: true as const, value: { deviceId: DEVICE_B, name: 'desktop' } };
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function handoff(enabled: boolean, onRun?: () => void): ITerminalHandoff {
|
|
70
|
+
return {
|
|
71
|
+
canHandoffTerminal: enabled,
|
|
72
|
+
runWithTerminal: async (operation) => {
|
|
73
|
+
onRun?.();
|
|
74
|
+
return operation();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let session: ScriptedSessionHarness | undefined;
|
|
80
|
+
|
|
81
|
+
afterEach(async () => {
|
|
82
|
+
await session?.dispose();
|
|
83
|
+
session = undefined;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
function start(port: IDevicesCommandPort, terminal = handoff(true)): ScriptedSessionHarness {
|
|
87
|
+
session = scriptedSession({
|
|
88
|
+
turns: [{ text: 'unused' }],
|
|
89
|
+
terminalHandoff: terminal,
|
|
90
|
+
commandModules: [createDevicesCommandModule(port)],
|
|
91
|
+
});
|
|
92
|
+
return session;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('/devices command', () => {
|
|
96
|
+
it('is operator-only: never model-invocable, and palette metadata matches the executable', () => {
|
|
97
|
+
const module = createDevicesCommandModule(fakePort());
|
|
98
|
+
const palette = module.commandSources?.[0]?.getCommands()[0];
|
|
99
|
+
const executable = module.systemCommands?.[0];
|
|
100
|
+
expect(executable?.modelInvocable).toBe(false);
|
|
101
|
+
expect(palette?.modelInvocable).toBe(false);
|
|
102
|
+
expect(executable?.userInvocable).toBe(true);
|
|
103
|
+
expect(palette?.subcommands?.map(({ name }) => name)).toEqual([
|
|
104
|
+
'list',
|
|
105
|
+
'init',
|
|
106
|
+
'revoke',
|
|
107
|
+
'recover',
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('lists the roster with short ids, this device, the signing-key holder and expiry', async () => {
|
|
112
|
+
const port = fakePort();
|
|
113
|
+
const result = await start(port).command('devices', '');
|
|
114
|
+
expect(result?.success).toBe(true);
|
|
115
|
+
expect(result?.message).toContain(DEVICE_A.slice(0, 10));
|
|
116
|
+
expect(result?.message).not.toContain(DEVICE_A);
|
|
117
|
+
expect(result?.message).toMatch(/laptop.*this device.*signing key/);
|
|
118
|
+
expect(result?.message).toContain('2026-12-25');
|
|
119
|
+
expect(result?.message).toContain('desktop');
|
|
120
|
+
expect(result?.message).toMatch(/1 revoked/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('says so when this device has no identity yet', async () => {
|
|
124
|
+
const port = fakePort();
|
|
125
|
+
vi.mocked(port.list).mockResolvedValueOnce(undefined);
|
|
126
|
+
const result = await start(port).command('devices', 'list');
|
|
127
|
+
expect(result?.message).toMatch(/\/devices init/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('runs init, recover and revoke on the operator terminal', async () => {
|
|
131
|
+
const port = fakePort();
|
|
132
|
+
const runs = vi.fn();
|
|
133
|
+
const harness = start(port, handoff(true, runs));
|
|
134
|
+
const init = await harness.command('devices', 'init my laptop');
|
|
135
|
+
expect(init?.success).toBe(true);
|
|
136
|
+
expect(init?.message).toContain(DEVICE_A.slice(0, 10));
|
|
137
|
+
expect(init?.message).toContain('OS keychain');
|
|
138
|
+
const recover = await harness.command('devices', 'recover');
|
|
139
|
+
expect(recover?.success).toBe(true);
|
|
140
|
+
expect(recover?.message).toMatch(/signing key/i);
|
|
141
|
+
const revoke = await harness.command('devices', `revoke ${DEVICE_B.slice(0, 8)}`);
|
|
142
|
+
expect(revoke?.success).toBe(true);
|
|
143
|
+
expect(revoke?.message).toContain('desktop');
|
|
144
|
+
expect(port.calls).toEqual(['init:my laptop', 'recover', `revoke:${DEVICE_B.slice(0, 8)}`]);
|
|
145
|
+
expect(runs).toHaveBeenCalledTimes(3);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('reports a refusal by its reason', async () => {
|
|
149
|
+
const port = fakePort();
|
|
150
|
+
vi.mocked(port.recover).mockResolvedValueOnce({ ok: false, reason: 'phrase-mismatch' });
|
|
151
|
+
const result = await start(port).command('devices', 'recover');
|
|
152
|
+
expect(result?.success).toBe(false);
|
|
153
|
+
expect(result?.message).toMatch(/different identity/i);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('refuses without an interactive terminal and never reaches the port (fail closed)', async () => {
|
|
157
|
+
const port = fakePort();
|
|
158
|
+
const harness = start(port, handoff(false));
|
|
159
|
+
for (const args of ['init', 'recover', 'revoke abcdef']) {
|
|
160
|
+
const result = await harness.command('devices', args);
|
|
161
|
+
expect(result?.success).toBe(false);
|
|
162
|
+
expect(result?.message).toMatch(/interactive terminal/i);
|
|
163
|
+
}
|
|
164
|
+
expect(port.calls).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('refuses a model invocation', async () => {
|
|
168
|
+
const port = fakePort();
|
|
169
|
+
const harness = start(port);
|
|
170
|
+
const result = await harness.session.executeModelCommand('devices', 'init');
|
|
171
|
+
expect(result?.success ?? false).toBe(false);
|
|
172
|
+
expect(port.calls).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('refuses every verb from a remote surface', async () => {
|
|
176
|
+
const port = fakePort();
|
|
177
|
+
const harness = start(port);
|
|
178
|
+
for (const args of ['', 'init', 'recover', 'revoke abcdef']) {
|
|
179
|
+
const result = await harness.session.executeCommand('devices', args, 'remote');
|
|
180
|
+
expect(result?.success).toBe(false);
|
|
181
|
+
expect(result?.message).toMatch(/operator/i);
|
|
182
|
+
}
|
|
183
|
+
expect(port.calls).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('names add and join as not yet available rather than guessing', async () => {
|
|
187
|
+
const port = fakePort();
|
|
188
|
+
const harness = start(port);
|
|
189
|
+
for (const args of ['add', 'join abc']) {
|
|
190
|
+
const result = await harness.command('devices', args);
|
|
191
|
+
expect(result?.success).toBe(false);
|
|
192
|
+
expect(result?.message).toMatch(/not available yet/i);
|
|
193
|
+
}
|
|
194
|
+
expect(port.calls).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('requires a device id for revoke', async () => {
|
|
198
|
+
const port = fakePort();
|
|
199
|
+
const result = await start(port).command('devices', 'revoke');
|
|
200
|
+
expect(result?.success).toBe(false);
|
|
201
|
+
expect(result?.message).toMatch(/usage/i);
|
|
202
|
+
expect(port.calls).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('reports a failure without its stack', async () => {
|
|
206
|
+
const port = fakePort();
|
|
207
|
+
vi.mocked(port.init).mockRejectedValueOnce(new Error('credential store unavailable'));
|
|
208
|
+
const result = await start(port).command('devices', 'init');
|
|
209
|
+
expect(result?.success).toBe(false);
|
|
210
|
+
expect(result?.message).toContain('credential store unavailable');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/devices` — this device's place in the user's device identity: list the roster, create the
|
|
3
|
+
* identity, revoke a device, recover from the recovery phrase.
|
|
4
|
+
*
|
|
5
|
+
* Operator-only, twice over: the command is not model-invocable, and it refuses any invocation that
|
|
6
|
+
* is not the operator's own (a remote surface, the model). Every verb that touches a secret or
|
|
7
|
+
* asks for a confirmation runs inside the terminal handoff, so what is typed goes to the host's
|
|
8
|
+
* terminal and never through the session's input, prompt history or conversation. Without an
|
|
9
|
+
* interactive terminal those verbs refuse rather than fall back to anything else.
|
|
10
|
+
*/
|
|
11
|
+
import { createSystemCommandFromEntry } from '../command-module-utils.js';
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
IDevicesCommandPort,
|
|
15
|
+
IDevicesView,
|
|
16
|
+
TDevicesOutcome,
|
|
17
|
+
TDevicesRefusal,
|
|
18
|
+
} from './devices-command-port.js';
|
|
19
|
+
import type {
|
|
20
|
+
ICommandHostContext,
|
|
21
|
+
ICommandModule,
|
|
22
|
+
ISystemCommand,
|
|
23
|
+
} from '@robota-sdk/agent-framework';
|
|
24
|
+
import type { ICommand, ICommandResult, ICommandSource } from '@robota-sdk/agent-interface-command';
|
|
25
|
+
|
|
26
|
+
const SHORT_ID_CHARS = 10;
|
|
27
|
+
const USAGE = 'Usage: /devices [list|init [name]|revoke <device-id>|recover]';
|
|
28
|
+
|
|
29
|
+
export function createDevicesCommandEntry(): ICommand {
|
|
30
|
+
return {
|
|
31
|
+
name: 'devices',
|
|
32
|
+
displayName: 'Devices',
|
|
33
|
+
description:
|
|
34
|
+
"Manage this device's identity among the user's devices: list the roster, create the identity and its recovery phrase, revoke a device, or rotate the signing key from the phrase. Operator-only; the phrase is handled on the terminal and results carry only device ids and names.",
|
|
35
|
+
source: 'devices',
|
|
36
|
+
modelInvocable: false,
|
|
37
|
+
userInvocable: true,
|
|
38
|
+
argumentHint: '[list|init [name]|revoke <device-id>|recover]',
|
|
39
|
+
subcommands: [
|
|
40
|
+
{ name: 'list', description: 'List your devices', source: 'devices' },
|
|
41
|
+
{
|
|
42
|
+
name: 'init',
|
|
43
|
+
description: 'Create your device identity and recovery phrase',
|
|
44
|
+
source: 'devices',
|
|
45
|
+
argumentHint: '[name]',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'revoke',
|
|
49
|
+
description: 'Revoke a device with the signing key',
|
|
50
|
+
source: 'devices',
|
|
51
|
+
argumentHint: '<device-id>',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'recover',
|
|
55
|
+
description: 'Rotate the signing key from your recovery phrase',
|
|
56
|
+
source: 'devices',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const REFUSALS: Readonly<Record<TDevicesRefusal, string>> = {
|
|
63
|
+
'no-terminal': 'This needs an interactive terminal; the recovery phrase is never shown or read anywhere else.',
|
|
64
|
+
'not-initialized': 'This device has no identity yet. Run `/devices init` first.',
|
|
65
|
+
'already-initialized': 'This device already has an identity. Use `/devices recover` to rotate its signing key.',
|
|
66
|
+
cancelled: 'Cancelled. Nothing was changed.',
|
|
67
|
+
'confirmation-failed': 'The confirmation did not match. Nothing was changed.',
|
|
68
|
+
'phrase-invalid': 'That is not a valid recovery phrase. Nothing was changed.',
|
|
69
|
+
'phrase-mismatch':
|
|
70
|
+
'That phrase (with that passphrase) belongs to a different identity than this device. Nothing was changed.',
|
|
71
|
+
'no-signing-key': 'This device does not hold the signing key. Revoke from a device that does, or `/devices recover`.',
|
|
72
|
+
'signing-key-expired': 'The signing key on this device has expired. Run `/devices recover` to issue a new one.',
|
|
73
|
+
'unknown-device': 'No device in the roster has that id.',
|
|
74
|
+
'ambiguous-device': 'More than one device starts with that id; give more of it.',
|
|
75
|
+
'self-revocation': 'This device cannot revoke itself. Revoke it from another device, or `/devices recover`.',
|
|
76
|
+
'changed-concurrently':
|
|
77
|
+
'The device identity changed in another session while this was waiting. Nothing was changed; try again.',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function short(id: string): string {
|
|
81
|
+
return id.slice(0, SHORT_ID_CHARS);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function day(ms: number): string {
|
|
85
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function refused(reason: TDevicesRefusal): ICommandResult {
|
|
89
|
+
return { success: false, message: REFUSALS[reason] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatList(view: IDevicesView | undefined): ICommandResult {
|
|
93
|
+
if (view === undefined) return { success: true, message: REFUSALS['not-initialized'] };
|
|
94
|
+
const lines = view.devices.map((device) => {
|
|
95
|
+
const notes = [
|
|
96
|
+
...(device.thisDevice ? ['this device'] : []),
|
|
97
|
+
...(device.holdsSigningKey ? ['holds the signing key'] : []),
|
|
98
|
+
];
|
|
99
|
+
const suffix = notes.length > 0 ? ` (${notes.join(', ')})` : '';
|
|
100
|
+
return ` ${short(device.deviceId)} ${device.name}${suffix} certificate expires ${day(device.certificateExpiresAt)}`;
|
|
101
|
+
});
|
|
102
|
+
return {
|
|
103
|
+
success: true,
|
|
104
|
+
message: [
|
|
105
|
+
`Devices of user ${short(view.userId)}:`,
|
|
106
|
+
...lines,
|
|
107
|
+
`${view.revokedDeviceCount} revoked. Roster and revocation list valid until ${new Date(view.listsExpireAt).toISOString()}.`,
|
|
108
|
+
].join('\n'),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isOperator(context: ICommandHostContext): boolean {
|
|
113
|
+
return context.getCommandInvocationSource() === 'user';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Run a terminal-bound port call; a refusal or a thrown error becomes a failed result. */
|
|
117
|
+
async function onTerminal<T>(
|
|
118
|
+
context: ICommandHostContext,
|
|
119
|
+
run: () => Promise<TDevicesOutcome<T>>,
|
|
120
|
+
done: (value: T) => string,
|
|
121
|
+
): Promise<ICommandResult> {
|
|
122
|
+
if (!context.canHandoffTerminal()) return refused('no-terminal');
|
|
123
|
+
try {
|
|
124
|
+
const outcome = await context.runWithTerminal(run);
|
|
125
|
+
return outcome.ok ? { success: true, message: done(outcome.value) } : refused(outcome.reason);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return {
|
|
128
|
+
success: false,
|
|
129
|
+
message: `Device identity operation failed: ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function executeDevicesCommand(
|
|
135
|
+
port: IDevicesCommandPort,
|
|
136
|
+
context: ICommandHostContext,
|
|
137
|
+
args: string,
|
|
138
|
+
): Promise<ICommandResult> {
|
|
139
|
+
if (!isOperator(context)) {
|
|
140
|
+
return {
|
|
141
|
+
success: false,
|
|
142
|
+
message: '`/devices` runs only for the operator at this terminal, never from a remote surface or the model.',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const trimmed = args.trim();
|
|
146
|
+
const spaceAt = trimmed.indexOf(' ');
|
|
147
|
+
const verb = (spaceAt === -1 ? trimmed : trimmed.slice(0, spaceAt)).toLowerCase();
|
|
148
|
+
// Device ids are case-sensitive base64url, so the remainder keeps its case.
|
|
149
|
+
const rest = spaceAt === -1 ? '' : trimmed.slice(spaceAt + 1).trim();
|
|
150
|
+
|
|
151
|
+
switch (verb) {
|
|
152
|
+
case '':
|
|
153
|
+
case 'list':
|
|
154
|
+
try {
|
|
155
|
+
return formatList(await port.list());
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
message: `Device identity could not be read: ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
case 'init':
|
|
163
|
+
return onTerminal(
|
|
164
|
+
context,
|
|
165
|
+
() => port.init(rest.length > 0 ? { name: rest } : {}),
|
|
166
|
+
(value) =>
|
|
167
|
+
[
|
|
168
|
+
`Device identity created. This device: ${short(value.deviceId)}; user ${short(value.userId)}.`,
|
|
169
|
+
`It holds signing key ${short(value.signingKeyId)}.`,
|
|
170
|
+
...(value.keyStorage !== undefined ? [`Private keys are kept in: ${value.keyStorage}.`] : []),
|
|
171
|
+
'Keep the recovery phrase offline; it is the only way to recover this identity.',
|
|
172
|
+
].join('\n'),
|
|
173
|
+
);
|
|
174
|
+
case 'recover':
|
|
175
|
+
return onTerminal(
|
|
176
|
+
context,
|
|
177
|
+
() => port.recover(),
|
|
178
|
+
(value) =>
|
|
179
|
+
[
|
|
180
|
+
`Recovered. New signing key ${short(value.signingKeyId)}; ${value.revokedSigningKeyCount} old signing key(s) revoked.`,
|
|
181
|
+
`This device (${short(value.deviceId)}) is certified again.`,
|
|
182
|
+
...(value.droppedDeviceCount > 0
|
|
183
|
+
? [`${value.droppedDeviceCount} other device(s) were certified by a retired signing key and must enrol again.`]
|
|
184
|
+
: []),
|
|
185
|
+
].join('\n'),
|
|
186
|
+
);
|
|
187
|
+
case 'revoke':
|
|
188
|
+
if (rest.length === 0) return { success: false, message: 'Usage: /devices revoke <device-id>' };
|
|
189
|
+
return onTerminal(
|
|
190
|
+
context,
|
|
191
|
+
() => port.revoke(rest),
|
|
192
|
+
(value) => `Revoked ${value.name} (${short(value.deviceId)}). A new roster and revocation list were issued.`,
|
|
193
|
+
);
|
|
194
|
+
case 'add':
|
|
195
|
+
case 'join':
|
|
196
|
+
return {
|
|
197
|
+
success: false,
|
|
198
|
+
message: `\`/devices ${verb}\` is not available yet: enrolling another device needs the device connection, which is not built yet.`,
|
|
199
|
+
};
|
|
200
|
+
default:
|
|
201
|
+
return { success: false, message: `Unknown argument "${verb}". ${USAGE}` };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export class DevicesCommandSource implements ICommandSource {
|
|
206
|
+
readonly name = 'devices';
|
|
207
|
+
|
|
208
|
+
getCommands(): ICommand[] {
|
|
209
|
+
return [createDevicesCommandEntry()];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function createDevicesCommandModule(port: IDevicesCommandPort): ICommandModule {
|
|
214
|
+
const command: ISystemCommand = createSystemCommandFromEntry(createDevicesCommandEntry(), {
|
|
215
|
+
requiresPermission: false,
|
|
216
|
+
lifecycle: 'inline',
|
|
217
|
+
execute: (context, args) => executeDevicesCommand(port, context, args),
|
|
218
|
+
});
|
|
219
|
+
return {
|
|
220
|
+
name: 'agent-command-devices',
|
|
221
|
+
commandSources: [new DevicesCommandSource()],
|
|
222
|
+
systemCommands: [command],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `/devices` needs from the host that keeps this device's identity.
|
|
3
|
+
*
|
|
4
|
+
* The host owns every secret: the recovery phrase is shown and read on the host's own terminal, and
|
|
5
|
+
* private keys live in its credential store. What crosses this port back to the command is ids,
|
|
6
|
+
* names, times and closed refusal reasons — nothing the command formats can carry a secret, because
|
|
7
|
+
* the command never receives one. A thrown error's message is the host's promise to the same effect.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface IDeviceListEntry {
|
|
11
|
+
readonly deviceId: string;
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly thisDevice: boolean;
|
|
14
|
+
/** Whether this entry is known to hold the device-signing key. Only known for this device. */
|
|
15
|
+
readonly holdsSigningKey: boolean;
|
|
16
|
+
readonly certificateExpiresAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IDevicesView {
|
|
20
|
+
readonly userId: string;
|
|
21
|
+
readonly devices: readonly IDeviceListEntry[];
|
|
22
|
+
readonly revokedDeviceCount: number;
|
|
23
|
+
/** When the roster and revocation list this device holds stop being accepted. */
|
|
24
|
+
readonly listsExpireAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Why an operation did not happen. Closed, so no free text from the secret path reaches a result. */
|
|
28
|
+
export type TDevicesRefusal =
|
|
29
|
+
/** No interactive terminal: the phrase is never shown on or read from anything else. */
|
|
30
|
+
| 'no-terminal'
|
|
31
|
+
| 'not-initialized'
|
|
32
|
+
| 'already-initialized'
|
|
33
|
+
/** The operator cancelled at the terminal. */
|
|
34
|
+
| 'cancelled'
|
|
35
|
+
/** The re-typed words or passphrase did not match. */
|
|
36
|
+
| 'confirmation-failed'
|
|
37
|
+
| 'phrase-invalid'
|
|
38
|
+
/** The phrase is valid but derives another identity than this device's. */
|
|
39
|
+
| 'phrase-mismatch'
|
|
40
|
+
| 'no-signing-key'
|
|
41
|
+
| 'signing-key-expired'
|
|
42
|
+
| 'unknown-device'
|
|
43
|
+
| 'ambiguous-device'
|
|
44
|
+
| 'self-revocation'
|
|
45
|
+
/** Another process changed the identity while this operation waited on the operator. */
|
|
46
|
+
| 'changed-concurrently';
|
|
47
|
+
|
|
48
|
+
export type TDevicesOutcome<T> =
|
|
49
|
+
| { readonly ok: true; readonly value: T }
|
|
50
|
+
| { readonly ok: false; readonly reason: TDevicesRefusal };
|
|
51
|
+
|
|
52
|
+
export interface IDevicesInitResult {
|
|
53
|
+
readonly userId: string;
|
|
54
|
+
readonly deviceId: string;
|
|
55
|
+
readonly signingKeyId: string;
|
|
56
|
+
/** Where the private keys were kept, for the operator. */
|
|
57
|
+
readonly keyStorage?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface IDevicesRecoverResult {
|
|
61
|
+
readonly deviceId: string;
|
|
62
|
+
readonly signingKeyId: string;
|
|
63
|
+
readonly revokedSigningKeyCount: number;
|
|
64
|
+
/** Other devices that were certified by a retired signing key and must enrol again. */
|
|
65
|
+
readonly droppedDeviceCount: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface IDevicesRevokeResult {
|
|
69
|
+
readonly deviceId: string;
|
|
70
|
+
readonly name: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IDevicesCommandPort {
|
|
74
|
+
/** This device's view of the roster, or `undefined` when it has no identity yet. */
|
|
75
|
+
list(): Promise<IDevicesView | undefined>;
|
|
76
|
+
/** Create the identity. Runs on the operator terminal (it shows the phrase once). */
|
|
77
|
+
init(options: { readonly name?: string }): Promise<TDevicesOutcome<IDevicesInitResult>>;
|
|
78
|
+
/** Rotate the signing key from the phrase. Runs on the operator terminal. */
|
|
79
|
+
recover(): Promise<TDevicesOutcome<IDevicesRecoverResult>>;
|
|
80
|
+
/** Revoke a device by id prefix with the signing key. Confirms on the operator terminal. */
|
|
81
|
+
revoke(deviceIdPrefix: string): Promise<TDevicesOutcome<IDevicesRevokeResult>>;
|
|
82
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createDevicesCommandModule } from './devices-command-module.js';
|
|
2
|
+
export type {
|
|
3
|
+
IDeviceListEntry,
|
|
4
|
+
IDevicesCommandPort,
|
|
5
|
+
IDevicesInitResult,
|
|
6
|
+
IDevicesRecoverResult,
|
|
7
|
+
IDevicesRevokeResult,
|
|
8
|
+
IDevicesView,
|
|
9
|
+
TDevicesOutcome,
|
|
10
|
+
TDevicesRefusal,
|
|
11
|
+
} from './devices-command-port.js';
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { createDoctorCommandModule } from '../doctor-command-module.js';
|
|
7
|
+
import {
|
|
8
|
+
createDoctorFixture,
|
|
9
|
+
fixtureProviderDefinition,
|
|
10
|
+
installBrokenHome,
|
|
11
|
+
} from './doctor-fixture.js';
|
|
12
|
+
|
|
13
|
+
import type { IDoctorFixture } from './doctor-fixture.js';
|
|
14
|
+
import { CommandRegistry, SystemCommandExecutor } from '@robota-sdk/agent-framework';
|
|
15
|
+
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
16
|
+
|
|
17
|
+
import type { IUserInteraction, TActionResponse } from '@robota-sdk/agent-core';
|
|
18
|
+
|
|
19
|
+
const fixtures: IDoctorFixture[] = [];
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const fixture of fixtures.splice(0)) fixture.cleanup();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** The published conformant double, with only the interaction port overridden. */
|
|
25
|
+
function contextWith(interaction: IUserInteraction | undefined) {
|
|
26
|
+
return createTestCommandHost({ overrides: { getUserInteraction: () => interaction } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function answering(
|
|
30
|
+
response: TActionResponse,
|
|
31
|
+
): IUserInteraction & { ask: ReturnType<typeof vi.fn> } {
|
|
32
|
+
return { ask: vi.fn(async () => response) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('/doctor command module (OBSERVABILITY-1991 TC-06)', () => {
|
|
36
|
+
it('advertises repair arguments consistently in the palette and executable metadata', () => {
|
|
37
|
+
const fixture = createDoctorFixture({ env: {} });
|
|
38
|
+
fixtures.push(fixture);
|
|
39
|
+
const module = createDoctorCommandModule(fixture.inputs, fixture.deps);
|
|
40
|
+
const registry = new CommandRegistry();
|
|
41
|
+
registry.addModule(module);
|
|
42
|
+
const executor = new SystemCommandExecutor([...(module.systemCommands ?? [])]);
|
|
43
|
+
const entry = registry.getCommands().find((command) => command.name === 'doctor');
|
|
44
|
+
const command = executor.getCommand('doctor');
|
|
45
|
+
expect(entry).toMatchObject({ argumentHint: '[repair <check-id>]', modelInvocable: false });
|
|
46
|
+
expect(command).toMatchObject({
|
|
47
|
+
argumentHint: '[repair <check-id>]',
|
|
48
|
+
modelInvocable: false,
|
|
49
|
+
userInvocable: true,
|
|
50
|
+
requiresPermission: false,
|
|
51
|
+
lifecycle: 'inline',
|
|
52
|
+
});
|
|
53
|
+
expect(registry.getCapabilityDescriptors()).toContainEqual(
|
|
54
|
+
expect.objectContaining({
|
|
55
|
+
name: 'doctor',
|
|
56
|
+
argumentHint: '[repair <check-id>]',
|
|
57
|
+
userInvocable: true,
|
|
58
|
+
modelInvocable: false,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
expect(command?.description).toBe(entry?.description);
|
|
62
|
+
expect(executor.isModelInvocable('doctor')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders the same check set without constructing a provider or submitting input', async () => {
|
|
66
|
+
const createProvider = vi.fn(() => {
|
|
67
|
+
throw new Error('must not be called');
|
|
68
|
+
});
|
|
69
|
+
const fixture = createDoctorFixture({
|
|
70
|
+
env: {},
|
|
71
|
+
providerDefinitions: [fixtureProviderDefinition({ createProvider })],
|
|
72
|
+
});
|
|
73
|
+
fixtures.push(fixture);
|
|
74
|
+
installBrokenHome(fixture);
|
|
75
|
+
const module = createDoctorCommandModule(fixture.inputs, fixture.deps);
|
|
76
|
+
const command = module.systemCommands?.[0];
|
|
77
|
+
expect(command?.name).toBe('doctor');
|
|
78
|
+
const result = await command!.execute(contextWith(undefined), '');
|
|
79
|
+
expect(result.success).toBe(false);
|
|
80
|
+
expect(result.message).toContain('settings.user.robota');
|
|
81
|
+
expect(result.message).toContain('plugin.broken-plugin@fixture-market');
|
|
82
|
+
expect(result.data).toMatchObject({
|
|
83
|
+
failCount: expect.any(Number),
|
|
84
|
+
repairable: ['settings.user.robota'],
|
|
85
|
+
});
|
|
86
|
+
expect(createProvider).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('renders host-selected product wording in the interactive command', async () => {
|
|
90
|
+
const fixture = createDoctorFixture({ env: {} });
|
|
91
|
+
fixtures.push(fixture);
|
|
92
|
+
installBrokenHome(fixture);
|
|
93
|
+
const command = createDoctorCommandModule(fixture.inputs, fixture.deps, {
|
|
94
|
+
title: 'Atlas doctor',
|
|
95
|
+
productName: 'Atlas',
|
|
96
|
+
formatRepairCommand: (id) => `atlas doctor --repair ${id}`,
|
|
97
|
+
repairOffer: 'run atlas doctor --repair <check-id>',
|
|
98
|
+
}).systemCommands![0]!;
|
|
99
|
+
|
|
100
|
+
const result = await command.execute(contextWith(undefined), '');
|
|
101
|
+
expect(result.message).toContain('Atlas doctor');
|
|
102
|
+
expect(result.message).toContain('Atlas');
|
|
103
|
+
expect(result.message).toContain('atlas doctor --repair settings.user.robota');
|
|
104
|
+
expect(result.message).toContain('run atlas doctor --repair <check-id>');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('repair without an interaction port or with a cancelled answer writes nothing', async () => {
|
|
108
|
+
const fixture = createDoctorFixture({ env: {} });
|
|
109
|
+
fixtures.push(fixture);
|
|
110
|
+
installBrokenHome(fixture);
|
|
111
|
+
const path = join(fixture.home, '.robota', 'settings.json');
|
|
112
|
+
const command = createDoctorCommandModule(fixture.inputs, fixture.deps).systemCommands![0]!;
|
|
113
|
+
|
|
114
|
+
const noPort = await command.execute(contextWith(undefined), 'repair settings.user.robota');
|
|
115
|
+
expect(noPort.success).toBe(false);
|
|
116
|
+
expect(readFileSync(path, 'utf8')).toBe('');
|
|
117
|
+
|
|
118
|
+
const cancelled = answering({ type: 'cancelled' });
|
|
119
|
+
const declined = await command.execute(contextWith(cancelled), 'repair settings.user.robota');
|
|
120
|
+
expect(declined.success).toBe(false);
|
|
121
|
+
expect(cancelled.ask).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(readFileSync(path, 'utf8')).toBe('');
|
|
123
|
+
|
|
124
|
+
const usage = await command.execute(contextWith(cancelled), 'repair');
|
|
125
|
+
expect(usage.message).toContain('Usage');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('repair with a confirmed answer applies the allowlisted writer and re-renders', async () => {
|
|
129
|
+
const fixture = createDoctorFixture({ env: {} });
|
|
130
|
+
fixtures.push(fixture);
|
|
131
|
+
installBrokenHome(fixture);
|
|
132
|
+
const path = join(fixture.home, '.robota', 'settings.json');
|
|
133
|
+
const command = createDoctorCommandModule(fixture.inputs, fixture.deps).systemCommands![0]!;
|
|
134
|
+
const yes = answering({ type: 'answer', values: ['yes'] });
|
|
135
|
+
const result = await command.execute(contextWith(yes), 'repair settings.user.robota');
|
|
136
|
+
expect(result.success).toBe(true);
|
|
137
|
+
expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({});
|
|
138
|
+
expect(result.data).toMatchObject({
|
|
139
|
+
repair: 'settings.user.robota',
|
|
140
|
+
applied: true,
|
|
141
|
+
status: 'ok',
|
|
142
|
+
});
|
|
143
|
+
expect(yes.ask.mock.calls[0]?.[0]).toMatchObject({ id: 'doctor-repair:settings.user.robota' });
|
|
144
|
+
});
|
|
145
|
+
});
|