@dsh-electron/dsh-plugin-multi-root-workspace 0.1.5 → 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.
- package/CHANGELOG.en.md +56 -1
- package/CHANGELOG.md +56 -1
- package/README.en.md +157 -92
- package/README.md +158 -93
- package/cordis.patch.yml +61 -0
- package/lib/client.js +2819 -115
- package/lib/client.js.map +1 -1
- package/lib/command-CESuwlVt.js +1306 -0
- package/lib/command.js +2 -2
- package/lib/{compat-82Y27Iom.js → compat-DF_yL1bB.js} +180 -4
- package/lib/compat.js +1 -1
- package/lib/contract-DZ4zczDT.js +238 -0
- package/lib/{fs-D_Sw3o5z.js → fs-9JIzGaf3.js} +55 -19
- package/lib/fs.js +2 -1
- package/lib/index.js +10 -8
- package/lib/{instructions-Cn1Jj5Xi.js → instructions-D19jRxN0.js} +109 -28
- package/lib/instructions.js +1 -1
- package/lib/lsp.js +43 -0
- package/lib/method-wrapper-CFY3p3j2.js +26 -0
- package/lib/process-fence.js +280 -0
- package/lib/providers.js +87 -0
- package/lib/{registry-DySMbjgW.js → registry-DCxC5Lay.js} +256 -35
- package/lib/registry.js +2 -2
- package/lib/{roots-DasWEBJk.js → roots-B-0l8mNG.js} +117 -20
- package/lib/sandbox-Kup4_ZIb.js +747 -0
- package/lib/sandbox.js +2 -1
- package/lib/scope.js +240 -25
- package/lib/session-directory-BLOstut1.js +9 -0
- package/lib/tool-context-CpgIFcCB.js +6 -0
- package/lib/types/client/RootSwitcher.d.ts +63 -0
- package/lib/types/client/WorkspaceFoldersAction.d.ts +41 -0
- package/lib/types/client/files-tree/FilesBody.module.css.d.ts +24 -0
- package/lib/types/client/files-tree/FilesTree.d.ts +47 -0
- package/lib/types/client/files-tree/adapter.d.ts +94 -0
- package/lib/types/client/files-tree/directory-node.d.ts +62 -0
- package/lib/types/client/files-tree/face.d.ts +107 -0
- package/lib/types/client/files-tree/locales.d.ts +10 -0
- package/lib/types/client/files-tree/primitives.d.ts +33 -0
- package/lib/types/client/files-tree/store.d.ts +81 -0
- package/lib/types/client/index.d.ts +13 -2
- package/lib/types/client/locales.d.ts +60 -0
- package/lib/types/client/open-in-app/OpenInAppAction.d.ts +27 -0
- package/lib/types/client/open-in-app/OpenTargetButton.d.ts +52 -0
- package/lib/types/client/open-in-app/OpenTargetButton.module.css.d.ts +29 -0
- package/lib/types/client/open-in-app/adapter.d.ts +46 -0
- package/lib/types/client/open-in-app/applications.d.ts +4 -0
- package/lib/types/client/open-in-app/controller.d.ts +53 -0
- package/lib/types/client/open-in-app/locales.d.ts +10 -0
- package/lib/types/client/open-in-app/open-failure-toast.d.ts +11 -0
- package/lib/types/client/open-in-app/open-path.d.ts +13 -0
- package/lib/types/client/panel-client.d.ts +2 -1
- package/lib/types/client/styles.d.ts +1 -1
- package/lib/types/command.d.ts +16 -3
- package/lib/types/compat/client-icons.d.ts +18 -0
- package/lib/types/compat/client-session.d.ts +2 -0
- package/lib/types/compat/dsh-version.d.ts +5 -3
- package/lib/types/compat/instruction-overlap.d.ts +7 -0
- package/lib/types/compat/open-in-app-routes.d.ts +38 -0
- package/lib/types/compat/process-entrypoints.d.ts +31 -0
- package/lib/types/compat/process-result.d.ts +14 -0
- package/lib/types/compat/pty-marker.d.ts +83 -0
- package/lib/types/compat/sandbox-confine.d.ts +2 -0
- package/lib/types/compat/session-directory.d.ts +4 -0
- package/lib/types/compat/storage-zod.d.ts +65 -0
- package/lib/types/compat/tool-context.d.ts +9 -0
- package/lib/types/compat/windows-deny-preflight.d.ts +21 -0
- package/lib/types/compat/windows-native.d.ts +68 -0
- package/lib/types/compat/windows-path-guard.d.ts +56 -0
- package/lib/types/compat/workspace-files.d.ts +7 -0
- package/lib/types/contract.d.ts +35 -4
- package/lib/types/dialects.d.ts +99 -3
- package/lib/types/fs.d.ts +32 -10
- package/lib/types/index.d.ts +5 -0
- package/lib/types/lsp.d.ts +4 -0
- package/lib/types/method-wrapper.d.ts +2 -0
- package/lib/types/process-fence.d.ts +54 -0
- package/lib/types/profile-isolation.d.ts +178 -0
- package/lib/types/providers.d.ts +70 -0
- package/lib/types/registry.d.ts +132 -31
- package/lib/types/roots.d.ts +100 -13
- package/lib/types/sandbox.d.ts +59 -12
- package/lib/types/scope.d.ts +203 -14
- package/lib/types/windows-broker.d.ts +17 -0
- package/lib/types/windows-deny.d.ts +1 -0
- package/lib/types/windows-profile.d.ts +10 -0
- package/lib/types/windows-runner-contract.d.ts +97 -0
- package/lib/types/windows-runner.d.ts +1 -0
- package/lib/types/workspace-files.d.ts +4 -0
- package/lib/types/workspace-routing.d.ts +11 -0
- package/lib/windows-runner-contract-CEfmZzu0.js +141 -0
- package/lib/windows-runner.js +1311 -0
- package/lib/workspace-files.js +203 -0
- package/lib/workspace-routing-YPxaQ4V3.js +26 -0
- package/package.json +116 -51
- package/scripts/windows-broker/install.ps1 +89 -0
- package/scripts/windows-broker/rights.cs +58 -0
- package/scripts/windows-broker/service.cs +323 -0
- package/scripts/windows-broker/verify-install.ps1 +80 -0
- package/lib/command-DyOWujPo.js +0 -690
- package/lib/sandbox-D8Fw95FM.js +0 -290
- 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 };
|