windows-development-sandbox 1.1.0 → 1.4.1
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/.sandbox-config.example.json +2 -1
- package/README.md +3 -2
- package/config.example.json +39 -5
- package/dist/cli/args.d.ts +3 -0
- package/dist/cli/args.js +3 -1
- package/dist/cli/boot-progress.d.ts +22 -0
- package/dist/cli/boot-progress.js +59 -0
- package/dist/cli/commands/accounts.js +2 -2
- package/dist/cli/commands/agent-commands.js +1 -1
- package/dist/cli/commands/context.js +2 -1
- package/dist/cli/commands/data/clean.js +6 -1
- package/dist/cli/commands/data/collect.js +3 -3
- package/dist/cli/commands/index.d.ts +1 -1
- package/dist/cli/commands/index.js +1 -1
- package/dist/cli/commands/launch.d.ts +5 -2
- package/dist/cli/commands/launch.js +113 -25
- package/dist/cli/commands/maintenance.d.ts +2 -0
- package/dist/cli/commands/maintenance.js +70 -4
- package/dist/cli/commands/project.d.ts +10 -6
- package/dist/cli/commands/project.js +42 -3
- package/dist/cli/commands/restart.js +17 -7
- package/dist/cli/commands/sessions.js +5 -4
- package/dist/cli/commands/user-config.js +1 -1
- package/dist/cli/index.js +6 -4
- package/dist/cli/manifest.js +6 -1
- package/dist/cli/menu/apps.d.ts +21 -0
- package/dist/cli/menu/apps.js +122 -0
- package/dist/cli/menu/command-form.d.ts +28 -0
- package/dist/cli/menu/command-form.js +219 -0
- package/dist/cli/menu/index.d.ts +26 -0
- package/dist/cli/menu/index.js +180 -0
- package/dist/cli/menu/settings/categories.d.ts +9 -0
- package/dist/cli/menu/settings/categories.js +28 -0
- package/dist/cli/menu/settings/config-file.d.ts +40 -0
- package/dist/cli/menu/settings/config-file.js +71 -0
- package/dist/cli/menu/settings/editors.d.ts +30 -0
- package/dist/cli/menu/settings/editors.js +305 -0
- package/dist/cli/menu/settings/index.d.ts +32 -0
- package/dist/cli/menu/settings/index.js +136 -0
- package/dist/cli/menu/settings/schema-node.d.ts +62 -0
- package/dist/cli/menu/settings/schema-node.js +104 -0
- package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
- package/dist/cli/menu/several.js +102 -0
- package/dist/cli/progress.d.ts +15 -0
- package/dist/cli/progress.js +52 -0
- package/dist/cli/tui/app.d.ts +39 -0
- package/dist/cli/tui/app.js +52 -0
- package/dist/cli/tui/dialogs.d.ts +57 -0
- package/dist/cli/tui/dialogs.js +162 -0
- package/dist/cli/tui/frame.d.ts +18 -0
- package/dist/cli/tui/frame.js +27 -0
- package/dist/cli/tui/index.d.ts +7 -0
- package/dist/cli/tui/index.js +8 -0
- package/dist/cli/tui/list-screen.d.ts +101 -0
- package/dist/cli/tui/list-screen.js +299 -0
- package/dist/cli/tui/terminal.d.ts +37 -0
- package/dist/cli/tui/terminal.js +89 -0
- package/dist/cli/tui/text.d.ts +20 -0
- package/dist/cli/tui/text.js +105 -0
- package/dist/cli/tui/theme.d.ts +45 -0
- package/dist/cli/tui/theme.js +30 -0
- package/dist/core/config.js +10 -7
- package/dist/core/local-config.d.ts +31 -1
- package/dist/core/local-config.js +72 -7
- package/dist/core/project-policy.d.ts +50 -3
- package/dist/core/project-policy.js +90 -4
- package/dist/core/schema/apps.d.ts +40 -0
- package/dist/core/schema/apps.js +27 -0
- package/dist/core/schema/config.d.ts +69 -3
- package/dist/core/schema/config.js +18 -6
- package/dist/core/schema/profile.d.ts +5 -0
- package/dist/core/schema/profile.js +4 -2
- package/dist/core/schema/projects.d.ts +1 -0
- package/dist/core/schema/projects.js +1 -0
- package/dist/core/schema/sandbox.d.ts +29 -1
- package/dist/core/schema/sandbox.js +29 -1
- package/dist/core/schema/toolchains.d.ts +3 -0
- package/dist/core/schema/toolchains.js +6 -1
- package/dist/core/types.d.ts +24 -1
- package/dist/core/util.d.ts +2 -0
- package/dist/core/util.js +2 -0
- package/dist/core/workspace.d.ts +21 -4
- package/dist/core/workspace.js +99 -21
- package/dist/guest/apps/index.d.ts +10 -0
- package/dist/guest/apps/index.js +206 -0
- package/dist/guest/apps/resolvers.d.ts +8 -0
- package/dist/guest/apps/resolvers.js +90 -0
- package/dist/guest/audit-parse.d.ts +7 -0
- package/dist/guest/audit-parse.js +24 -0
- package/dist/guest/audit.d.ts +4 -0
- package/dist/guest/audit.js +47 -0
- package/dist/guest/daemon/relays.d.ts +1 -0
- package/dist/guest/daemon/relays.js +18 -0
- package/dist/guest/daemon.js +4 -0
- package/dist/guest/env.d.ts +7 -2
- package/dist/guest/env.js +17 -3
- package/dist/guest/helpers.d.ts +1 -1
- package/dist/guest/main.js +20 -3
- package/dist/guest/netproxy.d.ts +1 -0
- package/dist/guest/netproxy.js +100 -0
- package/dist/guest/ptyhost.js +14 -4
- package/dist/guest/setup/claude.d.ts +0 -4
- package/dist/guest/setup/claude.js +48 -2
- package/dist/guest/setup/network.d.ts +9 -0
- package/dist/guest/setup/network.js +97 -4
- package/dist/guest/setup/user.js +4 -0
- package/dist/guest/toolchains/index.js +29 -3
- package/dist/guest/toolchains/native/mesa.js +13 -3
- package/dist/guest/toolchains/plan.d.ts +3 -1
- package/dist/guest/toolchains/plan.js +53 -9
- package/dist/guest/toolchains/python.js +26 -2
- package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
- package/dist/guest/toolchains/vscode-profile.js +69 -0
- package/dist/guest/toolchains/vscode.d.ts +17 -0
- package/dist/guest/toolchains/vscode.js +87 -1
- package/dist/host/attach.js +2 -1
- package/dist/host/audit/collect.d.ts +34 -0
- package/dist/host/audit/collect.js +126 -0
- package/dist/host/audit/log.d.ts +46 -0
- package/dist/host/audit/log.js +143 -0
- package/dist/host/channel.d.ts +2 -2
- package/dist/host/channel.js +3 -2
- package/dist/host/generate.d.ts +7 -1
- package/dist/host/generate.js +23 -6
- package/dist/host/join.d.ts +7 -0
- package/dist/host/join.js +23 -9
- package/dist/host/launch/mappings.d.ts +7 -2
- package/dist/host/launch/mappings.js +68 -18
- package/dist/host/launch/projects.d.ts +2 -0
- package/dist/host/launch/projects.js +11 -5
- package/dist/host/launch/stage.js +1 -1
- package/dist/host/motw.d.ts +17 -0
- package/dist/host/motw.js +74 -0
- package/dist/host/platform.js +3 -0
- package/dist/host/port-forward.d.ts +8 -0
- package/dist/host/port-forward.js +37 -0
- package/dist/host/projects.d.ts +25 -2
- package/dist/host/projects.js +54 -5
- package/dist/host/repo-guard.d.ts +10 -3
- package/dist/host/repo-guard.js +142 -14
- package/dist/host/stops.d.ts +67 -0
- package/dist/host/stops.js +126 -0
- package/dist/host/terminal.d.ts +8 -0
- package/dist/host/terminal.js +45 -1
- package/dist/host/workspace-view/index.d.ts +20 -0
- package/dist/host/workspace-view/index.js +53 -0
- package/dist/host/workspace-view/rules.d.ts +26 -0
- package/dist/host/workspace-view/rules.js +93 -0
- package/dist/host/workspace-view/view.d.ts +50 -0
- package/dist/host/workspace-view/view.js +281 -0
- package/dist/runtime/daemon.js +1387 -558
- package/dist/runtime/db.js +199 -14
- package/dist/runtime/main.js +3082 -631
- package/dist/runtime/netproxy.js +25948 -0
- package/dist/runtime/view.js +531 -148
- package/dist/selftest/audit.test.d.ts +2 -0
- package/dist/selftest/audit.test.js +57 -0
- package/dist/selftest/channel.test.js +45 -2
- package/dist/selftest/cli.test.js +47 -17
- package/dist/selftest/fixtures.d.ts +47 -1
- package/dist/selftest/fixtures.js +3 -1
- package/dist/selftest/generate.test.js +7 -2
- package/dist/selftest/index.js +6 -2
- package/dist/selftest/live-network.test.d.ts +2 -0
- package/dist/selftest/live-network.test.js +63 -0
- package/dist/selftest/live-terminal.test.d.ts +2 -0
- package/dist/selftest/live-terminal.test.js +169 -0
- package/dist/selftest/local-config.test.js +42 -4
- package/dist/selftest/mirror.test.js +36 -7
- package/dist/selftest/network.test.js +12 -0
- package/dist/selftest/projects.test.js +77 -8
- package/dist/selftest/repo-guard.test.js +59 -1
- package/dist/selftest/sync.test.js +50 -10
- package/dist/selftest/toolchains.test.js +30 -0
- package/dist/selftest/workspace-view.test.d.ts +2 -0
- package/dist/selftest/workspace-view.test.js +89 -0
- package/dist/shared/apps.d.ts +49 -0
- package/dist/shared/apps.js +114 -0
- package/dist/shared/console-input.d.ts +11 -0
- package/dist/shared/console-input.js +61 -0
- package/dist/shared/download.d.ts +3 -0
- package/dist/shared/download.js +25 -2
- package/dist/shared/guest-paths.d.ts +27 -0
- package/dist/shared/guest-paths.js +68 -0
- package/dist/shared/netpolicy.d.ts +27 -0
- package/dist/shared/netpolicy.js +51 -0
- package/dist/shared/projects.d.ts +27 -4
- package/dist/shared/projects.js +72 -13
- package/dist/shared/pty-client.d.ts +26 -4
- package/dist/shared/pty-client.js +204 -25
- package/dist/shared/rundir.d.ts +2 -0
- package/dist/shared/rundir.js +2 -0
- package/dist/shared/schema.d.ts +34 -0
- package/dist/shared/schema.js +14 -0
- package/dist/shared/system.d.ts +1 -0
- package/dist/shared/system.js +1 -0
- package/dist/sync/claude-mem.js +3 -2
- package/dist/sync/history.d.ts +6 -0
- package/dist/sync/history.js +26 -11
- package/dist/sync/watcher.js +78 -6
- package/package.json +2 -1
- package/sandbox/cargoshim.cs +56 -0
- package/sandbox/cpucap.cs +33 -0
- package/sandbox/cpucap.ps1 +4 -35
- package/sandbox/logon.cmd +6 -0
- package/sandbox/memlog.ps1 +34 -0
- package/sandbox/pagefile.cs +31 -0
- package/sandbox/pagefile.ps1 +21 -0
- package/sandbox/ptyhost.cs +162 -7
- package/schemas/config.schema.json +318 -3
- package/schemas/sandbox-config.schema.json +129 -14
- package/dist/cli/menu-multi.js +0 -82
- package/dist/cli/menu.d.ts +0 -14
- package/dist/cli/menu.js +0 -290
- package/dist/cli/settings/edit.d.ts +0 -13
- package/dist/cli/settings/edit.js +0 -19
- package/dist/cli/settings/index.d.ts +0 -8
- package/dist/cli/settings/index.js +0 -131
- package/dist/cli/settings/prompts.d.ts +0 -7
- package/dist/cli/settings/prompts.js +0 -48
- package/dist/cli/settings/schema.d.ts +0 -33
- package/dist/cli/settings/schema.js +0 -29
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// One command as a form: its arguments and options listed with their current values, each changed in place, and the
|
|
2
|
+
// exact command line it will run shown live (the same line you could type yourself). Built from manifest.ts, so a new
|
|
3
|
+
// command or option appears here on its own.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { humanBytes } from '../../core/disk.js';
|
|
7
|
+
import { fail, plainText } from '../../core/util.js';
|
|
8
|
+
import { listAccounts } from '../../host/accounts.js';
|
|
9
|
+
import { runningSessions } from '../../host/channel.js';
|
|
10
|
+
import { isSandboxRunning } from '../../host/platform.js';
|
|
11
|
+
import { splitArgs } from '../args.js';
|
|
12
|
+
import { cleanTargetOf, collectDataEntries } from '../commands/data/collect.js';
|
|
13
|
+
import { commandLine } from '../history.js';
|
|
14
|
+
import { askedWhen } from '../manifest.js';
|
|
15
|
+
import { Text, Theme } from '../tui/index.js';
|
|
16
|
+
import { validNodeSpec, validPythonSpec } from '../validate.js';
|
|
17
|
+
/** What an option accepts, checked while typing (undefined = fine). */
|
|
18
|
+
export const optionProblem = (o, v) => {
|
|
19
|
+
if (v === '')
|
|
20
|
+
return undefined;
|
|
21
|
+
if (o.type === 'number' && !(Number(v) > 0))
|
|
22
|
+
return 'Enter a number greater than 0.';
|
|
23
|
+
if (o.flag === '--node' && !validNodeSpec(v))
|
|
24
|
+
return 'Use a number like 22 or 22.11.0, or lts, latest.';
|
|
25
|
+
if (o.flag === '--python' && !validPythonSpec(v))
|
|
26
|
+
return 'Use 3, 3.12 or 3.12.4.';
|
|
27
|
+
if (o.value === 'dir' && !fs.existsSync(path.resolve(v)))
|
|
28
|
+
return 'That folder does not exist.';
|
|
29
|
+
if (o.value === 'file') {
|
|
30
|
+
const missing = splitArgs(v).find((f) => !fs.existsSync(path.resolve(f)));
|
|
31
|
+
if (missing)
|
|
32
|
+
return missing + ' does not exist.';
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
};
|
|
36
|
+
const RUN = '@run';
|
|
37
|
+
export class CommandForm {
|
|
38
|
+
app;
|
|
39
|
+
ctx;
|
|
40
|
+
spec;
|
|
41
|
+
/** Answers by field id (arg:<name>, opt:<flag>, json, extra). */
|
|
42
|
+
values = new Map();
|
|
43
|
+
constructor(app, ctx, spec, preset = []) {
|
|
44
|
+
this.app = app;
|
|
45
|
+
this.ctx = ctx;
|
|
46
|
+
this.spec = spec;
|
|
47
|
+
for (let i = 0; i < preset.length; i++) {
|
|
48
|
+
const option = spec.options.find((o) => o.flag === preset[i]);
|
|
49
|
+
if (option?.type === 'boolean')
|
|
50
|
+
this.values.set('opt:' + option.flag, true);
|
|
51
|
+
else if (option)
|
|
52
|
+
this.values.set('opt:' + option.flag, [...(this.values.get('opt:' + option.flag) ?? []), preset[++i]]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Values a choices list names, filled in from config and the running sandbox. */
|
|
56
|
+
choices(choices) {
|
|
57
|
+
if (Array.isArray(choices))
|
|
58
|
+
return choices;
|
|
59
|
+
if (choices === 'harnesses')
|
|
60
|
+
return Object.keys(this.ctx.loaded.config.harnesses);
|
|
61
|
+
if (choices === 'accounts')
|
|
62
|
+
return listAccounts(this.ctx.storageRoot, this.ctx.dataPath).map((a) => a.name);
|
|
63
|
+
if (choices === 'sessions')
|
|
64
|
+
return isSandboxRunning() ? runningSessions(this.ctx.dataPath).map((s) => plainText(s.id)) : [];
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
first() { return this.values.get('arg:' + this.spec.args[0]?.name)?.[0] ?? ''; }
|
|
68
|
+
fields() {
|
|
69
|
+
const first = this.first();
|
|
70
|
+
return [
|
|
71
|
+
...this.spec.args.map((spec, index) => ({ kind: 'arg', spec, index })).filter((f) => f.kind === 'arg' && (f.index === 0 || askedWhen(f.spec.when, first))),
|
|
72
|
+
...this.spec.options.filter((o) => !o.noMenu && askedWhen(o.when, first) && !(typeof o.choices === 'string' && !this.choices(o.choices).length)).map((spec) => ({ kind: 'option', spec })),
|
|
73
|
+
...(this.spec.json && !(this.spec.name === 'data' && first === 'clean') ? [{ kind: 'json' }] : []),
|
|
74
|
+
...(this.spec.passthrough ? [{ kind: 'extra' }] : []),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
id(f) { return f.kind === 'arg' ? 'arg:' + f.spec.name : f.kind === 'option' ? 'opt:' + f.spec.flag : f.kind; }
|
|
78
|
+
/** The command line the form stands for now. */
|
|
79
|
+
argv() {
|
|
80
|
+
const fields = this.fields();
|
|
81
|
+
const list = (f) => this.values.get(this.id(f)) ?? [];
|
|
82
|
+
const argv = [this.spec.name, ...fields.filter((f) => f.kind === 'arg').flatMap(list)];
|
|
83
|
+
for (const f of fields) {
|
|
84
|
+
if (f.kind !== 'option')
|
|
85
|
+
continue;
|
|
86
|
+
const v = this.values.get(this.id(f));
|
|
87
|
+
if (v === true)
|
|
88
|
+
argv.push(f.spec.flag);
|
|
89
|
+
else if (Array.isArray(v))
|
|
90
|
+
for (const x of v)
|
|
91
|
+
argv.push(f.spec.flag, x);
|
|
92
|
+
}
|
|
93
|
+
if (this.values.get('json') === true)
|
|
94
|
+
argv.push('--json');
|
|
95
|
+
const extra = list({ kind: 'extra' });
|
|
96
|
+
if (extra.length)
|
|
97
|
+
argv.push(...(this.spec.passthrough === 'agent' ? ['--', ...extra] : extra));
|
|
98
|
+
return argv;
|
|
99
|
+
}
|
|
100
|
+
/** The name of a required argument that has no value yet. */
|
|
101
|
+
missing() {
|
|
102
|
+
const field = this.fields().find((f) => f.kind === 'arg' && f.spec.required && !(this.values.get(this.id(f))?.length));
|
|
103
|
+
return field?.kind === 'arg' ? Text.humanize(field.spec.name) : undefined;
|
|
104
|
+
}
|
|
105
|
+
item(f) {
|
|
106
|
+
const v = this.values.get(this.id(f));
|
|
107
|
+
const shown = v === true ? Theme.ok('On') : v === false || v === undefined ? Theme.muted(f.kind === 'option' && f.spec.type === 'boolean' || f.kind === 'json' ? 'Off' : f.kind === 'option' && f.spec.default !== undefined ? 'default ' + f.spec.default : f.kind === 'arg' && f.spec.required ? 'required' : 'not set') : Array.isArray(v) && v.length ? v.join(' ') : Theme.muted('not set');
|
|
108
|
+
if (f.kind === 'arg')
|
|
109
|
+
return { value: this.id(f), label: Text.humanize(f.spec.name), hint: shown, description: f.spec.description, section: 'What', keywords: f.spec.name };
|
|
110
|
+
if (f.kind === 'json')
|
|
111
|
+
return { value: 'json', label: 'Machine-readable output', hint: shown, description: 'Print JSON instead of a table (--json).', section: 'Output' };
|
|
112
|
+
if (f.kind === 'extra')
|
|
113
|
+
return { value: 'extra', label: this.spec.passthrough === 'agent' ? 'Extra arguments for the agent' : 'Arguments for the command', hint: shown, description: this.spec.passthrough === 'agent' ? 'Passed on to the agent, e.g. --continue.' : 'The rest of the command line.', section: 'Output' };
|
|
114
|
+
return { value: this.id(f), label: f.spec.flag.replace(/^--/, '').replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase()), hint: shown, description: f.spec.description, section: f.spec.advanced ? 'Advanced' : 'Options', keywords: f.spec.flag };
|
|
115
|
+
}
|
|
116
|
+
/** Shows the form until you run the command (its argv) or go back (null). */
|
|
117
|
+
async run() {
|
|
118
|
+
let focus;
|
|
119
|
+
let notice;
|
|
120
|
+
const path = [Text.humanize(this.spec.name)];
|
|
121
|
+
for (;;) {
|
|
122
|
+
const fields = this.fields();
|
|
123
|
+
const line = commandLine(this.argv());
|
|
124
|
+
const picked = await this.app.list({
|
|
125
|
+
path,
|
|
126
|
+
intro: this.spec.summary.replace(/^./, (c) => c.toUpperCase()) + '.',
|
|
127
|
+
status: Theme.muted('$ ') + Theme.accent(line),
|
|
128
|
+
items: [
|
|
129
|
+
{ value: RUN, label: Theme.ok('▶ Run'), hint: Theme.muted(Text.truncate(line, 60)), description: 'Runs: ' + line, section: 'Run' },
|
|
130
|
+
...fields.map((f) => this.item(f)),
|
|
131
|
+
],
|
|
132
|
+
startAt: focus,
|
|
133
|
+
actions: [{ key: 'r', label: 'clear' }],
|
|
134
|
+
notice,
|
|
135
|
+
});
|
|
136
|
+
notice = undefined;
|
|
137
|
+
if (picked.kind === 'back')
|
|
138
|
+
return null;
|
|
139
|
+
if (picked.kind === 'pick' && picked.value === RUN) {
|
|
140
|
+
const missing = this.missing();
|
|
141
|
+
if (missing) {
|
|
142
|
+
notice = Theme.warn(missing + ' is required.');
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
return this.argv();
|
|
146
|
+
}
|
|
147
|
+
if (picked.kind !== 'pick' && picked.kind !== 'action')
|
|
148
|
+
continue;
|
|
149
|
+
const field = fields.find((f) => this.id(f) === picked.value);
|
|
150
|
+
if (!field)
|
|
151
|
+
continue;
|
|
152
|
+
focus = picked.value;
|
|
153
|
+
if (picked.kind === 'action') {
|
|
154
|
+
this.values.delete(this.id(field));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
await this.edit(field, path);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async edit(f, path) {
|
|
161
|
+
const id = this.id(f);
|
|
162
|
+
const current = this.values.get(id);
|
|
163
|
+
if (f.kind === 'json' || (f.kind === 'option' && f.spec.type === 'boolean')) {
|
|
164
|
+
this.values.set(id, current !== true);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const label = f.kind === 'extra' ? 'Arguments' : f.kind === 'arg' ? Text.humanize(f.spec.name) : f.spec.flag;
|
|
168
|
+
const about = f.kind === 'extra' ? [] : [f.spec.description];
|
|
169
|
+
if (f.kind === 'arg' && this.spec.name === 'data' && f.spec.name === 'targets') {
|
|
170
|
+
const picked = await this.dataTargets(path);
|
|
171
|
+
if (picked)
|
|
172
|
+
this.values.set(id, picked);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const list = f.kind === 'extra' ? [] : this.choices(f.spec.choices);
|
|
176
|
+
const many = (f.kind === 'option' && f.spec.type === 'list') || (f.kind === 'arg' && f.spec.variadic);
|
|
177
|
+
if (list.length && many) {
|
|
178
|
+
const picked = await this.app.list({ path: [...path, label], intro: about, multi: true, items: list.map((v) => ({ value: v, label: v, checked: Array.isArray(current) && current.includes(v) })) });
|
|
179
|
+
if (picked.kind === 'multi')
|
|
180
|
+
this.values.set(id, picked.values);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (list.length) {
|
|
184
|
+
const optional = !(f.kind === 'arg' && f.spec.required);
|
|
185
|
+
const picked = await this.app.choose([...path, label], about.join(' '), [...list.map((v) => ({ value: v, label: v })), ...(optional ? [{ value: '', label: Theme.muted('(not set)') }] : [])], Array.isArray(current) ? current[0] : undefined);
|
|
186
|
+
if (picked !== undefined) {
|
|
187
|
+
if (picked === '')
|
|
188
|
+
this.values.delete(id);
|
|
189
|
+
else
|
|
190
|
+
this.values.set(id, [picked]);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (f.kind === 'arg' && typeof f.spec.choices === 'string')
|
|
195
|
+
fail('There is nothing to choose from: no ' + f.spec.choices + ' yet.', f.spec.choices === 'accounts' ? 'Sign in inside the sandbox, then run: sandbox account save <name>' : 'Start one first (sandbox claude).');
|
|
196
|
+
const typed = await this.app.text({
|
|
197
|
+
path: [...path, label], label, about, allowEmpty: true,
|
|
198
|
+
initial: Array.isArray(current) ? (many || f.kind === 'extra' ? current.map((x) => (/\s/.test(x) ? '"' + x + '"' : x)).join(' ') : current[0]) : '',
|
|
199
|
+
validate: (v) => (f.kind === 'option' ? optionProblem(f.spec, v) ?? true : true),
|
|
200
|
+
});
|
|
201
|
+
if (typed === undefined)
|
|
202
|
+
return;
|
|
203
|
+
const value = many || f.kind === 'extra' ? splitArgs(typed) : typed.trim() ? [typed.trim()] : [];
|
|
204
|
+
if (value.length)
|
|
205
|
+
this.values.set(id, value);
|
|
206
|
+
else
|
|
207
|
+
this.values.delete(id);
|
|
208
|
+
}
|
|
209
|
+
/** `data clean` targets, ticked from a list with their sizes (never "all": that is typed on purpose). */
|
|
210
|
+
async dataTargets(path) {
|
|
211
|
+
const rows = collectDataEntries(this.ctx.dataPath, this.ctx.storageRoot).flatMap((e) => { const target = cleanTargetOf(e); return target ? [{ target, bytes: e.bytes }] : []; });
|
|
212
|
+
if (!rows.length) {
|
|
213
|
+
await this.app.message({ path, title: 'Nothing is saved yet', lines: ['There is nothing to delete.'] });
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
const picked = await this.app.list({ path: [...path, 'Delete what'], intro: 'Tick what to delete (space), then Enter. You confirm once more before anything is deleted.', multi: true, items: rows.map((r) => ({ value: r.target, label: r.target, hint: humanBytes(r.bytes) })) });
|
|
217
|
+
return picked.kind === 'multi' && picked.values.length ? picked.values : undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Context } from '../commands/context.js';
|
|
2
|
+
export { optionProblem } from './command-form.js';
|
|
3
|
+
export { candidateFolders, severalAgentsArgv } from './several.js';
|
|
4
|
+
export declare class Menu {
|
|
5
|
+
private readonly ctx;
|
|
6
|
+
private readonly runArgv;
|
|
7
|
+
private readonly app;
|
|
8
|
+
private last;
|
|
9
|
+
/** The result of the last action (saved apps ...), shown on the home screen once. */
|
|
10
|
+
private notice;
|
|
11
|
+
constructor(ctx: Context, runArgv: (argv: string[]) => Promise<number>);
|
|
12
|
+
private status;
|
|
13
|
+
/** Is this agent already in the sandbox's persistent storage (so starting it needs no install)? */
|
|
14
|
+
private installed;
|
|
15
|
+
private homeItems;
|
|
16
|
+
/** Runs the menu until you leave it, or until a command that takes over the terminal (launch, attach) has run. */
|
|
17
|
+
start(): Promise<number>;
|
|
18
|
+
private fileFor;
|
|
19
|
+
/** Does what a home entry says. A number ends the menu with that exit code. */
|
|
20
|
+
private perform;
|
|
21
|
+
/** Runs an argv like typing it, on the normal screen; the menu comes back afterwards unless the command took over. */
|
|
22
|
+
private execute;
|
|
23
|
+
/** The settings screen may have switched agentCommands (Beta): apply it now, from the saved file. */
|
|
24
|
+
private applyAgentCommands;
|
|
25
|
+
}
|
|
26
|
+
export declare const menu: (ctx: Context, run: (argv: string[]) => Promise<number>) => Promise<number>;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The interactive menu (`sandbox` with no arguments): a full-screen app. The home screen groups everything into
|
|
2
|
+
// categories (start, sessions, sandbox, apps, settings ...); commands open as a form that shows the exact command line,
|
|
3
|
+
// which then runs like typing it. Settings and the project's .sandbox-config.json are edited in place.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { errMsg, plainText } from '../../core/util.js';
|
|
7
|
+
import { loadConfig } from '../../core/config.js';
|
|
8
|
+
import { runningSessions } from '../../host/channel.js';
|
|
9
|
+
import { isSandboxRunning } from '../../host/platform.js';
|
|
10
|
+
import { syncAgentCommands } from '../commands/agent-commands.js';
|
|
11
|
+
import { commandLine, readHistory } from '../history.js';
|
|
12
|
+
import { COMMANDS, commandSpec } from '../manifest.js';
|
|
13
|
+
import { App, ExitRequested, Frame, Text, Theme } from '../tui/index.js';
|
|
14
|
+
import { AppsCatalogue, appsSummary } from './apps.js';
|
|
15
|
+
import { CommandForm } from './command-form.js';
|
|
16
|
+
import { ConfigFile, SettingsEditor } from './settings/index.js';
|
|
17
|
+
import { SeveralAgents } from './several.js';
|
|
18
|
+
export { optionProblem } from './command-form.js';
|
|
19
|
+
export { candidateFolders, severalAgentsArgv } from './several.js';
|
|
20
|
+
const CATEGORIES = [
|
|
21
|
+
{ id: 'start', label: 'Start', description: 'Start an agent (or several) in a sandbox, or a sandbox with only your tools.' },
|
|
22
|
+
{ id: 'sessions', label: 'Sessions', description: 'Agent sessions in the running sandbox: show one in this terminal, list them, restart them.' },
|
|
23
|
+
{ id: 'sandbox', label: 'Sandbox', description: 'The running sandbox: its status, window, databases, projects, and stopping it.' },
|
|
24
|
+
{ id: 'apps', label: 'Apps & tools', description: 'Desktop apps and command-line tools for every sandbox: editors, database clients, API clients, game engines, cloud CLIs ... and programs of your own.' },
|
|
25
|
+
{ id: 'settings', label: 'Settings', description: 'Every setting, for all projects or just this one, edited in place.' },
|
|
26
|
+
{ id: 'accounts', label: 'Claude accounts', description: 'Saved Claude logins: save one, switch, set the default.' },
|
|
27
|
+
{ id: 'data', label: 'Data & cleanup', description: 'What is kept between sandboxes (tools, databases, logins, logs), and deleting it.' },
|
|
28
|
+
{ id: 'health', label: 'Health & repair', description: 'Check this PC, find and fix problems, read the logs.' },
|
|
29
|
+
];
|
|
30
|
+
/** Readable names for commands on the home screen (the manifest's summaries are the descriptions). */
|
|
31
|
+
const TITLES = {
|
|
32
|
+
start: 'Start a sandbox without an agent', 'host-claude': 'Claude on this PC (NOT sandboxed)', attach: 'Show a session in this terminal', sessions: 'List sessions',
|
|
33
|
+
restart: 'Restart this project\'s agents', status: 'Sandbox status', db: 'Databases', exec: 'Run a command inside', stop: 'Stop the sandbox', projects: 'Projects',
|
|
34
|
+
show: 'Show the sandbox window', hide: 'Hide the sandbox window', account: 'Claude accounts', data: 'Saved data', clean: 'Clean up old files', sync: 'Sync history now',
|
|
35
|
+
logs: 'Logs', setup: 'Set up this PC', doctor: 'Doctor (check this PC)', repair: 'Repair', 'self-test': 'Self-test', generate: 'Preview a start (no sandbox)',
|
|
36
|
+
config: 'Config file', audit: 'Audit log', 'agent-commands': 'Agent commands (Beta)', launch: 'Start an agent',
|
|
37
|
+
};
|
|
38
|
+
const CATEGORY_OF_GROUP = { Agents: 'start', Sandbox: 'sandbox', Sessions: 'sessions', Accounts: 'accounts', Data: 'data', Health: 'health', Config: 'settings' };
|
|
39
|
+
export class Menu {
|
|
40
|
+
ctx;
|
|
41
|
+
runArgv;
|
|
42
|
+
app = new App();
|
|
43
|
+
last;
|
|
44
|
+
/** The result of the last action (saved apps ...), shown on the home screen once. */
|
|
45
|
+
notice;
|
|
46
|
+
constructor(ctx, runArgv) {
|
|
47
|
+
this.ctx = ctx;
|
|
48
|
+
this.runArgv = runArgv;
|
|
49
|
+
this.app.status = () => this.status();
|
|
50
|
+
try {
|
|
51
|
+
Frame.version = JSON.parse(fs.readFileSync(path.join(ctx.packageRoot, 'package.json'), 'utf8')).version ?? '';
|
|
52
|
+
}
|
|
53
|
+
catch { /* no version shown */ }
|
|
54
|
+
}
|
|
55
|
+
status() {
|
|
56
|
+
const folder = Theme.muted(Text.middle(process.cwd(), 70));
|
|
57
|
+
if (!isSandboxRunning())
|
|
58
|
+
return Theme.muted(Theme.glyph.off + ' No sandbox running') + ' ' + folder;
|
|
59
|
+
const n = runningSessions(this.ctx.dataPath).length;
|
|
60
|
+
return Theme.ok(Theme.glyph.on + ' Sandbox running') + Theme.muted(' · ' + n + ' session' + (n === 1 ? '' : 's')) + ' ' + folder;
|
|
61
|
+
}
|
|
62
|
+
/** Is this agent already in the sandbox's persistent storage (so starting it needs no install)? */
|
|
63
|
+
installed(name) {
|
|
64
|
+
const h = this.ctx.loaded.config.harnesses[name];
|
|
65
|
+
const dir = path.join(this.ctx.dataPath, 'tools', 'agents', name);
|
|
66
|
+
return Boolean(h) && [h.hostExecutable, h.npmBin && h.npmBin + '.cmd', h.uvBin && h.uvBin + '.exe'].some((f) => f && fs.existsSync(path.join(dir, f)));
|
|
67
|
+
}
|
|
68
|
+
homeItems() {
|
|
69
|
+
const config = this.ctx.loaded.config;
|
|
70
|
+
const def = config.defaultHarness;
|
|
71
|
+
const last = readHistory()[0];
|
|
72
|
+
const items = [];
|
|
73
|
+
const add = (category, section, label, action, description, hint) => { items.push({ value: JSON.stringify(action), label, category, section, description, hint }); };
|
|
74
|
+
if (last)
|
|
75
|
+
add('start', 'Quick start', 'Repeat last', { kind: 'run', argv: last.argv }, 'Runs the last command again.', Theme.muted(Text.truncate(commandLine(last.argv), 50)));
|
|
76
|
+
add('start', 'Quick start', 'Start ' + def + ' in this folder', { kind: 'run', argv: ['launch', '--harness', def] }, 'Starts ' + def + ' in a sandbox for ' + process.cwd() + ' (or another session in the running sandbox).', this.installed(def) ? Theme.ok('ready') : Theme.muted('installs on first start'));
|
|
77
|
+
add('start', 'Quick start', 'Start ' + def + ' with options…', { kind: 'form', command: 'launch', preset: ['--harness', def] }, 'Choose options first: attach this terminal, Node or Python version, databases, scripts ...');
|
|
78
|
+
add('start', 'Quick start', 'Start several agents…', { kind: 'several' }, 'One or more projects, one or more agents each, all in one sandbox; attach to any of them.');
|
|
79
|
+
for (const name of Object.keys(config.harnesses).filter((n) => n !== def))
|
|
80
|
+
add('start', 'Other agents', 'Start ' + name, { kind: 'form', command: 'launch', preset: ['--harness', name] }, 'Opens the start form for ' + name + '.', this.installed(name) ? Theme.ok('ready') : Theme.muted('installs on first start'));
|
|
81
|
+
if (isSandboxRunning()) {
|
|
82
|
+
for (const s of runningSessions(this.ctx.dataPath).slice(-12)) {
|
|
83
|
+
add('sessions', 'Running now', plainText(s.harness) + ' · ' + plainText(path.win32.basename(s.cwd)), { kind: 'run', argv: ['attach', plainText(s.id)] }, 'Shows this session in this terminal (Ctrl+] detaches; the session keeps running).', Theme.muted(plainText(s.id)));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const userFile = ConfigFile.user(this.ctx.loaded.configPath);
|
|
87
|
+
add('apps', 'Apps', 'Browse the apps catalogue…', { kind: 'apps', target: 'user' }, 'Tick the apps every sandbox should have: by category, searchable, with sizes. Each downloads once and is kept.', Theme.muted(appsSummary(userFile)));
|
|
88
|
+
add('apps', 'Apps', 'Apps for this project only…', { kind: 'apps', target: 'project' }, 'Catalogue apps this project needs (saved in its .sandbox-config.json, added to yours).');
|
|
89
|
+
add('settings', 'Settings', 'Settings for all projects', { kind: 'settings', target: 'user' }, 'Your own settings, used by every project: languages, databases, the sandbox machine, agents, security ... (Tab switches to this project\'s.)', Theme.scopeGlobal(' YOURS '));
|
|
90
|
+
add('settings', 'Settings', 'Settings for this project', { kind: 'settings', target: 'project' }, 'This folder\'s .sandbox-config.json: only what a project may set, on top of yours. A changed file needs your approval at the next launch. (Tab switches to all projects.)', Theme.scopeProject(' PROJECT '));
|
|
91
|
+
for (const spec of COMMANDS.filter((s) => !s.hidden && s.name !== 'launch')) {
|
|
92
|
+
const category = CATEGORY_OF_GROUP[spec.group];
|
|
93
|
+
const section = category === 'start' ? 'More' : category === 'settings' ? 'Tools' : 'Commands';
|
|
94
|
+
add(category, section, TITLES[spec.name] ?? Text.humanize(spec.name), { kind: 'form', command: spec.name }, spec.summary.replace(/^./, (c) => c.toUpperCase()) + '.', Theme.muted('sandbox ' + spec.name));
|
|
95
|
+
}
|
|
96
|
+
return items;
|
|
97
|
+
}
|
|
98
|
+
/** Runs the menu until you leave it, or until a command that takes over the terminal (launch, attach) has run. */
|
|
99
|
+
async start() {
|
|
100
|
+
if (!process.stdin.isTTY) {
|
|
101
|
+
console.log('The menu needs an interactive terminal. Run sandbox --help for the commands.');
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
for (;;) {
|
|
106
|
+
const picked = await this.app.list({ path: [], items: this.homeItems(), categories: CATEGORIES, startAt: this.last, escapeLabel: 'quit', notice: this.notice });
|
|
107
|
+
this.notice = undefined;
|
|
108
|
+
if (picked.kind === 'back')
|
|
109
|
+
return 0;
|
|
110
|
+
if (picked.kind !== 'pick')
|
|
111
|
+
continue;
|
|
112
|
+
this.last = picked.value;
|
|
113
|
+
const code = await this.perform(JSON.parse(picked.value));
|
|
114
|
+
if (code !== undefined)
|
|
115
|
+
return code;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
// Ctrl+C anywhere: leave quietly.
|
|
120
|
+
if (e instanceof ExitRequested || (e instanceof Error && e.name === 'ExitPromptError'))
|
|
121
|
+
return 130;
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
this.app.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
fileFor(target) { return target === 'user' ? ConfigFile.user(this.ctx.loaded.configPath) : ConfigFile.project(process.cwd()); }
|
|
129
|
+
/** Does what a home entry says. A number ends the menu with that exit code. */
|
|
130
|
+
async perform(action) {
|
|
131
|
+
if (action.kind === 'settings') {
|
|
132
|
+
await new SettingsEditor(this.app, { user: this.fileFor('user'), project: this.fileFor('project') }, action.target, this.ctx.dataPath).run();
|
|
133
|
+
this.applyAgentCommands();
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
if (action.kind === 'apps') {
|
|
137
|
+
this.notice = await new AppsCatalogue(this.app, this.fileFor(action.target), this.ctx.dataPath).run();
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const argv = action.kind === 'run' ? action.argv : action.kind === 'several' ? await new SeveralAgents(this.app, this.ctx).run() : await new CommandForm(this.app, this.ctx, commandSpec(action.command), action.preset).run();
|
|
141
|
+
return argv ? this.execute(argv) : undefined;
|
|
142
|
+
}
|
|
143
|
+
/** Runs an argv like typing it, on the normal screen; the menu comes back afterwards unless the command took over. */
|
|
144
|
+
async execute(argv) {
|
|
145
|
+
const code = await this.app.suspend(async () => {
|
|
146
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H' + Theme.muted('$ ' + commandLine(argv)) + '\n\n');
|
|
147
|
+
return this.runArgv(argv);
|
|
148
|
+
});
|
|
149
|
+
if (commandSpec(argv[0])?.interactive)
|
|
150
|
+
return code;
|
|
151
|
+
await pressAnyKey((code === 0 ? Theme.ok('Done.') : Theme.warn('Exit code ' + code + '.')) + Theme.muted(' Press any key for the menu…'));
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
/** The settings screen may have switched agentCommands (Beta): apply it now, from the saved file. */
|
|
155
|
+
applyAgentCommands() {
|
|
156
|
+
try {
|
|
157
|
+
syncAgentCommands(this.ctx, loadConfig(this.ctx.loaded.configPath ?? undefined).config);
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
console.warn('Agent commands (Beta): ' + errMsg(e));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Waits for one key on the normal screen, so a command's output stays until you have read it. Ctrl+C leaves. */
|
|
165
|
+
const pressAnyKey = (message) => new Promise((resolve, reject) => {
|
|
166
|
+
process.stdout.write('\n' + message);
|
|
167
|
+
const { stdin } = process;
|
|
168
|
+
stdin.setRawMode(true);
|
|
169
|
+
stdin.resume();
|
|
170
|
+
stdin.once('data', (key) => {
|
|
171
|
+
stdin.setRawMode(false);
|
|
172
|
+
stdin.pause();
|
|
173
|
+
process.stdout.write('\n');
|
|
174
|
+
if (key[0] === 3)
|
|
175
|
+
reject(new ExitRequested());
|
|
176
|
+
else
|
|
177
|
+
resolve();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
export const menu = (ctx, run) => new Menu(ctx, run).start();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface SettingsCategory {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
description: string;
|
|
5
|
+
keys: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare const CATEGORIES: SettingsCategory[];
|
|
8
|
+
export declare const nameOf: (key: string) => string;
|
|
9
|
+
export declare const categoryOf: (topKey: string) => SettingsCategory | undefined;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// How the settings are grouped on screen: categories in the sidebar, readable names for sections and toolchains. Every
|
|
2
|
+
// top-level setting of both files must belong to a category (a self-test checks it), so nothing is ever hidden.
|
|
3
|
+
import { Text } from '../../tui/index.js';
|
|
4
|
+
export const CATEGORIES = [
|
|
5
|
+
{ id: 'general', label: 'General', description: 'Which agent starts, where the project appears in the sandbox, and extra folders from this PC.', keys: ['defaultHarness', 'harness', 'guestWorkspace', 'readOnly', 'mappings', 'scripts'] },
|
|
6
|
+
{ id: 'machine', label: 'Sandbox machine', description: 'Memory, CPU, window, GPU, clipboard, audio and network of the sandbox itself.', keys: ['sandbox'] },
|
|
7
|
+
{ id: 'languages', label: 'Languages & tools', description: 'Toolchains installed in the sandbox. "auto" follows your project\'s files and what this PC has.', keys: ['toolchains'] },
|
|
8
|
+
{ id: 'apps', label: 'Apps', description: 'Desktop apps installed in the sandbox: editors, database clients, API clients, game engines ... (also: Apps catalogue on the home screen).', keys: ['apps'] },
|
|
9
|
+
{ id: 'databases', label: 'Databases', description: 'Databases that run inside the sandbox. Their data persists between sandboxes.', keys: ['databases'] },
|
|
10
|
+
{ id: 'agents', label: 'Agents & attaching', description: 'The agents you can start, attaching your terminal to them, and the (Beta) agent commands.', keys: ['harnesses', 'attach', 'agentCommands'] },
|
|
11
|
+
{ id: 'claude', label: 'Claude profile', description: 'Which of your Claude settings, history and memory are copied in and synced back.', keys: ['claudeProfile'] },
|
|
12
|
+
{ id: 'network', label: 'Network', description: 'What the sandbox can reach: your LAN, only some hosts, blocked hosts, DNS, a proxy, and ports forwarded from this PC to servers inside. (No network at all: Sandbox machine > Networking.)', keys: ['network'] },
|
|
13
|
+
{ id: 'security', label: 'Security & privacy', description: 'Git-ignored files kept out, secrets kept out of the sandbox, download checks and what is kept on this PC.', keys: ['workspaceFilter', 'audit', 'secrets', 'downloads', 'storage'] },
|
|
14
|
+
{ id: 'setup', label: 'Setup & hooks', description: 'How tools get into the sandbox (copied from this PC or downloaded), Git, the terminal, and hook scripts.', keys: ['provisioning', 'hooks'] },
|
|
15
|
+
{ id: 'projects', label: 'Projects', description: 'Several projects in one sandbox: project roots mapped at every start, adding folders while it runs.', keys: ['projects'] },
|
|
16
|
+
{ id: 'desktop', label: 'Desktop look', description: 'Copy your theme, accent colour, taskbar and wallpaper into the sandbox.', keys: ['personalization'] },
|
|
17
|
+
];
|
|
18
|
+
/** Readable names for sections and toolchains (anything missing gets a name made from its key). */
|
|
19
|
+
const NAMES = {
|
|
20
|
+
sandbox: 'Sandbox machine', attach: 'Attaching your terminal', agentCommands: 'Agent commands (Beta)', provisioning: 'Provisioning', downloads: 'Downloads',
|
|
21
|
+
secrets: 'Secrets', claudeProfile: 'Claude profile', storage: 'Storage on this PC', network: 'Network', hooks: 'Hooks', personalization: 'Desktop look',
|
|
22
|
+
projects: 'Projects', harnesses: 'Agents', databases: 'Databases', toolchains: 'Toolchains', git: 'Git', terminal: 'Windows Terminal', conpty: 'Pseudo console',
|
|
23
|
+
node: 'Node.js', python: 'Python', java: 'Java (JDK)', dotnet: '.NET', go: 'Go', rust: 'Rust', cpp: 'C / C++ (MinGW)', msvc: 'Visual C++ (MSVC)', ruby: 'Ruby',
|
|
24
|
+
php: 'PHP', deno: 'Deno', bun: 'Bun', pwsh: 'PowerShell 7', vscode: 'VS Code', mesa: 'OpenGL (Mesa)', uv: 'uv', pnpm: 'pnpm', yarn: 'Yarn', cli: 'Command-line tools',
|
|
25
|
+
postgres: 'PostgreSQL', mysql: 'MySQL', mongodb: 'MongoDB', redis: 'Redis', dynamodb: 'DynamoDB Local', environment: 'Environment variables', workspaceFilter: 'Git-ignored files', audit: 'Audit log',
|
|
26
|
+
};
|
|
27
|
+
export const nameOf = (key) => NAMES[key] ?? Text.humanize(key);
|
|
28
|
+
export const categoryOf = (topKey) => CATEGORIES.find((c) => c.keys.includes(topKey));
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Config } from '../../../core/types.js';
|
|
2
|
+
import { SchemaNode } from './schema-node.js';
|
|
3
|
+
/** The user's own config, or a project's .sandbox-config.json (a smaller schema; nothing is merged with defaults). */
|
|
4
|
+
export type SettingsTarget = 'user' | 'project';
|
|
5
|
+
/** What the value is in this JSON text (undefined when it is not set there). */
|
|
6
|
+
export declare const fileValue: (text: string, key: string[]) => unknown;
|
|
7
|
+
/**
|
|
8
|
+
* Applies one change (undefined = remove the line, back to the default) to config text and returns the new text, or
|
|
9
|
+
* the problems that make the result invalid. Pure; ConfigFile writes it.
|
|
10
|
+
*/
|
|
11
|
+
export declare const editConfigText: (text: string, key: string[], value: unknown, target?: SettingsTarget) => {
|
|
12
|
+
text: string;
|
|
13
|
+
} | {
|
|
14
|
+
problems: string[];
|
|
15
|
+
};
|
|
16
|
+
export declare class ConfigFile {
|
|
17
|
+
readonly target: SettingsTarget;
|
|
18
|
+
readonly file: string;
|
|
19
|
+
readonly schema: SchemaNode;
|
|
20
|
+
constructor(target: SettingsTarget, file: string);
|
|
21
|
+
static user(configPath: string | null): ConfigFile;
|
|
22
|
+
static project(dir: string): ConfigFile;
|
|
23
|
+
get isProject(): boolean;
|
|
24
|
+
get exists(): boolean;
|
|
25
|
+
text(): string;
|
|
26
|
+
/** The value written in this file (undefined = not set here). */
|
|
27
|
+
valueInFile(key: string[]): unknown;
|
|
28
|
+
/** Your own settings with the built-in defaults filled in (what a sandbox uses without a project file). */
|
|
29
|
+
userConfig(): Config;
|
|
30
|
+
/** What is in effect: for your file the merged config, for a project file only what the file sets. */
|
|
31
|
+
effective(key: string[], config?: Config): unknown;
|
|
32
|
+
defaultOf(key: string[]): unknown;
|
|
33
|
+
/**
|
|
34
|
+
* Saves one setting. In your own file a value equal to the default is removed instead (no line needed). Returns
|
|
35
|
+
* 'unchanged', 'saved', or the problems that stopped it.
|
|
36
|
+
*/
|
|
37
|
+
set(key: string[], value: unknown): 'unchanged' | 'saved' | {
|
|
38
|
+
problems: string[];
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// One config file being edited: your own settings, or a project's .sandbox-config.json. One setting is changed at a time
|
|
2
|
+
// through jsonc-parser, so comments and formatting stay, and nothing is written unless the whole file is still valid.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { applyEdits, modify, parse } from 'jsonc-parser';
|
|
6
|
+
import _ from 'lodash';
|
|
7
|
+
import { DEFAULTS, loadConfig, mergeConfig, userConfigPath } from '../../../core/config.js';
|
|
8
|
+
import { LOCAL_CONFIG_NAME } from '../../../core/local-config.js';
|
|
9
|
+
import { Config as ConfigSchema, describeIssues, LocalOverrides } from '../../../core/schema.js';
|
|
10
|
+
import { SchemaNode } from './schema-node.js';
|
|
11
|
+
/** What the value is in this JSON text (undefined when it is not set there). */
|
|
12
|
+
export const fileValue = (text, key) => _.get(parse(text) ?? {}, key);
|
|
13
|
+
/**
|
|
14
|
+
* Applies one change (undefined = remove the line, back to the default) to config text and returns the new text, or
|
|
15
|
+
* the problems that make the result invalid. Pure; ConfigFile writes it.
|
|
16
|
+
*/
|
|
17
|
+
export const editConfigText = (text, key, value, target = 'user') => {
|
|
18
|
+
// Removing a setting the file does not have changes nothing (jsonc-parser throws on it).
|
|
19
|
+
const edits = value === undefined && fileValue(text || '{}', key) === undefined ? [] : modify(text || '{}', key, value, { formattingOptions: { insertSpaces: true, tabSize: 2, eol: '\n' } });
|
|
20
|
+
const next = applyEdits(text || '{}', edits);
|
|
21
|
+
const parsed = target === 'project' ? LocalOverrides.safeParse(parse(next) ?? {}) : ConfigSchema.safeParse(mergeConfig(DEFAULTS, parse(next) ?? {}));
|
|
22
|
+
return parsed.success ? { text: next.endsWith('\n') ? next : next + '\n' } : { problems: describeIssues(parsed.error) };
|
|
23
|
+
};
|
|
24
|
+
export class ConfigFile {
|
|
25
|
+
target;
|
|
26
|
+
file;
|
|
27
|
+
schema;
|
|
28
|
+
constructor(target, file) {
|
|
29
|
+
this.target = target;
|
|
30
|
+
this.file = file;
|
|
31
|
+
this.schema = new SchemaNode(target === 'project' ? LocalOverrides : ConfigSchema);
|
|
32
|
+
}
|
|
33
|
+
static user(configPath) { return new ConfigFile('user', configPath ?? userConfigPath()); }
|
|
34
|
+
static project(dir) { return new ConfigFile('project', path.join(dir, LOCAL_CONFIG_NAME)); }
|
|
35
|
+
get isProject() { return this.target === 'project'; }
|
|
36
|
+
get exists() { return fs.existsSync(this.file); }
|
|
37
|
+
text() { return this.exists ? fs.readFileSync(this.file, 'utf8') : ''; }
|
|
38
|
+
/** The value written in this file (undefined = not set here). */
|
|
39
|
+
valueInFile(key) { return fileValue(this.text(), key); }
|
|
40
|
+
/** Your own settings with the built-in defaults filled in (what a sandbox uses without a project file). */
|
|
41
|
+
userConfig() {
|
|
42
|
+
const file = this.isProject ? userConfigPath() : this.file;
|
|
43
|
+
try {
|
|
44
|
+
return loadConfig(fs.existsSync(file) ? file : undefined).config;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return loadConfig(undefined).config;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** What is in effect: for your file the merged config, for a project file only what the file sets. */
|
|
51
|
+
effective(key, config = this.userConfig()) {
|
|
52
|
+
return this.isProject ? this.valueInFile(key) : _.get(config, key);
|
|
53
|
+
}
|
|
54
|
+
defaultOf(key) { return _.get(DEFAULTS, key); }
|
|
55
|
+
/**
|
|
56
|
+
* Saves one setting. In your own file a value equal to the default is removed instead (no line needed). Returns
|
|
57
|
+
* 'unchanged', 'saved', or the problems that stopped it.
|
|
58
|
+
*/
|
|
59
|
+
set(key, value) {
|
|
60
|
+
const text = this.text();
|
|
61
|
+
const next = !this.isProject && value !== undefined && _.isEqual(value, this.defaultOf(key)) ? undefined : value;
|
|
62
|
+
if (_.isEqual(next, fileValue(text, key)) && (this.isProject || _.isEqual(value, _.get(this.userConfig(), key))))
|
|
63
|
+
return 'unchanged';
|
|
64
|
+
const result = editConfigText(text, key, next, this.target);
|
|
65
|
+
if ('problems' in result)
|
|
66
|
+
return result;
|
|
67
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
68
|
+
fs.writeFileSync(this.file, result.text, 'utf8');
|
|
69
|
+
return 'saved';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { App } from '../../tui/index.js';
|
|
2
|
+
import { SchemaNode } from './schema-node.js';
|
|
3
|
+
/** The new value, or undefined when you backed out without changing anything. */
|
|
4
|
+
export type Edited = {
|
|
5
|
+
value: unknown;
|
|
6
|
+
} | undefined;
|
|
7
|
+
/** A value in one short line, for the right-hand column. */
|
|
8
|
+
export declare const formatValue: (value: unknown, max?: number) => string;
|
|
9
|
+
/** Optional extra knowledge: values to suggest for some settings (the configured agents for defaultHarness). */
|
|
10
|
+
export interface EditorHints {
|
|
11
|
+
suggestions?: (key: string[]) => string[] | undefined;
|
|
12
|
+
}
|
|
13
|
+
export declare class ValueEditor {
|
|
14
|
+
private readonly app;
|
|
15
|
+
private readonly hints;
|
|
16
|
+
constructor(app: App, hints?: EditorHints);
|
|
17
|
+
edit(path: string[], key: string[], node: SchemaNode, current: unknown): Promise<Edited>;
|
|
18
|
+
private pick;
|
|
19
|
+
private suggest;
|
|
20
|
+
private json;
|
|
21
|
+
}
|
|
22
|
+
/** One object as a form: each field with its value, Enter to change it, r to clear an optional one. */
|
|
23
|
+
export declare class ObjectEditor {
|
|
24
|
+
private readonly app;
|
|
25
|
+
private readonly path;
|
|
26
|
+
private readonly node;
|
|
27
|
+
private readonly editor;
|
|
28
|
+
constructor(app: App, path: string[], node: SchemaNode, editor: ValueEditor);
|
|
29
|
+
run(start: Record<string, unknown>, isNew?: boolean): Promise<Edited>;
|
|
30
|
+
}
|