@sparkelf/dsh-plus 0.2.0-rc.34 → 0.2.0-rc.36

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/lib/bin.js CHANGED
@@ -160,15 +160,21 @@ async function interviewCapabilities(interactive) {
160
160
  ...mineruEndpoint === void 0 ? {} : { mineruEndpoint }
161
161
  };
162
162
  }
163
- /** Names this module owns in the deployment env file. */
164
- const CAPABILITY_ENV_NAMES = ["DSH_MINERU_ENDPOINT", "EXA_API_KEY"];
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"];
165
172
  /**
166
173
  * The deployment env file, with one assignment per enabled capability that reaches
167
174
  * its service through the launch environment.
168
175
  *
169
- * MinerU is switched on by the presence of its endpoint and Exa reads its key from
170
- * the launch environment, so a capability the user selected is enabled here as well
171
- * as in the profile layer. Only the names this interview owns are rewritten: a
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
172
178
  * deployment that keeps its own assignments in the same file keeps them, and
173
179
  * deselecting a capability removes the assignment that turned it on.
174
180
  *
@@ -179,13 +185,20 @@ const CAPABILITY_ENV_NAMES = ["DSH_MINERU_ENDPOINT", "EXA_API_KEY"];
179
185
  function capabilityEnvironment(answers, existing = "") {
180
186
  const enabled = new Set(answers.enabled);
181
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
+ }
182
193
  const kept = existing.split("\n").filter((line) => {
183
194
  const name = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
184
195
  return line.trim() !== "" && !line.trimStart().startsWith("#") && (name === void 0 || !owned.has(name));
185
196
  });
186
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."];
187
- if (enabled.has("mineru")) written.push("DSH_MINERU_ENDPOINT=" + (answers.mineruEndpoint ?? "http://127.0.0.1:8000/file_parse"));
188
- if (enabled.has("exa") && answers.exaApiKey !== void 0) written.push("EXA_API_KEY=" + answers.exaApiKey);
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
+ }
189
202
  return [...written, ...kept].join("\n") + "\n";
190
203
  }
191
204
  /**
@@ -208,6 +221,8 @@ function capabilityPatchLayer(answers) {
208
221
  const rows = [];
209
222
  if (enabled.has("exa")) rows.push(" - id: web-search-exa", " name: '@deepseek-ai/dsh-web-search-exa'", " config:", " searchType: auto", " numResults: 8");
210
223
  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'");
224
+ if (enabled.has("officecli")) rows.push(" - id: officecli", " name: '@sparkelf/dsh-officecli'");
225
+ if (enabled.has("mineru")) rows.push(" - id: mineru", " name: '@sparkelf/dsh-mineru'", " config:", " endpoint: " + (answers.mineruEndpoint ?? "http://127.0.0.1:8000/file_parse"));
211
226
  const lines = ["# Written by dsh-plus start from the capability interview. Enabling or disabling a capability", "# rewrites this file; edits here are replaced."];
212
227
  if (rows.length > 0) lines.push("- insert:", ...rows);
213
228
  else lines.push("[]");
@@ -219,6 +234,212 @@ function runStep(command, args) {
219
234
  return spawnSync(command, [...args], { stdio: "inherit" }).status === 0;
220
235
  }
221
236
  /**
237
+ * The capability record a previous configured run wrote, or `undefined` when none exists.
238
+ *
239
+ * The record is what lets a later run report the deployment's answers instead of asking
240
+ * again. A malformed or unreadable file is treated as absent rather than fatal: it is a
241
+ * convenience cache, and refusing to boot over it would strand a deployment whose record
242
+ * was corrupted by an interrupted write.
243
+ *
244
+ * @param home - the deployment home holding the record.
245
+ * @returns the enabled ids, and the MinerU endpoint when the record carries one.
246
+ */
247
+ function readCapabilityRecord(home) {
248
+ const path = join(home, CAPABILITY_RECORD);
249
+ let parsed;
250
+ try {
251
+ parsed = JSON.parse(readFileSync(path, "utf8"));
252
+ } catch {
253
+ return;
254
+ }
255
+ if (typeof parsed !== "object" || parsed === null) return void 0;
256
+ const record = parsed;
257
+ if (!Array.isArray(record.enabled)) return void 0;
258
+ return {
259
+ enabled: record.enabled.filter((id) => typeof id === "string"),
260
+ ...typeof record.mineruEndpoint === "string" ? { mineruEndpoint: record.mineruEndpoint } : {}
261
+ };
262
+ }
263
+ /**
264
+ * The capabilities a deployment actually has mounted, read from the files the loader and
265
+ * the launch environment consume.
266
+ *
267
+ * A record is a cache of an earlier answer and can drift from the deployment — a profile
268
+ * layer edited by hand, a record deleted, a plugin removed from the distribution. What a
269
+ * capability is *doing* is decided by the profile patch rows and the env file, so that is
270
+ * what a report reads.
271
+ *
272
+ * @param profilePatchText - the profile layer's YAML text.
273
+ * @param envText - the deployment env file's text.
274
+ * @returns the ids found in the profile layer and the env names present.
275
+ */
276
+ function detectEnabledCapabilities(profilePatchText, envText) {
277
+ const mounted = [];
278
+ if (/^\s*- id: web-search-exa\s*$/mu.test(profilePatchText)) mounted.push("exa");
279
+ if (/^\s*- id: mineru\s*$/mu.test(profilePatchText)) mounted.push("mineru");
280
+ if (/^\s*- id: officecli\s*$/mu.test(profilePatchText)) mounted.push("officecli");
281
+ if (/^\s*- id: computer-use\s*$/mu.test(profilePatchText)) mounted.push("computer-use");
282
+ const env = [];
283
+ for (const line of envText.split("\n")) {
284
+ const name = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
285
+ if (name !== void 0 && !env.includes(name)) env.push(name);
286
+ }
287
+ return {
288
+ mounted,
289
+ env
290
+ };
291
+ }
292
+ /**
293
+ * The patch row one capability mounts, as a list of YAML lines, or `undefined` when the
294
+ * capability is turned on by something other than a row this module owns.
295
+ *
296
+ * MinerU is enabled by a row too, but its endpoint is per-deployment, so the caller passes
297
+ * the answers rather than a fixed row.
298
+ *
299
+ * @param id - the capability id.
300
+ * @returns the insert payload lines, without the surrounding `- insert:` entry.
301
+ */
302
+ function capabilityRows(id) {
303
+ if (id === "exa") return [
304
+ " - id: web-search-exa",
305
+ " name: '@deepseek-ai/dsh-web-search-exa'",
306
+ " config:",
307
+ " searchType: auto",
308
+ " numResults: 8"
309
+ ];
310
+ if (id === "officecli") return [" - id: officecli", " name: '@sparkelf/dsh-officecli'"];
311
+ if (id === "computer-use") return [
312
+ " - id: computer-use",
313
+ " name: '@deepseek-ai/dsh-computer-use'",
314
+ " - id: computer-use-cua-driver-mcp",
315
+ " name: '@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp'"
316
+ ];
317
+ }
318
+ /** The row ids a capability owns in the profile layer. */
319
+ function capabilityRowIds(id) {
320
+ if (id === "exa") return ["web-search-exa"];
321
+ if (id === "officecli") return ["officecli"];
322
+ if (id === "mineru") return ["mineru"];
323
+ if (id === "computer-use") return ["computer-use", "computer-use-cua-driver-mcp"];
324
+ return [];
325
+ }
326
+ /**
327
+ * One capability's effect, added to or removed from an existing profile layer without
328
+ * disturbing the rest of the file.
329
+ *
330
+ * `dsh-plus start` owns the whole layer and rewrites it from the interview's answers, which
331
+ * is correct for an install that answered once and never edits afterwards. A deployment
332
+ * that maintains its own rows in that same layer cannot be served that way: replacing the
333
+ * file drops every row the operator wrote. This edits the rows this module owns and leaves
334
+ * every other entry, comment, and blank line exactly where it was.
335
+ *
336
+ * @param existing - the layer's current YAML text.
337
+ * @param id - the capability to add or remove.
338
+ * @param enable - whether to add the capability.
339
+ * @param mineruEndpoint - the endpoint to write when enabling MinerU.
340
+ * @returns the layer's new text.
341
+ */
342
+ function editCapabilityPatchLayer(existing, id, enable, mineruEndpoint) {
343
+ const withoutRows = dropOwnedRows(existing, capabilityRowIds(id), id === "exa");
344
+ const mineruRows = [
345
+ " - id: mineru",
346
+ " name: '@sparkelf/dsh-mineru'",
347
+ " config:",
348
+ " endpoint: " + (mineruEndpoint ?? "http://127.0.0.1:8000/file_parse")
349
+ ];
350
+ const rows = id === "mineru" && enable ? mineruRows : capabilityRows(id);
351
+ const trimmed = (enable && rows !== void 0 ? insertCapabilityRows(withoutRows, rows, id === "exa") : withoutRows.text).replace(/\n+$/u, "");
352
+ return trimmed === "" ? "\n" : trimmed + "\n";
353
+ }
354
+ /**
355
+ * Remove the rows one capability owns, and for Exa the `web` selector row that routes
356
+ * search to it.
357
+ *
358
+ * A row is dropped with the block that belongs to it: the `- id:` line plus its indented
359
+ * continuation lines, and any comment line directly above that documented it. Comment and
360
+ * blank lines separating it from the next entry stay, so the surrounding file keeps its
361
+ * shape.
362
+ *
363
+ * @param existing - the layer's current YAML text.
364
+ * @param ids - the row ids this capability owns.
365
+ * @param withSelector - whether the capability also owns the shared `web` selector row.
366
+ * @returns the remaining text and whether anything was actually removed.
367
+ */
368
+ function dropOwnedRows(existing, ids, withSelector) {
369
+ const lines = existing.split("\n");
370
+ const out = [];
371
+ let removed = false;
372
+ for (let index = 0; index < lines.length; index += 1) {
373
+ const line = lines[index] ?? "";
374
+ const match = /^(\s*)- id:\s*(\S+)\s*$/.exec(line);
375
+ const isSelector = withSelector && match?.[2] === "web" && match[1] === "";
376
+ if (match === null || !ids.includes(match[2] ?? "") && !isSelector) {
377
+ out.push(line);
378
+ continue;
379
+ }
380
+ removed = true;
381
+ const indent = match[1] ?? "";
382
+ while (out.length > 0) {
383
+ const previous = out[out.length - 1] ?? "";
384
+ if (previous.trimStart().startsWith("#") && previous.trim() !== "") out.pop();
385
+ else break;
386
+ }
387
+ for (index += 1; index < lines.length; index += 1) {
388
+ const next = lines[index] ?? "";
389
+ if (next.trim() === "") {
390
+ const after = lines[index + 1] ?? "";
391
+ if (after !== "" && (after.startsWith(indent + " ") || after.startsWith(indent + " ") || after.trimStart().startsWith("#"))) continue;
392
+ index -= 1;
393
+ break;
394
+ }
395
+ if ((/^\s*/.exec(next)?.[0] ?? "").length > indent.length) continue;
396
+ index -= 1;
397
+ break;
398
+ }
399
+ }
400
+ return {
401
+ text: out.join("\n"),
402
+ removed
403
+ };
404
+ }
405
+ /**
406
+ * Add a capability's rows to the layer, creating the `- insert:` entry when the file has
407
+ * none and appending to the existing one otherwise.
408
+ *
409
+ * The loader indexes inserted rows under the layer that inserted them, so appending to an
410
+ * existing `- insert:` list and adding a second one both work; appending keeps the file
411
+ * readable instead of growing one `- insert:` per capability.
412
+ *
413
+ * @param state - the layer with this capability's own rows already removed.
414
+ * @param rows - the insert payload lines to add.
415
+ * @param withSelector - whether Exa's `web` selector row must also be present.
416
+ * @returns the layer's new text.
417
+ */
418
+ function insertCapabilityRows(state, rows, withSelector) {
419
+ const lines = state.text.split("\n");
420
+ const insertIndex = lines.findIndex((line) => /^- insert:\s*$/.test(line));
421
+ if (insertIndex >= 0) {
422
+ let end = insertIndex + 1;
423
+ while (end < lines.length) {
424
+ const line = lines[end] ?? "";
425
+ if (line.trim() === "" || /^\s/.test(line)) end += 1;
426
+ else break;
427
+ }
428
+ while (end > insertIndex + 1 && (lines[end - 1] ?? "").trim() === "") end -= 1;
429
+ lines.splice(end, 0, ...rows);
430
+ } else {
431
+ const emptyIndex = lines.findIndex((line) => line.trim() === "[]");
432
+ if (emptyIndex >= 0) lines.splice(emptyIndex, 1, "- insert:", ...rows);
433
+ else {
434
+ const trailing = lines.length > 0 && (lines[lines.length - 1] ?? "") === "" ? lines.pop() ?? "" : void 0;
435
+ lines.push("- insert:", ...rows);
436
+ if (trailing !== void 0) lines.push(trailing);
437
+ }
438
+ }
439
+ 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");
440
+ return lines.join("\n");
441
+ }
442
+ /**
222
443
  * Install and start the backing services the selected capabilities need.
223
444
  *
224
445
  * Enabling a capability means the deployment expects its service to answer, so the
@@ -1388,6 +1609,134 @@ function confirm(question) {
1388
1609
  });
1389
1610
  });
1390
1611
  }
1612
+ /**
1613
+ * Report and change which optional capabilities this deployment has enabled.
1614
+ *
1615
+ * The interview runs once, at install time, and a deployment that answered it keeps its
1616
+ * answers — so this is the only way to see what it chose or to change one answer later.
1617
+ * The report is read from the profile layer and the env file rather than from the record,
1618
+ * because those two are what the loader and the launch environment actually consume: a
1619
+ * record deleted, or a layer edited by hand, would otherwise describe a deployment that
1620
+ * does not exist.
1621
+ *
1622
+ * `--enable` and `--disable` edit only the rows this module owns. The profile layer is
1623
+ * shared with rows an operator wrote themselves, so it is never replaced wholesale.
1624
+ *
1625
+ * @param argv - arguments after the command word.
1626
+ * @returns the process exit code.
1627
+ */
1628
+ function capabilities(argv) {
1629
+ const paths = resolvePaths(installationAnchor());
1630
+ const patchPath = join(paths.profileDirectory, CAPABILITY_PATCH_FILE);
1631
+ const envPath = join(paths.home, CAPABILITY_ENV_FILE);
1632
+ const patchText = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "";
1633
+ const envText = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
1634
+ const actual = detectEnabledCapabilities(patchText, envText);
1635
+ const record = readCapabilityRecord(paths.home);
1636
+ let enable = [];
1637
+ let disable = [];
1638
+ let asJson = false;
1639
+ for (let index = 0; index < argv.length; index += 1) {
1640
+ const token = argv[index];
1641
+ if (token === "--json") {
1642
+ asJson = true;
1643
+ continue;
1644
+ }
1645
+ if (token === "--enable" || token === "--disable") {
1646
+ const value = argv[index + 1];
1647
+ const ids = (value ?? "").split(",").map((id) => id.trim()).filter((id) => id !== "");
1648
+ if (value === void 0 || ids.length === 0) {
1649
+ console.error(token + " requires a comma-separated list of capability ids");
1650
+ return 1;
1651
+ }
1652
+ const unknown = ids.filter((id) => !CAPABILITIES.some((capability) => capability.id === id));
1653
+ if (unknown.length > 0) {
1654
+ console.error("unknown capability: " + unknown.join(", "));
1655
+ console.error("Known: " + CAPABILITIES.map((capability) => capability.id).join(", "));
1656
+ return 1;
1657
+ }
1658
+ if (token === "--enable") enable = [...new Set([...enable, ...ids])];
1659
+ else disable = [...new Set([...disable, ...ids])];
1660
+ index += 1;
1661
+ continue;
1662
+ }
1663
+ console.error("unknown option: " + String(token));
1664
+ return 1;
1665
+ }
1666
+ const conflicting = enable.filter((id) => disable.includes(id));
1667
+ if (conflicting.length > 0) {
1668
+ console.error("cannot both enable and disable: " + conflicting.join(", "));
1669
+ return 1;
1670
+ }
1671
+ if (enable.length > 0 || disable.length > 0) {
1672
+ const enabledNow = new Set(actual.mounted);
1673
+ let nextPatch = patchText;
1674
+ for (const id of enable) {
1675
+ nextPatch = editCapabilityPatchLayer(nextPatch, id, true, record?.mineruEndpoint);
1676
+ enabledNow.add(id);
1677
+ }
1678
+ for (const id of disable) {
1679
+ nextPatch = editCapabilityPatchLayer(nextPatch, id, false);
1680
+ enabledNow.delete(id);
1681
+ }
1682
+ if (!existsSync(patchPath) || !patchText.includes("Written by dsh-plus start from the capability interview.")) {
1683
+ const backup = patchPath + ".before-capabilities";
1684
+ if (patchText !== "" && !existsSync(backup)) writeFileSync(backup, patchText);
1685
+ }
1686
+ writeFileSync(patchPath, nextPatch);
1687
+ const nextEnv = capabilityEnvironment({
1688
+ enabled: [...enabledNow],
1689
+ ...record?.mineruEndpoint === void 0 ? {} : { mineruEndpoint: record.mineruEndpoint }
1690
+ }, envText);
1691
+ writeFileSync(envPath, nextEnv);
1692
+ const after = detectEnabledCapabilities(nextPatch, nextEnv);
1693
+ console.log("Updated cordis.patch.yml and .env.");
1694
+ console.log("Run dsh-plus start (or restart the server) to apply the change.");
1695
+ return reportCapabilities(after, record, asJson);
1696
+ }
1697
+ return reportCapabilities(actual, record, asJson);
1698
+ }
1699
+ /**
1700
+ * Print the capability report.
1701
+ *
1702
+ * @param actual - the capabilities found in the profile layer and env file.
1703
+ * @param record - what the last configured run recorded, when a record exists.
1704
+ * @param asJson - when true, print machine-readable JSON instead of the table.
1705
+ * @returns the process exit code, always 0.
1706
+ */
1707
+ function reportCapabilities(actual, record, asJson) {
1708
+ const rows = CAPABILITIES.map((capability) => {
1709
+ const mounted = actual.mounted.includes(capability.id);
1710
+ const recorded = record?.enabled.includes(capability.id);
1711
+ return {
1712
+ id: capability.id,
1713
+ title: capability.title,
1714
+ enabled: mounted,
1715
+ recorded: recorded ?? null
1716
+ };
1717
+ });
1718
+ if (asJson) {
1719
+ console.log(JSON.stringify({
1720
+ capabilities: rows,
1721
+ env: actual.env,
1722
+ recordPresent: record !== void 0
1723
+ }, null, 2));
1724
+ return 0;
1725
+ }
1726
+ console.log("Optional capabilities:");
1727
+ for (const row of rows) {
1728
+ const state = row.enabled ? "enabled " : "disabled";
1729
+ const drift = row.recorded !== null && row.recorded !== row.enabled ? " (record says " + (row.recorded ? "enabled" : "disabled") + ")" : "";
1730
+ console.log(" " + (row.enabled ? "ok " : "-- ") + row.id.padEnd(14) + state + drift);
1731
+ }
1732
+ if (record === void 0) {
1733
+ console.log("");
1734
+ console.log("No capabilities.json yet; the next dsh-plus start will run the interview.");
1735
+ }
1736
+ console.log("");
1737
+ console.log("Change with: dsh-plus capabilities --enable <id> | --disable <id>");
1738
+ return 0;
1739
+ }
1391
1740
  async function doctor() {
1392
1741
  const paths = resolvePaths(installationAnchor());
1393
1742
  let failures = 0;
@@ -1400,6 +1749,12 @@ async function doctor() {
1400
1749
  check(existsSync(join(paths.profileDirectory, "package.json")), "profile at " + paths.profileDirectory + " (dsh-plus start creates it)");
1401
1750
  const free = await portAvailable(DEFAULT_PORT);
1402
1751
  check(true, "port " + String(DEFAULT_PORT) + (free ? " is free" : " is in use; start will choose the next free one"));
1752
+ const patchPath = join(paths.profileDirectory, CAPABILITY_PATCH_FILE);
1753
+ const envPath = join(paths.home, CAPABILITY_ENV_FILE);
1754
+ const actual = detectEnabledCapabilities(existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "", existsSync(envPath) ? readFileSync(envPath, "utf8") : "");
1755
+ const record = readCapabilityRecord(paths.home);
1756
+ const drifted = record === void 0 ? [] : CAPABILITIES.filter((capability) => record.enabled.includes(capability.id) !== actual.mounted.includes(capability.id));
1757
+ 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)");
1403
1758
  console.log(failures === 0 ? "No problems found." : String(failures) + " problem(s) found.");
1404
1759
  return failures === 0 ? 0 : 1;
1405
1760
  }
@@ -1407,11 +1762,12 @@ const USAGE = [
1407
1762
  "Usage: dsh-plus <command> [options]",
1408
1763
  "",
1409
1764
  "Commands:",
1410
- " start start the server (first run creates the profile)",
1411
- " stop stop the server",
1412
- " status report whether the server is running",
1413
- " update move to a newer distribution release",
1414
- " doctor check this installation",
1765
+ " start start the server (first run creates the profile)",
1766
+ " stop stop the server",
1767
+ " status report whether the server is running",
1768
+ " capabilities report or change the optional capabilities",
1769
+ " update move to a newer distribution release",
1770
+ " doctor check this installation",
1415
1771
  "",
1416
1772
  "start options:",
1417
1773
  " --port <n> port to prefer (default " + String(DEFAULT_PORT) + "; a taken port moves to the next free one)",
@@ -1419,6 +1775,11 @@ const USAGE = [
1419
1775
  " --no-open do not open a browser",
1420
1776
  " --foreground run in this terminal instead of in the background",
1421
1777
  "",
1778
+ "capabilities options:",
1779
+ " --enable <ids> enable the comma-separated capability ids",
1780
+ " --disable <ids> disable the comma-separated capability ids",
1781
+ " --json print the report as JSON",
1782
+ "",
1422
1783
  "update options:",
1423
1784
  " --check report the available release without installing it",
1424
1785
  " --yes install without asking"
@@ -1438,6 +1799,7 @@ async function runStandaloneCli(argv) {
1438
1799
  if (command === "start") return await start(rest);
1439
1800
  if (command === "stop") return stop();
1440
1801
  if (command === "status") return status();
1802
+ if (command === "capabilities") return capabilities(rest);
1441
1803
  if (command === "update") return await update(rest);
1442
1804
  if (command === "doctor") return doctor();
1443
1805
  console.error("unknown command: " + command);
@@ -66,9 +66,8 @@ export declare function interviewCapabilities(interactive: boolean): Promise<Cap
66
66
  * The deployment env file, with one assignment per enabled capability that reaches
67
67
  * its service through the launch environment.
68
68
  *
69
- * MinerU is switched on by the presence of its endpoint and Exa reads its key from
70
- * the launch environment, so a capability the user selected is enabled here as well
71
- * as in the profile layer. Only the names this interview owns are rewritten: a
69
+ * Exa reads its key from the launch environment, so a capability the user selected is enabled here
70
+ * as well as in the profile layer. Only the names this interview owns are rewritten: a
72
71
  * deployment that keeps its own assignments in the same file keeps them, and
73
72
  * deselecting a capability removes the assignment that turned it on.
74
73
  *
@@ -93,6 +92,52 @@ export declare function capabilityEnvironment(answers: CapabilityAnswers, existi
93
92
  * @returns the patch layer's YAML text.
94
93
  */
95
94
  export declare function capabilityPatchLayer(answers: CapabilityAnswers): string;
95
+ /**
96
+ * The capability record a previous configured run wrote, or `undefined` when none exists.
97
+ *
98
+ * The record is what lets a later run report the deployment's answers instead of asking
99
+ * again. A malformed or unreadable file is treated as absent rather than fatal: it is a
100
+ * convenience cache, and refusing to boot over it would strand a deployment whose record
101
+ * was corrupted by an interrupted write.
102
+ *
103
+ * @param home - the deployment home holding the record.
104
+ * @returns the enabled ids, and the MinerU endpoint when the record carries one.
105
+ */
106
+ export declare function readCapabilityRecord(home: string): CapabilityAnswers | undefined;
107
+ /**
108
+ * The capabilities a deployment actually has mounted, read from the files the loader and
109
+ * the launch environment consume.
110
+ *
111
+ * A record is a cache of an earlier answer and can drift from the deployment — a profile
112
+ * layer edited by hand, a record deleted, a plugin removed from the distribution. What a
113
+ * capability is *doing* is decided by the profile patch rows and the env file, so that is
114
+ * what a report reads.
115
+ *
116
+ * @param profilePatchText - the profile layer's YAML text.
117
+ * @param envText - the deployment env file's text.
118
+ * @returns the ids found in the profile layer and the env names present.
119
+ */
120
+ export declare function detectEnabledCapabilities(profilePatchText: string, envText: string): {
121
+ readonly mounted: readonly string[];
122
+ readonly env: readonly string[];
123
+ };
124
+ /**
125
+ * One capability's effect, added to or removed from an existing profile layer without
126
+ * disturbing the rest of the file.
127
+ *
128
+ * `dsh-plus start` owns the whole layer and rewrites it from the interview's answers, which
129
+ * is correct for an install that answered once and never edits afterwards. A deployment
130
+ * that maintains its own rows in that same layer cannot be served that way: replacing the
131
+ * file drops every row the operator wrote. This edits the rows this module owns and leaves
132
+ * every other entry, comment, and blank line exactly where it was.
133
+ *
134
+ * @param existing - the layer's current YAML text.
135
+ * @param id - the capability to add or remove.
136
+ * @param enable - whether to add the capability.
137
+ * @param mineruEndpoint - the endpoint to write when enabling MinerU.
138
+ * @returns the layer's new text.
139
+ */
140
+ export declare function editCapabilityPatchLayer(existing: string, id: string, enable: boolean, mineruEndpoint?: string): string;
96
141
  /**
97
142
  * Install and start the backing services the selected capabilities need.
98
143
  *
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  /** Capabilities offered at install time, in interview order. */
5
5
  export const CAPABILITIES = [
@@ -96,15 +96,21 @@ export async function interviewCapabilities(interactive) {
96
96
  ...mineruEndpoint === undefined ? {} : { mineruEndpoint },
97
97
  };
98
98
  }
99
- /** Names this module owns in the deployment env file. */
100
- const CAPABILITY_ENV_NAMES = ['DSH_MINERU_ENDPOINT', 'EXA_API_KEY'];
99
+ /**
100
+ * Names this module owns in the deployment env file.
101
+ *
102
+ * Only names the loader accepts here: a `DSH_`-prefixed variable is refused in any `.env` because
103
+ * `DSH_HOME` is itself bootstrap-only and the file could otherwise relocate the home it is read
104
+ * from. MinerU's endpoint therefore travels in the profile layer, which is also where the plugin
105
+ * reads it.
106
+ */
107
+ const CAPABILITY_ENV_NAMES = ['EXA_API_KEY'];
101
108
  /**
102
109
  * The deployment env file, with one assignment per enabled capability that reaches
103
110
  * its service through the launch environment.
104
111
  *
105
- * MinerU is switched on by the presence of its endpoint and Exa reads its key from
106
- * the launch environment, so a capability the user selected is enabled here as well
107
- * as in the profile layer. Only the names this interview owns are rewritten: a
112
+ * Exa reads its key from the launch environment, so a capability the user selected is enabled here
113
+ * as well as in the profile layer. Only the names this interview owns are rewritten: a
108
114
  * deployment that keeps its own assignments in the same file keeps them, and
109
115
  * deselecting a capability removes the assignment that turned it on.
110
116
  *
@@ -115,6 +121,17 @@ const CAPABILITY_ENV_NAMES = ['DSH_MINERU_ENDPOINT', 'EXA_API_KEY'];
115
121
  export function capabilityEnvironment(answers, existing = '') {
116
122
  const enabled = new Set(answers.enabled);
117
123
  const owned = new Set(CAPABILITY_ENV_NAMES);
124
+ // Values the file already carries for the names this module owns. Re-running a
125
+ // configured start must not delete a credential the deployment already holds: the
126
+ // interview returns no key when the user says they will add it later, and enabling an
127
+ // unrelated capability still rewrites this whole file. Reading the present value back
128
+ // is what keeps `start` from undoing an earlier answer.
129
+ const carried = new Map();
130
+ for (const line of existing.split('\n')) {
131
+ const parsed = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
132
+ if (parsed?.[1] !== undefined && owned.has(parsed[1]))
133
+ carried.set(parsed[1], parsed[2] ?? '');
134
+ }
118
135
  const kept = existing.split('\n').filter((line) => {
119
136
  const name = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
120
137
  return line.trim() !== '' && !line.trimStart().startsWith('#') && (name === undefined || !owned.has(name));
@@ -123,11 +140,10 @@ export function capabilityEnvironment(answers, existing = '') {
123
140
  '# Written by dsh-plus start from the capability interview. Enabling or',
124
141
  '# disabling a capability rewrites the lines below; other lines are kept.',
125
142
  ];
126
- if (enabled.has('mineru')) {
127
- written.push('DSH_MINERU_ENDPOINT=' + (answers.mineruEndpoint ?? DEFAULT_MINERU_ENDPOINT));
128
- }
129
- if (enabled.has('exa') && answers.exaApiKey !== undefined) {
130
- written.push('EXA_API_KEY=' + answers.exaApiKey);
143
+ if (enabled.has('exa')) {
144
+ const key = answers.exaApiKey ?? carried.get('EXA_API_KEY');
145
+ if (key !== undefined && key !== '')
146
+ written.push('EXA_API_KEY=' + key);
131
147
  }
132
148
  return [...written, ...kept].join('\n') + '\n';
133
149
  }
@@ -155,6 +171,20 @@ export function capabilityPatchLayer(answers) {
155
171
  if (enabled.has('computer-use')) {
156
172
  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'");
157
173
  }
174
+ if (enabled.has('officecli')) {
175
+ // The plugin ships inside the mounted bundle and carries its own binary, so the
176
+ // capability needs no host step — but a selection that writes no row mounts nothing,
177
+ // and the capability then reports as ready while the tools stay absent. The row id
178
+ // and package match the plugin's own bundle layer.
179
+ rows.push(' - id: officecli', " name: '@sparkelf/dsh-officecli'");
180
+ }
181
+ // MinerU reads its endpoint from plugin config, not from the environment, and a name prefixed
182
+ // `DSH_` is refused in any .env file because `DSH_HOME` is itself bootstrap-only — the file could
183
+ // otherwise relocate the home it is read from. Writing the endpoint as a profile row is both the
184
+ // plugin's own contract and the only place the loader accepts it.
185
+ if (enabled.has('mineru')) {
186
+ rows.push(' - id: mineru', " name: '@sparkelf/dsh-mineru'", ' config:', ' endpoint: ' + (answers.mineruEndpoint ?? DEFAULT_MINERU_ENDPOINT));
187
+ }
158
188
  const lines = [
159
189
  '# ' + CAPABILITY_MARKER + ' Enabling or disabling a capability',
160
190
  '# rewrites this file; edits here are replaced.',
@@ -179,6 +209,266 @@ function runStep(command, args) {
179
209
  const result = spawnSync(command, [...args], { stdio: 'inherit' });
180
210
  return result.status === 0;
181
211
  }
212
+ /**
213
+ * The capability record a previous configured run wrote, or `undefined` when none exists.
214
+ *
215
+ * The record is what lets a later run report the deployment's answers instead of asking
216
+ * again. A malformed or unreadable file is treated as absent rather than fatal: it is a
217
+ * convenience cache, and refusing to boot over it would strand a deployment whose record
218
+ * was corrupted by an interrupted write.
219
+ *
220
+ * @param home - the deployment home holding the record.
221
+ * @returns the enabled ids, and the MinerU endpoint when the record carries one.
222
+ */
223
+ export function readCapabilityRecord(home) {
224
+ const path = join(home, CAPABILITY_RECORD);
225
+ let parsed;
226
+ try {
227
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
228
+ }
229
+ catch {
230
+ // Absent and unreadable are the same answer here: the caller falls back to the
231
+ // profile and env files, which are the authoritative state a record only mirrors.
232
+ return undefined;
233
+ }
234
+ if (typeof parsed !== 'object' || parsed === null)
235
+ return undefined;
236
+ const record = parsed;
237
+ if (!Array.isArray(record.enabled))
238
+ return undefined;
239
+ const enabled = record.enabled.filter((id) => typeof id === 'string');
240
+ return {
241
+ enabled,
242
+ ...typeof record.mineruEndpoint === 'string' ? { mineruEndpoint: record.mineruEndpoint } : {},
243
+ };
244
+ }
245
+ /**
246
+ * The capabilities a deployment actually has mounted, read from the files the loader and
247
+ * the launch environment consume.
248
+ *
249
+ * A record is a cache of an earlier answer and can drift from the deployment — a profile
250
+ * layer edited by hand, a record deleted, a plugin removed from the distribution. What a
251
+ * capability is *doing* is decided by the profile patch rows and the env file, so that is
252
+ * what a report reads.
253
+ *
254
+ * @param profilePatchText - the profile layer's YAML text.
255
+ * @param envText - the deployment env file's text.
256
+ * @returns the ids found in the profile layer and the env names present.
257
+ */
258
+ export function detectEnabledCapabilities(profilePatchText, envText) {
259
+ const mounted = [];
260
+ // Matching the row id is what the loader does; matching a bare package name would also
261
+ // hit a comment or a name mentioned in an unrelated row's config.
262
+ if (/^\s*- id: web-search-exa\s*$/mu.test(profilePatchText))
263
+ mounted.push('exa');
264
+ if (/^\s*- id: mineru\s*$/mu.test(profilePatchText))
265
+ mounted.push('mineru');
266
+ if (/^\s*- id: officecli\s*$/mu.test(profilePatchText))
267
+ mounted.push('officecli');
268
+ if (/^\s*- id: computer-use\s*$/mu.test(profilePatchText))
269
+ mounted.push('computer-use');
270
+ const env = [];
271
+ for (const line of envText.split('\n')) {
272
+ const name = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
273
+ if (name !== undefined && !env.includes(name))
274
+ env.push(name);
275
+ }
276
+ return { mounted, env };
277
+ }
278
+ /**
279
+ * The patch row one capability mounts, as a list of YAML lines, or `undefined` when the
280
+ * capability is turned on by something other than a row this module owns.
281
+ *
282
+ * MinerU is enabled by a row too, but its endpoint is per-deployment, so the caller passes
283
+ * the answers rather than a fixed row.
284
+ *
285
+ * @param id - the capability id.
286
+ * @returns the insert payload lines, without the surrounding `- insert:` entry.
287
+ */
288
+ function capabilityRows(id) {
289
+ if (id === 'exa') {
290
+ return [
291
+ ' - id: web-search-exa',
292
+ " name: '@deepseek-ai/dsh-web-search-exa'",
293
+ ' config:',
294
+ ' searchType: auto',
295
+ ' numResults: 8',
296
+ ];
297
+ }
298
+ if (id === 'officecli') {
299
+ return [
300
+ ' - id: officecli',
301
+ " name: '@sparkelf/dsh-officecli'",
302
+ ];
303
+ }
304
+ if (id === 'computer-use') {
305
+ return [
306
+ ' - id: computer-use',
307
+ " name: '@deepseek-ai/dsh-computer-use'",
308
+ ' - id: computer-use-cua-driver-mcp',
309
+ " name: '@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp'",
310
+ ];
311
+ }
312
+ return undefined;
313
+ }
314
+ /** The row ids a capability owns in the profile layer. */
315
+ function capabilityRowIds(id) {
316
+ if (id === 'exa')
317
+ return ['web-search-exa'];
318
+ if (id === 'officecli')
319
+ return ['officecli'];
320
+ if (id === 'mineru')
321
+ return ['mineru'];
322
+ if (id === 'computer-use')
323
+ return ['computer-use', 'computer-use-cua-driver-mcp'];
324
+ return [];
325
+ }
326
+ /**
327
+ * One capability's effect, added to or removed from an existing profile layer without
328
+ * disturbing the rest of the file.
329
+ *
330
+ * `dsh-plus start` owns the whole layer and rewrites it from the interview's answers, which
331
+ * is correct for an install that answered once and never edits afterwards. A deployment
332
+ * that maintains its own rows in that same layer cannot be served that way: replacing the
333
+ * file drops every row the operator wrote. This edits the rows this module owns and leaves
334
+ * every other entry, comment, and blank line exactly where it was.
335
+ *
336
+ * @param existing - the layer's current YAML text.
337
+ * @param id - the capability to add or remove.
338
+ * @param enable - whether to add the capability.
339
+ * @param mineruEndpoint - the endpoint to write when enabling MinerU.
340
+ * @returns the layer's new text.
341
+ */
342
+ export function editCapabilityPatchLayer(existing, id, enable, mineruEndpoint) {
343
+ const owned = capabilityRowIds(id);
344
+ const withoutRows = dropOwnedRows(existing, owned, id === 'exa');
345
+ const mineruRows = [
346
+ ' - id: mineru',
347
+ " name: '@sparkelf/dsh-mineru'",
348
+ ' config:',
349
+ ' endpoint: ' + (mineruEndpoint ?? DEFAULT_MINERU_ENDPOINT),
350
+ ];
351
+ const rows = id === 'mineru' && enable ? mineruRows : capabilityRows(id);
352
+ const text = enable && rows !== undefined
353
+ ? insertCapabilityRows(withoutRows, rows, id === 'exa')
354
+ : withoutRows.text;
355
+ // Every file this repository writes ends with exactly one newline; an edited layer must
356
+ // not lose that, and the loader's YAML parse is indifferent to it either way.
357
+ const trimmed = text.replace(/\n+$/u, '');
358
+ return trimmed === '' ? '\n' : trimmed + '\n';
359
+ }
360
+ /**
361
+ * Remove the rows one capability owns, and for Exa the `web` selector row that routes
362
+ * search to it.
363
+ *
364
+ * A row is dropped with the block that belongs to it: the `- id:` line plus its indented
365
+ * continuation lines, and any comment line directly above that documented it. Comment and
366
+ * blank lines separating it from the next entry stay, so the surrounding file keeps its
367
+ * shape.
368
+ *
369
+ * @param existing - the layer's current YAML text.
370
+ * @param ids - the row ids this capability owns.
371
+ * @param withSelector - whether the capability also owns the shared `web` selector row.
372
+ * @returns the remaining text and whether anything was actually removed.
373
+ */
374
+ function dropOwnedRows(existing, ids, withSelector) {
375
+ const lines = existing.split('\n');
376
+ const out = [];
377
+ let removed = false;
378
+ for (let index = 0; index < lines.length; index += 1) {
379
+ const line = lines[index] ?? '';
380
+ const match = /^(\s*)- id:\s*(\S+)\s*$/.exec(line);
381
+ // The `web` selector is shared with the built-in provider: dropping it turns Exa off
382
+ // without touching the row's other keys, because the bundle layer's row reapplies.
383
+ const isSelector = withSelector && match?.[2] === 'web' && match[1] === '';
384
+ if (match === null || (!ids.includes(match[2] ?? '') && !isSelector)) {
385
+ out.push(line);
386
+ continue;
387
+ }
388
+ removed = true;
389
+ const indent = match[1] ?? '';
390
+ // Drop the comments that documented this row, stopping at a blank line so an unrelated
391
+ // comment above a blank line is not consumed.
392
+ while (out.length > 0) {
393
+ const previous = out[out.length - 1] ?? '';
394
+ if (previous.trimStart().startsWith('#') && previous.trim() !== '')
395
+ out.pop();
396
+ else
397
+ break;
398
+ }
399
+ // Then the row's own continuation lines: deeper-indented or blank lines until the next
400
+ // entry at the same or a shallower level.
401
+ for (index += 1; index < lines.length; index += 1) {
402
+ const next = lines[index] ?? '';
403
+ if (next.trim() === '') {
404
+ // A blank line inside a row's block keeps it; a blank line starting a new block
405
+ // belongs to the file's spacing and goes back.
406
+ const after = lines[index + 1] ?? '';
407
+ if (after !== '' && (after.startsWith(indent + ' ') || after.startsWith(indent + '\t') || after.trimStart().startsWith('#'))) {
408
+ continue;
409
+ }
410
+ index -= 1;
411
+ break;
412
+ }
413
+ const nextIndent = /^\s*/.exec(next)?.[0] ?? '';
414
+ if (nextIndent.length > indent.length)
415
+ continue;
416
+ index -= 1;
417
+ break;
418
+ }
419
+ }
420
+ return { text: out.join('\n'), removed };
421
+ }
422
+ /**
423
+ * Add a capability's rows to the layer, creating the `- insert:` entry when the file has
424
+ * none and appending to the existing one otherwise.
425
+ *
426
+ * The loader indexes inserted rows under the layer that inserted them, so appending to an
427
+ * existing `- insert:` list and adding a second one both work; appending keeps the file
428
+ * readable instead of growing one `- insert:` per capability.
429
+ *
430
+ * @param state - the layer with this capability's own rows already removed.
431
+ * @param rows - the insert payload lines to add.
432
+ * @param withSelector - whether Exa's `web` selector row must also be present.
433
+ * @returns the layer's new text.
434
+ */
435
+ function insertCapabilityRows(state, rows, withSelector) {
436
+ const lines = state.text.split('\n');
437
+ const insertIndex = lines.findIndex(line => /^- insert:\s*$/.test(line));
438
+ if (insertIndex >= 0) {
439
+ // Append at the end of the existing insert block: walk past its payload lines.
440
+ let end = insertIndex + 1;
441
+ while (end < lines.length) {
442
+ const line = lines[end] ?? '';
443
+ if (line.trim() === '' || /^\s/.test(line))
444
+ end += 1;
445
+ else
446
+ break;
447
+ }
448
+ while (end > insertIndex + 1 && (lines[end - 1] ?? '').trim() === '')
449
+ end -= 1;
450
+ lines.splice(end, 0, ...rows);
451
+ }
452
+ else {
453
+ // A layer with no entries is the literal `[]`; replacing it keeps one valid array.
454
+ const emptyIndex = lines.findIndex(line => line.trim() === '[]');
455
+ if (emptyIndex >= 0)
456
+ lines.splice(emptyIndex, 1, '- insert:', ...rows);
457
+ else {
458
+ const trailing = lines.length > 0 && (lines[lines.length - 1] ?? '') === '' ? lines.pop() ?? '' : undefined;
459
+ lines.push('- insert:', ...rows);
460
+ if (trailing !== undefined)
461
+ lines.push(trailing);
462
+ }
463
+ }
464
+ if (withSelector && !/^\s*- id: web\s*$/mu.test(lines.join('\n'))) {
465
+ // The selector is a top-level row: it patches the bundle layer's own `web` entry, and
466
+ // it must restate `fetchProvider` because a patch replaces the whole `config` object
467
+ // rather than merging into it.
468
+ lines.push('', '# Route web_search through Exa rather than the built-in provider.', '- id: web', ' config:', ' searchProvider: exa', ' fetchProvider: http');
469
+ }
470
+ return lines.join('\n');
471
+ }
182
472
  /**
183
473
  * Install and start the backing services the selected capabilities need.
184
474
  *
@@ -13,7 +13,7 @@ import { createRequire } from 'node:module';
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { newerVersion } from "./registry-versions.js";
16
- import { CAPABILITIES, CAPABILITY_ENV_FILE, CAPABILITY_MARKER, CAPABILITY_RECORD, DEFAULT_MINERU_ENDPOINT, capabilityEnvironment, capabilityPatchLayer, installCapabilityServices, interviewCapabilities, } from "./standalone-capabilities.js";
16
+ import { CAPABILITIES, CAPABILITY_ENV_FILE, CAPABILITY_MARKER, CAPABILITY_RECORD, DEFAULT_MINERU_ENDPOINT, capabilityEnvironment, capabilityPatchLayer, detectEnabledCapabilities, editCapabilityPatchLayer, installCapabilityServices, interviewCapabilities, readCapabilityRecord, } from "./standalone-capabilities.js";
17
17
  import { DEFAULT_PORT, STOP_GRACE_MILLISECONDS, choosePort, clearState, isRunning, portAvailable, readState, spawnServer, stateDirectory, waitForAuthenticatedUrl, waitForServer, writeState, } from "./standalone-server.js";
18
18
  import { STANDALONE_PROFILE, applyProfileNpmPatches, ensureProfile, pnpmAvailable, pnpmInstallCommand, pnpmInstallCommands, registryOrder, readDistributionProfile, resolvePaths, } from "./standalone-profile.js";
19
19
  /** Milliseconds a start waits for the server to answer before reporting failure. */
@@ -414,6 +414,140 @@ function confirm(question) {
414
414
  });
415
415
  });
416
416
  }
417
+ /**
418
+ * Report and change which optional capabilities this deployment has enabled.
419
+ *
420
+ * The interview runs once, at install time, and a deployment that answered it keeps its
421
+ * answers — so this is the only way to see what it chose or to change one answer later.
422
+ * The report is read from the profile layer and the env file rather than from the record,
423
+ * because those two are what the loader and the launch environment actually consume: a
424
+ * record deleted, or a layer edited by hand, would otherwise describe a deployment that
425
+ * does not exist.
426
+ *
427
+ * `--enable` and `--disable` edit only the rows this module owns. The profile layer is
428
+ * shared with rows an operator wrote themselves, so it is never replaced wholesale.
429
+ *
430
+ * @param argv - arguments after the command word.
431
+ * @returns the process exit code.
432
+ */
433
+ function capabilities(argv) {
434
+ const anchor = installationAnchor();
435
+ const paths = resolvePaths(anchor);
436
+ const patchPath = join(paths.profileDirectory, CAPABILITY_PATCH_FILE);
437
+ const envPath = join(paths.home, CAPABILITY_ENV_FILE);
438
+ const patchText = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
439
+ const envText = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
440
+ const actual = detectEnabledCapabilities(patchText, envText);
441
+ const record = readCapabilityRecord(paths.home);
442
+ let enable = [];
443
+ let disable = [];
444
+ let asJson = false;
445
+ for (let index = 0; index < argv.length; index += 1) {
446
+ const token = argv[index];
447
+ if (token === '--json') {
448
+ asJson = true;
449
+ continue;
450
+ }
451
+ if (token === '--enable' || token === '--disable') {
452
+ const value = argv[index + 1];
453
+ const ids = (value ?? '').split(',').map(id => id.trim()).filter(id => id !== '');
454
+ if (value === undefined || ids.length === 0) {
455
+ console.error(token + ' requires a comma-separated list of capability ids');
456
+ return 1;
457
+ }
458
+ const unknown = ids.filter(id => !CAPABILITIES.some(capability => capability.id === id));
459
+ if (unknown.length > 0) {
460
+ console.error('unknown capability: ' + unknown.join(', '));
461
+ console.error('Known: ' + CAPABILITIES.map(capability => capability.id).join(', '));
462
+ return 1;
463
+ }
464
+ if (token === '--enable')
465
+ enable = [...new Set([...enable, ...ids])];
466
+ else
467
+ disable = [...new Set([...disable, ...ids])];
468
+ index += 1;
469
+ continue;
470
+ }
471
+ console.error('unknown option: ' + String(token));
472
+ return 1;
473
+ }
474
+ const conflicting = enable.filter(id => disable.includes(id));
475
+ if (conflicting.length > 0) {
476
+ console.error('cannot both enable and disable: ' + conflicting.join(', '));
477
+ return 1;
478
+ }
479
+ if (enable.length > 0 || disable.length > 0) {
480
+ const enabledNow = new Set(actual.mounted);
481
+ let nextPatch = patchText;
482
+ for (const id of enable) {
483
+ nextPatch = editCapabilityPatchLayer(nextPatch, id, true, record?.mineruEndpoint);
484
+ enabledNow.add(id);
485
+ }
486
+ for (const id of disable) {
487
+ nextPatch = editCapabilityPatchLayer(nextPatch, id, false);
488
+ enabledNow.delete(id);
489
+ }
490
+ if (!existsSync(patchPath) || !patchText.includes(CAPABILITY_MARKER)) {
491
+ // The layer belongs to the deployment, not to this command. Keep the previous text
492
+ // beside it so a structural mistake is recoverable without a backup.
493
+ const backup = patchPath + '.before-capabilities';
494
+ if (patchText !== '' && !existsSync(backup))
495
+ writeFileSync(backup, patchText);
496
+ }
497
+ writeFileSync(patchPath, nextPatch);
498
+ // The env file is rewritten from the answers so a disabled capability drops the
499
+ // assignment that turned it on, and an enabled one keeps a key already in the file.
500
+ const answers = {
501
+ enabled: [...enabledNow],
502
+ ...record?.mineruEndpoint === undefined ? {} : { mineruEndpoint: record.mineruEndpoint },
503
+ };
504
+ const nextEnv = capabilityEnvironment(answers, envText);
505
+ writeFileSync(envPath, nextEnv);
506
+ const after = detectEnabledCapabilities(nextPatch, nextEnv);
507
+ console.log('Updated ' + CAPABILITY_PATCH_FILE + ' and ' + CAPABILITY_ENV_FILE + '.');
508
+ console.log('Run dsh-plus start (or restart the server) to apply the change.');
509
+ return reportCapabilities(after, record, asJson);
510
+ }
511
+ return reportCapabilities(actual, record, asJson);
512
+ }
513
+ /**
514
+ * Print the capability report.
515
+ *
516
+ * @param actual - the capabilities found in the profile layer and env file.
517
+ * @param record - what the last configured run recorded, when a record exists.
518
+ * @param asJson - when true, print machine-readable JSON instead of the table.
519
+ * @returns the process exit code, always 0.
520
+ */
521
+ function reportCapabilities(actual, record, asJson) {
522
+ const rows = CAPABILITIES.map((capability) => {
523
+ const mounted = actual.mounted.includes(capability.id);
524
+ const recorded = record?.enabled.includes(capability.id);
525
+ return { id: capability.id, title: capability.title, enabled: mounted, recorded: recorded ?? null };
526
+ });
527
+ if (asJson) {
528
+ console.log(JSON.stringify({
529
+ capabilities: rows,
530
+ env: actual.env,
531
+ recordPresent: record !== undefined,
532
+ }, null, 2));
533
+ return 0;
534
+ }
535
+ console.log('Optional capabilities:');
536
+ for (const row of rows) {
537
+ const state = row.enabled ? 'enabled ' : 'disabled';
538
+ // A record that disagrees with the files is the one case worth calling out: the two
539
+ // are supposed to describe the same deployment.
540
+ const drift = row.recorded !== null && row.recorded !== row.enabled ? ' (record says ' + (row.recorded ? 'enabled' : 'disabled') + ')' : '';
541
+ console.log(' ' + (row.enabled ? 'ok ' : '-- ') + row.id.padEnd(14) + state + drift);
542
+ }
543
+ if (record === undefined) {
544
+ console.log('');
545
+ console.log('No ' + CAPABILITY_RECORD + ' yet; the next dsh-plus start will run the interview.');
546
+ }
547
+ console.log('');
548
+ console.log('Change with: dsh-plus capabilities --enable <id> | --disable <id>');
549
+ return 0;
550
+ }
417
551
  async function doctor() {
418
552
  const anchor = installationAnchor();
419
553
  const paths = resolvePaths(anchor);
@@ -429,6 +563,21 @@ async function doctor() {
429
563
  check(existsSync(join(paths.profileDirectory, 'package.json')), 'profile at ' + paths.profileDirectory + ' (dsh-plus start creates it)');
430
564
  const free = await portAvailable(DEFAULT_PORT);
431
565
  check(true, 'port ' + String(DEFAULT_PORT) + (free ? ' is free' : ' is in use; start will choose the next free one'));
566
+ // The capability record and the files that actually enable a capability are written by
567
+ // different runs, so they can disagree: a layer edited by hand, or a record deleted.
568
+ // Reporting the drift here is what turns the record from a write-only file into
569
+ // something a user can trust.
570
+ const patchPath = join(paths.profileDirectory, CAPABILITY_PATCH_FILE);
571
+ const envPath = join(paths.home, CAPABILITY_ENV_FILE);
572
+ const actual = detectEnabledCapabilities(existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '', existsSync(envPath) ? readFileSync(envPath, 'utf8') : '');
573
+ const record = readCapabilityRecord(paths.home);
574
+ const drifted = record === undefined
575
+ ? []
576
+ : CAPABILITIES.filter(capability => record.enabled.includes(capability.id) !== actual.mounted.includes(capability.id));
577
+ check(drifted.length === 0, drifted.length === 0
578
+ ? 'capabilities: ' + (actual.mounted.length === 0 ? '(none enabled)' : actual.mounted.join(', '))
579
+ : 'capabilities disagree with ' + CAPABILITY_RECORD + ': ' + drifted.map(capability => capability.id).join(', ')
580
+ + ' (run dsh-plus capabilities)');
432
581
  console.log(failures === 0 ? 'No problems found.' : String(failures) + ' problem(s) found.');
433
582
  return failures === 0 ? 0 : 1;
434
583
  }
@@ -436,11 +585,12 @@ const USAGE = [
436
585
  'Usage: dsh-plus <command> [options]',
437
586
  '',
438
587
  'Commands:',
439
- ' start start the server (first run creates the profile)',
440
- ' stop stop the server',
441
- ' status report whether the server is running',
442
- ' update move to a newer distribution release',
443
- ' doctor check this installation',
588
+ ' start start the server (first run creates the profile)',
589
+ ' stop stop the server',
590
+ ' status report whether the server is running',
591
+ ' capabilities report or change the optional capabilities',
592
+ ' update move to a newer distribution release',
593
+ ' doctor check this installation',
444
594
  '',
445
595
  'start options:',
446
596
  ' --port <n> port to prefer (default ' + String(DEFAULT_PORT) + '; a taken port moves to the next free one)',
@@ -448,6 +598,11 @@ const USAGE = [
448
598
  ' --no-open do not open a browser',
449
599
  ' --foreground run in this terminal instead of in the background',
450
600
  '',
601
+ 'capabilities options:',
602
+ ' --enable <ids> enable the comma-separated capability ids',
603
+ ' --disable <ids> disable the comma-separated capability ids',
604
+ ' --json print the report as JSON',
605
+ '',
451
606
  'update options:',
452
607
  ' --check report the available release without installing it',
453
608
  ' --yes install without asking',
@@ -470,6 +625,8 @@ export async function runStandaloneCli(argv) {
470
625
  return stop();
471
626
  if (command === 'status')
472
627
  return status();
628
+ if (command === 'capabilities')
629
+ return capabilities(rest);
473
630
  if (command === 'update')
474
631
  return await update(rest);
475
632
  if (command === 'doctor')
package/package.json CHANGED
@@ -217,5 +217,5 @@
217
217
  },
218
218
  "type": "module",
219
219
  "types": "lib/types/index.d.ts",
220
- "version": "0.2.0-rc.34"
220
+ "version": "0.2.0-rc.36"
221
221
  }