windows-development-sandbox 1.1.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +3 -2
  2. package/config.example.json +38 -5
  3. package/dist/cli/args.d.ts +3 -0
  4. package/dist/cli/args.js +3 -1
  5. package/dist/cli/commands/accounts.js +2 -2
  6. package/dist/cli/commands/agent-commands.js +1 -1
  7. package/dist/cli/commands/context.js +2 -1
  8. package/dist/cli/commands/data/clean.js +6 -1
  9. package/dist/cli/commands/data/collect.js +3 -3
  10. package/dist/cli/commands/index.d.ts +1 -1
  11. package/dist/cli/commands/index.js +1 -1
  12. package/dist/cli/commands/launch.js +36 -3
  13. package/dist/cli/commands/maintenance.d.ts +2 -0
  14. package/dist/cli/commands/maintenance.js +35 -2
  15. package/dist/cli/commands/sessions.js +3 -3
  16. package/dist/cli/commands/user-config.js +1 -1
  17. package/dist/cli/index.js +6 -4
  18. package/dist/cli/manifest.js +5 -0
  19. package/dist/cli/menu/apps.d.ts +21 -0
  20. package/dist/cli/menu/apps.js +122 -0
  21. package/dist/cli/menu/command-form.d.ts +28 -0
  22. package/dist/cli/menu/command-form.js +219 -0
  23. package/dist/cli/menu/index.d.ts +26 -0
  24. package/dist/cli/menu/index.js +180 -0
  25. package/dist/cli/menu/settings/categories.d.ts +9 -0
  26. package/dist/cli/menu/settings/categories.js +28 -0
  27. package/dist/cli/menu/settings/config-file.d.ts +40 -0
  28. package/dist/cli/menu/settings/config-file.js +71 -0
  29. package/dist/cli/menu/settings/editors.d.ts +30 -0
  30. package/dist/cli/menu/settings/editors.js +305 -0
  31. package/dist/cli/menu/settings/index.d.ts +32 -0
  32. package/dist/cli/menu/settings/index.js +136 -0
  33. package/dist/cli/menu/settings/schema-node.d.ts +62 -0
  34. package/dist/cli/menu/settings/schema-node.js +104 -0
  35. package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
  36. package/dist/cli/menu/several.js +102 -0
  37. package/dist/cli/tui/app.d.ts +39 -0
  38. package/dist/cli/tui/app.js +52 -0
  39. package/dist/cli/tui/dialogs.d.ts +57 -0
  40. package/dist/cli/tui/dialogs.js +162 -0
  41. package/dist/cli/tui/frame.d.ts +18 -0
  42. package/dist/cli/tui/frame.js +27 -0
  43. package/dist/cli/tui/index.d.ts +7 -0
  44. package/dist/cli/tui/index.js +8 -0
  45. package/dist/cli/tui/list-screen.d.ts +101 -0
  46. package/dist/cli/tui/list-screen.js +299 -0
  47. package/dist/cli/tui/terminal.d.ts +37 -0
  48. package/dist/cli/tui/terminal.js +89 -0
  49. package/dist/cli/tui/text.d.ts +20 -0
  50. package/dist/cli/tui/text.js +105 -0
  51. package/dist/cli/tui/theme.d.ts +45 -0
  52. package/dist/cli/tui/theme.js +30 -0
  53. package/dist/core/config.js +9 -6
  54. package/dist/core/local-config.d.ts +15 -0
  55. package/dist/core/local-config.js +63 -4
  56. package/dist/core/schema/apps.d.ts +40 -0
  57. package/dist/core/schema/apps.js +27 -0
  58. package/dist/core/schema/config.d.ts +66 -1
  59. package/dist/core/schema/config.js +18 -6
  60. package/dist/core/schema/profile.d.ts +5 -0
  61. package/dist/core/schema/profile.js +4 -2
  62. package/dist/core/schema/sandbox.d.ts +28 -0
  63. package/dist/core/schema/sandbox.js +28 -0
  64. package/dist/core/schema/toolchains.d.ts +3 -0
  65. package/dist/core/schema/toolchains.js +6 -1
  66. package/dist/core/types.d.ts +6 -0
  67. package/dist/core/util.d.ts +2 -0
  68. package/dist/core/util.js +2 -0
  69. package/dist/core/workspace.js +22 -5
  70. package/dist/guest/apps/index.d.ts +10 -0
  71. package/dist/guest/apps/index.js +206 -0
  72. package/dist/guest/apps/resolvers.d.ts +8 -0
  73. package/dist/guest/apps/resolvers.js +90 -0
  74. package/dist/guest/audit-parse.d.ts +7 -0
  75. package/dist/guest/audit-parse.js +24 -0
  76. package/dist/guest/audit.d.ts +4 -0
  77. package/dist/guest/audit.js +47 -0
  78. package/dist/guest/daemon/relays.d.ts +1 -0
  79. package/dist/guest/daemon/relays.js +18 -0
  80. package/dist/guest/daemon.js +4 -0
  81. package/dist/guest/env.d.ts +1 -0
  82. package/dist/guest/env.js +3 -1
  83. package/dist/guest/helpers.d.ts +1 -1
  84. package/dist/guest/main.js +15 -2
  85. package/dist/guest/netproxy.d.ts +1 -0
  86. package/dist/guest/netproxy.js +100 -0
  87. package/dist/guest/ptyhost.js +14 -4
  88. package/dist/guest/setup/claude.d.ts +0 -4
  89. package/dist/guest/setup/claude.js +48 -2
  90. package/dist/guest/setup/network.d.ts +9 -0
  91. package/dist/guest/setup/network.js +97 -4
  92. package/dist/guest/setup/user.js +4 -0
  93. package/dist/guest/toolchains/native/mesa.js +13 -3
  94. package/dist/guest/toolchains/plan.d.ts +3 -1
  95. package/dist/guest/toolchains/plan.js +53 -9
  96. package/dist/guest/toolchains/python.js +26 -2
  97. package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
  98. package/dist/guest/toolchains/vscode-profile.js +69 -0
  99. package/dist/guest/toolchains/vscode.d.ts +17 -0
  100. package/dist/guest/toolchains/vscode.js +87 -1
  101. package/dist/host/attach.js +2 -1
  102. package/dist/host/audit/collect.d.ts +34 -0
  103. package/dist/host/audit/collect.js +126 -0
  104. package/dist/host/audit/log.d.ts +46 -0
  105. package/dist/host/audit/log.js +143 -0
  106. package/dist/host/generate.d.ts +6 -0
  107. package/dist/host/generate.js +10 -0
  108. package/dist/host/launch/mappings.js +5 -1
  109. package/dist/host/launch/stage.js +1 -1
  110. package/dist/host/motw.d.ts +17 -0
  111. package/dist/host/motw.js +74 -0
  112. package/dist/host/platform.js +3 -0
  113. package/dist/host/port-forward.d.ts +8 -0
  114. package/dist/host/port-forward.js +37 -0
  115. package/dist/host/repo-guard.d.ts +10 -3
  116. package/dist/host/repo-guard.js +142 -14
  117. package/dist/host/stops.d.ts +67 -0
  118. package/dist/host/stops.js +126 -0
  119. package/dist/host/terminal.d.ts +8 -0
  120. package/dist/host/terminal.js +45 -1
  121. package/dist/host/workspace-view/index.d.ts +20 -0
  122. package/dist/host/workspace-view/index.js +53 -0
  123. package/dist/host/workspace-view/rules.d.ts +26 -0
  124. package/dist/host/workspace-view/rules.js +93 -0
  125. package/dist/host/workspace-view/view.d.ts +50 -0
  126. package/dist/host/workspace-view/view.js +281 -0
  127. package/dist/runtime/daemon.js +1319 -552
  128. package/dist/runtime/db.js +168 -13
  129. package/dist/runtime/main.js +2898 -608
  130. package/dist/runtime/netproxy.js +25936 -0
  131. package/dist/runtime/view.js +272 -23
  132. package/dist/selftest/audit.test.d.ts +2 -0
  133. package/dist/selftest/audit.test.js +57 -0
  134. package/dist/selftest/channel.test.js +7 -0
  135. package/dist/selftest/cli.test.js +47 -17
  136. package/dist/selftest/fixtures.d.ts +45 -0
  137. package/dist/selftest/fixtures.js +3 -1
  138. package/dist/selftest/generate.test.js +7 -2
  139. package/dist/selftest/index.js +5 -2
  140. package/dist/selftest/live-network.test.d.ts +2 -0
  141. package/dist/selftest/live-network.test.js +63 -0
  142. package/dist/selftest/local-config.test.js +16 -3
  143. package/dist/selftest/network.test.js +12 -0
  144. package/dist/selftest/projects.test.js +1 -1
  145. package/dist/selftest/repo-guard.test.js +59 -1
  146. package/dist/selftest/sync.test.js +50 -10
  147. package/dist/selftest/toolchains.test.js +30 -0
  148. package/dist/selftest/workspace-view.test.d.ts +2 -0
  149. package/dist/selftest/workspace-view.test.js +89 -0
  150. package/dist/shared/apps.d.ts +49 -0
  151. package/dist/shared/apps.js +114 -0
  152. package/dist/shared/netpolicy.d.ts +27 -0
  153. package/dist/shared/netpolicy.js +51 -0
  154. package/dist/shared/pty-client.d.ts +9 -4
  155. package/dist/shared/pty-client.js +139 -17
  156. package/dist/shared/rundir.d.ts +2 -0
  157. package/dist/shared/rundir.js +2 -0
  158. package/dist/shared/schema.d.ts +10 -0
  159. package/dist/shared/schema.js +2 -0
  160. package/dist/shared/system.d.ts +1 -0
  161. package/dist/shared/system.js +1 -0
  162. package/dist/sync/claude-mem.js +3 -2
  163. package/dist/sync/history.d.ts +6 -0
  164. package/dist/sync/history.js +26 -11
  165. package/dist/sync/watcher.js +78 -6
  166. package/package.json +2 -1
  167. package/sandbox/cargoshim.cs +56 -0
  168. package/sandbox/cpucap.cs +33 -0
  169. package/sandbox/cpucap.ps1 +4 -35
  170. package/sandbox/logon.cmd +6 -0
  171. package/sandbox/memlog.ps1 +34 -0
  172. package/sandbox/pagefile.cs +31 -0
  173. package/sandbox/pagefile.ps1 +21 -0
  174. package/sandbox/ptyhost.cs +105 -7
  175. package/schemas/config.schema.json +303 -2
  176. package/schemas/sandbox-config.schema.json +126 -12
  177. package/dist/cli/menu-multi.js +0 -82
  178. package/dist/cli/menu.d.ts +0 -14
  179. package/dist/cli/menu.js +0 -290
  180. package/dist/cli/settings/edit.d.ts +0 -13
  181. package/dist/cli/settings/edit.js +0 -19
  182. package/dist/cli/settings/index.d.ts +0 -8
  183. package/dist/cli/settings/index.js +0 -131
  184. package/dist/cli/settings/prompts.d.ts +0 -7
  185. package/dist/cli/settings/prompts.js +0 -48
  186. package/dist/cli/settings/schema.d.ts +0 -33
  187. package/dist/cli/settings/schema.js +0 -29
@@ -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
+ }
@@ -0,0 +1,305 @@
1
+ // Editing one value with the screen that fits it: on/off flips, choices are picked from a list, numbers and text are
2
+ // typed and checked as you type, lists and maps get an editor of their own (add, change, remove), and objects (a
3
+ // mapped folder, an agent) open as a small form. JSON is only the last resort for shapes none of these fit.
4
+ import _ from 'lodash';
5
+ import { Text, Theme } from '../../tui/index.js';
6
+ /** A value in one short line, for the right-hand column. */
7
+ export const formatValue = (value, max = 60) => {
8
+ if (value === undefined)
9
+ return Theme.muted('not set');
10
+ if (value === true)
11
+ return Theme.ok('On');
12
+ if (value === false)
13
+ return Theme.muted('Off');
14
+ if (value === null)
15
+ return Theme.muted('none');
16
+ if (typeof value === 'string')
17
+ return value === '' ? Theme.muted('(empty)') : Text.truncate(value, max);
18
+ if (typeof value === 'number')
19
+ return String(value);
20
+ if (Array.isArray(value))
21
+ return value.length === 0 ? Theme.muted('none') : Text.truncate(value.length + ' · ' + value.map((v) => (typeof v === 'object' ? summary(v) : String(v))).join(', '), max);
22
+ const keys = Object.keys(value);
23
+ return keys.length ? Text.truncate(keys.length + ' · ' + keys.join(', '), max) : Theme.muted('none');
24
+ };
25
+ /** A one-line name for an object in a list (a mapping's folders, an agent's package ...). */
26
+ const summary = (value) => {
27
+ if (!_.isPlainObject(value))
28
+ return String(value);
29
+ const o = value;
30
+ const strings = Object.entries(o).filter(([, v]) => typeof v === 'string' && v);
31
+ if (typeof o.host === 'string' && typeof o.guest === 'string')
32
+ return o.host + ' → ' + o.guest + (o.readOnly ? ' (read-only)' : '');
33
+ return strings.slice(0, 2).map(([, v]) => v).join(' · ') || '{ ' + Object.keys(o).join(', ') + ' }';
34
+ };
35
+ const choiceLabel = (v) => (v === true ? 'On' : v === false ? 'Off' : String(v));
36
+ export class ValueEditor {
37
+ app;
38
+ hints;
39
+ constructor(app, hints = {}) {
40
+ this.app = app;
41
+ this.hints = hints;
42
+ }
43
+ async edit(path, key, node, current) {
44
+ const about = node.description ? [node.description] : [];
45
+ const suggestions = this.hints.suggestions?.(key);
46
+ if (suggestions?.length && node.type === 'string')
47
+ return this.suggest(path, key, node, current, suggestions);
48
+ switch (node.editor) {
49
+ case 'boolean': return { value: !(current ?? false) };
50
+ case 'choice': return this.pick(path, node, current, node.choiceValues);
51
+ case 'union': {
52
+ const open = node.openOption;
53
+ const custom = '@custom';
54
+ const picked = await this.app.choose(path, node.description ?? '', [...node.choiceValues.map((v) => ({ value: v, label: choiceLabel(v) })), ...(open ? [{ value: custom, label: 'Custom ' + open.type + '…', description: 'Type a ' + open.type + ' of your own.' }] : [])], node.choiceValues.includes(current) ? current : open && current !== undefined ? custom : undefined);
55
+ if (picked === undefined)
56
+ return undefined;
57
+ return picked === custom && open ? this.edit(path, key, open, node.choiceValues.includes(current) ? undefined : current) : { value: picked };
58
+ }
59
+ case 'number': {
60
+ const range = node.min !== undefined || node.max !== undefined ? ' (' + (node.min ?? '…') + ' to ' + (node.max ?? '…') + ')' : '';
61
+ const typed = await this.app.text({ path, label: Text.humanize(key[key.length - 1]) + range, about, initial: current === undefined ? '' : String(current), validate: (v) => (v.trim() === '' || !Number.isFinite(Number(v)) ? 'Enter a number.' : node.check(Number(v))) });
62
+ return typed === undefined ? undefined : { value: Number(typed) };
63
+ }
64
+ case 'string': {
65
+ const typed = await this.app.text({ path, label: Text.humanize(key[key.length - 1]), about, initial: typeof current === 'string' ? current : '', allowEmpty: node.check('') === true, validate: (v) => node.check(v) });
66
+ return typed === undefined ? undefined : { value: typed };
67
+ }
68
+ case 'choice-list': {
69
+ const ticked = Array.isArray(current) ? current : [];
70
+ const picked = await this.app.list({ path, intro: node.description, multi: true, items: node.element.choices.map((v) => ({ value: v, label: String(v), checked: ticked.includes(v) })) });
71
+ return picked.kind === 'multi' ? { value: picked.values } : undefined;
72
+ }
73
+ case 'string-list': return new StringListEditor(this.app, path, node, this).run(Array.isArray(current) ? current : []);
74
+ case 'string-map': return new MapEditor(this.app, path, node, this).run(_.isPlainObject(current) ? current : {});
75
+ case 'object-list': return new ObjectListEditor(this.app, path, node, this).run(Array.isArray(current) ? current : []);
76
+ case 'object-map': return new ObjectMapEditor(this.app, path, node, this).run(_.isPlainObject(current) ? current : {});
77
+ case 'object': return new ObjectEditor(this.app, path, node, this).run(_.isPlainObject(current) ? current : node.initial());
78
+ default: return this.json(path, key, node, current);
79
+ }
80
+ }
81
+ async pick(path, node, current, values) {
82
+ const picked = await this.app.choose(path, node.description ?? '', values.map((v) => ({ value: v, label: choiceLabel(v) })), current);
83
+ return picked === undefined ? undefined : { value: picked };
84
+ }
85
+ async suggest(path, key, node, current, suggestions) {
86
+ const other = '@other';
87
+ const picked = await this.app.choose(path, node.description ?? '', [...suggestions.map((s) => ({ value: s, label: s })), { value: other, label: 'Other…', description: 'Type a name that is not in the list.' }], typeof current === 'string' ? current : undefined);
88
+ if (picked === undefined)
89
+ return undefined;
90
+ if (picked !== other)
91
+ return { value: picked };
92
+ const typed = await this.app.text({ path, label: Text.humanize(key[key.length - 1]), initial: typeof current === 'string' ? current : '', validate: (v) => node.check(v) });
93
+ return typed === undefined ? undefined : { value: typed };
94
+ }
95
+ async json(path, key, node, current) {
96
+ const typed = await this.app.text({
97
+ path, label: Text.humanize(key[key.length - 1]) + ' (JSON)', about: node.description ? [node.description] : [], initial: current === undefined ? '' : JSON.stringify(current),
98
+ validate: (v) => { try {
99
+ return node.check(JSON.parse(v));
100
+ }
101
+ catch {
102
+ return 'This is not valid JSON.';
103
+ } },
104
+ });
105
+ return typed === undefined ? undefined : { value: JSON.parse(typed) };
106
+ }
107
+ }
108
+ // ---- collections -----------------------------------------------------------------------------------------------------
109
+ const SAVE = '@save';
110
+ const ADD = '@add';
111
+ /**
112
+ * The shared shape of the list and map editors: a working copy shown as a list with "Save" and "Add" at the top, Enter
113
+ * to change an entry, d to delete it. Esc with unsaved changes asks first.
114
+ */
115
+ class CollectionEditor {
116
+ app;
117
+ path;
118
+ node;
119
+ editor;
120
+ value;
121
+ original;
122
+ constructor(app, path, node, editor) {
123
+ this.app = app;
124
+ this.path = path;
125
+ this.node = node;
126
+ this.editor = editor;
127
+ }
128
+ noun = 'entry';
129
+ async run(start) {
130
+ this.original = structuredClone(start);
131
+ this.value = structuredClone(start);
132
+ let focus;
133
+ for (;;) {
134
+ const dirty = !_.isEqual(this.value, this.original);
135
+ const entries = this.entries();
136
+ const picked = await this.app.list({
137
+ path: this.path,
138
+ intro: this.node.description,
139
+ items: [
140
+ { value: SAVE, label: dirty ? Theme.ok(Theme.glyph.ok + ' Save changes') : Theme.muted(Theme.glyph.ok + ' Done (no changes)'), section: 'Actions' },
141
+ { value: ADD, label: '+ Add ' + this.noun, section: 'Actions' },
142
+ ...entries.map((e) => ({ ...e, section: entries.length + ' ' + this.noun + (entries.length === 1 ? '' : 's') })),
143
+ ],
144
+ startAt: focus,
145
+ actions: [{ key: 'd', label: 'delete' }, ...(this.move ? [{ key: 'u', label: 'move up' }, { key: 'j', label: 'move down' }] : [])],
146
+ empty: 'No ' + this.noun + 's yet.',
147
+ });
148
+ if (picked.kind === 'back') {
149
+ if (!dirty || await this.app.confirm({ path: this.path, question: 'Discard your changes?', yes: 'Discard', no: 'Keep editing', initial: false, danger: true }))
150
+ return undefined;
151
+ continue;
152
+ }
153
+ if (picked.kind === 'pick' && picked.value === SAVE)
154
+ return dirty ? { value: this.value } : undefined;
155
+ if (picked.kind === 'pick' && picked.value === ADD) {
156
+ await this.add();
157
+ continue;
158
+ }
159
+ const id = picked.kind === 'pick' || picked.kind === 'action' ? picked.value : undefined;
160
+ if (id === undefined || id === SAVE || id === ADD)
161
+ continue;
162
+ focus = id;
163
+ if (picked.kind === 'pick')
164
+ await this.open(id);
165
+ else if (picked.kind === 'action' && picked.key === 'd')
166
+ this.remove(id);
167
+ else if (picked.kind === 'action' && this.move)
168
+ focus = this.move(id, picked.key === 'u' ? -1 : 1) ?? id;
169
+ }
170
+ }
171
+ }
172
+ /** A list of strings (project roots, scripts, extra packages ...). */
173
+ class StringListEditor extends CollectionEditor {
174
+ noun = 'item';
175
+ entries() { return this.value.map((v, i) => ({ value: String(i), label: v || Theme.muted('(empty)') })); }
176
+ async ask(initial) {
177
+ const element = this.node.element;
178
+ return this.app.text({ path: this.path, label: 'Value', about: this.node.description ? [this.node.description] : [], initial, validate: (v) => element.check(v) });
179
+ }
180
+ async add() { const v = await this.ask(''); if (v !== undefined)
181
+ this.value.push(v); }
182
+ async open(id) { const v = await this.ask(this.value[Number(id)]); if (v !== undefined)
183
+ this.value[Number(id)] = v; }
184
+ remove(id) { this.value.splice(Number(id), 1); }
185
+ move(id, by) {
186
+ const from = Number(id);
187
+ const to = Math.max(0, Math.min(this.value.length - 1, from + by));
188
+ const [item] = this.value.splice(from, 1);
189
+ this.value.splice(to, 0, item);
190
+ return String(to);
191
+ }
192
+ }
193
+ /** A map of names to simple values (environment variables). */
194
+ class MapEditor extends CollectionEditor {
195
+ noun = 'variable';
196
+ entries() { return Object.entries(this.value).map(([k, v]) => ({ value: k, label: k, hint: formatValue(v) })); }
197
+ async add() {
198
+ const name = await this.app.text({ path: this.path, label: 'Name', about: ['Letters, digits and _ only, e.g. NPM_CONFIG_REGISTRY.'], validate: (v) => (this.value[v] !== undefined ? 'That name is there already.' : /^[A-Za-z_][A-Za-z0-9_]*$/.test(v) ? true : 'Letters, digits and _ only.') });
199
+ if (name === undefined)
200
+ return;
201
+ const edited = await this.editor.edit([...this.path, name], [name], this.node.element, this.node.element.initial());
202
+ if (edited)
203
+ this.value[name] = edited.value;
204
+ }
205
+ async open(id) {
206
+ const edited = await this.editor.edit([...this.path, id], [id], this.node.element, this.value[id]);
207
+ if (edited)
208
+ this.value[id] = edited.value;
209
+ }
210
+ remove(id) { delete this.value[id]; }
211
+ }
212
+ /** A list of objects (mapped folders). */
213
+ class ObjectListEditor extends CollectionEditor {
214
+ noun = 'entry';
215
+ entries() { return this.value.map((v, i) => ({ value: String(i), label: summary(v) })); }
216
+ async add() {
217
+ const edited = await new ObjectEditor(this.app, [...this.path, 'New'], this.node.element, this.editor).run(this.node.element.initial(), true);
218
+ if (edited)
219
+ this.value.push(edited.value);
220
+ }
221
+ async open(id) {
222
+ const edited = await new ObjectEditor(this.app, [...this.path, summary(this.value[Number(id)])], this.node.element, this.editor).run(this.value[Number(id)]);
223
+ if (edited)
224
+ this.value[Number(id)] = edited.value;
225
+ }
226
+ remove(id) { this.value.splice(Number(id), 1); }
227
+ }
228
+ /** A map of names to objects (agents). */
229
+ class ObjectMapEditor extends CollectionEditor {
230
+ noun = 'entry';
231
+ entries() { return Object.entries(this.value).map(([k, v]) => ({ value: k, label: k, hint: Theme.muted(summary(v)) })); }
232
+ async add() {
233
+ const name = await this.app.text({ path: this.path, label: 'Name', validate: (v) => (this.value[v] !== undefined ? 'That name is there already.' : /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v) ? true : 'Letters, digits, . _ and - only.') });
234
+ if (name === undefined)
235
+ return;
236
+ const edited = await new ObjectEditor(this.app, [...this.path, name], this.node.element, this.editor).run(this.node.element.initial(), true);
237
+ if (edited)
238
+ this.value[name] = edited.value;
239
+ }
240
+ async open(id) {
241
+ const edited = await new ObjectEditor(this.app, [...this.path, id], this.node.element, this.editor).run(this.value[id]);
242
+ if (edited)
243
+ this.value[id] = edited.value;
244
+ }
245
+ remove(id) { delete this.value[id]; }
246
+ }
247
+ /** One object as a form: each field with its value, Enter to change it, r to clear an optional one. */
248
+ export class ObjectEditor {
249
+ app;
250
+ path;
251
+ node;
252
+ editor;
253
+ constructor(app, path, node, editor) {
254
+ this.app = app;
255
+ this.path = path;
256
+ this.node = node;
257
+ this.editor = editor;
258
+ }
259
+ async run(start, isNew = false) {
260
+ const value = structuredClone(start);
261
+ let focus;
262
+ let notice;
263
+ for (;;) {
264
+ const dirty = isNew || !_.isEqual(value, start);
265
+ const problem = this.node.check(value);
266
+ const fields = this.node.children();
267
+ const picked = await this.app.list({
268
+ path: this.path,
269
+ intro: this.node.description,
270
+ notice,
271
+ items: [
272
+ { value: SAVE, label: problem === true ? (dirty ? Theme.ok(Theme.glyph.ok + ' Save') : Theme.muted(Theme.glyph.ok + ' Done (no changes)')) : Theme.warn(Theme.glyph.cross + ' Not complete yet'), description: problem === true ? undefined : problem, section: 'Actions' },
273
+ ...fields.map(([name, field]) => ({ value: name, label: Text.humanize(name), hint: formatValue(value[name]), description: field.description, section: 'Fields' })),
274
+ ],
275
+ startAt: focus,
276
+ actions: [{ key: 'r', label: 'clear' }],
277
+ });
278
+ notice = undefined;
279
+ if (picked.kind === 'back') {
280
+ if (!dirty || await this.app.confirm({ path: this.path, question: 'Discard your changes?', yes: 'Discard', no: 'Keep editing', initial: false, danger: true }))
281
+ return undefined;
282
+ continue;
283
+ }
284
+ if (picked.kind === 'pick' && picked.value === SAVE) {
285
+ if (problem !== true) {
286
+ notice = Theme.warn(problem);
287
+ continue;
288
+ }
289
+ return dirty ? { value } : undefined;
290
+ }
291
+ const name = picked.kind === 'pick' || picked.kind === 'action' ? picked.value : undefined;
292
+ if (!name || name === SAVE)
293
+ continue;
294
+ focus = name;
295
+ const field = this.node.child(name);
296
+ if (picked.kind === 'action') {
297
+ delete value[name];
298
+ continue;
299
+ }
300
+ const edited = await this.editor.edit([...this.path, Text.humanize(name)], [name], field, value[name]);
301
+ if (edited)
302
+ value[name] = edited.value;
303
+ }
304
+ }
305
+ }
@@ -0,0 +1,32 @@
1
+ import { App } from '../../tui/index.js';
2
+ import { ConfigFile } from './config-file.js';
3
+ import type { SchemaNode } from './schema-node.js';
4
+ export { CATEGORIES, nameOf } from './categories.js';
5
+ export { ConfigFile, editConfigText, fileValue, type SettingsTarget } from './config-file.js';
6
+ export { SchemaNode } from './schema-node.js';
7
+ interface Setting {
8
+ key: string[];
9
+ node: SchemaNode;
10
+ category: string;
11
+ section?: string;
12
+ }
13
+ export type Scope = 'user' | 'project';
14
+ export declare class SettingsEditor {
15
+ private readonly app;
16
+ private readonly files;
17
+ private scope;
18
+ private readonly dataPath;
19
+ private readonly editor;
20
+ /** files: your config and this folder's project file; dataPath: where the apps catalogue looks for installed apps. */
21
+ constructor(app: App, files: Record<Scope, ConfigFile>, scope?: Scope, dataPath?: string);
22
+ /** The file the current tab edits. */
23
+ get file(): ConfigFile;
24
+ private get title();
25
+ /** Every editable setting with the category and heading it is shown under. */
26
+ settings(): Setting[];
27
+ private item;
28
+ run(): Promise<void>;
29
+ private edit;
30
+ /** Saves (or resets) one setting and returns the notice to show. */
31
+ private save;
32
+ }
@@ -0,0 +1,136 @@
1
+ // The settings screen: two scopes on tabs, your own settings ("All projects", blue) and this folder's
2
+ // .sandbox-config.json ("This project", purple); Tab switches and keeps your place. Every setting is grouped into
3
+ // categories (sidebar), searchable, with its value in both scopes, its default and description. Enter edits (on/off
4
+ // flips at once), r resets. Edits keep the file's comments and formatting, and nothing is saved unless the whole file
5
+ // stays valid.
6
+ import _ from 'lodash';
7
+ import { NEVER_LOOSER } from '../../../core/local-config.js';
8
+ import { Text, Theme } from '../../tui/index.js';
9
+ import { AppsCatalogue } from '../apps.js';
10
+ import { CATEGORIES, categoryOf, nameOf } from './categories.js';
11
+ import { formatValue, ValueEditor } from './editors.js';
12
+ export { CATEGORIES, nameOf } from './categories.js';
13
+ export { ConfigFile, editConfigText, fileValue } from './config-file.js';
14
+ export { SchemaNode } from './schema-node.js';
15
+ const SCOPES = [
16
+ { scope: 'user', tab: { label: 'All projects', badge: 'YOURS', paint: Theme.scopeGlobal } },
17
+ { scope: 'project', tab: { label: 'This project', badge: 'PROJECT', paint: Theme.scopeProject } },
18
+ ];
19
+ export class SettingsEditor {
20
+ app;
21
+ files;
22
+ scope;
23
+ dataPath;
24
+ editor;
25
+ /** files: your config and this folder's project file; dataPath: where the apps catalogue looks for installed apps. */
26
+ constructor(app, files, scope = 'user', dataPath = '') {
27
+ this.app = app;
28
+ this.files = files;
29
+ this.scope = scope;
30
+ this.dataPath = dataPath;
31
+ this.editor = new ValueEditor(app, { suggestions: (key) => (['defaultHarness', 'harness'].includes(key.join('.')) ? Object.keys(this.file.userConfig().harnesses) : undefined) });
32
+ }
33
+ /** The file the current tab edits. */
34
+ get file() { return this.files[this.scope]; }
35
+ get title() { return 'Settings · ' + (this.file.isProject ? 'This project' : 'All projects'); }
36
+ /** Every editable setting with the category and heading it is shown under. */
37
+ settings() {
38
+ return this.file.schema.children().flatMap(([top, node]) => {
39
+ const category = categoryOf(top);
40
+ if (!category)
41
+ return [];
42
+ const several = category.keys.filter((k) => this.file.schema.child(k)).length > 1;
43
+ if (!node.isSection)
44
+ return [{ key: [top], node, category: category.id, section: several && category.id !== 'general' ? nameOf(top) : undefined }];
45
+ return node.leaves([top]).map(({ key, node: leaf }) => {
46
+ const heading = [several ? nameOf(top) : undefined, key.length > 2 ? key.slice(1, -1).map(nameOf).join(' › ') : undefined].filter(Boolean).join(' › ');
47
+ return { key, node: leaf, category: category.id, section: heading || undefined };
48
+ });
49
+ });
50
+ }
51
+ item(s, config) {
52
+ const inFile = this.file.valueInFile(s.key);
53
+ const set = inFile !== undefined;
54
+ const yours = _.get(config, s.key);
55
+ const id = s.key.join('.');
56
+ const project = this.files.project;
57
+ const inProject = project.schema.leaves().some((l) => l.key.join('.') === id) ? project.valueInFile(s.key) : undefined;
58
+ const paint = this.file.isProject ? Theme.project : Theme.changed;
59
+ const value = this.file.isProject && !set ? Theme.muted('all projects: ') + formatValue(yours, 40) : formatValue(this.file.effective(s.key, config));
60
+ const details = [
61
+ Theme.muted('Setting ') + id,
62
+ Theme.muted('All projects ') + formatValue(yours, 40) + Theme.muted(this.files.user.valueInFile(s.key) === undefined ? ' (built-in default)' : ' (your file)'),
63
+ Theme.muted('This project ') + (inProject === undefined ? Theme.muted('not set') : Theme.project(Text.strip(formatValue(inProject, 40)))),
64
+ ...(!this.file.isProject ? [Theme.muted('Default ') + formatValue(this.file.defaultOf(s.key), 40)] : []),
65
+ ...(this.file.isProject && NEVER_LOOSER.includes(id) ? [Theme.warn(Theme.glyph.lock + ' A project can only make this stricter than your own setting.')] : []),
66
+ ];
67
+ return {
68
+ value: id,
69
+ label: Text.humanize(s.key[s.key.length - 1]),
70
+ hint: set ? paint(Text.strip(value)) + ' ' + paint(Theme.glyph.changed) : value,
71
+ description: s.node.description,
72
+ details,
73
+ section: s.section,
74
+ category: s.category,
75
+ keywords: id,
76
+ };
77
+ }
78
+ async run() {
79
+ let focus;
80
+ let notice;
81
+ for (;;) {
82
+ const config = this.file.userConfig();
83
+ const settings = this.settings();
84
+ const categories = CATEGORIES.filter((c) => settings.some((s) => s.category === c.id)).map((c) => ({ id: c.id, label: c.label, description: c.description }));
85
+ const picked = await this.app.list({
86
+ path: [this.title],
87
+ tabs: { items: SCOPES.map((x) => x.tab), active: SCOPES.findIndex((x) => x.scope === this.scope) },
88
+ status: (this.file.isProject ? Theme.project('●') : Theme.accent('●')) + Theme.muted(' Editing ') + Text.middle(this.file.file, 80) + (this.file.exists ? '' : Theme.muted(' (created when you save)')) + (this.file.isProject ? Theme.muted(' · only what a project may set; needs your approval at the next launch') : ''),
89
+ items: settings.map((s) => this.item(s, config)),
90
+ categories,
91
+ startAt: focus,
92
+ actions: [{ key: 'r', label: this.file.isProject ? 'remove from project' : 'reset to default' }],
93
+ notice,
94
+ });
95
+ notice = undefined;
96
+ if (picked.kind === 'back')
97
+ return;
98
+ // The other tab, on the same setting when that scope has it.
99
+ if (picked.kind === 'tab') {
100
+ this.scope = SCOPES[picked.index].scope;
101
+ continue;
102
+ }
103
+ if (picked.kind !== 'pick' && picked.kind !== 'action')
104
+ continue;
105
+ if (picked.value === undefined)
106
+ continue;
107
+ focus = picked.value;
108
+ const setting = settings.find((s) => s.key.join('.') === picked.value);
109
+ if (picked.kind === 'action')
110
+ notice = this.save(setting.key, undefined, 'reset');
111
+ else
112
+ notice = await this.edit(setting, config);
113
+ }
114
+ }
115
+ async edit(s, config) {
116
+ // The apps list is picked from the catalogue (by category, with sizes), not typed.
117
+ if (s.key.join('.') === 'apps.install')
118
+ return new AppsCatalogue(this.app, this.file, this.dataPath).run();
119
+ const current = this.file.isProject ? this.file.valueInFile(s.key) ?? _.get(config, s.key) : _.get(config, s.key);
120
+ const path = [this.title, ...s.key.slice(0, -1).map(nameOf), Text.humanize(s.key[s.key.length - 1])];
121
+ const edited = await this.editor.edit(path, s.key, s.node, current);
122
+ return edited ? this.save(s.key, edited.value, 'save') : undefined;
123
+ }
124
+ /** Saves (or resets) one setting and returns the notice to show. */
125
+ save(key, value, how) {
126
+ const name = key.join('.');
127
+ const result = this.file.set(key, value);
128
+ if (result === 'unchanged')
129
+ return Theme.muted(how === 'reset' ? name + ' is already the default.' : 'Unchanged.');
130
+ if (typeof result === 'object')
131
+ return Theme.error(Theme.glyph.cross + ' Not saved: ' + result.problems.join('; '));
132
+ // The approval is tied to the file's exact contents, so any change makes the project file need approval again.
133
+ const after = this.file.isProject ? ' The next launch asks you to approve the project file again.' : ' New sandboxes use it.';
134
+ return Theme.ok(Theme.glyph.ok + ' ' + (how === 'reset' ? 'Reset ' + name + '.' : 'Saved ' + name + '.')) + Theme.muted(after);
135
+ }
136
+ }