dsh-superagent-console 0.1.1 → 0.1.3

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/index.d.ts CHANGED
@@ -28,6 +28,8 @@ interface Prereq {
28
28
  name: string;
29
29
  ok: boolean;
30
30
  detail: string;
31
+ /** Suggested remediation command for a failed check; absent when none applies. */
32
+ hint?: string;
31
33
  }
32
34
  interface EngineHealth {
33
35
  exit: number;
@@ -86,6 +88,16 @@ interface EngineActionResult {
86
88
  }
87
89
  /** Run the lifecycle script's start/restart action and wait for its verdict. */
88
90
  declare function engineAction(config: ResolvedConsoleConfig, action: 'start' | 'restart', env?: InstallEnv): Promise<EngineActionResult>;
91
+ /**
92
+ * Collect `link:` dependencies whose target no longer exists. The official
93
+ * CLI sails through the package operation itself (pnpm trusts the lockfile's
94
+ * recorded link resolution without checking the target) but its post-install
95
+ * bundle reconciliation probes `node_modules/<name>/package.json` and exits 1
96
+ * with an opaque "cannot resolve profile bundle" error — the shape seen after
97
+ * a hosting checkout is moved and only the manifest specifiers are rewritten.
98
+ * Refusing up front keeps the failure readable and names the repair.
99
+ */
100
+ declare function danglingLinks(config: ResolvedConsoleConfig): string[];
89
101
  /** Minimal structural view of the settings service the sync reads through. */
90
102
  interface SettingsFace {
91
103
  describe(): Array<{
@@ -163,4 +175,4 @@ declare function proxyHandler(options: ProxyOptions): (req: IncomingMessage, res
163
175
  declare const name: string;
164
176
  declare const inject: string[];
165
177
  declare function apply(ctx: Context, config?: Partial<ResolvedConsoleConfig>): void;
166
- export { Config, type ConsoleState, type DetectResult, type Prereq, type ProviderDiff, type ResolvedConsoleConfig, type SettingsFace, type SetupState, type StageId, type SyncMode, type SyncReport, apply, applyProviderSync, detect, diffProviders, engineAction, inject, name, proxyHandler, readEngineProviders, readHostProviders, readSetupState, runInstall };
178
+ export { Config, type ConsoleState, type DetectResult, type Prereq, type ProviderDiff, type ResolvedConsoleConfig, type SettingsFace, type SetupState, type StageId, type SyncMode, type SyncReport, apply, applyProviderSync, danglingLinks, detect, diffProviders, engineAction, inject, name, proxyHandler, readEngineProviders, readHostProviders, readSetupState, runInstall };
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import fs, { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
- import { execFile } from "node:child_process";
5
+ import { execFile, spawn } from "node:child_process";
6
6
  import { parse, stringify } from "yaml";
7
7
  import { Readable } from "node:stream";
8
8
  // src/config.ts
@@ -37,13 +37,31 @@ const Config = z.object({
37
37
  * Every spawn goes through `execFile` with an argv array (never a shell
38
38
  * string), an explicit timeout, and an augmented PATH: the desktop app
39
39
  * process may run with a minimal GUI PATH, so the node installation bin
40
- * directory and the configured `extraPath` entries are prepended.
40
+ * directory, the configured `extraPath` entries, and the app's bundled
41
+ * dsh CLI directory are prepended (in that order; user entries win).
41
42
  */
42
43
  const MAX_BUFFER = 16777216;
43
- /** PATH prepended for every spawned tool: the running node's bin dir plus the configured extras. */
44
+ /**
45
+ * Directory of the dsh CLI shipped inside the desktop app bundle: the
46
+ * Electron binary lives at `<App>.app/Contents/MacOS/<bin>`, so the bundled
47
+ * CLI resolves to `../Resources/runtime/cli/bin`. Null outside an app
48
+ * bundle (plain node runs, tests) or when the bundle carries no CLI.
49
+ * Uncached on purpose: spawns are rare and the probe is one existsSync.
50
+ */
51
+ function bundledCliDir(execPath = process.execPath) {
52
+ const candidate = path.join(path.dirname(execPath), "..", "Resources", "runtime", "cli", "bin");
53
+ return existsSync(path.join(candidate, "dsh")) ? candidate : null;
54
+ }
55
+ /**
56
+ * PATH prepended for every spawned tool: the running node's bin dir, the
57
+ * configured extras, the app's bundled CLI dir (fallback only, so an
58
+ * explicit user install keeps winning), then the inherited PATH.
59
+ */
44
60
  function spawnPath(extraPath) {
45
61
  const parts = [path.dirname(process.execPath)];
46
62
  if (extraPath) parts.push(...extraPath.split(":").filter(Boolean));
63
+ const bundled = bundledCliDir();
64
+ if (bundled) parts.push(bundled);
47
65
  const inherited = process.env.PATH ?? "";
48
66
  return [...parts, inherited].filter(Boolean).join(":");
49
67
  }
@@ -74,6 +92,73 @@ function run(argv, options = {}) {
74
92
  });
75
93
  });
76
94
  }
95
+ /**
96
+ * Spawn like {@link run} but deliver output as it arrives: the plugins API
97
+ * mirrors each chunk into the operation state so the dialog can stream the
98
+ * install log the way the native manager does. Output stays bounded by the
99
+ * consumer (it keeps a tail); this runner only concatenates what it saw.
100
+ */
101
+ function runStreaming(argv, options = {}, onChunk) {
102
+ const [file, ...args] = argv;
103
+ if (!file) throw new Error("runStreaming() needs a command");
104
+ const { cwd, timeoutMs = 3e4, extraPath } = options;
105
+ const child = spawn(file, args, {
106
+ cwd,
107
+ stdio: [
108
+ "ignore",
109
+ "pipe",
110
+ "pipe"
111
+ ],
112
+ env: {
113
+ ...process.env,
114
+ PATH: spawnPath(extraPath)
115
+ },
116
+ windowsHide: true
117
+ });
118
+ let stdout = "";
119
+ let stderr = "";
120
+ let timedOut = false;
121
+ const timer = setTimeout(() => {
122
+ timedOut = true;
123
+ child.kill("SIGKILL");
124
+ }, timeoutMs);
125
+ child.stdout?.on("data", (chunk) => {
126
+ stdout += chunk.toString();
127
+ onChunk?.(chunk.toString());
128
+ });
129
+ child.stderr?.on("data", (chunk) => {
130
+ stderr += chunk.toString();
131
+ onChunk?.(chunk.toString());
132
+ });
133
+ return {
134
+ promise: new Promise((resolve) => {
135
+ const startedAt = Date.now();
136
+ child.on("error", (error) => {
137
+ clearTimeout(timer);
138
+ resolve({
139
+ code: -1,
140
+ stdout,
141
+ stderr: stderr + String(error),
142
+ timedOut
143
+ });
144
+ });
145
+ child.on("close", (code) => {
146
+ clearTimeout(timer);
147
+ const hitTimeout = timedOut && Date.now() - startedAt >= timeoutMs - 1;
148
+ resolve({
149
+ code: code ?? (timedOut || hitTimeout ? -1 : 0),
150
+ stdout,
151
+ stderr,
152
+ timedOut: timedOut || hitTimeout
153
+ });
154
+ });
155
+ }),
156
+ kill: () => {
157
+ clearTimeout(timer);
158
+ child.kill("SIGKILL");
159
+ }
160
+ };
161
+ }
77
162
  /** Truncate captured output for audit logging. */
78
163
  function tail$1(text, max = 2e3) {
79
164
  const value = text.trim();
@@ -220,7 +305,13 @@ async function engineHealth(engineDir, profileName, target, env = {}) {
220
305
  };
221
306
  }
222
307
  }
223
- /** L3: toolchain probe; never throws, missing tools surface as ok:false entries. */
308
+ /** Locale-neutral remediation commands surfaced next to a failed check (darwin only). */
309
+ function missingHint(name) {
310
+ if (process.platform !== "darwin") return void 0;
311
+ if (name === "tmux") return "brew install tmux";
312
+ if (name === "pnpm") return "npm install -g pnpm";
313
+ }
314
+ /** L3: toolchain probe; never throws, missing tools surface as ok:false entries (with a hint where one applies). */
224
315
  async function prereqCheck(env = {}) {
225
316
  const checks = [nodeOk()];
226
317
  for (const tool of TOOL_CHECKS) {
@@ -233,7 +324,8 @@ async function prereqCheck(env = {}) {
233
324
  checks.push({
234
325
  name: tool.name,
235
326
  ok: false,
236
- detail: result.code === -1 ? "not found on PATH" : tail(output, 200)
327
+ detail: result.code === -1 ? "not found on PATH" : tail(output, 200),
328
+ hint: missingHint(tool.name)
237
329
  });
238
330
  continue;
239
331
  }
@@ -242,7 +334,8 @@ async function prereqCheck(env = {}) {
242
334
  checks.push({
243
335
  name: tool.name,
244
336
  ok,
245
- detail: ok ? version : `${version} (needs >= ${tool.min})`
337
+ detail: ok ? version : `${version} (needs >= ${tool.min})`,
338
+ hint: ok ? void 0 : missingHint(tool.name)
246
339
  });
247
340
  }
248
341
  return checks;
@@ -302,6 +395,11 @@ async function detect(config, env = {}) {
302
395
  * own host profile);
303
396
  * - install specs must be non-empty, option-free single tokens; remove
304
397
  * names must already be profile dependencies;
398
+ * - before any install/remove, `link:` dependencies are realpath-checked;
399
+ * a dangling target (checkout moved, manifest rewritten without
400
+ * reinstalling) makes the official CLI's post-install bundle
401
+ * reconciliation exit 1 with an opaque resolve error, so the operation
402
+ * refuses up front and names the repair;
305
403
  * - the engine core package (`dsh-superagent`) can be neither removed
306
404
  * nor toggled — without it the console has no subject;
307
405
  * - operations are single-flight in-process: install, remove, and
@@ -407,6 +505,71 @@ function validateRegistry(raw) {
407
505
  if (!/^https?:\/\/\S+$/.test(registry) || registry.length > 256) throw new PluginRequestError("invalid registry: an http(s) URL is required");
408
506
  return registry;
409
507
  }
508
+ /**
509
+ * Collect `link:` dependencies whose target no longer exists. The official
510
+ * CLI sails through the package operation itself (pnpm trusts the lockfile's
511
+ * recorded link resolution without checking the target) but its post-install
512
+ * bundle reconciliation probes `node_modules/<name>/package.json` and exits 1
513
+ * with an opaque "cannot resolve profile bundle" error — the shape seen after
514
+ * a hosting checkout is moved and only the manifest specifiers are rewritten.
515
+ * Refusing up front keeps the failure readable and names the repair.
516
+ */
517
+ function danglingLinks(config) {
518
+ const manifest = readManifest(config.profileName);
519
+ if (!manifest) return [];
520
+ const dir = profileDir(config.profileName);
521
+ const broken = [];
522
+ for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
523
+ if (!spec.startsWith("link:")) continue;
524
+ const target = spec.slice(5);
525
+ const resolved = path.isAbsolute(target) ? target : path.resolve(dir, target);
526
+ if (!existsSync(resolved)) broken.push(`${name} (${spec})`);
527
+ }
528
+ return broken;
529
+ }
530
+ /** Package-name reading of an install spec, when derivable offline. */
531
+ function specPackageName(config, spec) {
532
+ const trimmed = spec.trim();
533
+ if (trimmed.startsWith("link:") || trimmed.startsWith("file:")) {
534
+ const target = trimmed.slice(trimmed.indexOf(":") + 1);
535
+ const resolved = path.isAbsolute(target) ? target : path.resolve(profileDir(config.profileName), target);
536
+ try {
537
+ const manifest = JSON.parse(readFileSync(path.join(resolved, "package.json"), "utf8"));
538
+ return typeof manifest.name === "string" ? manifest.name : null;
539
+ } catch {
540
+ return null;
541
+ }
542
+ }
543
+ if (/^https?:\/\//.test(trimmed) || trimmed.startsWith("git")) return null;
544
+ if (trimmed.startsWith("@")) {
545
+ const at = trimmed.indexOf("@", 1);
546
+ return at === -1 ? trimmed : trimmed.slice(0, at);
547
+ }
548
+ const at = trimmed.indexOf("@");
549
+ return at === -1 ? trimmed : trimmed.slice(0, at);
550
+ }
551
+ /**
552
+ * Refuse installs before they start when the spec is malformed, already a
553
+ * profile dependency, or the profile carries dangling link: dependencies
554
+ * (the official CLI's post-install reconciliation exits 1 on those). Mirrors
555
+ * the native manager's checking phase and its refusal screen.
556
+ */
557
+ function checkProblems(config, raw) {
558
+ const spec = typeof raw === "string" ? raw.trim() : "";
559
+ if (!spec || spec.length > 512 || /[\r\n]/.test(spec) || spec.startsWith("-")) return [{
560
+ kind: "invalid",
561
+ detail: "a non-empty package name, git URL, tarball, or absolute path is required"
562
+ }];
563
+ const problems = [];
564
+ const broken = danglingLinks(config);
565
+ if (broken.length > 0) problems.push({
566
+ kind: "links",
567
+ detail: broken.join(", ")
568
+ });
569
+ const name = specPackageName(config, spec);
570
+ if (name && Object.hasOwn(readManifest(config.profileName)?.dependencies ?? {}, name)) problems.push({ kind: "installed" });
571
+ return problems;
572
+ }
410
573
  /** Validate a remove/enable target: an existing profile dependency, not the engine core. */
411
574
  function validateName(config, raw, allowProtected = false) {
412
575
  const name = typeof raw === "string" ? raw.trim() : "";
@@ -434,12 +597,29 @@ async function runCliOperation(config, env, kind, target, registryUrl) {
434
597
  target: redact$1(target),
435
598
  ...registryUrl ? { registry: registryUrl } : {}
436
599
  });
437
- const result = await run(argv, {
600
+ const before = Object.keys(readManifest(config.profileName)?.dependencies ?? {});
601
+ let output = "";
602
+ const result = await runStreaming(argv, {
438
603
  timeoutMs: kind === "install" ? 9e5 : 6e5,
439
604
  extraPath: env.extraPath
440
- });
441
- const output = tail$1(redact$1((result.stdout + "\n" + result.stderr).trim()), 4e3);
605
+ }, (chunk) => {
606
+ output = (output + chunk).slice(-4e3);
607
+ last = {
608
+ ...last,
609
+ output: redact$1(output.trim())
610
+ };
611
+ }).promise;
442
612
  const failed = result.code !== 0;
613
+ let installed;
614
+ if (!failed && kind === "install") {
615
+ const after = readManifest(config.profileName)?.dependencies ?? {};
616
+ installed = Object.entries(after).filter(([name]) => !before.includes(name)).map(([name, spec]) => ({
617
+ name,
618
+ spec,
619
+ version: readInstalledManifest(config.profileName, name)?.version ?? null,
620
+ bundle: readInstalledManifest(config.profileName, name)?.dsh?.bundle !== void 0
621
+ }));
622
+ }
443
623
  const state = {
444
624
  running: false,
445
625
  kind,
@@ -448,8 +628,10 @@ async function runCliOperation(config, env, kind, target, registryUrl) {
448
628
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
449
629
  exitCode: result.code,
450
630
  timedOut: result.timedOut,
451
- output,
452
- error: failed ? result.timedOut ? "command timed out" : `exit ${result.code}` : null
631
+ output: tail$1(redact$1((result.stdout + "\n" + result.stderr).trim()), 4e3),
632
+ error: failed ? result.timedOut ? "command timed out" : `exit ${result.code}` : null,
633
+ installed,
634
+ profileDir: profileDir(config.profileName)
453
635
  };
454
636
  last = state;
455
637
  audit$1(config, {
@@ -470,6 +652,8 @@ async function runCliOperation(config, env, kind, target, registryUrl) {
470
652
  function runPluginOperation(config, env, kind, target, registry) {
471
653
  if (config.profileName === "desktop") return Promise.reject(new PluginRequestError("refusing to manage plugins of the desktop profile"));
472
654
  if (inFlight$1) return Promise.reject(new PluginRequestError("another plugin operation is already running"));
655
+ const broken = danglingLinks(config);
656
+ if (broken.length > 0) return Promise.reject(new PluginRequestError(`dangling link dependencies in the ${config.profileName} profile — the official installer's bundle reconciliation would fail; repair them first:\n ` + broken.join("\n ") + `\nFix: correct the targets in package.json and the pnpm-lock version fields, then run 'dsh plugin --profile ${config.profileName} install'.`));
473
657
  const prepared = kind === "install" ? validateSpec(target) : validateName(config, target);
474
658
  const registryUrl = kind === "install" ? validateRegistry(registry) : null;
475
659
  last = {
@@ -1504,6 +1688,11 @@ function consoleApiHandler(options) {
1504
1688
  case "GET /superagent-console/api/plugins/operation":
1505
1689
  sendJson(res, 200, pluginOperation());
1506
1690
  return;
1691
+ case "POST /superagent-console/api/plugins/check": {
1692
+ const body = await readJson(req);
1693
+ sendJson(res, 200, { problems: checkProblems(config, body.spec) });
1694
+ return;
1695
+ }
1507
1696
  case "POST /superagent-console/api/plugins/install": {
1508
1697
  const body = await readJson(req);
1509
1698
  if (pluginBusy()) {
@@ -1730,4 +1919,4 @@ function apply(ctx, config) {
1730
1919
  ctx.logger?.info?.("dsh-superagent-console: provider sync source attached (autoSync %s, mode %s)", resolved.autoSync ? "on" : "off", resolved.syncMode);
1731
1920
  });
1732
1921
  }
1733
- export { Config, apply, applyProviderSync, detect, diffProviders, engineAction, inject, name, proxyHandler, readEngineProviders, readHostProviders, readSetupState, runInstall };
1922
+ export { Config, apply, applyProviderSync, danglingLinks, detect, diffProviders, engineAction, inject, name, proxyHandler, readEngineProviders, readHostProviders, readSetupState, runInstall };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-superagent-console",
3
- "description": "Desktop console for the Superagent engine: the official panel embedded over a same-origin proxy. Detects the engine's state, installs it online (source build into a dedicated profile) when missing, and starts/restarts it on demand; the engine stays resident across desktop restarts.",
4
- "version": "0.1.1",
3
+ "description": "Desktop console for the dsh-superagent engine: same-origin proxied panel with engine detection, guided online install, plugin management, and model-provider sync.",
4
+ "version": "0.1.3",
5
5
  "license": "MIT",
6
6
  "author": "Shuai-S",
7
7
  "type": "module",
@@ -12,7 +12,9 @@
12
12
  "types": "./lib/index.d.ts",
13
13
  "default": "./lib/index.js"
14
14
  },
15
- "./client": "./lib/client.js"
15
+ "./client": "./lib/client.js",
16
+ "./locale/*": "./locale/*",
17
+ "./package.json": "./package.json"
16
18
  },
17
19
  "files": [
18
20
  "lib",
@@ -71,6 +73,7 @@
71
73
  "@deepseek-ai/dsh-client-locale": "0.2.1-alpha.2",
72
74
  "@deepseek-ai/dsh-client-ui-layout": "0.2.1-alpha.2",
73
75
  "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-alpha.2",
76
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.1-rc.5",
74
77
  "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-alpha.2",
75
78
  "@deepseek-ai/dsh-client-ui-settings": "0.2.1-alpha.2",
76
79
  "@deepseek-ai/dsh-client-ui-sidebar": "0.2.1-alpha.2",