@sparkelf/dsh-plus 0.2.0-rc.9 → 0.2.1-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +62 -39
- package/lib/bin.js +866 -21
- package/lib/types/standalone-capabilities.d.ts +139 -0
- package/lib/types/standalone-capabilities.js +688 -0
- package/lib/types/standalone-cli.js +300 -18
- package/lib/types/standalone-profile.d.ts +45 -1
- package/lib/types/standalone-profile.js +84 -5
- package/package.json +115 -80
package/lib/bin.js
CHANGED
|
@@ -75,6 +75,565 @@ function newerVersion(name, installed, registry = "https://registry.npmjs.org")
|
|
|
75
75
|
return publishedVersions(name, registry).filter((entry) => semver.gt(entry.version, installed)).at(-1);
|
|
76
76
|
}
|
|
77
77
|
//#endregion
|
|
78
|
+
//#region lib/types/standalone-capabilities.js
|
|
79
|
+
/** Capabilities offered at install time, in interview order. */
|
|
80
|
+
const CAPABILITIES = [
|
|
81
|
+
{
|
|
82
|
+
id: "exa",
|
|
83
|
+
title: "Web search (Exa)",
|
|
84
|
+
detail: "Search the web through Exa instead of the built-in provider; needs an API key",
|
|
85
|
+
needsEndpoint: false
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "mineru",
|
|
89
|
+
title: "PDF parsing (MinerU)",
|
|
90
|
+
detail: "Parse uploaded PDFs locally; the service is installed and started here",
|
|
91
|
+
needsEndpoint: true
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "officecli",
|
|
95
|
+
title: "Office documents (OfficeCLI)",
|
|
96
|
+
detail: "Create and open DOCX, XLSX, and PPTX deliverables",
|
|
97
|
+
needsEndpoint: false
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "computer-use",
|
|
101
|
+
title: "Windows desktop control (computer-use)",
|
|
102
|
+
detail: "Drive the Windows desktop from WSL through the cua driver",
|
|
103
|
+
needsEndpoint: false
|
|
104
|
+
}
|
|
105
|
+
];
|
|
106
|
+
/** Where this command starts MinerU when the capability is enabled. */
|
|
107
|
+
const DEFAULT_MINERU_ENDPOINT = "http://127.0.0.1:8000/file_parse";
|
|
108
|
+
/** File under the deployment home recording which capabilities were enabled. */
|
|
109
|
+
const CAPABILITY_RECORD = "capabilities.json";
|
|
110
|
+
/** Deployment env file the launcher reads as its `user-env` layer. */
|
|
111
|
+
const CAPABILITY_ENV_FILE = ".env";
|
|
112
|
+
/** One prompt on the terminal, resolving the trimmed line the user typed. */
|
|
113
|
+
function ask(question) {
|
|
114
|
+
return new Promise((resolveAnswer) => {
|
|
115
|
+
process.stdout.write(question);
|
|
116
|
+
process.stdin.once("data", (chunk) => {
|
|
117
|
+
resolveAnswer(String(chunk).trim());
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** Ask one yes/no question, defaulting to yes. */
|
|
122
|
+
async function confirmDefaultYes(question) {
|
|
123
|
+
const answer = (await ask(question + " [Y/n] ")).toLowerCase();
|
|
124
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Interview the user about the optional capabilities.
|
|
128
|
+
*
|
|
129
|
+
* Each is offered on its own line and defaults to enabled, so pressing Enter
|
|
130
|
+
* through the interview produces a deployment whose features work. The answers are
|
|
131
|
+
* returned rather than applied, so a caller can report what it will do before it
|
|
132
|
+
* touches the profile.
|
|
133
|
+
*
|
|
134
|
+
* @param interactive - when false, every capability is enabled without asking.
|
|
135
|
+
* @returns the ids to enable and the values the enabled ones need.
|
|
136
|
+
*/
|
|
137
|
+
async function interviewCapabilities(interactive) {
|
|
138
|
+
if (!interactive) return { enabled: CAPABILITIES.map((capability) => capability.id) };
|
|
139
|
+
console.log("");
|
|
140
|
+
console.log("Optional capabilities. Press Enter to accept the default (all enabled),");
|
|
141
|
+
console.log("or answer n for any you do not want.");
|
|
142
|
+
console.log("");
|
|
143
|
+
const enabled = [];
|
|
144
|
+
for (const capability of CAPABILITIES) if (await confirmDefaultYes(" Enable " + capability.title + "? (" + capability.detail + ")")) enabled.push(capability.id);
|
|
145
|
+
let exaApiKey;
|
|
146
|
+
if (enabled.includes("exa")) {
|
|
147
|
+
console.log("");
|
|
148
|
+
console.log(" Exa needs an API key. Create one at https://dashboard.exa.ai/api-keys");
|
|
149
|
+
const typed = await ask(" Exa API key (leave empty to add it later): ");
|
|
150
|
+
if (typed !== "") exaApiKey = typed;
|
|
151
|
+
}
|
|
152
|
+
let mineruEndpoint;
|
|
153
|
+
if (enabled.includes("mineru")) {
|
|
154
|
+
const typed = await ask(" MinerU endpoint [http://127.0.0.1:8000/file_parse]: ");
|
|
155
|
+
mineruEndpoint = typed === "" ? DEFAULT_MINERU_ENDPOINT : typed;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
enabled,
|
|
159
|
+
...exaApiKey === void 0 ? {} : { exaApiKey },
|
|
160
|
+
...mineruEndpoint === void 0 ? {} : { mineruEndpoint }
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Names this module owns in the deployment env file.
|
|
165
|
+
*
|
|
166
|
+
* Only names the loader accepts here: a `DSH_`-prefixed variable is refused in any `.env` because
|
|
167
|
+
* `DSH_HOME` is itself bootstrap-only and the file could otherwise relocate the home it is read
|
|
168
|
+
* from. MinerU's endpoint therefore travels in the profile layer, which is also where the plugin
|
|
169
|
+
* reads it.
|
|
170
|
+
*/
|
|
171
|
+
const CAPABILITY_ENV_NAMES = ["EXA_API_KEY"];
|
|
172
|
+
/**
|
|
173
|
+
* The deployment env file, with one assignment per enabled capability that reaches
|
|
174
|
+
* its service through the launch environment.
|
|
175
|
+
*
|
|
176
|
+
* Exa reads its key from the launch environment, so a capability the user selected is enabled here
|
|
177
|
+
* as well as in the profile layer. Only the names this interview owns are rewritten: a
|
|
178
|
+
* deployment that keeps its own assignments in the same file keeps them, and
|
|
179
|
+
* deselecting a capability removes the assignment that turned it on.
|
|
180
|
+
*
|
|
181
|
+
* @param answers - the interview's answers.
|
|
182
|
+
* @param existing - the file's current text, when it already exists.
|
|
183
|
+
* @returns the environment file's text.
|
|
184
|
+
*/
|
|
185
|
+
function capabilityEnvironment(answers, existing = "") {
|
|
186
|
+
const enabled = new Set(answers.enabled);
|
|
187
|
+
const owned = new Set(CAPABILITY_ENV_NAMES);
|
|
188
|
+
const carried = /* @__PURE__ */ new Map();
|
|
189
|
+
for (const line of existing.split("\n")) {
|
|
190
|
+
const parsed = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
|
|
191
|
+
if (parsed?.[1] !== void 0 && owned.has(parsed[1])) carried.set(parsed[1], parsed[2] ?? "");
|
|
192
|
+
}
|
|
193
|
+
const kept = existing.split("\n").filter((line) => {
|
|
194
|
+
const name = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
|
|
195
|
+
return line.trim() !== "" && !line.trimStart().startsWith("#") && (name === void 0 || !owned.has(name));
|
|
196
|
+
});
|
|
197
|
+
const written = ["# Written by dsh-plus start from the capability interview. Enabling or", "# disabling a capability rewrites the lines below; other lines are kept."];
|
|
198
|
+
if (enabled.has("exa")) {
|
|
199
|
+
const key = answers.exaApiKey ?? carried.get("EXA_API_KEY");
|
|
200
|
+
if (key !== void 0 && key !== "") written.push("EXA_API_KEY=" + key);
|
|
201
|
+
}
|
|
202
|
+
return [...written, ...kept].join("\n") + "\n";
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The profile patch layer that turns the selected capabilities on.
|
|
206
|
+
*
|
|
207
|
+
* The file is data the profile's loader merges, so enabling a capability writes a
|
|
208
|
+
* row rather than editing the deployment. An empty selection still writes the file,
|
|
209
|
+
* because a layer that exists and mounts nothing is how a previously enabled
|
|
210
|
+
* capability is turned back off.
|
|
211
|
+
*
|
|
212
|
+
* The profile's own layer is a separate file with its own name: a deployment that
|
|
213
|
+
* configured something by hand keeps it, and the capability rows are rewritten
|
|
214
|
+
* whole on every start.
|
|
215
|
+
*
|
|
216
|
+
* @param answers - the interview's answers.
|
|
217
|
+
* @returns the patch layer's YAML text.
|
|
218
|
+
*/
|
|
219
|
+
/**
|
|
220
|
+
* The patch rows a previous version of this file carried, minus the ones this module owns.
|
|
221
|
+
*
|
|
222
|
+
* The module writes a fixed set of rows, so everything else in the file belongs to the
|
|
223
|
+
* deployment. A row starts at a top-level `- ` entry and runs to the next one, which keeps
|
|
224
|
+
* each row's own config and comments with it.
|
|
225
|
+
*
|
|
226
|
+
* @param existing - the file's current text.
|
|
227
|
+
* @returns the rows to write back after this module's own.
|
|
228
|
+
*/
|
|
229
|
+
function foreignPatchRows(existing) {
|
|
230
|
+
if (existing.trim() === "") return [];
|
|
231
|
+
const lines = existing.split("\n");
|
|
232
|
+
const rows = [];
|
|
233
|
+
let current = null;
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
if (/^-\s/.test(line)) {
|
|
236
|
+
if (current !== null) rows.push(current);
|
|
237
|
+
current = [line];
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (current !== null) current.push(line);
|
|
241
|
+
}
|
|
242
|
+
if (current !== null) rows.push(current);
|
|
243
|
+
const ownedHeads = new Set(["insert", "web"]);
|
|
244
|
+
return rows.filter((row) => {
|
|
245
|
+
const head = row[0] ?? "";
|
|
246
|
+
const id = /^-\s+id:\s*(\S+)/.exec(head)?.[1];
|
|
247
|
+
if (id !== void 0) return !ownedHeads.has(id);
|
|
248
|
+
if (/^-\s+insert:\s*$/.test(head)) {
|
|
249
|
+
const ownedChildren = new Set([
|
|
250
|
+
"web-search-exa",
|
|
251
|
+
"computer-use",
|
|
252
|
+
"computer-use-cua-driver-mcp",
|
|
253
|
+
"mineru"
|
|
254
|
+
]);
|
|
255
|
+
return row.filter((line, index) => {
|
|
256
|
+
if (index === 0) return false;
|
|
257
|
+
const childId = /^\s+-\s+id:\s*(\S+)/.exec(line)?.[1];
|
|
258
|
+
return childId === void 0 || !ownedChildren.has(childId);
|
|
259
|
+
}).some((line) => /^\s+-\s+/.test(line));
|
|
260
|
+
}
|
|
261
|
+
return true;
|
|
262
|
+
}).map((row) => row.join("\n").trimEnd());
|
|
263
|
+
}
|
|
264
|
+
function capabilityPatchLayer(answers, existing = "") {
|
|
265
|
+
const enabled = new Set(answers.enabled);
|
|
266
|
+
const rows = [];
|
|
267
|
+
if (enabled.has("exa")) rows.push(" - id: web-search-exa", " name: '@deepseek-ai/dsh-web-search-exa'", " config:", " searchType: auto", " numResults: 8");
|
|
268
|
+
if (enabled.has("computer-use")) rows.push(" - id: computer-use", " name: '@deepseek-ai/dsh-computer-use'", " - id: computer-use-cua-driver-mcp", " name: '@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp'");
|
|
269
|
+
if (enabled.has("officecli")) rows.push(" - id: officecli", " name: '@sparkelf/dsh-officecli'");
|
|
270
|
+
if (enabled.has("mineru")) rows.push(" - id: mineru", " name: '@sparkelf/dsh-mineru'", " config:", " endpoint: " + (answers.mineruEndpoint ?? "http://127.0.0.1:8000/file_parse"));
|
|
271
|
+
const lines = ["# Written by dsh-plus start from the capability interview. Enabling or disabling a capability", "# rewrites this file; edits here are replaced."];
|
|
272
|
+
const foreign = foreignPatchRows(existing);
|
|
273
|
+
if (rows.length > 0) lines.push("- insert:", ...rows);
|
|
274
|
+
else lines.push("[]");
|
|
275
|
+
if (enabled.has("exa")) lines.push("", "# Route web_search through Exa rather than the built-in provider.", "- id: web", " config:", " searchProvider: exa");
|
|
276
|
+
if (foreign.length > 0) lines.push("", "# Rows this file carried before the capability interview; kept verbatim.", ...foreign);
|
|
277
|
+
return lines.join("\n") + "\n";
|
|
278
|
+
}
|
|
279
|
+
/** Run one command, reporting a failure rather than throwing. */
|
|
280
|
+
function runStep(command, args) {
|
|
281
|
+
return spawnSync(command, [...args], { stdio: "inherit" }).status === 0;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The capability record a previous configured run wrote, or `undefined` when none exists.
|
|
285
|
+
*
|
|
286
|
+
* The record is what lets a later run report the deployment's answers instead of asking
|
|
287
|
+
* again. A malformed or unreadable file is treated as absent rather than fatal: it is a
|
|
288
|
+
* convenience cache, and refusing to boot over it would strand a deployment whose record
|
|
289
|
+
* was corrupted by an interrupted write.
|
|
290
|
+
*
|
|
291
|
+
* @param home - the deployment home holding the record.
|
|
292
|
+
* @returns the enabled ids, and the MinerU endpoint when the record carries one.
|
|
293
|
+
*/
|
|
294
|
+
function readCapabilityRecord(home) {
|
|
295
|
+
const path = join(home, CAPABILITY_RECORD);
|
|
296
|
+
let parsed;
|
|
297
|
+
try {
|
|
298
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
299
|
+
} catch {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
303
|
+
const record = parsed;
|
|
304
|
+
if (!Array.isArray(record.enabled)) return void 0;
|
|
305
|
+
return {
|
|
306
|
+
enabled: record.enabled.filter((id) => typeof id === "string"),
|
|
307
|
+
...typeof record.mineruEndpoint === "string" ? { mineruEndpoint: record.mineruEndpoint } : {}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The capabilities a deployment actually has mounted, read from the files the loader and
|
|
312
|
+
* the launch environment consume.
|
|
313
|
+
*
|
|
314
|
+
* A record is a cache of an earlier answer and can drift from the deployment — a profile
|
|
315
|
+
* layer edited by hand, a record deleted, a plugin removed from the distribution. What a
|
|
316
|
+
* capability is *doing* is decided by the profile patch rows and the env file, so that is
|
|
317
|
+
* what a report reads.
|
|
318
|
+
*
|
|
319
|
+
* @param profilePatchText - the profile layer's YAML text.
|
|
320
|
+
* @param envText - the deployment env file's text.
|
|
321
|
+
* @returns the ids found in the profile layer and the env names present.
|
|
322
|
+
*/
|
|
323
|
+
function detectEnabledCapabilities(profilePatchText, envText) {
|
|
324
|
+
const mounted = [];
|
|
325
|
+
if (/^\s*- id: web-search-exa\s*$/mu.test(profilePatchText)) mounted.push("exa");
|
|
326
|
+
if (/^\s*- id: mineru\s*$/mu.test(profilePatchText)) mounted.push("mineru");
|
|
327
|
+
if (/^\s*- id: officecli\s*$/mu.test(profilePatchText)) mounted.push("officecli");
|
|
328
|
+
if (/^\s*- id: computer-use\s*$/mu.test(profilePatchText)) mounted.push("computer-use");
|
|
329
|
+
const env = [];
|
|
330
|
+
for (const line of envText.split("\n")) {
|
|
331
|
+
const name = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
|
|
332
|
+
if (name !== void 0 && !env.includes(name)) env.push(name);
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
mounted,
|
|
336
|
+
env
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The patch row one capability mounts, as a list of YAML lines, or `undefined` when the
|
|
341
|
+
* capability is turned on by something other than a row this module owns.
|
|
342
|
+
*
|
|
343
|
+
* MinerU is enabled by a row too, but its endpoint is per-deployment, so the caller passes
|
|
344
|
+
* the answers rather than a fixed row.
|
|
345
|
+
*
|
|
346
|
+
* @param id - the capability id.
|
|
347
|
+
* @returns the insert payload lines, without the surrounding `- insert:` entry.
|
|
348
|
+
*/
|
|
349
|
+
function capabilityRows(id) {
|
|
350
|
+
if (id === "exa") return [
|
|
351
|
+
" - id: web-search-exa",
|
|
352
|
+
" name: '@deepseek-ai/dsh-web-search-exa'",
|
|
353
|
+
" config:",
|
|
354
|
+
" searchType: auto",
|
|
355
|
+
" numResults: 8"
|
|
356
|
+
];
|
|
357
|
+
if (id === "officecli") return [" - id: officecli", " name: '@sparkelf/dsh-officecli'"];
|
|
358
|
+
if (id === "computer-use") return [
|
|
359
|
+
" - id: computer-use",
|
|
360
|
+
" name: '@deepseek-ai/dsh-computer-use'",
|
|
361
|
+
" - id: computer-use-cua-driver-mcp",
|
|
362
|
+
" name: '@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp'"
|
|
363
|
+
];
|
|
364
|
+
}
|
|
365
|
+
/** The row ids a capability owns in the profile layer. */
|
|
366
|
+
function capabilityRowIds(id) {
|
|
367
|
+
if (id === "exa") return ["web-search-exa"];
|
|
368
|
+
if (id === "officecli") return ["officecli"];
|
|
369
|
+
if (id === "mineru") return ["mineru"];
|
|
370
|
+
if (id === "computer-use") return ["computer-use", "computer-use-cua-driver-mcp"];
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* One capability's effect, added to or removed from an existing profile layer without
|
|
375
|
+
* disturbing the rest of the file.
|
|
376
|
+
*
|
|
377
|
+
* `dsh-plus start` owns the whole layer and rewrites it from the interview's answers, which
|
|
378
|
+
* is correct for an install that answered once and never edits afterwards. A deployment
|
|
379
|
+
* that maintains its own rows in that same layer cannot be served that way: replacing the
|
|
380
|
+
* file drops every row the operator wrote. This edits the rows this module owns and leaves
|
|
381
|
+
* every other entry, comment, and blank line exactly where it was.
|
|
382
|
+
*
|
|
383
|
+
* @param existing - the layer's current YAML text.
|
|
384
|
+
* @param id - the capability to add or remove.
|
|
385
|
+
* @param enable - whether to add the capability.
|
|
386
|
+
* @param mineruEndpoint - the endpoint to write when enabling MinerU.
|
|
387
|
+
* @returns the layer's new text.
|
|
388
|
+
*/
|
|
389
|
+
function editCapabilityPatchLayer(existing, id, enable, mineruEndpoint) {
|
|
390
|
+
const withoutRows = dropOwnedRows(existing, capabilityRowIds(id), id === "exa");
|
|
391
|
+
const mineruRows = [
|
|
392
|
+
" - id: mineru",
|
|
393
|
+
" name: '@sparkelf/dsh-mineru'",
|
|
394
|
+
" config:",
|
|
395
|
+
" endpoint: " + (mineruEndpoint ?? "http://127.0.0.1:8000/file_parse")
|
|
396
|
+
];
|
|
397
|
+
const rows = id === "mineru" && enable ? mineruRows : capabilityRows(id);
|
|
398
|
+
const trimmed = (enable && rows !== void 0 ? insertCapabilityRows(withoutRows, rows, id === "exa") : withoutRows.text).replace(/\n+$/u, "");
|
|
399
|
+
return trimmed === "" ? "\n" : trimmed + "\n";
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Remove the rows one capability owns, and for Exa the `web` selector row that routes
|
|
403
|
+
* search to it.
|
|
404
|
+
*
|
|
405
|
+
* A row is dropped with the block that belongs to it: the `- id:` line plus its indented
|
|
406
|
+
* continuation lines, and any comment line directly above that documented it. Comment and
|
|
407
|
+
* blank lines separating it from the next entry stay, so the surrounding file keeps its
|
|
408
|
+
* shape.
|
|
409
|
+
*
|
|
410
|
+
* @param existing - the layer's current YAML text.
|
|
411
|
+
* @param ids - the row ids this capability owns.
|
|
412
|
+
* @param withSelector - whether the capability also owns the shared `web` selector row.
|
|
413
|
+
* @returns the remaining text and whether anything was actually removed.
|
|
414
|
+
*/
|
|
415
|
+
function dropOwnedRows(existing, ids, withSelector) {
|
|
416
|
+
const lines = existing.split("\n");
|
|
417
|
+
const out = [];
|
|
418
|
+
let removed = false;
|
|
419
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
420
|
+
const line = lines[index] ?? "";
|
|
421
|
+
const match = /^(\s*)- id:\s*(\S+)\s*$/.exec(line);
|
|
422
|
+
const isSelector = withSelector && match?.[2] === "web" && match[1] === "";
|
|
423
|
+
if (match === null || !ids.includes(match[2] ?? "") && !isSelector) {
|
|
424
|
+
out.push(line);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
removed = true;
|
|
428
|
+
const indent = match[1] ?? "";
|
|
429
|
+
while (out.length > 0) {
|
|
430
|
+
const previous = out[out.length - 1] ?? "";
|
|
431
|
+
if (previous.trimStart().startsWith("#") && previous.trim() !== "") out.pop();
|
|
432
|
+
else break;
|
|
433
|
+
}
|
|
434
|
+
for (index += 1; index < lines.length; index += 1) {
|
|
435
|
+
const next = lines[index] ?? "";
|
|
436
|
+
if (next.trim() === "") {
|
|
437
|
+
const after = lines[index + 1] ?? "";
|
|
438
|
+
if (after !== "" && (after.startsWith(indent + " ") || after.startsWith(indent + " ") || after.trimStart().startsWith("#"))) continue;
|
|
439
|
+
index -= 1;
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
if ((/^\s*/.exec(next)?.[0] ?? "").length > indent.length) continue;
|
|
443
|
+
index -= 1;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
text: out.join("\n"),
|
|
449
|
+
removed
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Add a capability's rows to the layer, creating the `- insert:` entry when the file has
|
|
454
|
+
* none and appending to the existing one otherwise.
|
|
455
|
+
*
|
|
456
|
+
* The loader indexes inserted rows under the layer that inserted them, so appending to an
|
|
457
|
+
* existing `- insert:` list and adding a second one both work; appending keeps the file
|
|
458
|
+
* readable instead of growing one `- insert:` per capability.
|
|
459
|
+
*
|
|
460
|
+
* @param state - the layer with this capability's own rows already removed.
|
|
461
|
+
* @param rows - the insert payload lines to add.
|
|
462
|
+
* @param withSelector - whether Exa's `web` selector row must also be present.
|
|
463
|
+
* @returns the layer's new text.
|
|
464
|
+
*/
|
|
465
|
+
function insertCapabilityRows(state, rows, withSelector) {
|
|
466
|
+
const lines = state.text.split("\n");
|
|
467
|
+
const insertIndex = lines.findIndex((line) => /^- insert:\s*$/.test(line));
|
|
468
|
+
if (insertIndex >= 0) {
|
|
469
|
+
let end = insertIndex + 1;
|
|
470
|
+
while (end < lines.length) {
|
|
471
|
+
const line = lines[end] ?? "";
|
|
472
|
+
if (line.trim() === "" || /^\s/.test(line)) end += 1;
|
|
473
|
+
else break;
|
|
474
|
+
}
|
|
475
|
+
while (end > insertIndex + 1 && (lines[end - 1] ?? "").trim() === "") end -= 1;
|
|
476
|
+
lines.splice(end, 0, ...rows);
|
|
477
|
+
} else {
|
|
478
|
+
const emptyIndex = lines.findIndex((line) => line.trim() === "[]");
|
|
479
|
+
if (emptyIndex >= 0) lines.splice(emptyIndex, 1, "- insert:", ...rows);
|
|
480
|
+
else {
|
|
481
|
+
const trailing = lines.length > 0 && (lines[lines.length - 1] ?? "") === "" ? lines.pop() ?? "" : void 0;
|
|
482
|
+
lines.push("- insert:", ...rows);
|
|
483
|
+
if (trailing !== void 0) lines.push(trailing);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (withSelector && !/^\s*- id: web\s*$/mu.test(lines.join("\n"))) lines.push("", "# Route web_search through Exa rather than the built-in provider.", "- id: web", " config:", " searchProvider: exa", " fetchProvider: http");
|
|
487
|
+
return lines.join("\n");
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Install and start the backing services the selected capabilities need.
|
|
491
|
+
*
|
|
492
|
+
* Enabling a capability means the deployment expects its service to answer, so the
|
|
493
|
+
* command that offers the choice is also the command that provides it. Each step
|
|
494
|
+
* reports what it is doing and, on failure, the command a user can run by hand —
|
|
495
|
+
* a missing runtime is a fact about the host, not a reason to abandon the install.
|
|
496
|
+
*
|
|
497
|
+
* @param answers - the interview's answers.
|
|
498
|
+
* @param home - the deployment home, where generated service units are recorded.
|
|
499
|
+
* @returns the ids whose service is ready.
|
|
500
|
+
*/
|
|
501
|
+
async function installCapabilityServices(answers, home) {
|
|
502
|
+
const enabled = new Set(answers.enabled);
|
|
503
|
+
const ready = [];
|
|
504
|
+
mkdirSync(home, { recursive: true });
|
|
505
|
+
writeFileSync(join(home, CAPABILITY_RECORD), JSON.stringify({
|
|
506
|
+
enabled: [...answers.enabled],
|
|
507
|
+
...answers.mineruEndpoint === void 0 ? {} : { mineruEndpoint: answers.mineruEndpoint }
|
|
508
|
+
}, null, 2) + "\n");
|
|
509
|
+
if (enabled.has("mineru")) {
|
|
510
|
+
console.log("");
|
|
511
|
+
console.log(" MinerU: installing the parser and starting its service...");
|
|
512
|
+
if (installMineru(home) && startMineru(home)) {
|
|
513
|
+
ready.push("mineru");
|
|
514
|
+
console.log(" MinerU: ready at " + (answers.mineruEndpoint ?? "http://127.0.0.1:8000/file_parse"));
|
|
515
|
+
} else {
|
|
516
|
+
console.log(" MinerU: not installed. Install it later with:");
|
|
517
|
+
console.log(" pip install -U \"mineru[core]\"");
|
|
518
|
+
console.log(" mineru-api --host 127.0.0.1 --port 8000");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (enabled.has("officecli")) ready.push("officecli");
|
|
522
|
+
if (enabled.has("computer-use")) if (desktopDriverAvailable()) ready.push("computer-use");
|
|
523
|
+
else {
|
|
524
|
+
console.log("");
|
|
525
|
+
console.log(" computer-use: the cua driver was not found on PATH. The plugin is");
|
|
526
|
+
console.log(" still mounted, so it starts once the driver is installed:");
|
|
527
|
+
console.log(" install the cua-driver release for this architecture, then restart");
|
|
528
|
+
}
|
|
529
|
+
return ready;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Install MinerU when it is absent and start its API service.
|
|
533
|
+
*
|
|
534
|
+
* MinerU is a Python package whose API server answers on a local port; the profile
|
|
535
|
+
* points at that port. The parser is installed into a virtual environment under the
|
|
536
|
+
* deployment home and an existing one is upgraded rather than skipped, so enabling
|
|
537
|
+
* the capability keeps the parser current without touching the system interpreter.
|
|
538
|
+
*
|
|
539
|
+
* @param home - the deployment home that owns the venv.
|
|
540
|
+
* @returns whether the package is installed; not whether it answered.
|
|
541
|
+
*/
|
|
542
|
+
function installMineru(home) {
|
|
543
|
+
const python = pythonInterpreter();
|
|
544
|
+
if (python === void 0) {
|
|
545
|
+
console.log(" MinerU: no Python interpreter found on PATH.");
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
const venv = join(home, ".mineru-venv");
|
|
549
|
+
if (!runStep(python, [
|
|
550
|
+
"-m",
|
|
551
|
+
"venv",
|
|
552
|
+
venv
|
|
553
|
+
])) return false;
|
|
554
|
+
return runStep(join(venv, "bin", "python"), [
|
|
555
|
+
"-m",
|
|
556
|
+
"pip",
|
|
557
|
+
"install",
|
|
558
|
+
"-U",
|
|
559
|
+
"mineru[all]"
|
|
560
|
+
]);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Start the MinerU API server as a background service when none answers yet.
|
|
564
|
+
*
|
|
565
|
+
* The service has to outlive the install command, so it is registered with the
|
|
566
|
+
* host's service manager when one is available and otherwise started detached.
|
|
567
|
+
*
|
|
568
|
+
* @returns whether the server is running after this call.
|
|
569
|
+
*/
|
|
570
|
+
function startMineru(home) {
|
|
571
|
+
const api = mineruApiBinary(home);
|
|
572
|
+
if (api === void 0) return false;
|
|
573
|
+
if (mineruAnswers()) return true;
|
|
574
|
+
if (spawnSync("systemctl", ["--version"], { stdio: "ignore" }).status === 0) {
|
|
575
|
+
writeFileSync("/etc/systemd/system/mineru-api.service", [
|
|
576
|
+
"[Unit]",
|
|
577
|
+
"Description=MinerU document parsing API for DeepSeek Harness Plus",
|
|
578
|
+
"After=network-online.target",
|
|
579
|
+
"Wants=network-online.target",
|
|
580
|
+
"",
|
|
581
|
+
"[Service]",
|
|
582
|
+
"Type=simple",
|
|
583
|
+
"User=root",
|
|
584
|
+
"ExecStart=" + api + " --host 127.0.0.1 --port 8000",
|
|
585
|
+
"Restart=on-failure",
|
|
586
|
+
"RestartSec=2",
|
|
587
|
+
"TimeoutStopSec=15",
|
|
588
|
+
"",
|
|
589
|
+
"[Install]",
|
|
590
|
+
"WantedBy=multi-user.target",
|
|
591
|
+
""
|
|
592
|
+
].join("\n"));
|
|
593
|
+
spawnSync("systemctl", ["daemon-reload"], { stdio: "ignore" });
|
|
594
|
+
return spawnSync("systemctl", [
|
|
595
|
+
"enable",
|
|
596
|
+
"--now",
|
|
597
|
+
"mineru-api.service"
|
|
598
|
+
], { stdio: "ignore" }).status === 0;
|
|
599
|
+
}
|
|
600
|
+
console.log(" MinerU: start the API server with:");
|
|
601
|
+
console.log(" " + api + " --host 127.0.0.1 --port 8000");
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
/** The Python interpreter to install MinerU with, preferring python3. */
|
|
605
|
+
function pythonInterpreter() {
|
|
606
|
+
return ["python3", "python"].find((candidate) => spawnSync(candidate, ["--version"], { stdio: "ignore" }).status === 0);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* The MinerU API entry point, wherever the install placed it.
|
|
610
|
+
*
|
|
611
|
+
* The venv this command creates comes first: it is the interpreter the parser was
|
|
612
|
+
* installed into, so its `mineru-api` is the one that can import MinerU. A binary on
|
|
613
|
+
* PATH is a fallback for a deployment that installed MinerU some other way.
|
|
614
|
+
*
|
|
615
|
+
* @param home - the deployment home that owns the venv.
|
|
616
|
+
* @returns the command to run, or `undefined` when none answers.
|
|
617
|
+
*/
|
|
618
|
+
function mineruApiBinary(home) {
|
|
619
|
+
return [join(home, ".mineru-venv", "bin", "mineru-api"), "mineru-api"].find((candidate) => spawnSync(candidate, ["--help"], { stdio: "ignore" }).status === 0);
|
|
620
|
+
}
|
|
621
|
+
/** Whether a MinerU API server already answers on the default port. */
|
|
622
|
+
function mineruAnswers() {
|
|
623
|
+
return spawnSync("curl", [
|
|
624
|
+
"-sf",
|
|
625
|
+
"-o",
|
|
626
|
+
"/dev/null",
|
|
627
|
+
"--max-time",
|
|
628
|
+
"3",
|
|
629
|
+
"http://127.0.0.1:8000/docs"
|
|
630
|
+
], { stdio: "ignore" }).status === 0;
|
|
631
|
+
}
|
|
632
|
+
/** Whether the Windows-side desktop driver answers from this shell. */
|
|
633
|
+
function desktopDriverAvailable() {
|
|
634
|
+
return spawnSync("cua-driver", ["--version"], { stdio: "ignore" }).status === 0;
|
|
635
|
+
}
|
|
636
|
+
//#endregion
|
|
78
637
|
//#region lib/types/standalone-server.js
|
|
79
638
|
/**
|
|
80
639
|
* Process lifecycle for one standalone Plus server.
|
|
@@ -242,8 +801,55 @@ async function waitForAuthenticatedUrl(logPath, timeoutMilliseconds) {
|
|
|
242
801
|
* the launcher mounts exactly the bundles the profile names and nothing expands a
|
|
243
802
|
* bundle's own list.
|
|
244
803
|
*/
|
|
245
|
-
/** Profile name a standalone installation owns. */
|
|
804
|
+
/** Profile name a standalone installation owns when its package declares none. */
|
|
246
805
|
const STANDALONE_PROFILE = "plus";
|
|
806
|
+
/**
|
|
807
|
+
* Read the deployment facts the installing package declares.
|
|
808
|
+
*
|
|
809
|
+
* A standalone package describes the profile it materializes and the capabilities it
|
|
810
|
+
* omits, so a reduced variant owns a differently named profile and a reduced install
|
|
811
|
+
* without the distribution naming either. The declaration travels with the package
|
|
812
|
+
* because npm resolves the tree long before any command of ours runs.
|
|
813
|
+
*
|
|
814
|
+
* @param anchor - path inside the installing package's tree.
|
|
815
|
+
* @param fallback - profile name to use when the declaration is absent.
|
|
816
|
+
* @returns the declared profile name and omitted capabilities.
|
|
817
|
+
*/
|
|
818
|
+
function readStandaloneDeclaration(anchor, fallback = STANDALONE_PROFILE) {
|
|
819
|
+
let current = resolve(anchor);
|
|
820
|
+
for (;;) {
|
|
821
|
+
const declared = readStandaloneFacts(join(current, "package.json"));
|
|
822
|
+
if (declared !== void 0) return declared;
|
|
823
|
+
const parent = dirname(current);
|
|
824
|
+
if (parent === current) return {
|
|
825
|
+
profileName: fallback,
|
|
826
|
+
omittedPackages: {}
|
|
827
|
+
};
|
|
828
|
+
current = parent;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/** Read one manifest's standalone declaration, or undefined when it carries none. */
|
|
832
|
+
function readStandaloneFacts(manifestPath) {
|
|
833
|
+
if (!existsSync(manifestPath)) return void 0;
|
|
834
|
+
let standalone;
|
|
835
|
+
try {
|
|
836
|
+
standalone = JSON.parse(readFileSync(manifestPath, "utf8")).dshPlusStandalone;
|
|
837
|
+
} catch {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const profile = standalone?.profile;
|
|
841
|
+
if (standalone === void 0 || typeof profile !== "string" || profile === "") return void 0;
|
|
842
|
+
const rawOmitted = standalone.omittedPackages;
|
|
843
|
+
const omittedPackages = {};
|
|
844
|
+
if (rawOmitted !== void 0 && typeof rawOmitted === "object" && !Array.isArray(rawOmitted)) for (const [name, spec] of Object.entries(rawOmitted)) {
|
|
845
|
+
if (typeof spec !== "string" || spec === "") throw new Error("dshPlusStandalone.omittedPackages." + name + " must be a non-empty string");
|
|
846
|
+
omittedPackages[name] = spec;
|
|
847
|
+
}
|
|
848
|
+
return {
|
|
849
|
+
profileName: profile,
|
|
850
|
+
omittedPackages
|
|
851
|
+
};
|
|
852
|
+
}
|
|
247
853
|
function requireRecord(value, label) {
|
|
248
854
|
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(label + " must be an object");
|
|
249
855
|
return value;
|
|
@@ -524,9 +1130,12 @@ function linkBundle(consumerModules, name, source) {
|
|
|
524
1130
|
/** Resolve every path a command needs, without creating anything. */
|
|
525
1131
|
function resolvePaths(anchor, env = process.env) {
|
|
526
1132
|
const home = resolveHome(env);
|
|
1133
|
+
const declaration = readStandaloneDeclaration(anchor);
|
|
527
1134
|
return {
|
|
528
1135
|
home,
|
|
529
|
-
|
|
1136
|
+
profileName: declaration.profileName,
|
|
1137
|
+
omittedPackages: declaration.omittedPackages,
|
|
1138
|
+
profileDirectory: join(home, "profiles", declaration.profileName),
|
|
530
1139
|
distributionDirectory: resolveDistributionDirectory(anchor)
|
|
531
1140
|
};
|
|
532
1141
|
}
|
|
@@ -550,7 +1159,7 @@ function ensureProfile(paths, consumerDirectory) {
|
|
|
550
1159
|
const manifestPath = join(paths.profileDirectory, "package.json");
|
|
551
1160
|
const distribution = readDistributionProfile(paths.distributionDirectory);
|
|
552
1161
|
if (existsSync(manifestPath)) {
|
|
553
|
-
writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds);
|
|
1162
|
+
writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds, paths.omittedPackages);
|
|
554
1163
|
installProfilePackages(paths, consumerDirectory);
|
|
555
1164
|
return false;
|
|
556
1165
|
}
|
|
@@ -570,7 +1179,7 @@ function ensureProfile(paths, consumerDirectory) {
|
|
|
570
1179
|
} }
|
|
571
1180
|
};
|
|
572
1181
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
573
|
-
writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds);
|
|
1182
|
+
writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds, paths.omittedPackages);
|
|
574
1183
|
installProfilePackages(paths, consumerDirectory);
|
|
575
1184
|
return true;
|
|
576
1185
|
}
|
|
@@ -584,13 +1193,14 @@ function ensureProfile(paths, consumerDirectory) {
|
|
|
584
1193
|
* @param profileDirectory - the standalone profile directory.
|
|
585
1194
|
* @param overrides - official package name to published replacement spec.
|
|
586
1195
|
*/
|
|
587
|
-
function writeProfileOverrides(profileDirectory, overrides, allowBuilds) {
|
|
1196
|
+
function writeProfileOverrides(profileDirectory, overrides, allowBuilds, omittedPackages) {
|
|
588
1197
|
const workspacePath = join(profileDirectory, "pnpm-workspace.yaml");
|
|
589
1198
|
const document = parseDocument(existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : "");
|
|
590
1199
|
const [documentError] = document.errors;
|
|
591
1200
|
if (documentError !== void 0) throw new Error("Plus profile workspace is not valid YAML", { cause: documentError });
|
|
592
1201
|
if (document.get("packages") === void 0) document.set("packages", ["."]);
|
|
593
1202
|
for (const [name, spec] of Object.entries(overrides)) document.setIn(["overrides", name], spec);
|
|
1203
|
+
for (const [name, spec] of Object.entries(omittedPackages)) document.setIn(["overrides", name], spec);
|
|
594
1204
|
for (const [name, allowed] of Object.entries(allowBuilds)) document.setIn(["allowBuilds", name], allowed);
|
|
595
1205
|
if (document.get("nodeLinker") === void 0) document.set("nodeLinker", "hoisted");
|
|
596
1206
|
if (document.get("autoInstallPeers") === void 0) document.set("autoInstallPeers", false);
|
|
@@ -681,11 +1291,14 @@ function resolveInstalledPackage(from, packageName) {
|
|
|
681
1291
|
*/
|
|
682
1292
|
/** Milliseconds a start waits for the server to answer before reporting failure. */
|
|
683
1293
|
const READY_TIMEOUT_MILLISECONDS = 9e4;
|
|
1294
|
+
/** Profile patch file the capability interview rewrites (the profile's user layer). */
|
|
1295
|
+
const CAPABILITY_PATCH_FILE = "cordis.patch.yml";
|
|
684
1296
|
function parseStartOptions(argv) {
|
|
685
1297
|
let port = DEFAULT_PORT;
|
|
686
1298
|
let host = "127.0.0.1";
|
|
687
1299
|
let open = true;
|
|
688
1300
|
let foreground = false;
|
|
1301
|
+
let capabilities;
|
|
689
1302
|
for (let index = 0; index < argv.length; index += 1) {
|
|
690
1303
|
const token = argv[index];
|
|
691
1304
|
if (token === "--port" || token === "-p") {
|
|
@@ -710,13 +1323,41 @@ function parseStartOptions(argv) {
|
|
|
710
1323
|
foreground = true;
|
|
711
1324
|
continue;
|
|
712
1325
|
}
|
|
1326
|
+
if (token === "--capabilities") {
|
|
1327
|
+
const value = argv[index + 1];
|
|
1328
|
+
if (value === void 0) throw new Error("--capabilities requires a comma-separated list, or an empty string for none");
|
|
1329
|
+
const stated = value === "" ? [] : value.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
|
|
1330
|
+
const offered = new Set(CAPABILITIES.map((capability) => capability.id));
|
|
1331
|
+
for (const id of stated) if (!offered.has(id)) throw new Error("unknown capability \"" + id + "\"; this release offers " + [...offered].join(", "));
|
|
1332
|
+
capabilities = stated;
|
|
1333
|
+
index += 1;
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
713
1336
|
throw new Error("unknown option: " + String(token));
|
|
714
1337
|
}
|
|
715
1338
|
return {
|
|
716
1339
|
port,
|
|
717
1340
|
host,
|
|
718
1341
|
open,
|
|
719
|
-
foreground
|
|
1342
|
+
foreground,
|
|
1343
|
+
capabilities
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Resolve a stated capability selection.
|
|
1348
|
+
*
|
|
1349
|
+
* An unattended install states what it wants instead of answering the interview, so a
|
|
1350
|
+
* name it does not offer has to fail here rather than silently enable nothing: the
|
|
1351
|
+
* deployment would otherwise start with a capability the caller believes it selected.
|
|
1352
|
+
*
|
|
1353
|
+
* @param ids - capability ids the caller selected.
|
|
1354
|
+
* @returns the answers the interview would have returned.
|
|
1355
|
+
*/
|
|
1356
|
+
function selectCapabilities(ids) {
|
|
1357
|
+
const selected = [...new Set(ids)];
|
|
1358
|
+
return {
|
|
1359
|
+
enabled: selected,
|
|
1360
|
+
...selected.includes("mineru") ? { mineruEndpoint: DEFAULT_MINERU_ENDPOINT } : {}
|
|
720
1361
|
};
|
|
721
1362
|
}
|
|
722
1363
|
/**
|
|
@@ -747,16 +1388,35 @@ function installationRoot() {
|
|
|
747
1388
|
function installationAnchor() {
|
|
748
1389
|
return join(installationRoot(), "package.json");
|
|
749
1390
|
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Path to the installed command's own file, which is where a declaration lookup starts.
|
|
1393
|
+
*
|
|
1394
|
+
* The declaration belongs to the package the user installed, and that package is a
|
|
1395
|
+
* sibling of this module rather than an ancestor: a variant's forwarder imports this
|
|
1396
|
+
* CLI in-process, so `import.meta.url` names `@sparkelf/dsh-plus` while the installed
|
|
1397
|
+
* command is the variant. `process.argv[1]` is the entry that was actually invoked,
|
|
1398
|
+
* which is the package whose declaration applies.
|
|
1399
|
+
*
|
|
1400
|
+
* Walking out from the installation root cannot reach it either: that root is the
|
|
1401
|
+
* project the user ran the command in, so every variant would fall back to the full
|
|
1402
|
+
* profile and keep the capabilities it excluded.
|
|
1403
|
+
*
|
|
1404
|
+
* @returns absolute path to the invoked command, or this module when argv carries none.
|
|
1405
|
+
*/
|
|
1406
|
+
function declarationAnchor() {
|
|
1407
|
+
const invoked = process.argv[1];
|
|
1408
|
+
return invoked === void 0 || invoked === "" ? fileURLToPath(import.meta.url) : resolve(invoked);
|
|
1409
|
+
}
|
|
750
1410
|
/** The launcher entry this installation must drive. */
|
|
751
1411
|
function launcherEntry(anchor) {
|
|
752
1412
|
return createRequire(anchor).resolve("@deepseek-ai/dsh/lib/bin.js");
|
|
753
1413
|
}
|
|
754
1414
|
/** Run the server in this process, inheriting stdio. */
|
|
755
|
-
function runForeground(entry, port, host, open) {
|
|
1415
|
+
function runForeground(entry, profileName, port, host, open) {
|
|
756
1416
|
const args = [
|
|
757
1417
|
entry,
|
|
758
1418
|
"--profile",
|
|
759
|
-
|
|
1419
|
+
profileName,
|
|
760
1420
|
"--port",
|
|
761
1421
|
String(port),
|
|
762
1422
|
"--host",
|
|
@@ -768,7 +1428,7 @@ function runForeground(entry, port, host, open) {
|
|
|
768
1428
|
async function start(argv) {
|
|
769
1429
|
const options = parseStartOptions(argv);
|
|
770
1430
|
const anchor = installationAnchor();
|
|
771
|
-
const paths = resolvePaths(
|
|
1431
|
+
const paths = resolvePaths(declarationAnchor());
|
|
772
1432
|
if (!pnpmAvailable()) {
|
|
773
1433
|
const command = pnpmInstallCommand();
|
|
774
1434
|
console.log("pnpm is required to install the plus profile, and was not found.");
|
|
@@ -799,19 +1459,29 @@ async function start(argv) {
|
|
|
799
1459
|
console.log("pnpm installed.");
|
|
800
1460
|
}
|
|
801
1461
|
const created = ensureProfile(paths, installationRoot());
|
|
802
|
-
console.log(created ? "Created the
|
|
1462
|
+
console.log(created ? "Created the " + paths.profileName + " profile at " + paths.profileDirectory : "Using the existing " + paths.profileName + " profile");
|
|
803
1463
|
for (const label of applyProfileNpmPatches(paths.distributionDirectory, paths.profileDirectory)) console.log("Applied the reviewed patch " + label);
|
|
1464
|
+
const answers = created || !existsSync(join(paths.home, "capabilities.json")) ? options.capabilities === void 0 ? await interviewCapabilities(true) : selectCapabilities(options.capabilities) : void 0;
|
|
1465
|
+
if (answers !== void 0) {
|
|
1466
|
+
const ready = await installCapabilityServices(answers, paths.home);
|
|
1467
|
+
writeCapabilityPatch(paths.profileDirectory, answers);
|
|
1468
|
+
writeCapabilityEnvironment(paths.home, answers);
|
|
1469
|
+
console.log(" Enabled: " + (answers.enabled.length === 0 ? "(none)" : answers.enabled.join(", ")));
|
|
1470
|
+
if (answers.enabled.length > 0) console.log(" Services ready: " + (ready.length === 0 ? "(none)" : ready.join(", ")));
|
|
1471
|
+
if (answers.enabled.includes("exa") && answers.exaApiKey === void 0) console.log(" Exa: add EXA_API_KEY to " + join(paths.home, CAPABILITY_ENV_FILE) + " when you have a key.");
|
|
1472
|
+
}
|
|
804
1473
|
const entry = launcherEntry(anchor);
|
|
805
|
-
if (options.foreground) return runForeground(entry, options.port, options.host, options.open);
|
|
1474
|
+
if (options.foreground) return runForeground(entry, paths.profileName, options.port, options.host, options.open);
|
|
806
1475
|
const existing = readState(paths.home);
|
|
807
1476
|
if (existing !== void 0) {
|
|
808
1477
|
console.log("Plus is already running at " + existing.url);
|
|
809
1478
|
console.log("Stop it with: dsh-plus stop");
|
|
810
1479
|
return 0;
|
|
811
1480
|
}
|
|
812
|
-
return startDetached(paths
|
|
1481
|
+
return startDetached(paths, entry, options);
|
|
813
1482
|
}
|
|
814
|
-
async function startDetached(
|
|
1483
|
+
async function startDetached(paths, entry, options) {
|
|
1484
|
+
const home = paths.home;
|
|
815
1485
|
const port = await choosePort(options.port, options.host);
|
|
816
1486
|
if (port === void 0) {
|
|
817
1487
|
console.error("No free port in the range " + String(options.port) + "-" + String(options.port + 9) + ".");
|
|
@@ -819,15 +1489,15 @@ async function startDetached(home, entry, options) {
|
|
|
819
1489
|
return 1;
|
|
820
1490
|
}
|
|
821
1491
|
if (port !== options.port) console.log("Port " + String(options.port) + " is in use; using " + String(port) + ".");
|
|
822
|
-
const logPath = join(stateDirectory(home), "server.log");
|
|
1492
|
+
const logPath = join(stateDirectory(paths.home), "server.log");
|
|
823
1493
|
const env = {
|
|
824
1494
|
...process.env,
|
|
825
|
-
DSH_HOME: home
|
|
1495
|
+
DSH_HOME: paths.home
|
|
826
1496
|
};
|
|
827
1497
|
const args = [
|
|
828
1498
|
entry,
|
|
829
1499
|
"--profile",
|
|
830
|
-
|
|
1500
|
+
paths.profileName,
|
|
831
1501
|
"--port",
|
|
832
1502
|
String(port),
|
|
833
1503
|
"--host",
|
|
@@ -942,6 +1612,40 @@ async function update(argv) {
|
|
|
942
1612
|
if (readState(paths.home) !== void 0) console.log("The running server still serves " + installed + "; run dsh-plus restart to load the new release.");
|
|
943
1613
|
return 0;
|
|
944
1614
|
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Write the profile's capability patch layer.
|
|
1617
|
+
*
|
|
1618
|
+
* The profile's loader merges this file, so enabling a capability is a data change
|
|
1619
|
+
* rather than an edit to the deployment. Writing it on every configured run also
|
|
1620
|
+
* turns a capability back off when the interview no longer selects it.
|
|
1621
|
+
*
|
|
1622
|
+
* @param profileDirectory - the profile whose layer is replaced.
|
|
1623
|
+
* @param answers - the interview's answers.
|
|
1624
|
+
*/
|
|
1625
|
+
function writeCapabilityPatch(profileDirectory, answers) {
|
|
1626
|
+
const path = join(profileDirectory, CAPABILITY_PATCH_FILE);
|
|
1627
|
+
const existing = existsSync(path) ? readFileSync(path, "utf8") : void 0;
|
|
1628
|
+
if (existing !== void 0 && !existing.includes("Written by dsh-plus start from the capability interview.")) {
|
|
1629
|
+
writeFileSync(path + ".before-capabilities", existing);
|
|
1630
|
+
console.log(" Kept the existing profile layer at cordis.patch.yml.before-capabilities");
|
|
1631
|
+
}
|
|
1632
|
+
writeFileSync(path, capabilityPatchLayer(answers, existing ?? ""));
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Write the deployment's capability environment file.
|
|
1636
|
+
*
|
|
1637
|
+
* MinerU is switched on by the presence of its endpoint and Exa reads its key from
|
|
1638
|
+
* the launch environment, so the answers reach those two through `$DSH_HOME/.env`
|
|
1639
|
+
* rather than through the profile layer. The file is replaced whole on every
|
|
1640
|
+
* configured start, which is also how a capability the user dropped stops applying.
|
|
1641
|
+
*
|
|
1642
|
+
* @param home - the deployment home whose env file the launcher reads.
|
|
1643
|
+
* @param answers - the interview's answers.
|
|
1644
|
+
*/
|
|
1645
|
+
function writeCapabilityEnvironment(home, answers) {
|
|
1646
|
+
const path = join(home, CAPABILITY_ENV_FILE);
|
|
1647
|
+
writeFileSync(path, capabilityEnvironment(answers, existsSync(path) ? readFileSync(path, "utf8") : ""));
|
|
1648
|
+
}
|
|
945
1649
|
/** Ask one yes/no question on the terminal. */
|
|
946
1650
|
function confirm(question) {
|
|
947
1651
|
return new Promise((resolveAnswer) => {
|
|
@@ -952,6 +1656,134 @@ function confirm(question) {
|
|
|
952
1656
|
});
|
|
953
1657
|
});
|
|
954
1658
|
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Report and change which optional capabilities this deployment has enabled.
|
|
1661
|
+
*
|
|
1662
|
+
* The interview runs once, at install time, and a deployment that answered it keeps its
|
|
1663
|
+
* answers — so this is the only way to see what it chose or to change one answer later.
|
|
1664
|
+
* The report is read from the profile layer and the env file rather than from the record,
|
|
1665
|
+
* because those two are what the loader and the launch environment actually consume: a
|
|
1666
|
+
* record deleted, or a layer edited by hand, would otherwise describe a deployment that
|
|
1667
|
+
* does not exist.
|
|
1668
|
+
*
|
|
1669
|
+
* `--enable` and `--disable` edit only the rows this module owns. The profile layer is
|
|
1670
|
+
* shared with rows an operator wrote themselves, so it is never replaced wholesale.
|
|
1671
|
+
*
|
|
1672
|
+
* @param argv - arguments after the command word.
|
|
1673
|
+
* @returns the process exit code.
|
|
1674
|
+
*/
|
|
1675
|
+
function capabilities(argv) {
|
|
1676
|
+
const paths = resolvePaths(installationAnchor());
|
|
1677
|
+
const patchPath = join(paths.profileDirectory, CAPABILITY_PATCH_FILE);
|
|
1678
|
+
const envPath = join(paths.home, CAPABILITY_ENV_FILE);
|
|
1679
|
+
const patchText = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "";
|
|
1680
|
+
const envText = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
1681
|
+
const actual = detectEnabledCapabilities(patchText, envText);
|
|
1682
|
+
const record = readCapabilityRecord(paths.home);
|
|
1683
|
+
let enable = [];
|
|
1684
|
+
let disable = [];
|
|
1685
|
+
let asJson = false;
|
|
1686
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1687
|
+
const token = argv[index];
|
|
1688
|
+
if (token === "--json") {
|
|
1689
|
+
asJson = true;
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
if (token === "--enable" || token === "--disable") {
|
|
1693
|
+
const value = argv[index + 1];
|
|
1694
|
+
const ids = (value ?? "").split(",").map((id) => id.trim()).filter((id) => id !== "");
|
|
1695
|
+
if (value === void 0 || ids.length === 0) {
|
|
1696
|
+
console.error(token + " requires a comma-separated list of capability ids");
|
|
1697
|
+
return 1;
|
|
1698
|
+
}
|
|
1699
|
+
const unknown = ids.filter((id) => !CAPABILITIES.some((capability) => capability.id === id));
|
|
1700
|
+
if (unknown.length > 0) {
|
|
1701
|
+
console.error("unknown capability: " + unknown.join(", "));
|
|
1702
|
+
console.error("Known: " + CAPABILITIES.map((capability) => capability.id).join(", "));
|
|
1703
|
+
return 1;
|
|
1704
|
+
}
|
|
1705
|
+
if (token === "--enable") enable = [...new Set([...enable, ...ids])];
|
|
1706
|
+
else disable = [...new Set([...disable, ...ids])];
|
|
1707
|
+
index += 1;
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
console.error("unknown option: " + String(token));
|
|
1711
|
+
return 1;
|
|
1712
|
+
}
|
|
1713
|
+
const conflicting = enable.filter((id) => disable.includes(id));
|
|
1714
|
+
if (conflicting.length > 0) {
|
|
1715
|
+
console.error("cannot both enable and disable: " + conflicting.join(", "));
|
|
1716
|
+
return 1;
|
|
1717
|
+
}
|
|
1718
|
+
if (enable.length > 0 || disable.length > 0) {
|
|
1719
|
+
const enabledNow = new Set(actual.mounted);
|
|
1720
|
+
let nextPatch = patchText;
|
|
1721
|
+
for (const id of enable) {
|
|
1722
|
+
nextPatch = editCapabilityPatchLayer(nextPatch, id, true, record?.mineruEndpoint);
|
|
1723
|
+
enabledNow.add(id);
|
|
1724
|
+
}
|
|
1725
|
+
for (const id of disable) {
|
|
1726
|
+
nextPatch = editCapabilityPatchLayer(nextPatch, id, false);
|
|
1727
|
+
enabledNow.delete(id);
|
|
1728
|
+
}
|
|
1729
|
+
if (!existsSync(patchPath) || !patchText.includes("Written by dsh-plus start from the capability interview.")) {
|
|
1730
|
+
const backup = patchPath + ".before-capabilities";
|
|
1731
|
+
if (patchText !== "" && !existsSync(backup)) writeFileSync(backup, patchText);
|
|
1732
|
+
}
|
|
1733
|
+
writeFileSync(patchPath, nextPatch);
|
|
1734
|
+
const nextEnv = capabilityEnvironment({
|
|
1735
|
+
enabled: [...enabledNow],
|
|
1736
|
+
...record?.mineruEndpoint === void 0 ? {} : { mineruEndpoint: record.mineruEndpoint }
|
|
1737
|
+
}, envText);
|
|
1738
|
+
writeFileSync(envPath, nextEnv);
|
|
1739
|
+
const after = detectEnabledCapabilities(nextPatch, nextEnv);
|
|
1740
|
+
console.log("Updated cordis.patch.yml and .env.");
|
|
1741
|
+
console.log("Run dsh-plus start (or restart the server) to apply the change.");
|
|
1742
|
+
return reportCapabilities(after, record, asJson);
|
|
1743
|
+
}
|
|
1744
|
+
return reportCapabilities(actual, record, asJson);
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Print the capability report.
|
|
1748
|
+
*
|
|
1749
|
+
* @param actual - the capabilities found in the profile layer and env file.
|
|
1750
|
+
* @param record - what the last configured run recorded, when a record exists.
|
|
1751
|
+
* @param asJson - when true, print machine-readable JSON instead of the table.
|
|
1752
|
+
* @returns the process exit code, always 0.
|
|
1753
|
+
*/
|
|
1754
|
+
function reportCapabilities(actual, record, asJson) {
|
|
1755
|
+
const rows = CAPABILITIES.map((capability) => {
|
|
1756
|
+
const mounted = actual.mounted.includes(capability.id);
|
|
1757
|
+
const recorded = record?.enabled.includes(capability.id);
|
|
1758
|
+
return {
|
|
1759
|
+
id: capability.id,
|
|
1760
|
+
title: capability.title,
|
|
1761
|
+
enabled: mounted,
|
|
1762
|
+
recorded: recorded ?? null
|
|
1763
|
+
};
|
|
1764
|
+
});
|
|
1765
|
+
if (asJson) {
|
|
1766
|
+
console.log(JSON.stringify({
|
|
1767
|
+
capabilities: rows,
|
|
1768
|
+
env: actual.env,
|
|
1769
|
+
recordPresent: record !== void 0
|
|
1770
|
+
}, null, 2));
|
|
1771
|
+
return 0;
|
|
1772
|
+
}
|
|
1773
|
+
console.log("Optional capabilities:");
|
|
1774
|
+
for (const row of rows) {
|
|
1775
|
+
const state = row.enabled ? "enabled " : "disabled";
|
|
1776
|
+
const drift = row.recorded !== null && row.recorded !== row.enabled ? " (record says " + (row.recorded ? "enabled" : "disabled") + ")" : "";
|
|
1777
|
+
console.log(" " + (row.enabled ? "ok " : "-- ") + row.id.padEnd(14) + state + drift);
|
|
1778
|
+
}
|
|
1779
|
+
if (record === void 0) {
|
|
1780
|
+
console.log("");
|
|
1781
|
+
console.log("No capabilities.json yet; the next dsh-plus start will run the interview.");
|
|
1782
|
+
}
|
|
1783
|
+
console.log("");
|
|
1784
|
+
console.log("Change with: dsh-plus capabilities --enable <id> | --disable <id>");
|
|
1785
|
+
return 0;
|
|
1786
|
+
}
|
|
955
1787
|
async function doctor() {
|
|
956
1788
|
const paths = resolvePaths(installationAnchor());
|
|
957
1789
|
let failures = 0;
|
|
@@ -964,6 +1796,12 @@ async function doctor() {
|
|
|
964
1796
|
check(existsSync(join(paths.profileDirectory, "package.json")), "profile at " + paths.profileDirectory + " (dsh-plus start creates it)");
|
|
965
1797
|
const free = await portAvailable(DEFAULT_PORT);
|
|
966
1798
|
check(true, "port " + String(DEFAULT_PORT) + (free ? " is free" : " is in use; start will choose the next free one"));
|
|
1799
|
+
const patchPath = join(paths.profileDirectory, CAPABILITY_PATCH_FILE);
|
|
1800
|
+
const envPath = join(paths.home, CAPABILITY_ENV_FILE);
|
|
1801
|
+
const actual = detectEnabledCapabilities(existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "", existsSync(envPath) ? readFileSync(envPath, "utf8") : "");
|
|
1802
|
+
const record = readCapabilityRecord(paths.home);
|
|
1803
|
+
const drifted = record === void 0 ? [] : CAPABILITIES.filter((capability) => record.enabled.includes(capability.id) !== actual.mounted.includes(capability.id));
|
|
1804
|
+
check(drifted.length === 0, drifted.length === 0 ? "capabilities: " + (actual.mounted.length === 0 ? "(none enabled)" : actual.mounted.join(", ")) : "capabilities disagree with capabilities.json: " + drifted.map((capability) => capability.id).join(", ") + " (run dsh-plus capabilities)");
|
|
967
1805
|
console.log(failures === 0 ? "No problems found." : String(failures) + " problem(s) found.");
|
|
968
1806
|
return failures === 0 ? 0 : 1;
|
|
969
1807
|
}
|
|
@@ -971,11 +1809,12 @@ const USAGE = [
|
|
|
971
1809
|
"Usage: dsh-plus <command> [options]",
|
|
972
1810
|
"",
|
|
973
1811
|
"Commands:",
|
|
974
|
-
" start
|
|
975
|
-
" stop
|
|
976
|
-
" status
|
|
977
|
-
"
|
|
978
|
-
"
|
|
1812
|
+
" start start the server (first run creates the profile)",
|
|
1813
|
+
" stop stop the server",
|
|
1814
|
+
" status report whether the server is running",
|
|
1815
|
+
" capabilities report or change the optional capabilities",
|
|
1816
|
+
" update move to a newer distribution release",
|
|
1817
|
+
" doctor check this installation",
|
|
979
1818
|
"",
|
|
980
1819
|
"start options:",
|
|
981
1820
|
" --port <n> port to prefer (default " + String(DEFAULT_PORT) + "; a taken port moves to the next free one)",
|
|
@@ -983,6 +1822,11 @@ const USAGE = [
|
|
|
983
1822
|
" --no-open do not open a browser",
|
|
984
1823
|
" --foreground run in this terminal instead of in the background",
|
|
985
1824
|
"",
|
|
1825
|
+
"capabilities options:",
|
|
1826
|
+
" --enable <ids> enable the comma-separated capability ids",
|
|
1827
|
+
" --disable <ids> disable the comma-separated capability ids",
|
|
1828
|
+
" --json print the report as JSON",
|
|
1829
|
+
"",
|
|
986
1830
|
"update options:",
|
|
987
1831
|
" --check report the available release without installing it",
|
|
988
1832
|
" --yes install without asking"
|
|
@@ -1002,6 +1846,7 @@ async function runStandaloneCli(argv) {
|
|
|
1002
1846
|
if (command === "start") return await start(rest);
|
|
1003
1847
|
if (command === "stop") return stop();
|
|
1004
1848
|
if (command === "status") return status();
|
|
1849
|
+
if (command === "capabilities") return capabilities(rest);
|
|
1005
1850
|
if (command === "update") return await update(rest);
|
|
1006
1851
|
if (command === "doctor") return doctor();
|
|
1007
1852
|
console.error("unknown command: " + command);
|