moqi-tui 0.2.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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +782 -0
  3. package/bin/moqi.mjs +40 -0
  4. package/cordis.patch.yml +41 -0
  5. package/lib/cross-find.js +217 -0
  6. package/lib/file-index.js +121 -0
  7. package/lib/fleet-sources.js +114 -0
  8. package/lib/index.js +3999 -0
  9. package/lib/persist.js +194 -0
  10. package/lib/plugins.js +371 -0
  11. package/lib/presence.js +144 -0
  12. package/lib/rename.js +35 -0
  13. package/lib/rewind.js +94 -0
  14. package/lib/sessions-store.js +134 -0
  15. package/lib/startup.js +92 -0
  16. package/lib/tui/atfile.js +154 -0
  17. package/lib/tui/export.js +48 -0
  18. package/lib/tui/fleet.js +346 -0
  19. package/lib/tui/i18n.js +201 -0
  20. package/lib/tui/jobs.js +65 -0
  21. package/lib/tui/keys.js +205 -0
  22. package/lib/tui/markdown.js +368 -0
  23. package/lib/tui/mcp.js +95 -0
  24. package/lib/tui/panels.js +231 -0
  25. package/lib/tui/screen.js +156 -0
  26. package/lib/tui/state.js +502 -0
  27. package/lib/tui/stream.js +109 -0
  28. package/lib/tui/text.js +173 -0
  29. package/lib/tui/theme.js +183 -0
  30. package/lib/tui/themes.js +153 -0
  31. package/lib/tui/tooldetail.js +140 -0
  32. package/lib/tui/view.js +830 -0
  33. package/lib/tui/vim.js +222 -0
  34. package/lib/tui-host-core.js +141 -0
  35. package/lib/tui-host.js +48 -0
  36. package/lib/types/cross-find.d.ts +66 -0
  37. package/lib/types/file-index.d.ts +34 -0
  38. package/lib/types/fleet-sources.d.ts +34 -0
  39. package/lib/types/index.d.ts +51 -0
  40. package/lib/types/persist.d.ts +116 -0
  41. package/lib/types/plugins.d.ts +218 -0
  42. package/lib/types/presence.d.ts +48 -0
  43. package/lib/types/rename.d.ts +32 -0
  44. package/lib/types/rewind.d.ts +75 -0
  45. package/lib/types/sessions-store.d.ts +46 -0
  46. package/lib/types/startup.d.ts +45 -0
  47. package/lib/types/tui/atfile.d.ts +90 -0
  48. package/lib/types/tui/export.d.ts +18 -0
  49. package/lib/types/tui/fleet.d.ts +209 -0
  50. package/lib/types/tui/i18n.d.ts +34 -0
  51. package/lib/types/tui/jobs.d.ts +28 -0
  52. package/lib/types/tui/keys.d.ts +52 -0
  53. package/lib/types/tui/markdown.d.ts +14 -0
  54. package/lib/types/tui/mcp.d.ts +34 -0
  55. package/lib/types/tui/panels.d.ts +125 -0
  56. package/lib/types/tui/screen.d.ts +79 -0
  57. package/lib/types/tui/state.d.ts +323 -0
  58. package/lib/types/tui/stream.d.ts +78 -0
  59. package/lib/types/tui/text.d.ts +28 -0
  60. package/lib/types/tui/theme.d.ts +87 -0
  61. package/lib/types/tui/themes.d.ts +70 -0
  62. package/lib/types/tui/tooldetail.d.ts +45 -0
  63. package/lib/types/tui/view.d.ts +163 -0
  64. package/lib/types/tui/vim.d.ts +64 -0
  65. package/lib/types/tui-host-core.d.ts +62 -0
  66. package/lib/types/tui-host.d.ts +42 -0
  67. package/lib/types/version.d.ts +8 -0
  68. package/lib/types/voice.d.ts +227 -0
  69. package/lib/version.js +32 -0
  70. package/lib/voice.js +405 -0
  71. package/package.json +119 -0
  72. package/scripts/harness-root.mjs +88 -0
  73. package/scripts/install-profile.mjs +133 -0
package/lib/persist.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Small durable state for the terminal app: composer history, UI
3
+ * preferences, and the sessions that were open, kept as one JSON file under
4
+ * `$DSH_HOME`.
5
+ *
6
+ * Everything here is best-effort by design. The app must run on a read-only
7
+ * or missing home just as well as on a writable one — persistence is a
8
+ * convenience, never a dependency.
9
+ * @module moqi-tui/persist
10
+ */
11
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
12
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ /** Version of the on-disk shape, so a future change can migrate or discard. */
16
+ const STATE_VERSION = 2;
17
+ /**
18
+ * How many sessions a single restore will bring back.
19
+ *
20
+ * Every restored tab costs one session-store lookup and one agent adoption,
21
+ * both of them I/O before the first paint. A state file that has grown a long
22
+ * tail — or been hand-edited — must not turn a launch into a multi-second
23
+ * stall, and nobody navigates more tabs than this by hand anyway.
24
+ */
25
+ export const MAX_RESTORED_SESSIONS = 16;
26
+ /** The state used when there is nothing readable on disk. */
27
+ function fallbackState() {
28
+ return { inputHistory: [], thinking: false, peers: [], sessions: [], activeSession: 0 };
29
+ }
30
+ /** Where the state file lives: `$DSH_HOME/tui-state.json`, default `~/.dsh`. */
31
+ export function statePath(env = process.env) {
32
+ const home = env['DSH_HOME'] !== undefined && env['DSH_HOME'] !== ''
33
+ ? env['DSH_HOME']
34
+ : join(homedir(), '.dsh');
35
+ return join(home, 'tui-state.json');
36
+ }
37
+ /** Coerce one on-disk session entry, or reject it outright. */
38
+ function readSession(value) {
39
+ if (typeof value !== 'object' || value === null)
40
+ return undefined;
41
+ const record = value;
42
+ const id = record['id'];
43
+ // Without an id there is nothing to re-adopt, so the entry is worthless.
44
+ if (typeof id !== 'string' || id === '')
45
+ return undefined;
46
+ return {
47
+ id,
48
+ model: typeof record['model'] === 'string' ? record['model'] : '',
49
+ title: typeof record['title'] === 'string' ? record['title'] : '',
50
+ };
51
+ }
52
+ /**
53
+ * Turn the raw file contents into state, without touching the filesystem.
54
+ *
55
+ * Parsing is separated from reading so the whole degrade-gracefully contract
56
+ * — bad JSON, a version from another release, a half-written array of
57
+ * sessions — can be exercised as a pure function. It never throws: an input
58
+ * it cannot make sense of yields the fallback, which is always usable.
59
+ */
60
+ export function decodeState(raw) {
61
+ let parsed;
62
+ try {
63
+ parsed = JSON.parse(raw);
64
+ }
65
+ catch {
66
+ return fallbackState();
67
+ }
68
+ if (typeof parsed !== 'object' || parsed === null)
69
+ return fallbackState();
70
+ // An entry from a different version is discarded rather than guessed at:
71
+ // the fields it does share may well have meant something else.
72
+ if (parsed.version !== STATE_VERSION)
73
+ return fallbackState();
74
+ const sessions = [];
75
+ if (Array.isArray(parsed.sessions)) {
76
+ for (const entry of parsed.sessions) {
77
+ const session = readSession(entry);
78
+ if (session !== undefined)
79
+ sessions.push(session);
80
+ }
81
+ }
82
+ // A malformed index would otherwise select a tab that is not there.
83
+ const rawActive = parsed.activeSession;
84
+ const activeSession = typeof rawActive === 'number' && Number.isInteger(rawActive) && rawActive >= 0 && rawActive < sessions.length
85
+ ? rawActive
86
+ : 0;
87
+ return {
88
+ inputHistory: Array.isArray(parsed.inputHistory)
89
+ ? parsed.inputHistory.filter((entry) => typeof entry === 'string')
90
+ : [],
91
+ thinking: parsed.thinking === true,
92
+ peers: Array.isArray(parsed.peers)
93
+ ? parsed.peers.filter((entry) => typeof entry === 'string')
94
+ : [],
95
+ theme: typeof parsed.theme === 'string' ? parsed.theme : undefined,
96
+ expandTools: parsed.expandTools === false ? false : undefined,
97
+ sessions,
98
+ activeSession,
99
+ };
100
+ }
101
+ /**
102
+ * Read the persisted state, returning the fallback when there is none or it
103
+ * cannot be understood.
104
+ */
105
+ export async function loadState(env = process.env) {
106
+ let raw;
107
+ try {
108
+ raw = await readFile(statePath(env), 'utf8');
109
+ }
110
+ catch {
111
+ return fallbackState();
112
+ }
113
+ return decodeState(raw);
114
+ }
115
+ /**
116
+ * Decide what a restore should attempt, given what is still on disk.
117
+ *
118
+ * The session store is not owned by this app: `/delete` prunes it, and so does
119
+ * anything else that touches `$DSH_HOME` between two runs. A remembered id
120
+ * whose directory has gone is therefore an ordinary outcome and not an error —
121
+ * it is dropped here, silently, before anything tries to adopt it.
122
+ *
123
+ * @param state - what was read back from disk.
124
+ * @param isAvailable - whether that session id still exists in the store.
125
+ */
126
+ export function restorePlan(state, isAvailable) {
127
+ const seen = new Set();
128
+ const sessions = [];
129
+ // The id the user was last looking at, so the active tab survives the gaps
130
+ // left by sessions that no longer exist.
131
+ const wanted = state.sessions[state.activeSession]?.id;
132
+ for (const session of state.sessions) {
133
+ if (seen.has(session.id))
134
+ continue;
135
+ if (!isAvailable(session.id))
136
+ continue;
137
+ seen.add(session.id);
138
+ sessions.push(session);
139
+ if (sessions.length >= MAX_RESTORED_SESSIONS)
140
+ break;
141
+ }
142
+ const found = sessions.findIndex((session) => session.id === wanted);
143
+ return { sessions, active: found === -1 ? 0 : found };
144
+ }
145
+ /**
146
+ * A scratch path for the write-then-rename, unique to this process.
147
+ *
148
+ * The rename is what makes a save atomic, but the file it renames has to be
149
+ * this process's alone. Several sessions of this app run at once -- that is
150
+ * the point of the fleet -- and when two of them saved at the same moment they
151
+ * wrote the same `tui-state.json.tmp` on top of each other and renamed the
152
+ * interleaved result into place. The file that came out was one complete
153
+ * document followed by a fragment of another, which every later read then
154
+ * discarded as unparseable, silently losing the remembered sessions, peers and
155
+ * theme. Observed, not hypothetical.
156
+ */
157
+ export function scratchPath(target) {
158
+ return `${target}.${String(process.pid)}.tmp`;
159
+ }
160
+ /**
161
+ * Write the state atomically: a temporary file in the same directory, then a
162
+ * rename, so a crash mid-write can never leave a half-written JSON behind.
163
+ */
164
+ export async function saveState(state, env = process.env) {
165
+ const target = statePath(env);
166
+ const temporary = scratchPath(target);
167
+ const payload = JSON.stringify({ ...state, version: STATE_VERSION });
168
+ try {
169
+ await mkdir(dirname(target), { recursive: true });
170
+ await writeFile(temporary, payload, 'utf8');
171
+ await rename(temporary, target);
172
+ }
173
+ catch {
174
+ // A read-only home or a missing directory is a valid environment.
175
+ }
176
+ }
177
+ /**
178
+ * The synchronous twin of {@link saveState}, for the teardown path: `quit()`
179
+ * asks the launcher to exit immediately, so an in-flight async write would be
180
+ * cut off and the last prompt lost.
181
+ */
182
+ export function saveStateSync(state, env = process.env) {
183
+ const target = statePath(env);
184
+ const temporary = scratchPath(target);
185
+ const payload = JSON.stringify({ ...state, version: STATE_VERSION });
186
+ try {
187
+ mkdirSync(dirname(target), { recursive: true });
188
+ writeFileSync(temporary, payload, 'utf8');
189
+ renameSync(temporary, target);
190
+ }
191
+ catch {
192
+ // A read-only home or a missing directory is a valid environment.
193
+ }
194
+ }
package/lib/plugins.js ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * The plugin set of the profile this app booted from.
3
+ *
4
+ * A Harness profile is a directory under `$DSH_HOME/profiles/<name>` whose
5
+ * `package.json` carries two lists that are easy to confuse: `dependencies`,
6
+ * which is what pnpm put on disk, and `dsh.profile.bundles`, the ordered
7
+ * layer stack the launcher actually composes. A package can sit in the first
8
+ * and not the second, and that gap is exactly what this app calls enabled and
9
+ * disabled — which is why the picker reads a manifest rather than asking the
10
+ * package manager what is installed.
11
+ *
12
+ * Everything that only reads or rewrites that manifest is a pure function
13
+ * over a parsed object, because the alternative is a feature whose only test
14
+ * is a network install. The two operations that cannot be pure — adding and
15
+ * removing a package — are one narrow `execFile` at the bottom of the file.
16
+ *
17
+ * Nothing here takes effect until the app is restarted: the bundle sets
18
+ * `patchReload: "startup"`, so a layer list edited under a running terminal is
19
+ * read at the next launch and not before.
20
+ * @module moqi-tui/plugins
21
+ */
22
+ import { execFile } from 'node:child_process';
23
+ import { readFileSync, writeFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ /** The layer every profile composes first; nothing else works without it. */
26
+ export const BASE_BUNDLE = '@deepseek-ai/dsh-base';
27
+ /** This app's own package, as the profile's dependency list spells it. */
28
+ export const APP_PACKAGE = 'moqi-tui';
29
+ /**
30
+ * The identifier this package used immediately before `moqi-tui`.
31
+ *
32
+ * A profile installed under the old name still lists it, and the app has to
33
+ * keep recognizing its own bundle as protected — otherwise the plugin pane
34
+ * offers to disable the terminal it is running inside. npm rejected the bare
35
+ * `moqi` as too close to existing packages, so the published name grew a
36
+ * suffix while the command stayed `moqi`.
37
+ */
38
+ export const LEGACY_APP_PACKAGE = 'moqi';
39
+ /**
40
+ * Packages the picker lists but refuses to change.
41
+ *
42
+ * Disabling the base layer leaves a profile with no agent, and disabling this
43
+ * app removes the very screen the command was typed on — in both cases the
44
+ * only way back is to hand-edit JSON, so the app does not offer the rope.
45
+ */
46
+ export const PROTECTED_PACKAGES = [BASE_BUNDLE, APP_PACKAGE, LEGACY_APP_PACKAGE];
47
+ /** The package manager profiles are installed with. */
48
+ export const PACKAGE_MANAGER = 'pnpm';
49
+ /**
50
+ * How long one install or removal may run before it is abandoned.
51
+ *
52
+ * Generous, because a cold store fetching a large dependency tree is slow and
53
+ * killing it halfway is worse than waiting; bounded, because the app must not
54
+ * be left with a status line that never resolves.
55
+ */
56
+ const PACKAGE_MANAGER_TIMEOUT_MS = 180_000;
57
+ /** The longest name the registry accepts; past it, it is not a package. */
58
+ const MAX_NAME_LENGTH = 214;
59
+ /**
60
+ * npm's name grammar, minus the leading `.`, `_` and `-` it still tolerates
61
+ * for names registered long ago. A string reaching this module is about to
62
+ * become an argument on a command line, where a leading dash reads as a flag.
63
+ */
64
+ const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
65
+ /**
66
+ * A version or range suffix. Deliberately narrow — no whitespace, no quotes,
67
+ * none of the shell's metacharacters — because the package manager is spawned
68
+ * through a shell on Windows, and rejecting the character is cheaper than
69
+ * reasoning about every path it could take afterwards.
70
+ */
71
+ const VERSION_SPEC = /^[A-Za-z0-9.^~><=*+][A-Za-z0-9.^~><=*+-]*$/;
72
+ /** Whether this app declines to enable or disable a package. */
73
+ export function isProtected(packageName) {
74
+ return PROTECTED_PACKAGES.includes(packageName);
75
+ }
76
+ /** Where a profile of this name lives under a given Harness home. */
77
+ export function resolveProfileDir(profileName, dshHome) {
78
+ return join(dshHome, 'profiles', profileName);
79
+ }
80
+ /**
81
+ * Which profile this process booted, read back off the launcher's own argv.
82
+ *
83
+ * The launcher strips `--profile` before the plugin tree mounts and hands the
84
+ * app only what followed it, so `ctx.cmdlineArgs` cannot answer this and no
85
+ * environment variable carries it either. `process.argv` is untouched, though,
86
+ * and the flag is still sitting in it.
87
+ * @param argv - a launcher command line; `process.argv` by default.
88
+ * @returns the profile name, or undefined when the app was not launched that way.
89
+ */
90
+ export function activeProfileName(argv = process.argv) {
91
+ const args = argv.slice(2);
92
+ const flag = '--profile=';
93
+ for (let index = 0; index < args.length; index += 1) {
94
+ const argument = args[index] ?? '';
95
+ if (argument === '--profile')
96
+ return profileNameOrUndefined(args[index + 1]);
97
+ if (argument.startsWith(flag))
98
+ return profileNameOrUndefined(argument.slice(flag.length));
99
+ }
100
+ return undefined;
101
+ }
102
+ /**
103
+ * Read a profile's manifest, or nothing at all.
104
+ *
105
+ * A profile directory that is missing, unreadable, or holding something other
106
+ * than a JSON object is reported as "no plugins" rather than as an error: the
107
+ * app has to keep running on a home it cannot read, and a broken manifest is
108
+ * not something the terminal can fix anyway.
109
+ * @param dir - the profile directory from {@link resolveProfileDir}.
110
+ */
111
+ export function readProfileManifest(dir) {
112
+ let raw;
113
+ try {
114
+ raw = readFileSync(join(dir, 'package.json'), 'utf8');
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ try {
120
+ const parsed = JSON.parse(raw);
121
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
122
+ return undefined;
123
+ return parsed;
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ /**
130
+ * Write a profile's manifest back.
131
+ *
132
+ * Two-space JSON with a trailing newline, which is what both the Harness's own
133
+ * `writeProfileManifest` and this repo's install script emit — matching them
134
+ * keeps an app-side edit out of the diff a user takes of their own profile.
135
+ * @param dir - the profile directory from {@link resolveProfileDir}.
136
+ * @param manifest - the manifest value to persist.
137
+ */
138
+ export function writeProfileManifest(dir, manifest) {
139
+ writeFileSync(join(dir, 'package.json'), `${JSON.stringify(manifest, undefined, 2)}\n`);
140
+ }
141
+ /**
142
+ * Every package of a profile, enabled ones first in composition order.
143
+ *
144
+ * The two lists are unioned rather than intersected because each holds names
145
+ * the other does not: an in-box layer like the base bundle is composed without
146
+ * ever being a dependency, and a package can be installed with no layer of its
147
+ * own. Enabled rows keep their `bundles` order, since that order is what the
148
+ * launcher applies; the rest are alphabetical, having no order to preserve.
149
+ * @param manifest - a parsed manifest, or undefined for an unreadable profile.
150
+ */
151
+ export function listPlugins(manifest) {
152
+ const dependencies = dependenciesOf(manifest);
153
+ const entries = [];
154
+ const seen = new Set();
155
+ for (const packageName of bundlesOf(manifest)) {
156
+ if (seen.has(packageName))
157
+ continue;
158
+ seen.add(packageName);
159
+ entries.push(pluginEntry(packageName, dependencies.get(packageName), true));
160
+ }
161
+ for (const packageName of [...dependencies.keys()].sort()) {
162
+ if (seen.has(packageName))
163
+ continue;
164
+ seen.add(packageName);
165
+ entries.push(pluginEntry(packageName, dependencies.get(packageName), false));
166
+ }
167
+ return entries;
168
+ }
169
+ /** Whether a package is currently composed into the app. */
170
+ export function isPluginEnabled(manifest, packageName) {
171
+ return bundlesOf(manifest).includes(packageName);
172
+ }
173
+ /**
174
+ * Add a package to the layer stack, or take it out of it.
175
+ *
176
+ * Enabling appends. Patch layers compose in order and the last one to touch a
177
+ * row wins, so a plugin somebody just turned on should be able to override
178
+ * what was already there rather than be overridden by it. The base layer is
179
+ * then pulled back to the front, because everything else patches over it.
180
+ *
181
+ * Nothing is installed or deleted here — the package stays exactly where the
182
+ * package manager left it, and only the composed stack moves.
183
+ * @param manifest - the profile manifest to edit.
184
+ * @param packageName - the dependency to enable or disable.
185
+ * @param enabled - the membership wanted.
186
+ * @returns the manifest to write, or the original plus the reason it did not move.
187
+ */
188
+ export function setPluginEnabled(manifest, packageName, enabled) {
189
+ const current = manifest ?? {};
190
+ if (isProtected(packageName)) {
191
+ return { manifest: current, changed: false, reason: `${packageName} is part of this profile` };
192
+ }
193
+ if (!listPlugins(manifest).some((entry) => entry.name === packageName)) {
194
+ return { manifest: current, changed: false, reason: `${packageName} is not in this profile` };
195
+ }
196
+ const bundles = bundlesOf(manifest);
197
+ if (bundles.includes(packageName) === enabled) {
198
+ const state = enabled ? 'enabled' : 'disabled';
199
+ return { manifest: current, changed: false, reason: `${packageName} is already ${state}` };
200
+ }
201
+ const next = enabled
202
+ ? [...bundles, packageName]
203
+ : bundles.filter((bundle) => bundle !== packageName);
204
+ return { manifest: withBundles(current, withBaseFirst(next)), changed: true, reason: '' };
205
+ }
206
+ /**
207
+ * Drop a package from the layer stack whatever its protection, for use on the
208
+ * way to removing it from disk.
209
+ *
210
+ * A name left in `dsh.profile.bundles` after its package is gone is not a
211
+ * cosmetic leftover: the launcher fails loud on a listed bundle it cannot
212
+ * resolve, so the profile would stop booting. The stack is therefore edited
213
+ * first and the package manager run second — an interrupted removal then
214
+ * leaves a package that is merely disabled, which still starts.
215
+ * @param manifest - the profile manifest to edit.
216
+ * @param packageName - the dependency about to be removed.
217
+ */
218
+ export function forgetPlugin(manifest, packageName) {
219
+ const current = manifest ?? {};
220
+ const bundles = bundlesOf(manifest);
221
+ if (!bundles.includes(packageName)) {
222
+ return { manifest: current, changed: false, reason: `${packageName} is not composed` };
223
+ }
224
+ const next = bundles.filter((bundle) => bundle !== packageName);
225
+ return { manifest: withBundles(current, withBaseFirst(next)), changed: true, reason: '' };
226
+ }
227
+ /**
228
+ * Decide whether a typed string may be handed to the package manager.
229
+ *
230
+ * There is no registry search behind this app, so the string is whatever
231
+ * somebody typed, and it becomes one argument of a spawned process. The
232
+ * grammar is therefore checked rather than escaped: a name that is not a name
233
+ * is refused with a reason instead of being quoted and hoped about.
234
+ * @param input - the raw `/plugins add` argument.
235
+ */
236
+ export function parsePackageRequest(input) {
237
+ const spec = input.trim();
238
+ if (spec === '')
239
+ return { ok: false, reason: 'name the package to install' };
240
+ // Index 0 is the scope sigil of `@scope/name`, never a version separator.
241
+ const separator = spec.lastIndexOf('@');
242
+ const versioned = separator > 0;
243
+ const name = versioned ? spec.slice(0, separator) : spec;
244
+ const version = versioned ? spec.slice(separator + 1) : '';
245
+ if (name.length > MAX_NAME_LENGTH) {
246
+ return { ok: false, reason: 'that is too long to be a package name' };
247
+ }
248
+ if (!PACKAGE_NAME.test(name)) {
249
+ return { ok: false, reason: `${JSON.stringify(name)} is not a package name` };
250
+ }
251
+ if (versioned && !VERSION_SPEC.test(version)) {
252
+ return { ok: false, reason: `${JSON.stringify(version)} is not a version` };
253
+ }
254
+ return { ok: true, request: { name, spec } };
255
+ }
256
+ /**
257
+ * Run the package manager in a profile directory.
258
+ *
259
+ * pnpm and not npm: a profile's dependency on a local checkout is a `link:`
260
+ * spec, which npm rewrites into its own idea of a link and then loses on the
261
+ * next install — so the Harness installs profiles with pnpm and this app has
262
+ * to agree with it or it would quietly break the very profile it is editing.
263
+ *
264
+ * The run never throws and never reaches the terminal: output is piped, not
265
+ * inherited, because the app owns the alternate screen and a package manager's
266
+ * progress bars drawn into it would corrupt the frame. A failure comes back as
267
+ * a line for the status bar.
268
+ * @param dir - the profile directory to run in.
269
+ * @param args - the manager's arguments, already validated.
270
+ */
271
+ export function runPackageManager(dir, args) {
272
+ return new Promise((settle) => {
273
+ execFile(PACKAGE_MANAGER, [...args], {
274
+ cwd: dir,
275
+ timeout: PACKAGE_MANAGER_TIMEOUT_MS,
276
+ encoding: 'utf8',
277
+ // On Windows pnpm is a shim only a shell can find, which is how the
278
+ // Harness spawns it too; every argument has already been through
279
+ // parsePackageRequest, so nothing unvalidated reaches that shell.
280
+ shell: process.platform === 'win32',
281
+ // A colorized answer would arrive as escape sequences and be painted
282
+ // into the status line verbatim.
283
+ env: { ...process.env, NO_COLOR: '1' },
284
+ }, (error, stdout, stderr) => {
285
+ if (error === null) {
286
+ settle({ ok: true, message: lastLine(stdout) });
287
+ return;
288
+ }
289
+ if (error.code === 'ENOENT') {
290
+ settle({
291
+ ok: false,
292
+ message: `${PACKAGE_MANAGER} is not on PATH — profiles are installed with it`,
293
+ });
294
+ return;
295
+ }
296
+ const detail = lastLine(stderr);
297
+ settle({ ok: false, message: detail === '' ? error.message : detail });
298
+ });
299
+ });
300
+ }
301
+ /** A profile name that could not escape the profiles directory. */
302
+ function profileNameOrUndefined(value) {
303
+ if (value === undefined)
304
+ return undefined;
305
+ const name = value.trim();
306
+ if (name === '' || name.startsWith('-'))
307
+ return undefined;
308
+ if (name.includes('/') || name.includes('\\') || name === '.' || name === '..')
309
+ return undefined;
310
+ return name;
311
+ }
312
+ /** The layer stack, tolerating a manifest that holds something else there. */
313
+ function bundlesOf(manifest) {
314
+ const bundles = manifest?.dsh?.profile?.bundles;
315
+ if (!Array.isArray(bundles))
316
+ return [];
317
+ return bundles.filter((bundle) => typeof bundle === 'string' && bundle !== '');
318
+ }
319
+ /** The installed packages, tolerating a manifest that holds something else there. */
320
+ function dependenciesOf(manifest) {
321
+ const dependencies = manifest?.dependencies;
322
+ const found = new Map();
323
+ if (typeof dependencies !== 'object' || dependencies === null)
324
+ return found;
325
+ for (const [packageName, spec] of Object.entries(dependencies)) {
326
+ if (packageName !== '' && typeof spec === 'string')
327
+ found.set(packageName, spec);
328
+ }
329
+ return found;
330
+ }
331
+ /** One listing row. */
332
+ function pluginEntry(packageName, spec, enabled) {
333
+ return {
334
+ name: packageName,
335
+ spec: spec ?? '',
336
+ installed: spec !== undefined,
337
+ enabled,
338
+ protected: isProtected(packageName),
339
+ };
340
+ }
341
+ /**
342
+ * Keep the base layer at the head of the stack. Every other bundle patches
343
+ * rows the base layer introduced, so a base that composed second would be
344
+ * overwriting the overrides instead of supplying the defaults.
345
+ */
346
+ function withBaseFirst(bundles) {
347
+ if (!bundles.includes(BASE_BUNDLE))
348
+ return [...bundles];
349
+ return [BASE_BUNDLE, ...bundles.filter((bundle) => bundle !== BASE_BUNDLE)];
350
+ }
351
+ /** A copy of the manifest carrying a new layer stack and nothing else changed. */
352
+ function withBundles(manifest, bundles) {
353
+ const dsh = isRecord(manifest.dsh) ? manifest.dsh : {};
354
+ const profile = isRecord(dsh.profile) ? dsh.profile : {};
355
+ return { ...manifest, dsh: { ...dsh, profile: { ...profile, bundles } } };
356
+ }
357
+ /** Whether a value is a plain object worth spreading. */
358
+ function isRecord(value) {
359
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
360
+ }
361
+ /** The last thing a command said, flattened to one printable line. */
362
+ function lastLine(output) {
363
+ const line = output
364
+ // NO_COLOR is a request, not a guarantee; strip what came anyway.
365
+ .replace(/\[[0-9;]*[A-Za-z]/g, '')
366
+ .split('\n')
367
+ .map((candidate) => candidate.trim())
368
+ .filter((candidate) => candidate !== '')
369
+ .at(-1);
370
+ return (line ?? '').slice(0, 160);
371
+ }