@dsh-electron/dsh-plugin-multi-root-workspace 0.1.6 → 0.1.7

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 (93) hide show
  1. package/CHANGELOG.en.md +44 -1
  2. package/CHANGELOG.md +44 -1
  3. package/README.en.md +155 -92
  4. package/README.md +156 -91
  5. package/cordis.patch.yml +57 -0
  6. package/lib/client.js +2678 -276
  7. package/lib/client.js.map +1 -1
  8. package/lib/command-CESuwlVt.js +1306 -0
  9. package/lib/command.js +2 -2
  10. package/lib/{compat-B6ZqbQBy.js → compat-DF_yL1bB.js} +160 -2
  11. package/lib/compat.js +1 -1
  12. package/lib/contract-DZ4zczDT.js +238 -0
  13. package/lib/{fs-D_Sw3o5z.js → fs-9JIzGaf3.js} +55 -19
  14. package/lib/fs.js +2 -1
  15. package/lib/index.js +10 -8
  16. package/lib/{instructions-DfzN-wVf.js → instructions-D19jRxN0.js} +3 -2
  17. package/lib/instructions.js +1 -1
  18. package/lib/lsp.js +7 -2
  19. package/lib/method-wrapper-CFY3p3j2.js +26 -0
  20. package/lib/process-fence.js +280 -0
  21. package/lib/providers.js +87 -0
  22. package/lib/{registry-DySMbjgW.js → registry-DCxC5Lay.js} +256 -35
  23. package/lib/registry.js +2 -2
  24. package/lib/{roots-DasWEBJk.js → roots-B-0l8mNG.js} +117 -20
  25. package/lib/sandbox-Kup4_ZIb.js +747 -0
  26. package/lib/sandbox.js +2 -1
  27. package/lib/scope.js +229 -20
  28. package/lib/types/client/RootSwitcher.d.ts +63 -0
  29. package/lib/types/client/WorkspaceFoldersAction.d.ts +39 -1
  30. package/lib/types/client/files-tree/FilesBody.module.css.d.ts +24 -0
  31. package/lib/types/client/files-tree/FilesTree.d.ts +47 -0
  32. package/lib/types/client/files-tree/adapter.d.ts +94 -0
  33. package/lib/types/client/files-tree/directory-node.d.ts +62 -0
  34. package/lib/types/client/files-tree/face.d.ts +107 -0
  35. package/lib/types/client/files-tree/locales.d.ts +10 -0
  36. package/lib/types/client/files-tree/primitives.d.ts +33 -0
  37. package/lib/types/client/files-tree/store.d.ts +81 -0
  38. package/lib/types/client/locales.d.ts +56 -10
  39. package/lib/types/client/open-in-app/OpenInAppAction.d.ts +27 -0
  40. package/lib/types/client/open-in-app/OpenTargetButton.d.ts +52 -0
  41. package/lib/types/client/open-in-app/OpenTargetButton.module.css.d.ts +29 -0
  42. package/lib/types/client/open-in-app/adapter.d.ts +46 -0
  43. package/lib/types/client/open-in-app/applications.d.ts +4 -0
  44. package/lib/types/client/open-in-app/controller.d.ts +53 -0
  45. package/lib/types/client/open-in-app/locales.d.ts +10 -0
  46. package/lib/types/client/open-in-app/open-failure-toast.d.ts +11 -0
  47. package/lib/types/client/open-in-app/open-path.d.ts +13 -0
  48. package/lib/types/client/panel-client.d.ts +2 -1
  49. package/lib/types/client/styles.d.ts +1 -1
  50. package/lib/types/command.d.ts +16 -3
  51. package/lib/types/compat/client-icons.d.ts +18 -0
  52. package/lib/types/compat/dsh-version.d.ts +1 -1
  53. package/lib/types/compat/open-in-app-routes.d.ts +38 -0
  54. package/lib/types/compat/process-entrypoints.d.ts +31 -0
  55. package/lib/types/compat/process-result.d.ts +14 -0
  56. package/lib/types/compat/pty-marker.d.ts +83 -0
  57. package/lib/types/compat/sandbox-confine.d.ts +2 -0
  58. package/lib/types/compat/storage-zod.d.ts +65 -0
  59. package/lib/types/compat/windows-deny-preflight.d.ts +21 -0
  60. package/lib/types/compat/windows-native.d.ts +68 -0
  61. package/lib/types/compat/windows-path-guard.d.ts +56 -0
  62. package/lib/types/compat/workspace-files.d.ts +2 -0
  63. package/lib/types/contract.d.ts +31 -19
  64. package/lib/types/dialects.d.ts +99 -3
  65. package/lib/types/fs.d.ts +32 -10
  66. package/lib/types/index.d.ts +7 -2
  67. package/lib/types/process-fence.d.ts +54 -0
  68. package/lib/types/profile-isolation.d.ts +178 -0
  69. package/lib/types/providers.d.ts +70 -0
  70. package/lib/types/registry.d.ts +132 -31
  71. package/lib/types/roots.d.ts +100 -13
  72. package/lib/types/sandbox.d.ts +59 -3
  73. package/lib/types/scope.d.ts +200 -14
  74. package/lib/types/windows-broker.d.ts +17 -0
  75. package/lib/types/windows-deny.d.ts +1 -0
  76. package/lib/types/windows-profile.d.ts +10 -13
  77. package/lib/types/windows-runner-contract.d.ts +97 -0
  78. package/lib/windows-runner-contract-CEfmZzu0.js +141 -0
  79. package/lib/windows-runner.js +1256 -13
  80. package/lib/workspace-files.js +138 -3
  81. package/lib/workspace-routing-YPxaQ4V3.js +26 -0
  82. package/package.json +84 -62
  83. package/scripts/windows-broker/install.ps1 +89 -0
  84. package/scripts/windows-broker/rights.cs +58 -0
  85. package/scripts/windows-broker/service.cs +323 -0
  86. package/scripts/windows-broker/verify-install.ps1 +80 -0
  87. package/lib/command-CTdNvwt1.js +0 -791
  88. package/lib/method-wrapper-Zc-JlDP3.js +0 -51
  89. package/lib/sandbox-BRXl_FpF.js +0 -132
  90. package/lib/types/client/RootFileTree.d.ts +0 -12
  91. package/lib/types/panel-files.d.ts +0 -3
  92. package/lib/windows-profile-CiFsFYIV.js +0 -216
  93. package/lib/{registry-lease-win32-dy8TQMD9.js → registry-lease-win32-DTO6QpT9.js} +2 -2
@@ -0,0 +1,1306 @@
1
+ import { t as effectiveDirectory } from "./session-directory-BLOstut1.js";
2
+ import { r as canonicalPath$1 } from "./windows-runner-contract-CEfmZzu0.js";
3
+ import { i as canonicalRoot, o as entryRootRef, p as resolveRootRef, r as availableRoots, t as RootValidationError } from "./roots-B-0l8mNG.js";
4
+ import { i as parsePanelCall, r as effectiveRootAccess, t as PANEL_CHANNEL } from "./contract-DZ4zczDT.js";
5
+ import "@deepseek-ai/cordis";
6
+ import { closeSync, lstatSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
+ import { isAbsolute, join } from "node:path";
8
+ import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
9
+ //#region src/profile-isolation.ts
10
+ /**
11
+ * Profile pnpm import isolation — the host-side maintenance behind
12
+ * `/workspace-folders isolate-profiles` (branch review Phase C2, finding R4).
13
+ *
14
+ * Why it exists: a Windows deny ACE applies to the file OBJECT, not to one
15
+ * hardlink name. The pnpm profile defaults upstream writes (`initProfile` in
16
+ * dsh-app-boot) never set `packageImportMethod`, so `dsh plugin add` imports
17
+ * the store into `<home>/profiles/<name>/node_modules` BY HARDLINK. Denying
18
+ * `$DSH_HOME` then rewrites security descriptors shared with every other name
19
+ * of the same objects — including the global pnpm store and other projects
20
+ * linking it. The C1 preflight therefore refuses such a home before any ACL
21
+ * write; this module is the remediation it points at: it detects the confirmed
22
+ * profiles, writes `packageImportMethod: copy` conservatively, and scans the
23
+ * whole home for the multi-link files that still need a reinstall.
24
+ *
25
+ * Boundaries of the subset it touches:
26
+ * - profile identification is conservative — only a directory under
27
+ * `profiles/` whose `package.json` parses to an object carrying
28
+ * `dsh.profile` as an object counts; everything else is enumerated but
29
+ * never written;
30
+ * - the YAML handling is a strict SUBSET scanner, not a parser substitute —
31
+ * the runtime cannot depend on a YAML package, so anything ambiguous is
32
+ * reported as a conflict and refused instead of guessed;
33
+ * - every write is atomic (temp sibling + rename) and never crosses a reparse
34
+ * point;
35
+ * - the whole-home scan never descends a reparse point: with plain `lstat` a
36
+ * symlink/junction reports as a link, and other reparse flavours still fall
37
+ * under the runner's own Win32-attribute preflight, which remains the
38
+ * authoritative gate;
39
+ * - the command sets configuration — it never deletes `node_modules`, never
40
+ * runs pnpm, and never repairs ACLs or ownership written in the past.
41
+ *
42
+ * @module profile-isolation
43
+ */
44
+ /** `pnpm-workspace.yaml` — the only file the maintenance verb ever writes. */
45
+ const WORKSPACE_YAML = "pnpm-workspace.yaml";
46
+ /** How many example paths a report keeps before it only counts the rest. */
47
+ const SAMPLE_CAP = 20;
48
+ /**
49
+ * A top-level key line the subset scanner accepts. The value is deliberately
50
+ * not validated here — its shape decides the entry kind.
51
+ */
52
+ const TOP_LEVEL_KEY = /^([A-Za-z0-9][A-Za-z0-9._-]*|'(?:[^']|'')+'|"(?:[^"\\]|\\.)*")\s*:/;
53
+ /** YAML double-quoted single-character escapes (the multi-hex forms follow). */
54
+ const DOUBLE_QUOTED_ESCAPES = {
55
+ "0": "\0",
56
+ a: "\x07",
57
+ b: "\b",
58
+ t: " ",
59
+ " ": " ",
60
+ n: "\n",
61
+ v: "\v",
62
+ f: "\f",
63
+ r: "\r",
64
+ e: "\x1B",
65
+ "\"": "\"",
66
+ "'": "'",
67
+ "\\": "\\",
68
+ "/": "/",
69
+ N: "…",
70
+ _: "\xA0",
71
+ L: "\u2028",
72
+ P: "\u2029",
73
+ " ": " "
74
+ };
75
+ /**
76
+ * Unquote a single-quoted or double-quoted YAML token.
77
+ * @param token - the token including its quotes.
78
+ * @param fail - the rejection callback for escapes outside the subset.
79
+ * @returns the unquoted text.
80
+ */
81
+ function unquoteYamlToken(token, fail) {
82
+ if (token.startsWith("'")) return token.slice(1, -1).replaceAll("''", "'");
83
+ const inner = token.slice(1, -1);
84
+ let out = "";
85
+ for (let i = 0; i < inner.length; i += 1) {
86
+ const char = inner[i];
87
+ if (char !== "\\") {
88
+ out += char;
89
+ continue;
90
+ }
91
+ const escape = inner[i + 1];
92
+ if (escape === void 0) fail("a backslash ends the quoted token");
93
+ if (escape === "x" || escape === "u" || escape === "U") {
94
+ const digits = escape === "x" ? 2 : escape === "u" ? 4 : 8;
95
+ const hex = inner.slice(i + 2, i + 2 + digits);
96
+ if (!new RegExp(`^[0-9a-fA-F]{${digits}}$`).test(hex)) fail(`\\${escape} wants ${digits} hex digits, got ${JSON.stringify(hex)}`);
97
+ out += String.fromCodePoint(Number.parseInt(hex, 16));
98
+ i += 1 + digits;
99
+ continue;
100
+ }
101
+ const mapped = DOUBLE_QUOTED_ESCAPES[escape];
102
+ if (mapped === void 0) fail(`unsupported escape \\${escape}`);
103
+ out += mapped;
104
+ i += 1;
105
+ }
106
+ return out;
107
+ }
108
+ /**
109
+ * Locate the first `#` starting a comment in a plain (unquoted) value: the
110
+ * marker must be preceded by separation whitespace per YAML's comment rule.
111
+ * @param value - the value text after `key:`.
112
+ * @returns the comment offset inside `value`, or `-1`.
113
+ */
114
+ function plainCommentStart(value) {
115
+ for (let i = 0; i < value.length; i += 1) if (value[i] === "#" && i > 0 && (value[i - 1] === " " || value[i - 1] === " ")) return i;
116
+ return -1;
117
+ }
118
+ /**
119
+ * Read the top-level `key: value` lines of a `pnpm-workspace.yaml` text.
120
+ *
121
+ * This is a strict subset scanner, deliberately NOT a YAML parser: it accepts
122
+ * only top-level `key:` lines (plain or quoted keys) plus indented nested
123
+ * content it never interprets, and throws — naming the file and the 1-based
124
+ * line — on an indented first entry, tabs in indentation, `%` / `---` / `...` directives, duplicate
125
+ * top-level keys, or any other non-indented content line outside the key
126
+ * form. A `#` preceded by whitespace ends a plain scalar; quoted scalars are
127
+ * unquoted for the reported value. `valueStart`/`valueEnd` are the UTF-16
128
+ * offsets of the value token itself, so a caller can replace exactly the
129
+ * scalar while comments and line endings survive untouched.
130
+ *
131
+ * @param text - the yaml text to scan.
132
+ * @param path - the file name used in rejection messages.
133
+ * @returns the top-level entries keyed by their unquoted names.
134
+ */
135
+ function parseWorkspaceYamlKeys(text, path = WORKSPACE_YAML) {
136
+ const fail = (line, what) => {
137
+ throw new Error(`${path}:${line}: ${what}`);
138
+ };
139
+ const entries = /* @__PURE__ */ new Map();
140
+ let cursor = 0;
141
+ let line = 0;
142
+ while (cursor <= text.length) {
143
+ line += 1;
144
+ const lineStart = cursor;
145
+ const newline = text.indexOf("\n", cursor);
146
+ const lineEnd = newline < 0 ? text.length : newline;
147
+ const raw = lineEnd > lineStart && text[lineEnd - 1] === "\r" ? text.slice(lineStart, lineEnd - 1) : text.slice(lineStart, lineEnd);
148
+ cursor = newline < 0 ? text.length + 1 : lineEnd + 1;
149
+ const indentation = /^[ \t]*/.exec(raw)[0];
150
+ const content = raw.slice(indentation.length);
151
+ if (content === "") continue;
152
+ if (indentation.includes(" ")) fail(line, "tab in indentation");
153
+ if (content.startsWith("#")) continue;
154
+ if (indentation.length > 0) {
155
+ if (entries.size === 0) fail(line, "indented top-level mappings are outside the supported subset");
156
+ continue;
157
+ }
158
+ if (content.startsWith("%")) fail(line, "a `%` directive is outside the supported subset");
159
+ if (/^-{3}(?:\s|$)/.test(content)) fail(line, "a `---` document marker is outside the supported subset");
160
+ if (/^\.{3}(?:\s|$)/.test(content)) fail(line, "a `...` document end is outside the supported subset");
161
+ const keyToken = (TOP_LEVEL_KEY.exec(content) ?? fail(line, "a top-level line must read `key: value`, `key:`, or a comment"))[1];
162
+ const key = keyToken.startsWith("'") || keyToken.startsWith("\"") ? unquoteYamlToken(keyToken, (what) => fail(line, what)) : keyToken;
163
+ if (entries.has(key)) fail(line, `duplicate top-level key ${JSON.stringify(key)}`);
164
+ const colon = content.indexOf(":", keyToken.length);
165
+ const separation = /^[ \t]*/.exec(content.slice(colon + 1))[0].length;
166
+ const valueStart = lineStart + colon + 1 + separation;
167
+ const value = content.slice(colon + 1 + separation);
168
+ if (value === "" || value.startsWith("#")) {
169
+ entries.set(key, {
170
+ value: void 0,
171
+ kind: "block",
172
+ line,
173
+ valueStart,
174
+ valueEnd: valueStart
175
+ });
176
+ continue;
177
+ }
178
+ const head = value[0];
179
+ if ("&*!|>{[".includes(head)) {
180
+ const tokenLength = /^[^\s]+/.exec(value)[0].length;
181
+ entries.set(key, {
182
+ value: void 0,
183
+ kind: "complex",
184
+ line,
185
+ valueStart,
186
+ valueEnd: valueStart + tokenLength
187
+ });
188
+ continue;
189
+ }
190
+ if (head === "'" || head === "\"") {
191
+ let close = -1;
192
+ for (let i = 1; i < value.length; i += 1) {
193
+ if (head === "'" && value[i] === "'" && value[i + 1] === "'") {
194
+ i += 1;
195
+ continue;
196
+ }
197
+ if (head === "\"" && value[i] === "\\") {
198
+ i += 1;
199
+ continue;
200
+ }
201
+ if (value[i] === head) {
202
+ close = i;
203
+ break;
204
+ }
205
+ }
206
+ if (close < 0) {
207
+ entries.set(key, {
208
+ value: void 0,
209
+ kind: "complex",
210
+ line,
211
+ valueStart,
212
+ valueEnd: valueStart + value.length
213
+ });
214
+ continue;
215
+ }
216
+ const trailing = value.slice(close + 1);
217
+ let scalar;
218
+ try {
219
+ scalar = unquoteYamlToken(value.slice(0, close + 1), (what) => {
220
+ throw new Error(what);
221
+ });
222
+ } catch {
223
+ scalar = void 0;
224
+ }
225
+ if (!/^\s*(#|$)/.test(trailing)) entries.set(key, {
226
+ value: void 0,
227
+ kind: "complex",
228
+ line,
229
+ valueStart,
230
+ valueEnd: valueStart + close + 1
231
+ });
232
+ else if (scalar === void 0) entries.set(key, {
233
+ value: void 0,
234
+ kind: "complex",
235
+ line,
236
+ valueStart,
237
+ valueEnd: valueStart + close + 1
238
+ });
239
+ else entries.set(key, {
240
+ value: scalar,
241
+ kind: "scalar",
242
+ line,
243
+ valueStart,
244
+ valueEnd: valueStart + close + 1
245
+ });
246
+ continue;
247
+ }
248
+ const commentAt = plainCommentStart(value);
249
+ const scalarText = (commentAt < 0 ? value : value.slice(0, commentAt)).replace(/[ \t]+$/, "");
250
+ entries.set(key, {
251
+ value: scalarText,
252
+ kind: "scalar",
253
+ line,
254
+ valueStart,
255
+ valueEnd: valueStart + scalarText.length
256
+ });
257
+ }
258
+ return entries;
259
+ }
260
+ const describeError = (error) => error instanceof Error ? error.message : String(error);
261
+ /**
262
+ * Judge whether one directory is a confirmed DSH profile: a `package.json`
263
+ * that parses to an object whose `dsh.profile` is itself an object.
264
+ */
265
+ function isConfirmedProfile(dir) {
266
+ let manifest;
267
+ try {
268
+ manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
269
+ } catch {
270
+ return false;
271
+ }
272
+ if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) return false;
273
+ const dsh = manifest.dsh;
274
+ if (dsh === null || typeof dsh !== "object" || Array.isArray(dsh)) return false;
275
+ const profile = dsh.profile;
276
+ return profile !== null && typeof profile === "object" && !Array.isArray(profile);
277
+ }
278
+ /** Classify one confirmed profile's `pnpm-workspace.yaml`. */
279
+ function inspectYaml(dir) {
280
+ const yamlPath = join(dir, WORKSPACE_YAML);
281
+ let stat;
282
+ try {
283
+ stat = lstatSync(yamlPath);
284
+ } catch {
285
+ stat = void 0;
286
+ }
287
+ if (stat !== void 0 && stat.isSymbolicLink()) return {
288
+ yamlPath,
289
+ yamlText: void 0,
290
+ entry: void 0,
291
+ yaml: {
292
+ state: "conflict",
293
+ detail: `${WORKSPACE_YAML} is a reparse point — refusing to follow it`
294
+ }
295
+ };
296
+ if (stat !== void 0 && !stat.isFile()) return {
297
+ yamlPath,
298
+ yamlText: void 0,
299
+ entry: void 0,
300
+ yaml: {
301
+ state: "conflict",
302
+ detail: `${WORKSPACE_YAML} exists but is not a plain file`
303
+ }
304
+ };
305
+ if (stat === void 0) return {
306
+ yamlPath,
307
+ yamlText: void 0,
308
+ entry: void 0,
309
+ yaml: { state: "absent" }
310
+ };
311
+ let text;
312
+ try {
313
+ text = readFileSync(yamlPath, "utf8");
314
+ } catch (error) {
315
+ return {
316
+ yamlPath,
317
+ yamlText: void 0,
318
+ entry: void 0,
319
+ yaml: {
320
+ state: "unreadable",
321
+ detail: describeError(error)
322
+ }
323
+ };
324
+ }
325
+ let keys;
326
+ try {
327
+ keys = parseWorkspaceYamlKeys(text, yamlPath);
328
+ } catch (error) {
329
+ return {
330
+ yamlPath,
331
+ yamlText: text,
332
+ entry: void 0,
333
+ yaml: {
334
+ state: "unreadable",
335
+ detail: describeError(error)
336
+ }
337
+ };
338
+ }
339
+ const entry = keys.get("packageImportMethod");
340
+ if (entry === void 0) return {
341
+ yamlPath,
342
+ yamlText: text,
343
+ entry,
344
+ yaml: { state: "absent" }
345
+ };
346
+ if (entry.kind === "scalar" && entry.value === "copy") return {
347
+ yamlPath,
348
+ yamlText: text,
349
+ entry,
350
+ yaml: { state: "copy" }
351
+ };
352
+ const detail = entry.kind === "scalar" ? `packageImportMethod is ${JSON.stringify(entry.value)}` : "packageImportMethod has a block or complex value";
353
+ return {
354
+ yamlPath,
355
+ yamlText: text,
356
+ entry,
357
+ yaml: {
358
+ state: "conflict",
359
+ detail
360
+ }
361
+ };
362
+ }
363
+ /**
364
+ * Discover the confirmed profiles and scan the whole home. Shared by the
365
+ * read-only report and `--apply` so both look at exactly the same world.
366
+ * @param home - the DSH home (canonicalized inside).
367
+ * @returns the inspected home, profiles, skipped entries, and scan counts.
368
+ */
369
+ function inspectHome(home) {
370
+ let stat;
371
+ try {
372
+ stat = lstatSync(home);
373
+ } catch (error) {
374
+ throw new Error(`the DSH home ${home} cannot be inspected: ${describeError(error)}`);
375
+ }
376
+ if (stat.isSymbolicLink()) throw new Error(`the DSH home ${home} is a reparse point — refusing to work through it`);
377
+ const canonical = canonicalPath$1(home);
378
+ if (!stat.isDirectory()) throw new Error(`the DSH home ${canonical} is not a directory`);
379
+ const scan = {
380
+ directories: 0,
381
+ files: 0,
382
+ multiLinkTotal: 0,
383
+ multiLinkPaths: [],
384
+ uninspectable: [],
385
+ uninspectableTotal: 0
386
+ };
387
+ const uninspectable = (path, error) => {
388
+ scan.uninspectableTotal += 1;
389
+ if (scan.uninspectable.length < SAMPLE_CAP) scan.uninspectable.push({
390
+ path,
391
+ reason: describeError(error)
392
+ });
393
+ };
394
+ const walk = (dir) => {
395
+ scan.directories += 1;
396
+ let names;
397
+ try {
398
+ names = readdirSync(dir);
399
+ } catch (error) {
400
+ uninspectable(dir, error);
401
+ return;
402
+ }
403
+ for (const name of names) {
404
+ const entryPath = join(dir, name);
405
+ let entry;
406
+ try {
407
+ entry = lstatSync(entryPath);
408
+ } catch (error) {
409
+ uninspectable(entryPath, error);
410
+ continue;
411
+ }
412
+ if (entry.isSymbolicLink()) {
413
+ scan.files += 1;
414
+ continue;
415
+ }
416
+ if (entry.isDirectory()) {
417
+ walk(entryPath);
418
+ continue;
419
+ }
420
+ scan.files += 1;
421
+ if (entry.nlink > 1) {
422
+ scan.multiLinkTotal += 1;
423
+ if (scan.multiLinkPaths.length < SAMPLE_CAP) scan.multiLinkPaths.push(entryPath);
424
+ }
425
+ }
426
+ };
427
+ walk(canonical);
428
+ const profilesDir = join(canonical, "profiles");
429
+ const profiles = [];
430
+ const nonProfileEntries = [];
431
+ let listing = [];
432
+ try {
433
+ const profilesStat = lstatSync(profilesDir);
434
+ if (profilesStat.isSymbolicLink()) throw new Error(`${profilesDir} is a reparse point — refusing to work through it`);
435
+ if (profilesStat.isDirectory()) listing = readdirSync(profilesDir);
436
+ else nonProfileEntries.push(profilesDir);
437
+ } catch (error) {
438
+ const code = error.code;
439
+ if (code === "ENOENT" || code === "ENOTDIR") {} else if (error instanceof Error && error.message.includes("reparse point")) throw error;
440
+ else uninspectable(profilesDir, error);
441
+ }
442
+ for (const name of listing) {
443
+ const dir = join(profilesDir, name);
444
+ let entry;
445
+ try {
446
+ entry = lstatSync(dir);
447
+ } catch (error) {
448
+ uninspectable(dir, error);
449
+ continue;
450
+ }
451
+ if (entry.isSymbolicLink() || !entry.isDirectory() || !isConfirmedProfile(dir)) {
452
+ nonProfileEntries.push(dir);
453
+ continue;
454
+ }
455
+ profiles.push({
456
+ name,
457
+ path: dir,
458
+ ...inspectYaml(dir)
459
+ });
460
+ }
461
+ return {
462
+ home: canonical,
463
+ profiles,
464
+ nonProfileEntries,
465
+ scan
466
+ };
467
+ }
468
+ const toReport = (inspected) => ({
469
+ home: inspected.home,
470
+ profiles: inspected.profiles.map(({ name, path, yaml }) => ({
471
+ name,
472
+ path,
473
+ yaml
474
+ })),
475
+ nonProfileEntries: inspected.nonProfileEntries,
476
+ scan: { ...inspected.scan },
477
+ incomplete: inspected.scan.uninspectableTotal > 0
478
+ });
479
+ /**
480
+ * Read-only inspection of a DSH home: confirmed profiles under `profiles/`,
481
+ * each one's `packageImportMethod` verdict, and a whole-home `lstat` scan
482
+ * counting directories, files, multi-link files, and uninspectable entries.
483
+ * Nothing is written. The report is `incomplete` — never clean — whenever any
484
+ * entry could not be inspected.
485
+ * @param home - the DSH home to inspect (canonicalized).
486
+ * @returns the isolation report.
487
+ */
488
+ function isolateReport(home) {
489
+ return toReport(inspectHome(home));
490
+ }
491
+ /**
492
+ * Write `packageImportMethod: copy` into every fixable confirmed profile.
493
+ * A missing file receives the upstream profile template plus the copy line;
494
+ * a missing key is appended; a same-line non-`copy` scalar is replaced by
495
+ * byte range so comments and CRLF survive; a block/complex/duplicate-key or
496
+ * unreadable file is refused untouched. Every write lands atomically through
497
+ * a sibling temp file + rename, and the command never descends or writes
498
+ * through a reparse point.
499
+ * @param home - the DSH home to migrate (canonicalized).
500
+ * @returns the read verdict plus one action per confirmed profile.
501
+ */
502
+ function applyIsolateConfig(home) {
503
+ const inspected = inspectHome(home);
504
+ const actions = [];
505
+ for (const profile of inspected.profiles) {
506
+ const yaml = profile.yaml;
507
+ if (yaml.state === "copy") {
508
+ actions.push({
509
+ name: profile.name,
510
+ action: "noop",
511
+ detail: "packageImportMethod: copy is already set"
512
+ });
513
+ continue;
514
+ }
515
+ if (yaml.state === "unreadable" || yaml.state === "conflict" && profile.entry?.kind !== "scalar") {
516
+ actions.push({
517
+ name: profile.name,
518
+ action: "refused",
519
+ ...yaml.detail === void 0 ? {} : { detail: yaml.detail }
520
+ });
521
+ continue;
522
+ }
523
+ const next = yaml.state === "conflict" ? profile.yamlText.slice(0, profile.entry.valueStart) + "copy" + profile.yamlText.slice(profile.entry.valueEnd) : profile.yamlText === void 0 ? "packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\npackageImportMethod: copy\n" : `${profile.yamlText.endsWith("\n") ? profile.yamlText : `${profile.yamlText}\n`}packageImportMethod: copy\n`;
524
+ try {
525
+ writeAtomically(profile.yamlPath, next);
526
+ actions.push({
527
+ name: profile.name,
528
+ action: yaml.state === "conflict" ? "replaced" : "written",
529
+ detail: "packageImportMethod: copy"
530
+ });
531
+ } catch (error) {
532
+ actions.push({
533
+ name: profile.name,
534
+ action: "refused",
535
+ detail: `write failed: ${describeError(error)}`
536
+ });
537
+ }
538
+ }
539
+ return {
540
+ ...toReport(inspected),
541
+ actions
542
+ };
543
+ }
544
+ /**
545
+ * Atomically replace (or create) a `pnpm-workspace.yaml`: the new text lands
546
+ * in a sibling temp file which is renamed into place, so a failure leaves the
547
+ * original untouched. A reparse point or non-file at the target is refused —
548
+ * the write never crosses it.
549
+ */
550
+ function writeAtomically(path, text) {
551
+ let target;
552
+ try {
553
+ target = lstatSync(path);
554
+ } catch {
555
+ target = void 0;
556
+ }
557
+ if (target !== void 0 && (target.isSymbolicLink() || !target.isFile())) throw new Error(`refusing to write through ${path}: not a plain file`);
558
+ const temp = `${path}.mrw-${process.pid}.tmp`;
559
+ const descriptor = openSync(temp, "wx");
560
+ try {
561
+ try {
562
+ writeFileSync(descriptor, text);
563
+ } finally {
564
+ closeSync(descriptor);
565
+ }
566
+ renameSync(temp, path);
567
+ } catch (error) {
568
+ try {
569
+ rmSync(temp, { force: true });
570
+ } catch {}
571
+ throw error;
572
+ }
573
+ }
574
+ const isInside = (path, dir) => path === dir || path.startsWith(dir.endsWith("/") ? dir : `${dir}/`) || path.startsWith(dir.endsWith("\\") ? dir : `${dir}\\`);
575
+ const isPlainDir = (path) => {
576
+ try {
577
+ return lstatSync(path).isDirectory() && !lstatSync(path).isSymbolicLink();
578
+ } catch {
579
+ return false;
580
+ }
581
+ };
582
+ const powershellQuote = (value) => `'${value.replaceAll("'", "''")}'`;
583
+ /**
584
+ * The remediation steps the user must run by hand — this command writes
585
+ * configuration only. For every confirmed profile whose dependencies still
586
+ * need re-installing as copies (config changed under it, or links were seen
587
+ * inside it), the steps say: quit the hosts using it, remove its
588
+ * `node_modules` yourself, reinstall from its lockfile. Refused profiles get
589
+ * a manual-resolution line instead of automated steps.
590
+ * @param report - the {@link ApplyReport} (or a check report) to explain.
591
+ * @returns the guidance lines, in English.
592
+ */
593
+ function remediationSteps(report) {
594
+ const actions = "actions" in report ? report.actions : void 0;
595
+ const actionOf = (name) => actions?.find((action) => action.name === name)?.action;
596
+ const needsWork = [];
597
+ const manual = [];
598
+ for (const profile of report.profiles) {
599
+ const action = actionOf(profile.name);
600
+ if (action === "refused" || action === void 0 && profile.yaml.state === "unreadable" || action === void 0 && profile.yaml.state === "conflict") {
601
+ manual.push(profile);
602
+ continue;
603
+ }
604
+ const nodeModules = join(profile.path, "node_modules");
605
+ const hasModules = isPlainDir(nodeModules);
606
+ const linksSeen = report.scan.multiLinkPaths.some((path) => isInside(path, nodeModules)) || hasModules && report.scan.multiLinkTotal > report.scan.multiLinkPaths.length;
607
+ if (!hasModules && !linksSeen) continue;
608
+ needsWork.push(profile);
609
+ }
610
+ const lines = [];
611
+ if (needsWork.length > 0) {
612
+ lines.push("Profiles that still carry hardlinked dependencies — configuration is set, the objects on disk are not yet copies:");
613
+ for (const profile of needsWork) {
614
+ lines.push(` profile ${JSON.stringify(profile.name)} — quit every DSH host using it, then in PowerShell:`);
615
+ lines.push(` Remove-Item -Recurse -Force ${powershellQuote(join(profile.path, "node_modules"))}`);
616
+ lines.push(` pnpm --dir ${powershellQuote(profile.path)} install`);
617
+ }
618
+ lines.push(" The command never deletes node_modules and never runs pnpm — the reinstall is yours, per the profile lockfile, while no host is running.");
619
+ }
620
+ for (const profile of manual) lines.push(` profile ${JSON.stringify(profile.name)} was refused — resolve its ${WORKSPACE_YAML} by hand, then apply the same reinstall steps.`);
621
+ lines.push("Setting the config does not isolate existing dependencies: hardlinks stay hardlinks until the reinstall replaces them.");
622
+ lines.push("Copy import does not repair Administrators ownership or deny ACEs already written onto the pnpm store or other projects; review those separately — no automatic reset exists.");
623
+ lines.push("Profiles created later need the same configuration — re-run this check after adding one.");
624
+ return lines;
625
+ }
626
+ //#endregion
627
+ //#region src/command.ts
628
+ /**
629
+ * The user-facing surface: the `/workspace-folders` command and the panel's
630
+ * RPC channel.
631
+ *
632
+ * They share one module because they are one thing — the operator-facing edge
633
+ * over `ctx.multiRootRegistry`. The command serves the text surfaces (the
634
+ * composer, headless diagnostics) and the channel serves the browser panel;
635
+ * both resolve the same canonical workspace root, call the same registry, and
636
+ * report the same `RootValidationCode` values.
637
+ *
638
+ * Both are also the two places a READ happens, so both go through the same
639
+ * revalidation entry point: `registry.refresh()` re-stats every registered
640
+ * directory, re-resolves it against the directory it was granted for, and
641
+ * republishes the scope BEFORE the list is rendered. Listing is therefore
642
+ * enough to notice that a directory disappeared (and enough to bring a
643
+ * restored one back) — which is what the panel's "Retry" button and the
644
+ * command's `list` both promise.
645
+ *
646
+ * Both halves are SOFT: the command registers only where a command registry is
647
+ * composed, and the channel only where a host Connection and web server exist
648
+ * (web), so a headless profile mounts this row with zero effect on the
649
+ * providers.
650
+ *
651
+ * The command's own text is English: a host-side command handler has no active
652
+ * locale to consult (the browser owns locale state). The panel is bilingual.
653
+ * That split is recorded in the M3 plan and the requirements as a known limit.
654
+ *
655
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/command
656
+ */
657
+ /** Services this row needs before it may activate. */
658
+ const inject = [
659
+ "commands",
660
+ "sandboxPolicy",
661
+ "multiRootRegistry"
662
+ ];
663
+ /** The command name, without the leading slash. */
664
+ const COMMAND_NAME = "workspace-folders";
665
+ /**
666
+ * Parse the text that follows `/workspace-folders`.
667
+ * @param rawInput - the verbatim `rawInput` of the command invocation.
668
+ * @returns the subcommand and its remaining text.
669
+ * @throws {RootValidationError} `invalid-ref` for an unknown subcommand.
670
+ */
671
+ function parseFoldersCommand(rawInput) {
672
+ const trimmed = rawInput.trim();
673
+ if (trimmed === "") return {
674
+ verb: "list",
675
+ rest: ""
676
+ };
677
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed);
678
+ const verb = match?.[1] ?? "";
679
+ const rest = (match?.[2] ?? "").trim();
680
+ switch (verb) {
681
+ case "list":
682
+ case "add":
683
+ case "remove":
684
+ case "alias":
685
+ case "access":
686
+ case "reveal":
687
+ case "isolate-profiles":
688
+ case "help": return {
689
+ verb,
690
+ rest
691
+ };
692
+ default: throw new RootValidationError("invalid-ref", `unknown subcommand "${verb}"; try /${COMMAND_NAME} help`);
693
+ }
694
+ }
695
+ /** Strip one layer of matching quotes from an operator-supplied path. */
696
+ function unquote(text) {
697
+ if (text.length >= 2) {
698
+ const first = text[0];
699
+ const last = text[text.length - 1];
700
+ if (first === "\"" && last === "\"" || first === "'" && last === "'") return text.slice(1, -1);
701
+ }
702
+ return text;
703
+ }
704
+ /**
705
+ * Render the roots report the command returns. Additional roots are numbered
706
+ * from 1, which is the numbering every other subcommand accepts; the workspace
707
+ * root is never numbered because it cannot be removed.
708
+ * @param primaryRoot - the canonical workspace root.
709
+ * @param statuses - the registry's status list.
710
+ * @param unavailable - the store failure, when the registry could not read it.
711
+ * @returns the report text.
712
+ */
713
+ function renderRootsReport(primaryRoot, statuses, unavailable, limit, mode) {
714
+ const lines = [`Workspace root (primary; access follows the current sandbox mode): ${primaryRoot}`];
715
+ if (unavailable !== void 0) {
716
+ lines.push(`Root registry unavailable: ${unavailable}`);
717
+ return lines.join("\n");
718
+ }
719
+ if (statuses.length === 0) {
720
+ lines.push(`No additional roots. Add one with /${COMMAND_NAME} add <absolute path>.`);
721
+ return lines.join("\n");
722
+ }
723
+ for (const [index, status] of statuses.entries()) {
724
+ const alias = status.alias === void 0 ? "" : ` [${status.alias}]`;
725
+ const state = status.state === "available" ? "" : ` (${status.state}: ${status.detail ?? "unavailable"})`;
726
+ lines.push(` ${index + 1} ${status.path}${alias} (${status.access})${state}`);
727
+ }
728
+ const withheld = statuses.length - availableRoots(statuses).length;
729
+ if (withheld > 0) lines.push(`${withheld} root(s) are registered but not writable right now; restore the directory and run /${COMMAND_NAME} list, or remove the entry.`);
730
+ if (limit !== void 0) {
731
+ lines.push(`${statuses.length} of ${limit} additional roots.`);
732
+ if (statuses.length > limit) lines.push(`Warning: the store exceeds the configured limit of ${limit}; every stored root stays granted, only new adds are refused.`);
733
+ }
734
+ const writable = statuses.filter((status) => status.state === "available" && status.access !== "read-only" && (mode !== "read-only" || status.access === "read-write")).length;
735
+ lines.push(`Writable additional roots${mode === void 0 ? "" : " under the current mode"}: ${writable} of ${statuses.length}.`);
736
+ return lines.join("\n");
737
+ }
738
+ /**
739
+ * Register the command and, where a host Connection exists, the panel channel.
740
+ * @param ctx - the host context carrying `commands`, `sandboxPolicy`, and the registry.
741
+ * @param config - the row's config; `dshHome` feeds `isolate-profiles` only.
742
+ */
743
+ function apply(ctx, config = {}) {
744
+ ctx.commands.register({
745
+ name: COMMAND_NAME,
746
+ description: "List, add, remove, alias, or reveal the additional workspace roots of this session, or isolate DSH profile imports",
747
+ input: { hint: "[list|add [path] [--inherit|--read-only|--read-write]|remove <n|path>|alias <n|path> [name]|access <n|path> <level>|reveal <n|path>|isolate-profiles [--apply]|help]" },
748
+ handler: async (invocation) => await runCommand(ctx, invocation, config)
749
+ });
750
+ ctx.inject(["connection", "webServer"], (connectionCtx) => {
751
+ const connection = connectionCtx.root.get("connection");
752
+ if (connection === void 0) {
753
+ ctx.logger.warn("multi-root: connection resolved without the service; the panel channel is not mounted");
754
+ return;
755
+ }
756
+ const dispose = connection.rpc.handle(PANEL_CHANNEL, async (endpoint, payload) => await dispatchPanelRequest(ctx, endpoint, payload));
757
+ return () => {
758
+ dispose();
759
+ };
760
+ });
761
+ }
762
+ /** Execute one `/workspace-folders` invocation. */
763
+ async function runCommand(ctx, invocation, config) {
764
+ let parsed;
765
+ try {
766
+ parsed = parseFoldersCommand(invocation.rawInput);
767
+ } catch (error) {
768
+ return {
769
+ kind: "error",
770
+ text: failureText(error)
771
+ };
772
+ }
773
+ const policy = ctx.sandboxPolicy.resolve({ session: invocation.agent.session });
774
+ const primaryRoot = policy.workspaceRoot;
775
+ const registry = ctx.multiRootRegistry;
776
+ try {
777
+ switch (parsed.verb) {
778
+ case "help": return {
779
+ kind: "success",
780
+ text: helpText()
781
+ };
782
+ case "list": return {
783
+ kind: "success",
784
+ text: await report(registry, primaryRoot, policy.mode)
785
+ };
786
+ case "add": {
787
+ const { text: rest, access } = splitAddAccess(parsed.rest);
788
+ const typed = unquote(rest);
789
+ const path = typed === "" ? await pickRootPath(ctx, invocation.signal) : typed;
790
+ if (path === void 0) return {
791
+ kind: "error",
792
+ text: `/${COMMAND_NAME}: no directory was selected; pass an absolute path or use the Workspace Folders panel`
793
+ };
794
+ await registry.add(primaryRoot, {
795
+ path,
796
+ ...access === void 0 ? {} : { access }
797
+ });
798
+ return {
799
+ kind: "success",
800
+ text: await report(registry, primaryRoot, policy.mode)
801
+ };
802
+ }
803
+ case "remove": {
804
+ const { ref } = await targetOf(registry, primaryRoot, parsed.rest);
805
+ await registry.removeAt(primaryRoot, ref);
806
+ return {
807
+ kind: "success",
808
+ text: await report(registry, primaryRoot, policy.mode)
809
+ };
810
+ }
811
+ case "alias": {
812
+ const { text: reference, rest: alias } = splitReference(parsed.rest);
813
+ const { ref } = await targetOf(registry, primaryRoot, reference);
814
+ await registry.setAlias(primaryRoot, ref, alias === "" ? void 0 : unquote(alias));
815
+ return {
816
+ kind: "success",
817
+ text: await report(registry, primaryRoot, policy.mode)
818
+ };
819
+ }
820
+ case "access": {
821
+ const { text: reference, rest: level } = splitReference(parsed.rest);
822
+ const access = parseAccessLevel(level);
823
+ const { ref } = await targetOf(registry, primaryRoot, reference);
824
+ await registry.setAccess(primaryRoot, ref, access);
825
+ return {
826
+ kind: "success",
827
+ text: await report(registry, primaryRoot, policy.mode)
828
+ };
829
+ }
830
+ case "reveal": {
831
+ const { status: target } = await targetOf(registry, primaryRoot, parsed.rest);
832
+ await revealRoot(ctx, target.path);
833
+ return {
834
+ kind: "success",
835
+ text: `revealed ${target.path}`
836
+ };
837
+ }
838
+ case "isolate-profiles": {
839
+ if (parsed.rest !== "" && parsed.rest !== "--apply") throw new RootValidationError("invalid-ref", `isolate-profiles accepts only --apply or nothing, got "${parsed.rest}"`);
840
+ const home = isolateHome(config);
841
+ return {
842
+ kind: "success",
843
+ text: renderIsolateReport(parsed.rest === "--apply" ? applyIsolateConfig(home) : isolateReport(home))
844
+ };
845
+ }
846
+ }
847
+ } catch (error) {
848
+ return {
849
+ kind: "error",
850
+ text: failureText(error)
851
+ };
852
+ }
853
+ }
854
+ /**
855
+ * The current report for one primary root, after re-checking every registered
856
+ * directory. The refresh is what makes the report describe the present rather
857
+ * than the moment the process started.
858
+ */
859
+ async function report(registry, primaryRoot, mode) {
860
+ const statuses = await registry.refresh(primaryRoot);
861
+ return renderRootsReport(canonicalRoot(primaryRoot), statuses, registry.unavailable, registry.limit, mode);
862
+ }
863
+ /** Render a failure the way the composer displays it. */
864
+ function failureText(error) {
865
+ if (error instanceof RootValidationError) return `/${COMMAND_NAME}: ${error.code}: ${error.message}`;
866
+ return `/${COMMAND_NAME}: ${error instanceof Error ? error.message : String(error)}`;
867
+ }
868
+ /**
869
+ * The DSH home `isolate-profiles` inspects — the row config's `dshHome`,
870
+ * an absolute path. Missing or invalid is a verb failure, not a guess: the
871
+ * home is the protected root the deny writer scans, and a wrong guess would
872
+ * either read the wrong tree or pretend a home was checked when none was.
873
+ */
874
+ function isolateHome(config) {
875
+ const home = config.dshHome;
876
+ if (typeof home !== "string" || !isAbsolute(home)) throw new RootValidationError("config-invalid", `isolate-profiles needs this row's dshHome config to be an absolute path, got ${JSON.stringify(home)}`);
877
+ return home;
878
+ }
879
+ /**
880
+ * Render the `isolate-profiles` report: one line per confirmed profile, the
881
+ * whole-home scan counts, then — on `--apply` — the per-profile action and
882
+ * the remediation steps. An incomplete scan is always called out, because an
883
+ * uninspectable entry is not evidence the home is clean.
884
+ */
885
+ function renderIsolateReport(report) {
886
+ const lines = [`/${COMMAND_NAME} isolate-profiles — pnpm import isolation for the DSH home`, `DSH home: ${report.home}`];
887
+ if (report.profiles.length === 0) lines.push("No confirmed profiles under profiles/ (a profile is a directory whose package.json has dsh.profile as an object).");
888
+ for (const profile of report.profiles) {
889
+ const yaml = profile.yaml;
890
+ const state = yaml.state === "copy" ? "packageImportMethod: copy — isolated on the next install" : yaml.state === "absent" ? "packageImportMethod is unset — pnpm defaults to hardlinks" : yaml.state === "conflict" ? `conflict: ${yaml.detail ?? "needs a manual fix"}` : `unreadable: ${yaml.detail ?? "cannot be read"}`;
891
+ lines.push(` ${profile.name}: ${state}`);
892
+ }
893
+ if (report.nonProfileEntries.length > 0) {
894
+ lines.push(`${report.nonProfileEntries.length} entr(ies) under profiles/ are not confirmed profiles and were left untouched:`);
895
+ for (const entry of report.nonProfileEntries) lines.push(` ${entry}`);
896
+ }
897
+ const scan = report.scan;
898
+ lines.push(`Home scan: scanned ${scan.directories} dirs/${scan.files} files, ${scan.multiLinkTotal} multi-link files, ${scan.uninspectableTotal} uninspectable`);
899
+ for (const path of scan.multiLinkPaths) lines.push(` multi-link: ${path}`);
900
+ if (scan.multiLinkTotal > scan.multiLinkPaths.length) lines.push(` … and ${scan.multiLinkTotal - scan.multiLinkPaths.length} more multi-link file(s)`);
901
+ for (const entry of scan.uninspectable) lines.push(` uninspectable: ${entry.path} (${entry.reason})`);
902
+ if (report.scan.uninspectableTotal > scan.uninspectable.length) lines.push(` … and ${scan.uninspectableTotal - scan.uninspectable.length} more uninspectable entr(ies)`);
903
+ if (report.incomplete) lines.push(`INCOMPLETE: ${scan.uninspectableTotal} entr(ies) could not be inspected — this report must not be read as clean.`);
904
+ if (report.profiles.filter((profile) => profile.yaml.state === "unreadable" || profile.yaml.state === "conflict").length > 0) lines.push("Profiles listed as conflict/unreadable are refused by --apply: fix or remove their pnpm-workspace.yaml by hand, then re-run.");
905
+ if ("actions" in report) {
906
+ lines.push("Apply results:");
907
+ for (const action of report.actions) lines.push(` ${action.name}: ${action.action}${action.detail === void 0 ? "" : ` — ${action.detail}`}`);
908
+ lines.push(...remediationSteps(report));
909
+ } else {
910
+ lines.push(`This was a read-only check; run /${COMMAND_NAME} isolate-profiles --apply to write packageImportMethod: copy into every fixable profile.`);
911
+ lines.push("A configured profile still needs its dependencies reinstalled to become copies — the check reports, --apply configures, the reinstall is yours.");
912
+ }
913
+ return lines.join("\n");
914
+ }
915
+ /** The command's own help text. */
916
+ function helpText() {
917
+ return [
918
+ `/${COMMAND_NAME} — additional workspace roots of this session`,
919
+ ` /${COMMAND_NAME} list`,
920
+ ` /${COMMAND_NAME} add [absolute path] [--inherit|--read-only|--read-write] (no path opens the directory picker when one is composed)`,
921
+ ` /${COMMAND_NAME} remove <n|path>`,
922
+ ` /${COMMAND_NAME} alias <n|path> [name] (no name clears the alias)`,
923
+ ` /${COMMAND_NAME} access <n|path> inherit|read-only|read-write`,
924
+ ` /${COMMAND_NAME} reveal <n|path>`,
925
+ ` /${COMMAND_NAME} isolate-profiles [--apply] (report or set packageImportMethod: copy for every profile under $DSH_HOME)`,
926
+ "Roots are canonicalized before they are stored: `~` expands, and a duplicate, a nested directory,",
927
+ "or this session's own workspace root is rejected.",
928
+ "Access levels: `inherit` follows the session mode; `read-only` refuses writes under every mode;",
929
+ "`read-write` stays writable even in a read-only session.",
930
+ "`isolate-profiles` exists because a Windows deny ACE applies to the file object, not one hardlink name:",
931
+ "profiles whose dependencies are hardlinked into the pnpm store make `$DSH_HOME` unsafe to deny;",
932
+ "the check scans the whole home (links > 1, uninspectable entries) and --apply rewrites the yaml atomically,",
933
+ "but the existing hardlinks are replaced only by the reinstall the report prints."
934
+ ].join("\n");
935
+ }
936
+ /**
937
+ * Split `add`'s argument into the path text and the access flag. Either flag
938
+ * is accepted as the first or the last token, so a path containing the flag
939
+ * spelling (quoted or unquoted) is never mistaken for the flag.
940
+ *
941
+ * The access is TRI-STATE on purpose: it stays `undefined` when the operator
942
+ * named no level. `registry.add` revives a withheld record with its stored
943
+ * access unless a level is given explicitly, so forwarding a default here
944
+ * would silently change the registration's authority.
945
+ */
946
+ function splitAddAccess(text) {
947
+ const trimmed = text.trim();
948
+ for (const access of [
949
+ "read-only",
950
+ "read-write",
951
+ "inherit"
952
+ ]) {
953
+ const flag = `--${access}`;
954
+ if (trimmed === flag) return {
955
+ text: "",
956
+ access
957
+ };
958
+ if (trimmed.startsWith(`${flag} `) || trimmed.startsWith(`${flag}\t`)) return {
959
+ text: trimmed.slice(flag.length).trim(),
960
+ access
961
+ };
962
+ if (new RegExp(`\\s${flag}$`).test(trimmed)) return {
963
+ text: trimmed.slice(0, -flag.length).trim(),
964
+ access
965
+ };
966
+ }
967
+ return {
968
+ text: trimmed,
969
+ access: void 0
970
+ };
971
+ }
972
+ /** Parse the access level an `access` subcommand must end with. */
973
+ function parseAccessLevel(text) {
974
+ const trimmed = text.trim();
975
+ if (trimmed === "read-only" || trimmed === "read-write" || trimmed === "inherit") return trimmed;
976
+ throw new RootValidationError("invalid-ref", `the access level must be inherit, read-only, or read-write, got "${trimmed || "nothing"}"`);
977
+ }
978
+ /**
979
+ * Split `alias`'s argument into a reference and the alias text.
980
+ *
981
+ * The reference is the first whitespace-delimited token, except when it opens
982
+ * with a quote: a quoted path may contain spaces, so the whole quoted span is
983
+ * the reference and the remainder is the alias.
984
+ */
985
+ function splitReference(text) {
986
+ const trimmed = text.trim();
987
+ if (trimmed === "") throw new RootValidationError("invalid-ref", "a root reference is required");
988
+ if (trimmed.startsWith("\"") || trimmed.startsWith("'")) {
989
+ const quote = trimmed[0] ?? "";
990
+ const end = trimmed.indexOf(quote, 1);
991
+ if (end > 0) return {
992
+ text: trimmed.slice(0, end + 1),
993
+ rest: trimmed.slice(end + 1).trim()
994
+ };
995
+ }
996
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed);
997
+ return {
998
+ text: match?.[1] ?? "",
999
+ rest: (match?.[2] ?? "").trim()
1000
+ };
1001
+ }
1002
+ /**
1003
+ * Resolve an operator reference — a 1-based number, a path, or an id — to the
1004
+ * registered root it names.
1005
+ * @param registry - the registry to read.
1006
+ * @param primaryRoot - the workspace root the registrations belong to.
1007
+ * @param text - the operator's text.
1008
+ * @returns the matched status, after re-checking the registered directories.
1009
+ * @throws {RootValidationError} `invalid-ref` for empty text, `not-found` when nothing matches.
1010
+ */
1011
+ async function targetOf(registry, primaryRoot, text) {
1012
+ const trimmed = text.trim();
1013
+ if (trimmed === "") throw new RootValidationError("invalid-ref", "a root reference is required");
1014
+ const statuses = await registry.refresh(primaryRoot);
1015
+ let status;
1016
+ if (/^\d+$/.test(trimmed)) {
1017
+ const ordinal = Number(trimmed);
1018
+ status = resolveRootRef(statuses, {
1019
+ kind: "ordinal",
1020
+ ordinal
1021
+ });
1022
+ return {
1023
+ status,
1024
+ ref: entryRootRef(status, ordinal)
1025
+ };
1026
+ }
1027
+ const asPath = unquote(trimmed);
1028
+ if (asPath === "~" || asPath.startsWith("~") || asPath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(asPath)) status = resolveRootRef(statuses, {
1029
+ kind: "path",
1030
+ path: asPath
1031
+ });
1032
+ else status = resolveRootRef(statuses, {
1033
+ kind: "id",
1034
+ id: trimmed
1035
+ });
1036
+ const ordinal = statuses.indexOf(status) + 1;
1037
+ return {
1038
+ status,
1039
+ ref: entryRootRef(status, ordinal)
1040
+ };
1041
+ }
1042
+ /**
1043
+ * Derive the primary root a panel request may act on.
1044
+ *
1045
+ * The browser names a live session; the host looks that session up and uses
1046
+ * its immutable cwd. There is no client-supplied path and no deployment-default
1047
+ * fallback: a panel call without a real host session has no workspace to
1048
+ * manage. Property access would demand an injected `sessions` dependency this
1049
+ * row does not name, so the lookup stays an explicit sibling `get` (the same
1050
+ * pattern as `directoryPicker` / `subprocess`).
1051
+ *
1052
+ * @param ctx - the host context that may carry `sessions`.
1053
+ * @param sessionId - the id the panel sent; already required by the wire schema.
1054
+ * @returns the canonical cwd of that session.
1055
+ * @throws {RootValidationError} `session-not-found` when the service is absent,
1056
+ * the id does not name a live session, or that session has no cwd.
1057
+ */
1058
+ function resolvePanelPrimaryRoot(ctx, sessionId) {
1059
+ const session = ctx.get("sessions")?.get(sessionId);
1060
+ const cwd = session?.header.cwd;
1061
+ if (session === void 0 || cwd === void 0 || cwd === "") throw new RootValidationError("session-not-found", `session "${sessionId}" is not an active host session`, { reference: sessionId });
1062
+ return canonicalPath(cwd);
1063
+ }
1064
+ /** Project one status into the panel's view. */
1065
+ function toRootView(status, index) {
1066
+ return {
1067
+ ordinal: index + 1,
1068
+ id: status.id,
1069
+ path: status.path,
1070
+ ...status.alias === void 0 ? {} : { alias: status.alias },
1071
+ addedAt: status.addedAt,
1072
+ state: status.state,
1073
+ access: status.access,
1074
+ ...status.detail === void 0 ? {} : { detail: status.detail }
1075
+ };
1076
+ }
1077
+ /**
1078
+ * Resolve the display name of the primary root from the host's workspace
1079
+ * registry — the title the operator set in the workspace browser, falling back
1080
+ * to nothing (the panel then shows the path's basename).
1081
+ *
1082
+ * The registry is an optional sibling service, read through the explicit
1083
+ * lookup like `sessions`/`subprocess`: property access would demand an
1084
+ * injected dependency this row does not name. Membership wins over cwd, which
1085
+ * mirrors the host's own session→workspace resolution.
1086
+ */
1087
+ async function primaryNameOf(ctx, primaryRoot, request) {
1088
+ const registry = ctx.get("workspaceRegistry");
1089
+ if (registry === void 0) return void 0;
1090
+ try {
1091
+ const sessionId = request.sessionId;
1092
+ if (sessionId !== void 0 && sessionId !== "") {
1093
+ const member = registry.list().find((workspace) => workspace.sessionIds.includes(sessionId));
1094
+ if (member !== void 0) return member.title;
1095
+ }
1096
+ return (await registry.resolveByPath(primaryRoot))?.title;
1097
+ } catch (error) {
1098
+ ctx.logger.warn("multi-root: workspace title lookup failed; the panel falls back to the path basename", error);
1099
+ return;
1100
+ }
1101
+ }
1102
+ /**
1103
+ * Build the panel's view of one primary root, after re-checking every
1104
+ * registered directory — the panel's read path is the same revalidation the
1105
+ * command uses, so "the list the operator sees" is always the list the scope
1106
+ * currently enforces.
1107
+ */
1108
+ async function rootsViewOf(ctx, registry, primaryRoot, request) {
1109
+ const statuses = await registry.refresh(primaryRoot);
1110
+ const unavailable = registry.unavailable;
1111
+ const primaryName = await primaryNameOf(ctx, primaryRoot, request);
1112
+ const session = ctx.get("sessions")?.get(request.sessionId);
1113
+ const effectiveCwd = session === void 0 ? void 0 : effectiveDirectory(ctx, session);
1114
+ if (session === void 0) throw new RootValidationError("session-not-found", "the panel session closed during the request");
1115
+ const policy = ctx.sandboxPolicy.resolve({ session });
1116
+ const scope = ctx.get("multiRootScope");
1117
+ return {
1118
+ primaryRoot: canonicalRoot(primaryRoot),
1119
+ ...primaryName === void 0 ? {} : { primaryName },
1120
+ ...effectiveCwd === void 0 ? {} : { effectiveCwd },
1121
+ mode: policy.mode,
1122
+ cappedTools: scope?.toolCaps() ?? [],
1123
+ toolAccessInvalid: scope?.toolAccessInvalid ?? false,
1124
+ roots: statuses.map((status, index) => ({
1125
+ ...toRootView(status, index),
1126
+ effectiveAccess: status.state === "available" ? effectiveRootAccess(status.access, policy.mode) : "unavailable"
1127
+ })),
1128
+ limit: registry.limit,
1129
+ count: statuses.length,
1130
+ ...unavailable === void 0 ? {} : { unavailable }
1131
+ };
1132
+ }
1133
+ /**
1134
+ * Serve one panel endpoint.
1135
+ *
1136
+ * The payload is validated before anything acts on it: it arrived from a
1137
+ * browser, and the host is the side that must not be surprised by a shape it
1138
+ * did not expect (see `contract.ts`, which owns the schema both halves share).
1139
+ * A malformed request is reported as `panel/bad-request` rather than throwing
1140
+ * through the channel.
1141
+ */
1142
+ async function dispatchPanelRequest(ctx, endpoint, payload) {
1143
+ try {
1144
+ const parsed = parsePanelCall(endpoint, payload);
1145
+ if (!parsed.ok) return {
1146
+ ok: false,
1147
+ error: {
1148
+ code: "panel/bad-request",
1149
+ message: parsed.message,
1150
+ details: {}
1151
+ }
1152
+ };
1153
+ const request = parsed.value;
1154
+ const registry = ctx.multiRootRegistry;
1155
+ const primaryRoot = resolvePanelPrimaryRoot(ctx, request.sessionId);
1156
+ switch (request.endpoint) {
1157
+ case "list": return {
1158
+ ok: true,
1159
+ value: await rootsViewOf(ctx, registry, primaryRoot, request)
1160
+ };
1161
+ case "add":
1162
+ if (request.path === void 0 || request.path === "") throw new RootValidationError("missing", "a directory path is required");
1163
+ await registry.add(primaryRoot, {
1164
+ path: request.path,
1165
+ ...request.alias === void 0 ? {} : { alias: request.alias },
1166
+ ...request.access === void 0 ? {} : { access: request.access }
1167
+ });
1168
+ return {
1169
+ ok: true,
1170
+ value: await rootsViewOf(ctx, registry, primaryRoot, request)
1171
+ };
1172
+ case "setAccess":
1173
+ if (request.access === void 0) throw new RootValidationError("invalid-ref", "an access level is required");
1174
+ await registry.setAccess(primaryRoot, requireEntry(request), request.access);
1175
+ return {
1176
+ ok: true,
1177
+ value: await rootsViewOf(ctx, registry, primaryRoot, request)
1178
+ };
1179
+ case "remove":
1180
+ await registry.removeAt(primaryRoot, requireEntry(request));
1181
+ return {
1182
+ ok: true,
1183
+ value: await rootsViewOf(ctx, registry, primaryRoot, request)
1184
+ };
1185
+ case "alias":
1186
+ await registry.setAlias(primaryRoot, requireEntry(request), request.alias);
1187
+ return {
1188
+ ok: true,
1189
+ value: await rootsViewOf(ctx, registry, primaryRoot, request)
1190
+ };
1191
+ case "move":
1192
+ await registry.move(primaryRoot, requireEntry(request), request.beforeEntry !== void 0 ? entryRefOf(request.beforeEntry) : request.beforeId === void 0 ? void 0 : {
1193
+ kind: "id",
1194
+ id: request.beforeId
1195
+ });
1196
+ return {
1197
+ ok: true,
1198
+ value: await rootsViewOf(ctx, registry, primaryRoot, request)
1199
+ };
1200
+ case "reveal": {
1201
+ const ref = requireEntry(request);
1202
+ const target = resolveRootRef(await registry.refresh(primaryRoot), ref);
1203
+ await revealRoot(ctx, target.path);
1204
+ return {
1205
+ ok: true,
1206
+ value: { revealed: target.path }
1207
+ };
1208
+ }
1209
+ }
1210
+ } catch (error) {
1211
+ if (error instanceof RootValidationError) return {
1212
+ ok: false,
1213
+ error: {
1214
+ code: error.code,
1215
+ message: error.message,
1216
+ details: {}
1217
+ }
1218
+ };
1219
+ return {
1220
+ ok: false,
1221
+ error: {
1222
+ code: "panel/internal",
1223
+ message: error instanceof Error ? error.message : String(error),
1224
+ details: {}
1225
+ }
1226
+ };
1227
+ }
1228
+ }
1229
+ /** Convert a validated wire snapshot to the registry's exact-entry reference. */
1230
+ function entryRefOf(entry) {
1231
+ return {
1232
+ kind: "entry",
1233
+ ...entry
1234
+ };
1235
+ }
1236
+ /** Require the exact list row a mutating panel request must carry. */
1237
+ function requireEntry(request) {
1238
+ if (request.entry !== void 0) return entryRefOf(request.entry);
1239
+ if (request.id !== void 0 && request.id !== "") return {
1240
+ kind: "id",
1241
+ id: request.id
1242
+ };
1243
+ throw new RootValidationError("invalid-ref", "an exact root entry is required");
1244
+ }
1245
+ /**
1246
+ * Open the composed directory picker when one exists, and answer `undefined`
1247
+ * otherwise (the command then reports the missing affordance).
1248
+ *
1249
+ * Only the `native` capability can answer a host-side command: the `browse`
1250
+ * capability is an in-app dialog that only the browser panel can render.
1251
+ */
1252
+ async function pickRootPath(ctx, signal) {
1253
+ const picker = ctx.get("directoryPicker");
1254
+ if (picker === void 0) return void 0;
1255
+ const capability = picker.capability();
1256
+ if (capability.kind !== "native") return void 0;
1257
+ return await capability.pick(signal) ?? void 0;
1258
+ }
1259
+ /** The per-platform argv that reveals one directory in the OS file manager. */
1260
+ function revealArgv(platform, path) {
1261
+ switch (platform) {
1262
+ case "darwin": return [
1263
+ "open",
1264
+ "-R",
1265
+ path
1266
+ ];
1267
+ case "win32": return ["explorer", `/select,${path}`];
1268
+ default: return ["xdg-open", path];
1269
+ }
1270
+ }
1271
+ /**
1272
+ * Whether the file manager's exit code means the reveal was accepted.
1273
+ * `explorer.exe` answers 1 even when it successfully selected the item, so on
1274
+ * Windows both 0 and 1 are success; the other managers honor the usual 0.
1275
+ */
1276
+ function revealAccepted(platform, exitCode) {
1277
+ return exitCode === 0 || platform === "win32" && exitCode === 1;
1278
+ }
1279
+ /**
1280
+ * Reveal one directory in the OS file manager through `ctx.subprocess`.
1281
+ *
1282
+ * This runs no confined command and grants the agent nothing: it is an
1283
+ * operator action taken from the panel or the command line, and the process is
1284
+ * the platform's own file manager. Failures are reported to the caller.
1285
+ */
1286
+ async function revealRoot(ctx, path) {
1287
+ const subprocess = ctx.get("subprocess");
1288
+ if (subprocess === void 0) throw new RootValidationError("reveal-unavailable", "revealing a root needs a process runtime, which this composition does not mount");
1289
+ const [program = "", ...args] = revealArgv(process.platform, path);
1290
+ const executable = await subprocess.resolveExecutable(program);
1291
+ const handle = subprocess.spawn({
1292
+ argv: [executable, ...args],
1293
+ cwd: path,
1294
+ stdio: {
1295
+ stdin: "ignore",
1296
+ stdout: { maxBytes: 4096 },
1297
+ stderr: { maxBytes: 4096 }
1298
+ },
1299
+ graceMs: 3e3
1300
+ });
1301
+ const outcome = await handle.done;
1302
+ await handle.waitForExit();
1303
+ if (!revealAccepted(process.platform, outcome.exitCode)) throw new RootValidationError("reveal-unavailable", `the file manager exited with ${String(outcome.exitCode)}`);
1304
+ }
1305
+ //#endregion
1306
+ export { renderRootsReport as a, revealArgv as c, parseFoldersCommand as i, apply as n, resolvePanelPrimaryRoot as o, inject as r, revealAccepted as s, COMMAND_NAME as t };