@sparkelf/dsh-plus 0.2.0-rc.2 → 0.2.0-rc.20

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
@@ -75,6 +75,297 @@ 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
+ /** Names this module owns in the deployment env file. */
164
+ const CAPABILITY_ENV_NAMES = ["DSH_MINERU_ENDPOINT", "EXA_API_KEY"];
165
+ /**
166
+ * The deployment env file, with one assignment per enabled capability that reaches
167
+ * its service through the launch environment.
168
+ *
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
172
+ * deployment that keeps its own assignments in the same file keeps them, and
173
+ * deselecting a capability removes the assignment that turned it on.
174
+ *
175
+ * @param answers - the interview's answers.
176
+ * @param existing - the file's current text, when it already exists.
177
+ * @returns the environment file's text.
178
+ */
179
+ function capabilityEnvironment(answers, existing = "") {
180
+ const enabled = new Set(answers.enabled);
181
+ const owned = new Set(CAPABILITY_ENV_NAMES);
182
+ const kept = existing.split("\n").filter((line) => {
183
+ const name = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
184
+ return line.trim() !== "" && !line.trimStart().startsWith("#") && (name === void 0 || !owned.has(name));
185
+ });
186
+ 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);
189
+ return [...written, ...kept].join("\n") + "\n";
190
+ }
191
+ /**
192
+ * The profile patch layer that turns the selected capabilities on.
193
+ *
194
+ * The file is data the profile's loader merges, so enabling a capability writes a
195
+ * row rather than editing the deployment. An empty selection still writes the file,
196
+ * because a layer that exists and mounts nothing is how a previously enabled
197
+ * capability is turned back off.
198
+ *
199
+ * The profile's own layer is a separate file with its own name: a deployment that
200
+ * configured something by hand keeps it, and the capability rows are rewritten
201
+ * whole on every start.
202
+ *
203
+ * @param answers - the interview's answers.
204
+ * @returns the patch layer's YAML text.
205
+ */
206
+ function capabilityPatchLayer(answers) {
207
+ const enabled = new Set(answers.enabled);
208
+ const rows = [];
209
+ if (enabled.has("exa")) rows.push(" - id: web-search-exa", " name: '@deepseek-ai/dsh-web-search-exa'", " config:", " searchType: auto", " numResults: 8");
210
+ 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'");
211
+ const lines = ["# Written by dsh-plus start from the capability interview. Enabling or disabling a capability", "# rewrites this file; edits here are replaced."];
212
+ if (rows.length > 0) lines.push("- insert:", ...rows);
213
+ else lines.push("[]");
214
+ if (enabled.has("exa")) lines.push("", "# Route web_search through Exa rather than the built-in provider.", "- id: web", " config:", " searchProvider: exa");
215
+ return lines.join("\n") + "\n";
216
+ }
217
+ /** Run one command, reporting a failure rather than throwing. */
218
+ function runStep(command, args) {
219
+ return spawnSync(command, [...args], { stdio: "inherit" }).status === 0;
220
+ }
221
+ /**
222
+ * Install and start the backing services the selected capabilities need.
223
+ *
224
+ * Enabling a capability means the deployment expects its service to answer, so the
225
+ * command that offers the choice is also the command that provides it. Each step
226
+ * reports what it is doing and, on failure, the command a user can run by hand —
227
+ * a missing runtime is a fact about the host, not a reason to abandon the install.
228
+ *
229
+ * @param answers - the interview's answers.
230
+ * @param home - the deployment home, where generated service units are recorded.
231
+ * @returns the ids whose service is ready.
232
+ */
233
+ async function installCapabilityServices(answers, home) {
234
+ const enabled = new Set(answers.enabled);
235
+ const ready = [];
236
+ mkdirSync(home, { recursive: true });
237
+ writeFileSync(join(home, CAPABILITY_RECORD), JSON.stringify({
238
+ enabled: [...answers.enabled],
239
+ ...answers.mineruEndpoint === void 0 ? {} : { mineruEndpoint: answers.mineruEndpoint }
240
+ }, null, 2) + "\n");
241
+ if (enabled.has("mineru")) {
242
+ console.log("");
243
+ console.log(" MinerU: installing the parser and starting its service...");
244
+ if (installMineru(home) && startMineru(home)) {
245
+ ready.push("mineru");
246
+ console.log(" MinerU: ready at " + (answers.mineruEndpoint ?? "http://127.0.0.1:8000/file_parse"));
247
+ } else {
248
+ console.log(" MinerU: not installed. Install it later with:");
249
+ console.log(" pip install -U \"mineru[core]\"");
250
+ console.log(" mineru-api --host 127.0.0.1 --port 8000");
251
+ }
252
+ }
253
+ if (enabled.has("officecli")) ready.push("officecli");
254
+ if (enabled.has("computer-use")) if (desktopDriverAvailable()) ready.push("computer-use");
255
+ else {
256
+ console.log("");
257
+ console.log(" computer-use: the cua driver was not found on PATH. The plugin is");
258
+ console.log(" still mounted, so it starts once the driver is installed:");
259
+ console.log(" install the cua-driver release for this architecture, then restart");
260
+ }
261
+ return ready;
262
+ }
263
+ /**
264
+ * Install MinerU when it is absent and start its API service.
265
+ *
266
+ * MinerU is a Python package whose API server answers on a local port; the profile
267
+ * points at that port. The parser is installed into a virtual environment under the
268
+ * deployment home and an existing one is upgraded rather than skipped, so enabling
269
+ * the capability keeps the parser current without touching the system interpreter.
270
+ *
271
+ * @param home - the deployment home that owns the venv.
272
+ * @returns whether the package is installed; not whether it answered.
273
+ */
274
+ function installMineru(home) {
275
+ const python = pythonInterpreter();
276
+ if (python === void 0) {
277
+ console.log(" MinerU: no Python interpreter found on PATH.");
278
+ return false;
279
+ }
280
+ const venv = join(home, ".mineru-venv");
281
+ if (!runStep(python, [
282
+ "-m",
283
+ "venv",
284
+ venv
285
+ ])) return false;
286
+ return runStep(join(venv, "bin", "python"), [
287
+ "-m",
288
+ "pip",
289
+ "install",
290
+ "-U",
291
+ "mineru[all]"
292
+ ]);
293
+ }
294
+ /**
295
+ * Start the MinerU API server as a background service when none answers yet.
296
+ *
297
+ * The service has to outlive the install command, so it is registered with the
298
+ * host's service manager when one is available and otherwise started detached.
299
+ *
300
+ * @returns whether the server is running after this call.
301
+ */
302
+ function startMineru(home) {
303
+ const api = mineruApiBinary(home);
304
+ if (api === void 0) return false;
305
+ if (mineruAnswers()) return true;
306
+ if (spawnSync("systemctl", ["--version"], { stdio: "ignore" }).status === 0) {
307
+ writeFileSync("/etc/systemd/system/mineru-api.service", [
308
+ "[Unit]",
309
+ "Description=MinerU document parsing API for DeepSeek Harness Plus",
310
+ "After=network-online.target",
311
+ "Wants=network-online.target",
312
+ "",
313
+ "[Service]",
314
+ "Type=simple",
315
+ "User=root",
316
+ "ExecStart=" + api + " --host 127.0.0.1 --port 8000",
317
+ "Restart=on-failure",
318
+ "RestartSec=2",
319
+ "TimeoutStopSec=15",
320
+ "",
321
+ "[Install]",
322
+ "WantedBy=multi-user.target",
323
+ ""
324
+ ].join("\n"));
325
+ spawnSync("systemctl", ["daemon-reload"], { stdio: "ignore" });
326
+ return spawnSync("systemctl", [
327
+ "enable",
328
+ "--now",
329
+ "mineru-api.service"
330
+ ], { stdio: "ignore" }).status === 0;
331
+ }
332
+ console.log(" MinerU: start the API server with:");
333
+ console.log(" " + api + " --host 127.0.0.1 --port 8000");
334
+ return false;
335
+ }
336
+ /** The Python interpreter to install MinerU with, preferring python3. */
337
+ function pythonInterpreter() {
338
+ return ["python3", "python"].find((candidate) => spawnSync(candidate, ["--version"], { stdio: "ignore" }).status === 0);
339
+ }
340
+ /**
341
+ * The MinerU API entry point, wherever the install placed it.
342
+ *
343
+ * The venv this command creates comes first: it is the interpreter the parser was
344
+ * installed into, so its `mineru-api` is the one that can import MinerU. A binary on
345
+ * PATH is a fallback for a deployment that installed MinerU some other way.
346
+ *
347
+ * @param home - the deployment home that owns the venv.
348
+ * @returns the command to run, or `undefined` when none answers.
349
+ */
350
+ function mineruApiBinary(home) {
351
+ return [join(home, ".mineru-venv", "bin", "mineru-api"), "mineru-api"].find((candidate) => spawnSync(candidate, ["--help"], { stdio: "ignore" }).status === 0);
352
+ }
353
+ /** Whether a MinerU API server already answers on the default port. */
354
+ function mineruAnswers() {
355
+ return spawnSync("curl", [
356
+ "-sf",
357
+ "-o",
358
+ "/dev/null",
359
+ "--max-time",
360
+ "3",
361
+ "http://127.0.0.1:8000/docs"
362
+ ], { stdio: "ignore" }).status === 0;
363
+ }
364
+ /** Whether the Windows-side desktop driver answers from this shell. */
365
+ function desktopDriverAvailable() {
366
+ return spawnSync("cua-driver", ["--version"], { stdio: "ignore" }).status === 0;
367
+ }
368
+ //#endregion
78
369
  //#region lib/types/standalone-server.js
79
370
  /**
80
371
  * Process lifecycle for one standalone Plus server.
@@ -177,8 +468,8 @@ function spawnServer(options) {
177
468
  env: options.env
178
469
  });
179
470
  const stream = createWriteStream(options.logPath);
180
- output.stdout?.pipe(stream);
181
- output.stderr?.pipe(stream);
471
+ output.stdout.pipe(stream);
472
+ output.stderr.pipe(stream);
182
473
  output.unref();
183
474
  if (output.pid === void 0) throw new Error("the server process did not start");
184
475
  return output.pid;
@@ -242,8 +533,55 @@ async function waitForAuthenticatedUrl(logPath, timeoutMilliseconds) {
242
533
  * the launcher mounts exactly the bundles the profile names and nothing expands a
243
534
  * bundle's own list.
244
535
  */
245
- /** Profile name a standalone installation owns. */
536
+ /** Profile name a standalone installation owns when its package declares none. */
246
537
  const STANDALONE_PROFILE = "plus";
538
+ /**
539
+ * Read the deployment facts the installing package declares.
540
+ *
541
+ * A standalone package describes the profile it materializes and the capabilities it
542
+ * omits, so a reduced variant owns a differently named profile and a reduced install
543
+ * without the distribution naming either. The declaration travels with the package
544
+ * because npm resolves the tree long before any command of ours runs.
545
+ *
546
+ * @param anchor - path inside the installing package's tree.
547
+ * @param fallback - profile name to use when the declaration is absent.
548
+ * @returns the declared profile name and omitted capabilities.
549
+ */
550
+ function readStandaloneDeclaration(anchor, fallback = STANDALONE_PROFILE) {
551
+ let current = resolve(anchor);
552
+ for (;;) {
553
+ const declared = readStandaloneFacts(join(current, "package.json"));
554
+ if (declared !== void 0) return declared;
555
+ const parent = dirname(current);
556
+ if (parent === current) return {
557
+ profileName: fallback,
558
+ omittedPackages: {}
559
+ };
560
+ current = parent;
561
+ }
562
+ }
563
+ /** Read one manifest's standalone declaration, or undefined when it carries none. */
564
+ function readStandaloneFacts(manifestPath) {
565
+ if (!existsSync(manifestPath)) return void 0;
566
+ let standalone;
567
+ try {
568
+ standalone = JSON.parse(readFileSync(manifestPath, "utf8")).dshPlusStandalone;
569
+ } catch {
570
+ return;
571
+ }
572
+ const profile = standalone?.profile;
573
+ if (standalone === void 0 || typeof profile !== "string" || profile === "") return void 0;
574
+ const rawOmitted = standalone.omittedPackages;
575
+ const omittedPackages = {};
576
+ if (rawOmitted !== void 0 && typeof rawOmitted === "object" && !Array.isArray(rawOmitted)) for (const [name, spec] of Object.entries(rawOmitted)) {
577
+ if (typeof spec !== "string" || spec === "") throw new Error("dshPlusStandalone.omittedPackages." + name + " must be a non-empty string");
578
+ omittedPackages[name] = spec;
579
+ }
580
+ return {
581
+ profileName: profile,
582
+ omittedPackages
583
+ };
584
+ }
247
585
  function requireRecord(value, label) {
248
586
  if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(label + " must be an object");
249
587
  return value;
@@ -524,9 +862,12 @@ function linkBundle(consumerModules, name, source) {
524
862
  /** Resolve every path a command needs, without creating anything. */
525
863
  function resolvePaths(anchor, env = process.env) {
526
864
  const home = resolveHome(env);
865
+ const declaration = readStandaloneDeclaration(anchor);
527
866
  return {
528
867
  home,
529
- profileDirectory: join(home, "profiles", STANDALONE_PROFILE),
868
+ profileName: declaration.profileName,
869
+ omittedPackages: declaration.omittedPackages,
870
+ profileDirectory: join(home, "profiles", declaration.profileName),
530
871
  distributionDirectory: resolveDistributionDirectory(anchor)
531
872
  };
532
873
  }
@@ -550,7 +891,7 @@ function ensureProfile(paths, consumerDirectory) {
550
891
  const manifestPath = join(paths.profileDirectory, "package.json");
551
892
  const distribution = readDistributionProfile(paths.distributionDirectory);
552
893
  if (existsSync(manifestPath)) {
553
- writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds);
894
+ writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds, paths.omittedPackages);
554
895
  installProfilePackages(paths, consumerDirectory);
555
896
  return false;
556
897
  }
@@ -570,7 +911,7 @@ function ensureProfile(paths, consumerDirectory) {
570
911
  } }
571
912
  };
572
913
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
573
- writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds);
914
+ writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds, paths.omittedPackages);
574
915
  installProfilePackages(paths, consumerDirectory);
575
916
  return true;
576
917
  }
@@ -584,13 +925,14 @@ function ensureProfile(paths, consumerDirectory) {
584
925
  * @param profileDirectory - the standalone profile directory.
585
926
  * @param overrides - official package name to published replacement spec.
586
927
  */
587
- function writeProfileOverrides(profileDirectory, overrides, allowBuilds) {
928
+ function writeProfileOverrides(profileDirectory, overrides, allowBuilds, omittedPackages) {
588
929
  const workspacePath = join(profileDirectory, "pnpm-workspace.yaml");
589
930
  const document = parseDocument(existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : "");
590
931
  const [documentError] = document.errors;
591
932
  if (documentError !== void 0) throw new Error("Plus profile workspace is not valid YAML", { cause: documentError });
592
933
  if (document.get("packages") === void 0) document.set("packages", ["."]);
593
934
  for (const [name, spec] of Object.entries(overrides)) document.setIn(["overrides", name], spec);
935
+ for (const [name, spec] of Object.entries(omittedPackages)) document.setIn(["overrides", name], spec);
594
936
  for (const [name, allowed] of Object.entries(allowBuilds)) document.setIn(["allowBuilds", name], allowed);
595
937
  if (document.get("nodeLinker") === void 0) document.set("nodeLinker", "hoisted");
596
938
  if (document.get("autoInstallPeers") === void 0) document.set("autoInstallPeers", false);
@@ -681,11 +1023,14 @@ function resolveInstalledPackage(from, packageName) {
681
1023
  */
682
1024
  /** Milliseconds a start waits for the server to answer before reporting failure. */
683
1025
  const READY_TIMEOUT_MILLISECONDS = 9e4;
1026
+ /** Profile patch file the capability interview rewrites (the profile's user layer). */
1027
+ const CAPABILITY_PATCH_FILE = "cordis.patch.yml";
684
1028
  function parseStartOptions(argv) {
685
1029
  let port = DEFAULT_PORT;
686
1030
  let host = "127.0.0.1";
687
1031
  let open = true;
688
1032
  let foreground = false;
1033
+ let capabilities;
689
1034
  for (let index = 0; index < argv.length; index += 1) {
690
1035
  const token = argv[index];
691
1036
  if (token === "--port" || token === "-p") {
@@ -710,13 +1055,41 @@ function parseStartOptions(argv) {
710
1055
  foreground = true;
711
1056
  continue;
712
1057
  }
713
- throw new Error("unknown option: " + token);
1058
+ if (token === "--capabilities") {
1059
+ const value = argv[index + 1];
1060
+ if (value === void 0) throw new Error("--capabilities requires a comma-separated list, or an empty string for none");
1061
+ const stated = value === "" ? [] : value.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
1062
+ const offered = new Set(CAPABILITIES.map((capability) => capability.id));
1063
+ for (const id of stated) if (!offered.has(id)) throw new Error("unknown capability \"" + id + "\"; this release offers " + [...offered].join(", "));
1064
+ capabilities = stated;
1065
+ index += 1;
1066
+ continue;
1067
+ }
1068
+ throw new Error("unknown option: " + String(token));
714
1069
  }
715
1070
  return {
716
1071
  port,
717
1072
  host,
718
1073
  open,
719
- foreground
1074
+ foreground,
1075
+ capabilities
1076
+ };
1077
+ }
1078
+ /**
1079
+ * Resolve a stated capability selection.
1080
+ *
1081
+ * An unattended install states what it wants instead of answering the interview, so a
1082
+ * name it does not offer has to fail here rather than silently enable nothing: the
1083
+ * deployment would otherwise start with a capability the caller believes it selected.
1084
+ *
1085
+ * @param ids - capability ids the caller selected.
1086
+ * @returns the answers the interview would have returned.
1087
+ */
1088
+ function selectCapabilities(ids) {
1089
+ const selected = [...new Set(ids)];
1090
+ return {
1091
+ enabled: selected,
1092
+ ...selected.includes("mineru") ? { mineruEndpoint: DEFAULT_MINERU_ENDPOINT } : {}
720
1093
  };
721
1094
  }
722
1095
  /**
@@ -747,16 +1120,35 @@ function installationRoot() {
747
1120
  function installationAnchor() {
748
1121
  return join(installationRoot(), "package.json");
749
1122
  }
1123
+ /**
1124
+ * Path to the installed command's own file, which is where a declaration lookup starts.
1125
+ *
1126
+ * The declaration belongs to the package the user installed, and that package is a
1127
+ * sibling of this module rather than an ancestor: a variant's forwarder imports this
1128
+ * CLI in-process, so `import.meta.url` names `@sparkelf/dsh-plus` while the installed
1129
+ * command is the variant. `process.argv[1]` is the entry that was actually invoked,
1130
+ * which is the package whose declaration applies.
1131
+ *
1132
+ * Walking out from the installation root cannot reach it either: that root is the
1133
+ * project the user ran the command in, so every variant would fall back to the full
1134
+ * profile and keep the capabilities it excluded.
1135
+ *
1136
+ * @returns absolute path to the invoked command, or this module when argv carries none.
1137
+ */
1138
+ function declarationAnchor() {
1139
+ const invoked = process.argv[1];
1140
+ return invoked === void 0 || invoked === "" ? fileURLToPath(import.meta.url) : resolve(invoked);
1141
+ }
750
1142
  /** The launcher entry this installation must drive. */
751
1143
  function launcherEntry(anchor) {
752
1144
  return createRequire(anchor).resolve("@deepseek-ai/dsh/lib/bin.js");
753
1145
  }
754
1146
  /** Run the server in this process, inheriting stdio. */
755
- function runForeground(entry, port, host, open) {
1147
+ function runForeground(entry, profileName, port, host, open) {
756
1148
  const args = [
757
1149
  entry,
758
1150
  "--profile",
759
- STANDALONE_PROFILE,
1151
+ profileName,
760
1152
  "--port",
761
1153
  String(port),
762
1154
  "--host",
@@ -768,7 +1160,7 @@ function runForeground(entry, port, host, open) {
768
1160
  async function start(argv) {
769
1161
  const options = parseStartOptions(argv);
770
1162
  const anchor = installationAnchor();
771
- const paths = resolvePaths(anchor);
1163
+ const paths = resolvePaths(declarationAnchor());
772
1164
  if (!pnpmAvailable()) {
773
1165
  const command = pnpmInstallCommand();
774
1166
  console.log("pnpm is required to install the plus profile, and was not found.");
@@ -799,19 +1191,29 @@ async function start(argv) {
799
1191
  console.log("pnpm installed.");
800
1192
  }
801
1193
  const created = ensureProfile(paths, installationRoot());
802
- console.log(created ? "Created the plus profile at " + paths.profileDirectory : "Using the existing plus profile");
1194
+ console.log(created ? "Created the " + paths.profileName + " profile at " + paths.profileDirectory : "Using the existing " + paths.profileName + " profile");
803
1195
  for (const label of applyProfileNpmPatches(paths.distributionDirectory, paths.profileDirectory)) console.log("Applied the reviewed patch " + label);
1196
+ const answers = created || !existsSync(join(paths.home, "capabilities.json")) ? options.capabilities === void 0 ? await interviewCapabilities(true) : selectCapabilities(options.capabilities) : void 0;
1197
+ if (answers !== void 0) {
1198
+ const ready = await installCapabilityServices(answers, paths.home);
1199
+ writeCapabilityPatch(paths.profileDirectory, answers);
1200
+ writeCapabilityEnvironment(paths.home, answers);
1201
+ console.log(" Enabled: " + (answers.enabled.length === 0 ? "(none)" : answers.enabled.join(", ")));
1202
+ if (answers.enabled.length > 0) console.log(" Services ready: " + (ready.length === 0 ? "(none)" : ready.join(", ")));
1203
+ 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.");
1204
+ }
804
1205
  const entry = launcherEntry(anchor);
805
- if (options.foreground) return runForeground(entry, options.port, options.host, options.open);
1206
+ if (options.foreground) return runForeground(entry, paths.profileName, options.port, options.host, options.open);
806
1207
  const existing = readState(paths.home);
807
1208
  if (existing !== void 0) {
808
1209
  console.log("Plus is already running at " + existing.url);
809
1210
  console.log("Stop it with: dsh-plus stop");
810
1211
  return 0;
811
1212
  }
812
- return startDetached(paths.home, entry, options);
1213
+ return startDetached(paths, entry, options);
813
1214
  }
814
- async function startDetached(home, entry, options) {
1215
+ async function startDetached(paths, entry, options) {
1216
+ const home = paths.home;
815
1217
  const port = await choosePort(options.port, options.host);
816
1218
  if (port === void 0) {
817
1219
  console.error("No free port in the range " + String(options.port) + "-" + String(options.port + 9) + ".");
@@ -819,15 +1221,15 @@ async function startDetached(home, entry, options) {
819
1221
  return 1;
820
1222
  }
821
1223
  if (port !== options.port) console.log("Port " + String(options.port) + " is in use; using " + String(port) + ".");
822
- const logPath = join(stateDirectory(home), "server.log");
1224
+ const logPath = join(stateDirectory(paths.home), "server.log");
823
1225
  const env = {
824
1226
  ...process.env,
825
- DSH_HOME: home
1227
+ DSH_HOME: paths.home
826
1228
  };
827
1229
  const args = [
828
1230
  entry,
829
1231
  "--profile",
830
- STANDALONE_PROFILE,
1232
+ paths.profileName,
831
1233
  "--port",
832
1234
  String(port),
833
1235
  "--host",
@@ -942,6 +1344,40 @@ async function update(argv) {
942
1344
  if (readState(paths.home) !== void 0) console.log("The running server still serves " + installed + "; run dsh-plus restart to load the new release.");
943
1345
  return 0;
944
1346
  }
1347
+ /**
1348
+ * Write the profile's capability patch layer.
1349
+ *
1350
+ * The profile's loader merges this file, so enabling a capability is a data change
1351
+ * rather than an edit to the deployment. Writing it on every configured run also
1352
+ * turns a capability back off when the interview no longer selects it.
1353
+ *
1354
+ * @param profileDirectory - the profile whose layer is replaced.
1355
+ * @param answers - the interview's answers.
1356
+ */
1357
+ function writeCapabilityPatch(profileDirectory, answers) {
1358
+ const path = join(profileDirectory, CAPABILITY_PATCH_FILE);
1359
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : void 0;
1360
+ if (existing !== void 0 && !existing.includes("Written by dsh-plus start from the capability interview.")) {
1361
+ writeFileSync(path + ".before-capabilities", existing);
1362
+ console.log(" Kept the existing profile layer at cordis.patch.yml.before-capabilities");
1363
+ }
1364
+ writeFileSync(path, capabilityPatchLayer(answers));
1365
+ }
1366
+ /**
1367
+ * Write the deployment's capability environment file.
1368
+ *
1369
+ * MinerU is switched on by the presence of its endpoint and Exa reads its key from
1370
+ * the launch environment, so the answers reach those two through `$DSH_HOME/.env`
1371
+ * rather than through the profile layer. The file is replaced whole on every
1372
+ * configured start, which is also how a capability the user dropped stops applying.
1373
+ *
1374
+ * @param home - the deployment home whose env file the launcher reads.
1375
+ * @param answers - the interview's answers.
1376
+ */
1377
+ function writeCapabilityEnvironment(home, answers) {
1378
+ const path = join(home, CAPABILITY_ENV_FILE);
1379
+ writeFileSync(path, capabilityEnvironment(answers, existsSync(path) ? readFileSync(path, "utf8") : ""));
1380
+ }
945
1381
  /** Ask one yes/no question on the terminal. */
946
1382
  function confirm(question) {
947
1383
  return new Promise((resolveAnswer) => {
@@ -1010,6 +1446,8 @@ async function runStandaloneCli(argv) {
1010
1446
  }
1011
1447
  //#endregion
1012
1448
  //#region lib/types/bin.js
1449
+ /** This package's own manifest, read beside the built entry. */
1450
+ const manifestPath = fileURLToPath(new URL("../package.json", import.meta.url));
1013
1451
  /**
1014
1452
  * Dispatch the command line.
1015
1453
  *
@@ -1018,11 +1456,22 @@ async function runStandaloneCli(argv) {
1018
1456
  * one executable for both, so a user who installed the distribution from the registry
1019
1457
  * never needs to know which half owns a command.
1020
1458
  *
1459
+ * `--version` answers with the installed distribution version. The release sequence
1460
+ * drives every family's entry through it to prove an installed artifact runs and reports
1461
+ * the version its tarball carried, so the standalone installer has to answer like the
1462
+ * official launcher does.
1463
+ *
1021
1464
  * @returns the process exit code.
1022
1465
  */
1023
1466
  async function main() {
1024
1467
  const argv = process.argv.slice(2);
1025
- if (argv[0] === "apply") {
1468
+ const first = argv[0];
1469
+ if (first === "--version" || first === "-v") {
1470
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
1471
+ console.log(String(manifest.version));
1472
+ return 0;
1473
+ }
1474
+ if (first === "apply") {
1026
1475
  runApply(argv);
1027
1476
  return 0;
1028
1477
  }
package/lib/types/bin.js CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
2
4
  import { runApply } from "./apply.js";
3
5
  import { runStandaloneCli } from "./standalone-cli.js";
6
+ /** This package's own manifest, read beside the built entry. */
7
+ const manifestPath = fileURLToPath(new URL('../package.json', import.meta.url));
4
8
  /**
5
9
  * Dispatch the command line.
6
10
  *
@@ -9,11 +13,21 @@ import { runStandaloneCli } from "./standalone-cli.js";
9
13
  * one executable for both, so a user who installed the distribution from the registry
10
14
  * never needs to know which half owns a command.
11
15
  *
16
+ * `--version` answers with the installed distribution version. The release sequence
17
+ * drives every family's entry through it to prove an installed artifact runs and reports
18
+ * the version its tarball carried, so the standalone installer has to answer like the
19
+ * official launcher does.
20
+ *
12
21
  * @returns the process exit code.
13
22
  */
14
23
  async function main() {
15
24
  const argv = process.argv.slice(2);
16
25
  const first = argv[0];
26
+ if (first === '--version' || first === '-v') {
27
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
28
+ console.log(String(manifest.version));
29
+ return 0;
30
+ }
17
31
  if (first === 'apply') {
18
32
  // `runApply` parses the command word itself, so it receives the arguments this
19
33
  // dispatcher already inspected rather than a slice that dropped it.