@sparkelf/dsh-plus 0.1.0-rc.9 → 0.2.0-rc.2

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
@@ -1,10 +1,1037 @@
1
1
  #!/usr/bin/env node
2
- import { runApply } from "./apply.js";
2
+ import { r as runApply } from "./apply-e0aMC4B4.js";
3
+ import { createRequire } from "node:module";
4
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
5
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, posix, resolve, win32 } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import semver from "semver";
9
+ import { parseDocument } from "yaml";
10
+ import { fileURLToPath } from "node:url";
11
+ import { createServer } from "node:net";
12
+ //#region lib/types/registry-versions.js
13
+ /**
14
+ * Registry queries behind the `update` command.
15
+ *
16
+ * A standalone installation pins one exact distribution version, so it needs two
17
+ * facts npm's own updater cannot give it: which versions exist, and which of them is
18
+ * newer than the one installed. `pnpm update` treats an exact pin as a range of one
19
+ * and always reports "already up to date".
20
+ */
21
+ /** Every published version of one package, oldest first, with publication times. */
22
+ function publishedVersions(name, registry = "https://registry.npmjs.org") {
23
+ let raw;
24
+ try {
25
+ raw = execFileSync("npm", [
26
+ "view",
27
+ name,
28
+ "time",
29
+ "--json",
30
+ "--registry",
31
+ registry
32
+ ], {
33
+ encoding: "utf8",
34
+ stdio: [
35
+ "ignore",
36
+ "pipe",
37
+ "ignore"
38
+ ]
39
+ });
40
+ } catch {
41
+ return [];
42
+ }
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ } catch {
47
+ return [];
48
+ }
49
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return [];
50
+ const times = parsed;
51
+ const entries = [];
52
+ for (const [version, time] of Object.entries(times)) {
53
+ if (version === "created" || version === "modified") continue;
54
+ if (semver.valid(version) === null) continue;
55
+ entries.push(typeof time === "string" ? {
56
+ version,
57
+ publishedAt: time
58
+ } : { version });
59
+ }
60
+ return entries.sort((left, right) => semver.compare(left.version, right.version));
61
+ }
62
+ /**
63
+ * The highest published version that is newer than the installed one.
64
+ *
65
+ * Prerelease ordering matters here: an installation on a release candidate must not
66
+ * be told that an older stable release is an upgrade, and must be told about a newer
67
+ * candidate.
68
+ *
69
+ * @param name - package to query.
70
+ * @param installed - version currently installed.
71
+ * @param registry - registry to query.
72
+ * @returns the newer version, or undefined when the installation is current.
73
+ */
74
+ function newerVersion(name, installed, registry = "https://registry.npmjs.org") {
75
+ return publishedVersions(name, registry).filter((entry) => semver.gt(entry.version, installed)).at(-1);
76
+ }
77
+ //#endregion
78
+ //#region lib/types/standalone-server.js
79
+ /**
80
+ * Process lifecycle for one standalone Plus server.
81
+ *
82
+ * A standalone user starts a server and later stops it, so the process outlives the
83
+ * command that started it. This module owns the state that makes that possible: a
84
+ * pid file beside the profile, an availability probe for the port, and the port
85
+ * search that keeps a second instance usable without asking the user to pick one.
86
+ */
87
+ /** Default port a standalone server binds when the user names none. */
88
+ const DEFAULT_PORT = 3080;
89
+ /** Milliseconds to wait for a stopped server to exit before forcing it. */
90
+ const STOP_GRACE_MILLISECONDS = 1e4;
91
+ /** Where one installation keeps its runtime state. */
92
+ function stateDirectory(home) {
93
+ return join(home, "standalone");
94
+ }
95
+ function statePath(home) {
96
+ return join(stateDirectory(home), "server.json");
97
+ }
98
+ /** Whether a process with this id exists and is signalable. */
99
+ function isRunning(pid) {
100
+ try {
101
+ process.kill(pid, 0);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+ /** Read the recorded server state, or undefined when absent or its process is gone. */
108
+ function readState(home) {
109
+ const path = statePath(home);
110
+ if (!existsSync(path)) return void 0;
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(readFileSync(path, "utf8"));
114
+ } catch {
115
+ return;
116
+ }
117
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
118
+ const record = parsed;
119
+ const pid = record.pid;
120
+ const port = record.port;
121
+ const url = record.url;
122
+ if (typeof pid !== "number" || typeof port !== "number" || typeof url !== "string") return void 0;
123
+ if (!isRunning(pid)) return void 0;
124
+ return {
125
+ pid,
126
+ port,
127
+ url
128
+ };
129
+ }
130
+ /** Record the running server, replacing any previous record. */
131
+ function writeState(home, state) {
132
+ mkdirSync(stateDirectory(home), { recursive: true });
133
+ writeFileSync(statePath(home), JSON.stringify(state, null, 2) + "\n");
134
+ }
135
+ /** Remove the recorded state, whether or not a process still matches it. */
136
+ function clearState(home) {
137
+ rmSync(statePath(home), { force: true });
138
+ }
139
+ /** Whether a TCP port on the loopback interface can be bound. */
140
+ function portAvailable(port, host = "127.0.0.1") {
141
+ return new Promise((resolveAvailability) => {
142
+ const probe = createServer();
143
+ probe.once("error", () => {
144
+ resolveAvailability(false);
145
+ });
146
+ probe.once("listening", () => {
147
+ probe.close(() => {
148
+ resolveAvailability(true);
149
+ });
150
+ });
151
+ probe.listen(port, host);
152
+ });
153
+ }
154
+ /**
155
+ * The first free port at or after `preferred`, so one occupied port never blocks a
156
+ * start the way a bare bind failure would.
157
+ *
158
+ * @param preferred - port the user asked for, or the default.
159
+ * @returns the chosen port, or undefined when the whole search window is occupied.
160
+ */
161
+ async function choosePort(preferred, host = "127.0.0.1") {
162
+ for (let offset = 0; offset < 10; offset += 1) {
163
+ const candidate = preferred + offset;
164
+ if (await portAvailable(candidate, host)) return candidate;
165
+ }
166
+ }
167
+ /** Start a detached server process and return its id. */
168
+ function spawnServer(options) {
169
+ mkdirSync(dirname(options.logPath), { recursive: true });
170
+ const output = spawn(options.command, [...options.args], {
171
+ detached: true,
172
+ stdio: [
173
+ "ignore",
174
+ "pipe",
175
+ "pipe"
176
+ ],
177
+ env: options.env
178
+ });
179
+ const stream = createWriteStream(options.logPath);
180
+ output.stdout?.pipe(stream);
181
+ output.stderr?.pipe(stream);
182
+ output.unref();
183
+ if (output.pid === void 0) throw new Error("the server process did not start");
184
+ return output.pid;
185
+ }
186
+ /**
187
+ * Wait until the server answers, so a start reports success only once the URL works.
188
+ *
189
+ * @param url - URL to poll.
190
+ * @param timeoutMilliseconds - how long to keep polling.
191
+ * @returns whether the server answered within the budget.
192
+ */
193
+ async function waitForServer(url, timeoutMilliseconds) {
194
+ const deadline = Date.now() + timeoutMilliseconds;
195
+ while (Date.now() < deadline) try {
196
+ await fetch(url, {
197
+ method: "GET",
198
+ redirect: "manual"
199
+ });
200
+ return true;
201
+ } catch {
202
+ await new Promise((resolveDelay) => {
203
+ setTimeout(resolveDelay, 250);
204
+ });
205
+ }
206
+ return false;
207
+ }
208
+ /**
209
+ * Wait until the launcher's log carries the authenticated URL.
210
+ *
211
+ * Readiness and the printed line are separate events: the server answers before the
212
+ * launcher finishes its own startup, so the log is polled here rather than read once.
213
+ *
214
+ * @param logPath - the launcher's log file.
215
+ * @param timeoutMilliseconds - how long to keep polling.
216
+ * @returns the printed URL, or undefined when it never appeared.
217
+ */
218
+ async function waitForAuthenticatedUrl(logPath, timeoutMilliseconds) {
219
+ const deadline = Date.now() + timeoutMilliseconds;
220
+ for (;;) {
221
+ let text = "";
222
+ try {
223
+ text = readFileSync(logPath, "utf8");
224
+ } catch {}
225
+ const found = /dsh web: (http:\/\/\S+)/u.exec(text);
226
+ if (found?.[1] !== void 0) return found[1];
227
+ if (Date.now() >= deadline) return void 0;
228
+ await new Promise((resolveDelay) => {
229
+ setTimeout(resolveDelay, 250);
230
+ });
231
+ }
232
+ }
233
+ //#endregion
234
+ //#region lib/types/standalone-profile.js
235
+ /**
236
+ * Runtime paths and profile composition for a standalone Plus installation.
237
+ *
238
+ * A standalone consumer installs the distribution from the registry and owns its
239
+ * profile, so this module answers the two questions every command shares: where the
240
+ * DSH home and profile live, and what the profile manifest must contain. The bundle
241
+ * order comes from the installed distribution rather than from a copy here, because
242
+ * the launcher mounts exactly the bundles the profile names and nothing expands a
243
+ * bundle's own list.
244
+ */
245
+ /** Profile name a standalone installation owns. */
246
+ const STANDALONE_PROFILE = "plus";
247
+ function requireRecord(value, label) {
248
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(label + " must be an object");
249
+ return value;
250
+ }
251
+ function requireStringArray(value, label) {
252
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) throw new Error(label + " must be a string array");
253
+ return value;
254
+ }
255
+ /** Resolve the DSH home, honouring the environment override the launcher itself reads. */
256
+ function resolveHome(env = process.env) {
257
+ const configured = env.DSH_HOME;
258
+ return configured === void 0 || configured === "" ? join(homedir(), ".dsh") : resolve(configured);
259
+ }
260
+ /**
261
+ * Whether one absolute path is the other or sits beneath it.
262
+ *
263
+ * A path outside the parent has a relative form starting with `..`, which every
264
+ * platform answers the same way. Comparing the text against a literal separator is
265
+ * not: Windows separates with a backslash, so a nested path never matched its own
266
+ * ancestor and every Windows installation reported the distribution as sitting
267
+ * outside its own tree.
268
+ *
269
+ * @param candidate - absolute path to test.
270
+ * @param parent - absolute path that may contain it.
271
+ * @param platform - separating convention, injectable so the Windows answer is
272
+ * testable where the suite runs on a POSIX host.
273
+ * @returns true when the candidate is the parent or inside it.
274
+ */
275
+ function isWithin(candidate, parent, platform = process.platform) {
276
+ if (candidate === parent) return true;
277
+ const path = platform === "win32" ? win32 : posix;
278
+ const inside = path.relative(parent, candidate);
279
+ return inside !== "" && !inside.startsWith("..") && !inside.startsWith(path.sep + "..");
280
+ }
281
+ /**
282
+ * Resolve the installed distribution directory from one requiring anchor.
283
+ *
284
+ * The anchor must be a path inside the consumer's own tree. Resolving from this
285
+ * module instead would find the distribution the module itself was loaded from,
286
+ * which is the repository during development and the consumer's install otherwise.
287
+ *
288
+ * @param anchor - `package.json` path or directory in the consumer tree.
289
+ * @returns absolute path to the installed distribution.
290
+ */
291
+ function resolveDistributionDirectory(anchor) {
292
+ let resolved;
293
+ try {
294
+ resolved = dirname(createRequire(anchor).resolve("@sparkelf/dsh-plus/package.json"));
295
+ } catch {
296
+ throw new Error("@sparkelf/dsh-plus is not installed in " + dirname(anchor) + "; run this command from the directory that installed it");
297
+ }
298
+ let current = resolve(dirname(anchor));
299
+ for (;;) {
300
+ if (isWithin(resolved, current)) return resolved;
301
+ const parent = dirname(current);
302
+ if (parent === current) break;
303
+ current = parent;
304
+ }
305
+ throw new Error("@sparkelf/dsh-plus resolved outside " + resolve(dirname(anchor)) + " (found " + resolved + "); run this command from the directory that installed it");
306
+ }
307
+ /** The reviewed bundle order and pins the installed distribution declares. */
308
+ function readDistributionProfile(distributionDirectory) {
309
+ const manifest = requireRecord(JSON.parse(readFileSync(join(distributionDirectory, "package.json"), "utf8")), "Plus distribution manifest");
310
+ const plus = requireRecord(manifest.dshPlus, "dshPlus");
311
+ const profile = requireRecord(plus.profile, "dshPlus.profile");
312
+ const rawDependencies = requireRecord(profile.dependencies, "dshPlus.profile.dependencies");
313
+ const rawAllowBuilds = requireRecord(profile.allowBuilds, "dshPlus.profile.allowBuilds");
314
+ const dependencies = {};
315
+ for (const [name, spec] of Object.entries(rawDependencies)) {
316
+ if (typeof spec !== "string" || spec === "") throw new Error("dshPlus.profile.dependencies." + name + " must be a non-empty string");
317
+ dependencies[name] = spec;
318
+ }
319
+ const allowBuilds = {};
320
+ for (const [name, allowed] of Object.entries(rawAllowBuilds)) {
321
+ if (typeof allowed !== "boolean") throw new Error("dshPlus.profile.allowBuilds." + name + " must be a boolean");
322
+ allowBuilds[name] = allowed;
323
+ }
324
+ const overrides = {};
325
+ const rawOverrides = profile.overrides === void 0 ? {} : requireRecord(profile.overrides, "dshPlus.profile.overrides");
326
+ for (const [name, spec] of Object.entries(rawOverrides)) {
327
+ if (typeof spec !== "string" || spec === "") throw new Error("dshPlus.profile.overrides." + name + " must be a non-empty string");
328
+ overrides[name] = spec;
329
+ }
330
+ const compatibility = requireRecord(plus.compatibility, "dshPlus.compatibility");
331
+ return {
332
+ name: String(manifest.name),
333
+ bundles: requireStringArray(profile.bundles, "dshPlus.profile.bundles"),
334
+ dependencies,
335
+ allowBuilds,
336
+ overrides,
337
+ dshRange: String(compatibility.dsh),
338
+ version: String(manifest.version)
339
+ };
340
+ }
341
+ /**
342
+ * Give the profile its own installed tree, built from the distribution's declarations.
343
+ *
344
+ * The launcher resolves a bundle from the profile directory, so the profile needs its
345
+ * own `node_modules`. Pointing it at the consumer's tree was cheaper, but it made the
346
+ * profile inherit whatever npm had already installed — including the official packages
347
+ * the distribution's `overrides` exist to replace. npm applies `overrides` only from a
348
+ * project's own root, so a profile without its own tree cannot receive them at all, and
349
+ * a patch delivered that way silently never arrives.
350
+ *
351
+ * Installing here makes the profile that root: pnpm reads `overrides` from the
352
+ * profile's own `pnpm-workspace.yaml`, which `writeProfileOverrides` writes before this
353
+ * runs. The install is skipped once the tree exists so a start does not pay for it
354
+ * twice; `dsh-plus apply` remains the command that reinstalls after a change.
355
+ *
356
+ * @param paths - resolved standalone paths.
357
+ * @param consumerDirectory - directory whose `node_modules` holds the installation.
358
+ */
359
+ function installProfilePackages(paths, consumerDirectory) {
360
+ if (!existsSync(join(consumerDirectory, "node_modules"))) throw new Error("no node_modules in " + consumerDirectory + "; run npm install there first");
361
+ const profileModules = join(paths.profileDirectory, "node_modules");
362
+ if (existsSync(profileModules)) {
363
+ linkNestedBundles(paths, profileModules);
364
+ return;
365
+ }
366
+ installWithRegistryFallback(paths);
367
+ alignReplacedPackageNames(profileModules);
368
+ linkNestedBundles(paths, profileModules);
369
+ }
370
+ /**
371
+ * Install the profile from the first registry that answers.
372
+ *
373
+ * The install fetches a full dependency closure, and a mainland consumer reaches the
374
+ * mirror far faster than the origin. Trying the preferred registry and falling back once
375
+ * keeps a first start quick without failing when the preferred one is unreachable.
376
+ *
377
+ * @param paths - resolved standalone paths.
378
+ */
379
+ function installWithRegistryFallback(paths) {
380
+ const registries = registryOrder();
381
+ for (const [index, registry] of registries.entries()) {
382
+ const result = spawnSync("pnpm", [
383
+ "install",
384
+ "--no-frozen-lockfile",
385
+ "--registry",
386
+ registry
387
+ ], {
388
+ cwd: paths.profileDirectory,
389
+ stdio: "inherit",
390
+ shell: process.platform === "win32"
391
+ });
392
+ if (result.error !== void 0) throw result.error;
393
+ if (result.status === 0) return;
394
+ const next = registries[index + 1];
395
+ if (next === void 0) throw new Error("pnpm install in the plus profile failed with exit code " + String(result.status));
396
+ console.log("Install from " + registry + " failed; trying " + next + ".");
397
+ }
398
+ }
399
+ /**
400
+ * Make each replaced package declare the name of the location it occupies.
401
+ *
402
+ * \`overrides\` installs our build at the official path, but the manifest inside still
403
+ * names our scope. The client module system resolves a loader entry's declared name and
404
+ * then requires the manifest it finds to declare that same name
405
+ * (\`client/modules\`: \`name === expectedPackageName\`); a mismatch makes the package own no
406
+ * browser module at all. The symptom is silent — the packages install, the server starts,
407
+ * and the panels those packages render simply never appear.
408
+ *
409
+ * The rewrite replaces the file rather than writing through it: pnpm hard-links a package
410
+ * manifest into its content-addressed store, so an in-place write would edit every
411
+ * profile sharing that store entry.
412
+ *
413
+ * @param profileModules - the profile's \`node_modules\` directory.
414
+ */
415
+ function alignReplacedPackageNames(profileModules) {
416
+ const scoped = join(profileModules, "@deepseek-ai");
417
+ if (!existsSync(scoped)) return;
418
+ for (const entry of readdirSync(scoped)) {
419
+ const manifestPath = join(scoped, entry, "package.json");
420
+ if (!existsSync(manifestPath)) continue;
421
+ const declared = JSON.parse(readFileSync(manifestPath, "utf8"));
422
+ const expected = "@deepseek-ai/" + entry;
423
+ if (declared.name === expected) continue;
424
+ const replaced = {
425
+ ...declared,
426
+ name: expected
427
+ };
428
+ const temporary = manifestPath + ".dsh-name";
429
+ writeFileSync(temporary, JSON.stringify(replaced, null, 2) + "\n");
430
+ renameSync(temporary, manifestPath);
431
+ }
432
+ }
433
+ /** The npm registry the distribution installs from by default. */
434
+ const OFFICIAL_REGISTRY = "https://registry.npmjs.org";
435
+ /** The mainland mirror, which serves the same packages and answers faster there. */
436
+ const MAINLAND_REGISTRY = "https://registry.npmmirror.com";
437
+ /**
438
+ * The registries to try, in order, for this machine.
439
+ *
440
+ * A mainland locale reaches the mirror faster than the origin, which is why the Desktop
441
+ * installer already prefers it there. The same choice belongs to the profile install: it
442
+ * fetches a full dependency closure, so the delay is the first thing a consumer notices.
443
+ * `DSH_PLUS_INSTALL_REGISTRY` overrides the choice, and a failure falls back once.
444
+ *
445
+ * @returns registries to try in order.
446
+ */
447
+ function registryOrder() {
448
+ const configured = process.env.DSH_PLUS_INSTALL_REGISTRY;
449
+ if (configured !== void 0 && configured !== "") return [configured, MAINLAND_REGISTRY];
450
+ const locale = [
451
+ process.env.LANG,
452
+ process.env.LC_ALL,
453
+ Intl.DateTimeFormat().resolvedOptions().locale
454
+ ].filter((value) => value !== void 0).join(" ").toLowerCase();
455
+ return locale.includes("zh") || locale.includes("cn") ? [MAINLAND_REGISTRY, OFFICIAL_REGISTRY] : [OFFICIAL_REGISTRY, MAINLAND_REGISTRY];
456
+ }
457
+ /**
458
+ * Report whether pnpm can run.
459
+ *
460
+ * The profile installs its own tree so its \`overrides\` apply, and only pnpm reads those
461
+ * from a workspace, so pnpm is a prerequisite the distribution cannot supply. Probing
462
+ * first turns a cryptic failure from the install into a message naming what is missing.
463
+ *
464
+ * @returns \`true\` when pnpm answers with a version.
465
+ */
466
+ function pnpmAvailable() {
467
+ return spawnSync("pnpm", ["--version"], {
468
+ stdio: "pipe",
469
+ shell: process.platform === "win32",
470
+ encoding: "utf8"
471
+ }).status === 0;
472
+ }
473
+ /**
474
+ * The commands that install pnpm, in the order worth trying.
475
+ *
476
+ * Corepack ships with Node and needs no download, so it comes first; npm is the fallback
477
+ * for an installation whose Corepack is absent or disabled. Both are the consumer's own
478
+ * toolchain, which is what lets the first start offer to install rather than only report.
479
+ *
480
+ * @returns commands to try in order, stopping at the first that works.
481
+ */
482
+ function pnpmInstallCommands() {
483
+ const registry = registryOrder()[0];
484
+ if (registry === void 0) return ["corepack enable pnpm", "npm install -g pnpm"];
485
+ return ["corepack enable pnpm", "npm install -g pnpm --registry " + registry];
486
+ }
487
+ /**
488
+ * The command to show a consumer who declines the automatic install.
489
+ *
490
+ * @returns the preferred command, which is the one the offer runs first.
491
+ */
492
+ function pnpmInstallCommand() {
493
+ return pnpmInstallCommands()[0] ?? "npm install -g pnpm";
494
+ }
495
+ /**
496
+ * Expose the distribution's nested packages at the top level the profile searches.
497
+ *
498
+ * The profile resolves a bundle from one directory, so a package npm nested under the
499
+ * distribution is invisible there even though the installation carries it. Linking each
500
+ * one beside the hoisted packages keeps a single installed copy and needs no reinstall.
501
+ *
502
+ * @param paths - resolved standalone paths.
503
+ * @param consumerModules - the consumer's `node_modules` directory.
504
+ */
505
+ function linkNestedBundles(paths, consumerModules) {
506
+ const nested = join(paths.distributionDirectory, "node_modules");
507
+ if (!existsSync(nested)) return;
508
+ for (const entry of readdirSync(nested)) {
509
+ if (entry.startsWith("@")) {
510
+ for (const scoped of readdirSync(join(nested, entry))) linkBundle(consumerModules, join(entry, scoped), join(nested, entry, scoped));
511
+ continue;
512
+ }
513
+ linkBundle(consumerModules, entry, join(nested, entry));
514
+ }
515
+ }
516
+ /** Link one nested bundle into the consumer's top level when it is not already there. */
517
+ function linkBundle(consumerModules, name, source) {
518
+ const destination = join(consumerModules, name);
519
+ if (existsSync(destination)) return;
520
+ try {
521
+ symlinkSync(source, destination, "junction");
522
+ } catch {}
523
+ }
524
+ /** Resolve every path a command needs, without creating anything. */
525
+ function resolvePaths(anchor, env = process.env) {
526
+ const home = resolveHome(env);
527
+ return {
528
+ home,
529
+ profileDirectory: join(home, "profiles", STANDALONE_PROFILE),
530
+ distributionDirectory: resolveDistributionDirectory(anchor)
531
+ };
532
+ }
533
+ /**
534
+ * Write the profile manifest when it is absent, leaving an existing one untouched.
535
+ *
536
+ * The distribution's own plugin dependencies are seeded here so the launcher can
537
+ * resolve every bundle from the profile directory: a bundle the profile cannot
538
+ * resolve fails activation with a module-resolution error, and no step in the
539
+ * launcher expands the distribution's bundle list on its own.
540
+ *
541
+ * The profile also links the consumer's installed packages, because the launcher
542
+ * resolves each bundle from the profile directory rather than from the directory
543
+ * that installed them.
544
+ *
545
+ * @param paths - resolved standalone paths.
546
+ * @param consumerDirectory - directory whose `node_modules` holds the installed packages.
547
+ * @returns whether this call created the manifest.
548
+ */
549
+ function ensureProfile(paths, consumerDirectory) {
550
+ const manifestPath = join(paths.profileDirectory, "package.json");
551
+ const distribution = readDistributionProfile(paths.distributionDirectory);
552
+ if (existsSync(manifestPath)) {
553
+ writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds);
554
+ installProfilePackages(paths, consumerDirectory);
555
+ return false;
556
+ }
557
+ mkdirSync(paths.profileDirectory, { recursive: true });
558
+ const manifest = {
559
+ name: "dsh-profile-plus",
560
+ private: true,
561
+ type: "module",
562
+ dependencies: {
563
+ "@sparkelf/dsh-plus": distribution.version,
564
+ "@deepseek-ai/dsh": distribution.dshRange,
565
+ ...distribution.dependencies
566
+ },
567
+ dsh: { profile: {
568
+ bundles: distribution.bundles,
569
+ patchReload: "live"
570
+ } }
571
+ };
572
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
573
+ writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds);
574
+ installProfilePackages(paths, consumerDirectory);
575
+ return true;
576
+ }
577
+ /**
578
+ * Record the distribution's package substitutions in the profile workspace.
579
+ *
580
+ * pnpm reads \`overrides\` from \`pnpm-workspace.yaml\` since version 10 and ignores the
581
+ * same key in \`package.json\`, so a profile that carried it in the manifest would
582
+ * silently install the official package the override meant to replace.
583
+ *
584
+ * @param profileDirectory - the standalone profile directory.
585
+ * @param overrides - official package name to published replacement spec.
586
+ */
587
+ function writeProfileOverrides(profileDirectory, overrides, allowBuilds) {
588
+ const workspacePath = join(profileDirectory, "pnpm-workspace.yaml");
589
+ const document = parseDocument(existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : "");
590
+ const [documentError] = document.errors;
591
+ if (documentError !== void 0) throw new Error("Plus profile workspace is not valid YAML", { cause: documentError });
592
+ if (document.get("packages") === void 0) document.set("packages", ["."]);
593
+ for (const [name, spec] of Object.entries(overrides)) document.setIn(["overrides", name], spec);
594
+ for (const [name, allowed] of Object.entries(allowBuilds)) document.setIn(["allowBuilds", name], allowed);
595
+ if (document.get("nodeLinker") === void 0) document.set("nodeLinker", "hoisted");
596
+ if (document.get("autoInstallPeers") === void 0) document.set("autoInstallPeers", false);
597
+ writeFileSync(workspacePath, String(document));
598
+ }
599
+ /** Run git in one directory, returning undefined instead of throwing when asked to. */
600
+ function git(root, args, acceptFailure = false) {
601
+ const result = spawnSync("git", args, {
602
+ cwd: root,
603
+ encoding: "utf8"
604
+ });
605
+ if (result.status === 0) return result.stdout.trim();
606
+ if (acceptFailure) return void 0;
607
+ const detail = result.stderr.trim();
608
+ throw new Error("git " + args.join(" ") + " failed" + (detail === "" ? "" : ": " + detail));
609
+ }
610
+ /**
611
+ * Apply the reviewed npm-target patches to the profile's installed packages.
612
+ *
613
+ * A standalone installation runs no `apply` step: it installs the distribution from
614
+ * the registry, links the consumer's packages, and starts. The npm patches a
615
+ * distribution declares therefore need an owner that does not require an official
616
+ * source checkout — the source half of the apply step needs one, this does not.
617
+ *
618
+ * The work is idempotent: a patch whose reverse already applies is left alone, so a
619
+ * second start neither re-applies nor fails. A reinstall restores the published bytes,
620
+ * which is why this runs on every start rather than once.
621
+ *
622
+ * @param distributionDirectory - the installed `@sparkelf/dsh-plus` directory.
623
+ * @param profileDirectory - the standalone profile directory.
624
+ * @returns the labels of the patches that were applied.
625
+ */
626
+ function applyProfileNpmPatches(distributionDirectory, profileDirectory) {
627
+ const names = requireRecord(requireRecord(JSON.parse(readFileSync(join(distributionDirectory, "package.json"), "utf8")), "Plus distribution manifest").dshPlus, "dshPlus").patchPackages;
628
+ if (!Array.isArray(names)) throw new Error("dshPlus.patchPackages must be an array");
629
+ const applied = [];
630
+ for (const value of names) {
631
+ if (typeof value !== "string" || value === "") throw new Error("dshPlus.patchPackages entries must be non-empty strings");
632
+ const patchPackage = resolveInstalledPackage(distributionDirectory, value);
633
+ const variants = requireRecord(patchPackage.manifest.dshPatch, value + " dshPatch").variants;
634
+ if (!Array.isArray(variants)) throw new Error(value + " dshPatch.variants must be an array");
635
+ for (const entry of variants) {
636
+ const variant = requireRecord(entry, value + " variant");
637
+ const target = requireRecord(variant.target, value + " variant target");
638
+ if (target.kind !== "npm") continue;
639
+ const targetName = String(target.name);
640
+ const patched = resolveInstalledPackage(profileDirectory, targetName);
641
+ const file = resolve(patchPackage.directory, String(variant.file));
642
+ if (git(patched.directory, [
643
+ "apply",
644
+ "--reverse",
645
+ "--check",
646
+ file
647
+ ], true) !== void 0) continue;
648
+ git(patched.directory, ["apply", file]);
649
+ applied.push(value + " -> " + targetName);
650
+ }
651
+ }
652
+ return applied;
653
+ }
654
+ /** Resolve one installed package's manifest from a requiring directory. */
655
+ function resolveInstalledPackage(from, packageName) {
656
+ const requireFrom = createRequire(join(from, "package.json"));
657
+ let manifestPath;
658
+ try {
659
+ manifestPath = requireFrom.resolve(packageName + "/package.json");
660
+ } catch {
661
+ throw new Error(packageName + " is not installed under " + from);
662
+ }
663
+ const manifest = requireRecord(JSON.parse(readFileSync(manifestPath, "utf8")), packageName + " manifest");
664
+ return {
665
+ name: packageName,
666
+ version: String(manifest.version),
667
+ directory: dirname(manifestPath),
668
+ manifest
669
+ };
670
+ }
671
+ //#endregion
672
+ //#region lib/types/standalone-cli.js
673
+ /**
674
+ * The `dsh-plus` command line for a standalone installation.
675
+ *
676
+ * The commands exist because the launcher underneath is a developer surface: it
677
+ * refuses an existing profile, reports a taken port as a module-resolution stack
678
+ * trace, and exits with the terminal. This dispatcher supplies the missing product
679
+ * layer — a profile created on first start, a free port chosen without asking, a
680
+ * server that survives the terminal, and one place to read the URL.
681
+ */
682
+ /** Milliseconds a start waits for the server to answer before reporting failure. */
683
+ const READY_TIMEOUT_MILLISECONDS = 9e4;
684
+ function parseStartOptions(argv) {
685
+ let port = DEFAULT_PORT;
686
+ let host = "127.0.0.1";
687
+ let open = true;
688
+ let foreground = false;
689
+ for (let index = 0; index < argv.length; index += 1) {
690
+ const token = argv[index];
691
+ if (token === "--port" || token === "-p") {
692
+ const value = argv[index + 1];
693
+ if (value === void 0 || !/^\d+$/u.test(value)) throw new Error("--port requires a number");
694
+ port = Number(value);
695
+ index += 1;
696
+ continue;
697
+ }
698
+ if (token === "--host") {
699
+ const value = argv[index + 1];
700
+ if (value === void 0) throw new Error("--host requires a value");
701
+ host = value;
702
+ index += 1;
703
+ continue;
704
+ }
705
+ if (token === "--no-open") {
706
+ open = false;
707
+ continue;
708
+ }
709
+ if (token === "--foreground") {
710
+ foreground = true;
711
+ continue;
712
+ }
713
+ throw new Error("unknown option: " + token);
714
+ }
715
+ return {
716
+ port,
717
+ host,
718
+ open,
719
+ foreground
720
+ };
721
+ }
722
+ /**
723
+ * Where the installation that owns this command keeps its packages.
724
+ *
725
+ * A global install places the command in a shared prefix and a local install places
726
+ * it in a project; neither has anything to do with the directory the user happens to
727
+ * be in. The search therefore starts at this module own file and climbs to the tree
728
+ * npm laid out around it.
729
+ *
730
+ * The test is the launcher, not the distribution: inside a workspace the package
731
+ * resolves its own name, so looking for the distribution would stop at the package
732
+ * rather than at the tree that owns every dependency.
733
+ *
734
+ * @returns absolute path to the installation root.
735
+ */
736
+ function installationRoot() {
737
+ const here = dirname(fileURLToPath(import.meta.url));
738
+ let current = here;
739
+ for (;;) {
740
+ if (existsSync(join(current, "node_modules", "@deepseek-ai", "dsh"))) return current;
741
+ const parent = dirname(current);
742
+ if (parent === current) return here;
743
+ current = parent;
744
+ }
745
+ }
746
+ /** The package.json this installation resolves its dependencies from. */
747
+ function installationAnchor() {
748
+ return join(installationRoot(), "package.json");
749
+ }
750
+ /** The launcher entry this installation must drive. */
751
+ function launcherEntry(anchor) {
752
+ return createRequire(anchor).resolve("@deepseek-ai/dsh/lib/bin.js");
753
+ }
754
+ /** Run the server in this process, inheriting stdio. */
755
+ function runForeground(entry, port, host, open) {
756
+ const args = [
757
+ entry,
758
+ "--profile",
759
+ STANDALONE_PROFILE,
760
+ "--port",
761
+ String(port),
762
+ "--host",
763
+ host
764
+ ];
765
+ if (!open) args.push("--no-open");
766
+ return spawnSync(process.execPath, args, { stdio: "inherit" }).status ?? 1;
767
+ }
768
+ async function start(argv) {
769
+ const options = parseStartOptions(argv);
770
+ const anchor = installationAnchor();
771
+ const paths = resolvePaths(anchor);
772
+ if (!pnpmAvailable()) {
773
+ const command = pnpmInstallCommand();
774
+ console.log("pnpm is required to install the plus profile, and was not found.");
775
+ console.log("Install it with: " + command);
776
+ if (!await confirm("Install pnpm now?")) {
777
+ console.log("Install pnpm and run dsh-plus start again.");
778
+ return 1;
779
+ }
780
+ let installed = false;
781
+ const preferred = registryOrder()[0];
782
+ for (const candidate of pnpmInstallCommands()) if (spawnSync(candidate, {
783
+ stdio: "inherit",
784
+ shell: true,
785
+ env: preferred === void 0 ? process.env : {
786
+ ...process.env,
787
+ COREPACK_NPM_REGISTRY: preferred
788
+ }
789
+ }).status === 0 && pnpmAvailable()) {
790
+ installed = true;
791
+ break;
792
+ }
793
+ if (!installed) {
794
+ console.log("Could not install pnpm automatically.");
795
+ console.log("Run one of these, then run dsh-plus start again:");
796
+ for (const candidate of pnpmInstallCommands()) console.log(" " + candidate);
797
+ return 1;
798
+ }
799
+ console.log("pnpm installed.");
800
+ }
801
+ const created = ensureProfile(paths, installationRoot());
802
+ console.log(created ? "Created the plus profile at " + paths.profileDirectory : "Using the existing plus profile");
803
+ for (const label of applyProfileNpmPatches(paths.distributionDirectory, paths.profileDirectory)) console.log("Applied the reviewed patch " + label);
804
+ const entry = launcherEntry(anchor);
805
+ if (options.foreground) return runForeground(entry, options.port, options.host, options.open);
806
+ const existing = readState(paths.home);
807
+ if (existing !== void 0) {
808
+ console.log("Plus is already running at " + existing.url);
809
+ console.log("Stop it with: dsh-plus stop");
810
+ return 0;
811
+ }
812
+ return startDetached(paths.home, entry, options);
813
+ }
814
+ async function startDetached(home, entry, options) {
815
+ const port = await choosePort(options.port, options.host);
816
+ if (port === void 0) {
817
+ console.error("No free port in the range " + String(options.port) + "-" + String(options.port + 9) + ".");
818
+ console.error("Pass --port with a free port.");
819
+ return 1;
820
+ }
821
+ if (port !== options.port) console.log("Port " + String(options.port) + " is in use; using " + String(port) + ".");
822
+ const logPath = join(stateDirectory(home), "server.log");
823
+ const env = {
824
+ ...process.env,
825
+ DSH_HOME: home
826
+ };
827
+ const args = [
828
+ entry,
829
+ "--profile",
830
+ STANDALONE_PROFILE,
831
+ "--port",
832
+ String(port),
833
+ "--host",
834
+ options.host,
835
+ "--no-open"
836
+ ];
837
+ const pid = spawnServer({
838
+ command: process.execPath,
839
+ args,
840
+ env,
841
+ logPath
842
+ });
843
+ const url = "http://" + options.host + ":" + String(port) + "/";
844
+ if (!await waitForServer(url, READY_TIMEOUT_MILLISECONDS)) {
845
+ console.error("The server did not answer within " + String(READY_TIMEOUT_MILLISECONDS / 1e3) + "s.");
846
+ console.error("Read " + logPath + " for the startup error.");
847
+ if (isRunning(pid)) process.kill(pid, "SIGTERM");
848
+ return 1;
849
+ }
850
+ const opened = await waitForAuthenticatedUrl(logPath, READY_TIMEOUT_MILLISECONDS) ?? url;
851
+ writeState(home, {
852
+ pid,
853
+ port,
854
+ url: opened
855
+ });
856
+ console.log("Plus is running at " + opened);
857
+ console.log("Logs: " + logPath);
858
+ console.log("Stop it with: dsh-plus stop");
859
+ return 0;
860
+ }
861
+ async function stop() {
862
+ const { home } = resolvePaths(installationAnchor());
863
+ const state = readState(home);
864
+ if (state === void 0) {
865
+ clearState(home);
866
+ console.log("Plus is not running.");
867
+ return 0;
868
+ }
869
+ process.kill(state.pid, "SIGTERM");
870
+ const deadline = Date.now() + STOP_GRACE_MILLISECONDS;
871
+ while (Date.now() < deadline && isRunning(state.pid)) await new Promise((resolveDelay) => {
872
+ setTimeout(resolveDelay, 200);
873
+ });
874
+ if (isRunning(state.pid)) {
875
+ console.log("The server did not exit in " + String(STOP_GRACE_MILLISECONDS / 1e3) + "s; forcing it.");
876
+ process.kill(state.pid, "SIGKILL");
877
+ }
878
+ clearState(home);
879
+ console.log("Plus has stopped.");
880
+ return 0;
881
+ }
882
+ function status() {
883
+ const state = readState(resolvePaths(installationAnchor()).home);
884
+ if (state === void 0) {
885
+ console.log("Plus is not running.");
886
+ console.log("Start it with: dsh-plus start");
887
+ return 0;
888
+ }
889
+ console.log("Plus is running at " + state.url);
890
+ console.log("Process " + String(state.pid) + " on port " + String(state.port));
891
+ return 0;
892
+ }
893
+ /**
894
+ * Move the installation to a newer published release.
895
+ *
896
+ * The profile pins the distribution exactly, so the new version is written into the
897
+ * profile manifest and reinstalled there. A running server keeps the version it
898
+ * started with, so the command reports that a restart is what makes the change take
899
+ * effect rather than pretending the running process changed underneath the user.
900
+ */
901
+ async function update(argv) {
902
+ const checkOnly = argv.includes("--check");
903
+ const assumeYes = argv.includes("--yes");
904
+ const paths = resolvePaths(installationAnchor());
905
+ const distribution = readDistributionProfile(paths.distributionDirectory);
906
+ const installed = distribution.version;
907
+ const newer = newerVersion(distribution.name, installed);
908
+ if (newer === void 0) {
909
+ console.log("Plus " + installed + " is the newest published release.");
910
+ return 0;
911
+ }
912
+ console.log("Installed: " + installed);
913
+ console.log("Available: " + newer.version + (newer.publishedAt === void 0 ? "" : " (" + newer.publishedAt + ")"));
914
+ if (checkOnly) return 0;
915
+ if (!assumeYes && !await confirm("Install " + newer.version + "?")) {
916
+ console.log("Nothing changed.");
917
+ return 0;
918
+ }
919
+ const profilePath = join(paths.profileDirectory, "package.json");
920
+ const profile = JSON.parse(readFileSync(profilePath, "utf8"));
921
+ const dependencies = profile.dependencies;
922
+ if (dependencies === null || typeof dependencies !== "object" || Array.isArray(dependencies)) throw new Error("the profile manifest has no dependencies to update");
923
+ dependencies["@sparkelf/dsh-plus"] = newer.version;
924
+ writeFileSync(profilePath, JSON.stringify(profile, null, 2) + "\n");
925
+ console.log("Updated the profile to " + newer.version + "; installing...");
926
+ if (spawnSync("npm", [
927
+ "install",
928
+ "--no-audit",
929
+ "--no-fund"
930
+ ], {
931
+ cwd: paths.profileDirectory,
932
+ stdio: "inherit",
933
+ env: {
934
+ ...process.env,
935
+ DSH_HOME: paths.home
936
+ }
937
+ }).status !== 0) {
938
+ console.error("The install failed; the profile still requests " + newer.version + ".");
939
+ return 1;
940
+ }
941
+ console.log("Plus is now " + newer.version + ".");
942
+ if (readState(paths.home) !== void 0) console.log("The running server still serves " + installed + "; run dsh-plus restart to load the new release.");
943
+ return 0;
944
+ }
945
+ /** Ask one yes/no question on the terminal. */
946
+ function confirm(question) {
947
+ return new Promise((resolveAnswer) => {
948
+ process.stdout.write(question + " [y/N] ");
949
+ process.stdin.once("data", (chunk) => {
950
+ const answer = String(chunk).trim().toLowerCase();
951
+ resolveAnswer(answer === "y" || answer === "yes");
952
+ });
953
+ });
954
+ }
955
+ async function doctor() {
956
+ const paths = resolvePaths(installationAnchor());
957
+ let failures = 0;
958
+ const check = (ok, line) => {
959
+ console.log((ok ? "ok " : "FAIL ") + line);
960
+ if (!ok) failures += 1;
961
+ };
962
+ check(Number(process.versions.node.split(".")[0]) >= 22, "Node " + process.versions.node + " (needs 22 or newer)");
963
+ check(existsSync(join(paths.distributionDirectory, "package.json")), "Plus distribution at " + paths.distributionDirectory);
964
+ check(existsSync(join(paths.profileDirectory, "package.json")), "profile at " + paths.profileDirectory + " (dsh-plus start creates it)");
965
+ const free = await portAvailable(DEFAULT_PORT);
966
+ check(true, "port " + String(DEFAULT_PORT) + (free ? " is free" : " is in use; start will choose the next free one"));
967
+ console.log(failures === 0 ? "No problems found." : String(failures) + " problem(s) found.");
968
+ return failures === 0 ? 0 : 1;
969
+ }
970
+ const USAGE = [
971
+ "Usage: dsh-plus <command> [options]",
972
+ "",
973
+ "Commands:",
974
+ " start start the server (first run creates the profile)",
975
+ " stop stop the server",
976
+ " status report whether the server is running",
977
+ " update move to a newer distribution release",
978
+ " doctor check this installation",
979
+ "",
980
+ "start options:",
981
+ " --port <n> port to prefer (default " + String(DEFAULT_PORT) + "; a taken port moves to the next free one)",
982
+ " --host <h> interface to bind (default 127.0.0.1)",
983
+ " --no-open do not open a browser",
984
+ " --foreground run in this terminal instead of in the background",
985
+ "",
986
+ "update options:",
987
+ " --check report the available release without installing it",
988
+ " --yes install without asking"
989
+ ].join("\n");
990
+ /**
991
+ * Dispatch one command line.
992
+ *
993
+ * @param argv - arguments after the executable and script name.
994
+ * @returns the process exit code.
995
+ */
996
+ async function runStandaloneCli(argv) {
997
+ const [command, ...rest] = argv;
998
+ if (command === void 0 || command === "--help" || command === "-h" || command === "help") {
999
+ console.log(USAGE);
1000
+ return 0;
1001
+ }
1002
+ if (command === "start") return await start(rest);
1003
+ if (command === "stop") return stop();
1004
+ if (command === "status") return status();
1005
+ if (command === "update") return await update(rest);
1006
+ if (command === "doctor") return doctor();
1007
+ console.error("unknown command: " + command);
1008
+ console.error(USAGE);
1009
+ return 1;
1010
+ }
1011
+ //#endregion
3
1012
  //#region lib/types/bin.js
1013
+ /**
1014
+ * Dispatch the command line.
1015
+ *
1016
+ * `apply` remains the materialization command a source-based installation runs; every
1017
+ * other word is a standalone lifecycle command. Reaching the dispatcher first keeps
1018
+ * one executable for both, so a user who installed the distribution from the registry
1019
+ * never needs to know which half owns a command.
1020
+ *
1021
+ * @returns the process exit code.
1022
+ */
1023
+ async function main() {
1024
+ const argv = process.argv.slice(2);
1025
+ if (argv[0] === "apply") {
1026
+ runApply(argv);
1027
+ return 0;
1028
+ }
1029
+ return await runStandaloneCli(argv);
1030
+ }
4
1031
  try {
5
- runApply(process.argv.slice(2));
1032
+ process.exitCode = await main();
6
1033
  } catch (error) {
7
- console.error(error instanceof Error ? error.stack : error);
1034
+ console.error(error instanceof Error ? error.message : String(error));
8
1035
  process.exitCode = 1;
9
1036
  }
10
1037
  //#endregion