dshmarket 1.2.3 → 1.3.0

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/dsh-cli.js CHANGED
@@ -11,7 +11,9 @@ import { dirname, resolve } from 'node:path';
11
11
  import { logEvent } from './log.js';
12
12
  import { pluginArgsFor } from './pnpm-compat.js';
13
13
  import { profileDir } from './profile.js';
14
- const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
14
+ // 15 min default (slow networks + git installs), overridable for CI/tests.
15
+ // (#6 by @qichuang321.)
16
+ const INSTALL_TIMEOUT_MS = Number(process.env.DSH_MARKET_INSTALL_TIMEOUT_MS) || 15 * 60 * 1000;
15
17
  /**
16
18
  * Windows npm/corepack/pnpm are `.cmd` shims. Node's `spawn` without a shell
17
19
  * cannot start them (ENOENT / EINVAL). Same pattern as dsh's `plugin` forwarder.
@@ -50,6 +52,55 @@ export function killChild(child) {
50
52
  }
51
53
  child.kill('SIGKILL');
52
54
  }
55
+ /** The child of the operation currently running, for /dsh-market/cancel. */
56
+ let activeChild = null;
57
+ let cancelRequested = false;
58
+ /**
59
+ * Kill a child and its whole tree, gracefully where the platform allows:
60
+ * taskkill /T /F on Windows (plain kill() leaves pnpm children running),
61
+ * SIGTERM with a 5s SIGKILL escalation elsewhere so pnpm can clean up.
62
+ * (Cancel flow contributed in #6 by @qichuang321.)
63
+ */
64
+ function killTree(child) {
65
+ if (process.platform === 'win32' && child.pid !== undefined) {
66
+ try {
67
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
68
+ return;
69
+ }
70
+ catch { /* fall through */ }
71
+ }
72
+ // POSIX: the dsh wrapper runs pnpm as a grandchild (spawnSync), which a
73
+ // plain child.kill() leaves running — it keeps our stdio pipes open, so
74
+ // the close event never fires and the market looks stuck "installing".
75
+ // The child is spawned detached as its own process GROUP; kill the group.
76
+ const signalTree = (signal) => {
77
+ if (child.pid === undefined)
78
+ return;
79
+ try {
80
+ process.kill(-child.pid, signal);
81
+ }
82
+ catch {
83
+ try {
84
+ child.kill(signal);
85
+ }
86
+ catch { /* already gone */ }
87
+ }
88
+ };
89
+ signalTree('SIGTERM');
90
+ const escalate = setTimeout(() => signalTree('SIGKILL'), 5000);
91
+ escalate.unref?.();
92
+ }
93
+ /**
94
+ * Cancel the plugin command currently running.
95
+ * @returns true when there was one to cancel.
96
+ */
97
+ export function cancelActive() {
98
+ if (activeChild === null)
99
+ return false;
100
+ cancelRequested = true;
101
+ killTree(activeChild);
102
+ return true;
103
+ }
53
104
  /** Whether `pnpm` resolves on PATH; success is cached, absence is re-probed. */
54
105
  let pnpmReady = false;
55
106
  /** Probe `pnpm --version` on PATH. */
@@ -117,7 +168,7 @@ export function runDshPlugin(profile, pluginArgs) {
117
168
  const target = pluginArgs[pluginArgs.length - 1] ?? '';
118
169
  if (!TARGET_RE.test(target)) {
119
170
  logEvent('error', 'install', `unsafe plugin target rejected: ${JSON.stringify(target)}`);
120
- return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: `unsafe plugin target rejected: ${JSON.stringify(target)}` });
171
+ return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: `unsafe plugin target rejected: ${JSON.stringify(target)}`, cancelled: false });
121
172
  }
122
173
  progress.active = true;
123
174
  progress.target = target;
@@ -132,13 +183,18 @@ export function runDshPlugin(profile, pluginArgs) {
132
183
  env: { ...process.env, CI: 'true' },
133
184
  stdio: ['ignore', 'pipe', 'pipe'],
134
185
  shell: viaShell,
186
+ // Own process group on POSIX so cancel/timeout can kill the whole
187
+ // tree (dsh wrapper + pnpm grandchild) with one group signal.
188
+ detached: process.platform !== 'win32',
135
189
  });
190
+ activeChild = child;
191
+ cancelRequested = false;
136
192
  let stdout = '';
137
193
  let stderr = '';
138
194
  let timedOut = false;
139
195
  const timer = setTimeout(() => {
140
196
  timedOut = true;
141
- killChild(child);
197
+ killTree(child);
142
198
  }, INSTALL_TIMEOUT_MS);
143
199
  child.stdout.on('data', (chunk) => {
144
200
  const text = chunk.toString();
@@ -153,12 +209,16 @@ export function runDshPlugin(profile, pluginArgs) {
153
209
  child.on('error', (error) => {
154
210
  clearTimeout(timer);
155
211
  progress.active = false;
156
- resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}` });
212
+ if (activeChild === child)
213
+ activeChild = null;
214
+ resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}`, cancelled: false });
157
215
  });
158
216
  child.on('close', (code) => {
159
217
  clearTimeout(timer);
160
218
  progress.active = false;
161
- resolvePromise({ exitCode: code, timedOut, stdout, stderr });
219
+ if (activeChild === child)
220
+ activeChild = null;
221
+ resolvePromise({ exitCode: code, timedOut, stdout, stderr, cancelled: cancelRequested });
162
222
  });
163
223
  });
164
224
  }
package/lib/index.js CHANGED
@@ -22,7 +22,10 @@ function argvProfile() {
22
22
  return undefined;
23
23
  }
24
24
  export function apply(ctx, config) {
25
- const resolved = { profile: config?.profile ?? argvProfile() ?? 'web' };
25
+ const resolved = {
26
+ profile: config?.profile ?? argvProfile() ?? 'web',
27
+ allowRestart: config?.allowRestart ?? true,
28
+ };
26
29
  ctx.inject(['webServer', 'loader'], (hostCtx) => {
27
30
  const host = hostCtx;
28
31
  host.effect(() => mountMarketRoutes(host, resolved), 'dsh-market: http routes');
package/lib/install.js CHANGED
@@ -19,7 +19,7 @@ import { logEvent } from './log.js';
19
19
  */
20
20
  export async function withHoistRecovery(run, profile, pluginArgs) {
21
21
  let result = await run(profile, pluginArgs);
22
- const ok = (r) => r.exitCode === 0 && !r.timedOut;
22
+ const ok = (r) => r.exitCode === 0 && !r.timedOut && !r.cancelled;
23
23
  if (!ok(result) && classifyPnpmFailure(`${result.stderr}\n${result.stdout}`)?.code === 'hoist-pattern-diff') {
24
24
  logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`);
25
25
  // --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
@@ -28,7 +28,7 @@ export async function withHoistRecovery(run, profile, pluginArgs) {
28
28
  if (ok(rebuild))
29
29
  result = await run(profile, pluginArgs);
30
30
  }
31
- if (!ok(result)) {
31
+ if (!ok(result) && !result.cancelled) {
32
32
  const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`);
33
33
  if (failure !== null)
34
34
  result = { ...result, stderr: `${result.stderr}\n\n${failure.message}` };
@@ -108,3 +108,25 @@ export function isStaleUpdate(check) {
108
108
  ? check.beforeCommit !== null && check.afterCommit === check.beforeCommit
109
109
  : check.beforeVersion !== null && check.afterVersion === check.beforeVersion;
110
110
  }
111
+ /**
112
+ * Package names pnpm reported as having their build scripts ignored
113
+ * ("Ignored build scripts: esbuild, koffi."). Empty when none.
114
+ * (#6 by @qichuang321.)
115
+ */
116
+ export function parseIgnoredBuilds(stdout, stderr) {
117
+ const m = /Ignored build scripts:?\s*([^\n]+)/i.exec(`${stdout}\n${stderr}`);
118
+ if (m === null)
119
+ return [];
120
+ const found = [];
121
+ for (const chunk of m[1].split(',')) {
122
+ // Entries may carry a version suffix and the sentence's final period.
123
+ const trimmed = chunk.trim().replace(/\.$/, '');
124
+ if (trimmed === '')
125
+ continue;
126
+ const at = trimmed.lastIndexOf('@');
127
+ const name = at > 0 ? trimmed.slice(0, at) : trimmed;
128
+ if (name !== '' && !found.includes(name))
129
+ found.push(name);
130
+ }
131
+ return found;
132
+ }
package/lib/profile.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * profile directory (manifest, lockfile, installed package trees). Pure
4
4
  * functions of the directory contents; no processes, no network.
5
5
  */
6
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  /** Resolve a profile name to its directory under DSH_HOME (default ~/.dsh). */
@@ -11,13 +11,25 @@ export function profileDir(profile) {
11
11
  const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
12
12
  return join(home, 'profiles', profile);
13
13
  }
14
- /** Community dependencies of the profile (official in-box scope filtered out). */
14
+ /**
15
+ * The in-box bundles dsh's profile templates install themselves — the ONLY
16
+ * names the market hides from the installed list. Community plugins may
17
+ * legitimately publish under the official scope (#28), so a whole-scope
18
+ * filter would make them invisible and fail install validation.
19
+ * (Diagnosis and fix proposed in #28 by @Lograthmic.)
20
+ */
21
+ const INBOX_BUNDLES = new Set([
22
+ '@deepseek-ai/dsh-base',
23
+ '@deepseek-ai/dsh-web-app',
24
+ '@deepseek-ai/dsh-headless',
25
+ ]);
26
+ /** Community dependencies of the profile (in-box bundles filtered out). */
15
27
  export function readInstalled(profile) {
16
28
  try {
17
29
  const manifest = JSON.parse(readFileSync(join(profileDir(profile), 'package.json'), 'utf8'));
18
30
  const installed = {};
19
31
  for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
20
- if (!name.startsWith('@deepseek-ai/'))
32
+ if (!INBOX_BUNDLES.has(name))
21
33
  installed[name] = spec;
22
34
  }
23
35
  return installed;
@@ -119,3 +131,36 @@ export function pluginSubdirs(root) {
119
131
  }
120
132
  return found.slice(0, 8);
121
133
  }
134
+ /**
135
+ * Allow the given packages' build scripts in the profile's
136
+ * pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
137
+ * merging with existing entries and leaving the rest of the yaml intact.
138
+ * (#6 by @qichuang321.)
139
+ * @returns every package now allowed.
140
+ */
141
+ export function setAllowBuilds(profile, packages) {
142
+ const file = join(profileDir(profile), 'pnpm-workspace.yaml');
143
+ let yaml = '';
144
+ try {
145
+ yaml = readFileSync(file, 'utf8');
146
+ }
147
+ catch { /* created below */ }
148
+ const blockRe = /allowBuilds:\n((?:[ \t]+[^\n]*\n?)*)/;
149
+ const map = {};
150
+ const blockMatch = blockRe.exec(yaml);
151
+ if (blockMatch !== null) {
152
+ for (const line of blockMatch[1].split('\n')) {
153
+ const m = /^[ \t]+([^:\s]+(?:\/[^:\s]+)?)\s*:\s*(\S.*)?$/.exec(line);
154
+ if (m !== null)
155
+ map[m[1]] = m[2] ?? 'true';
156
+ }
157
+ }
158
+ for (const pkg of packages) {
159
+ if (/^[A-Za-z0-9@/_.-]+$/.test(pkg))
160
+ map[pkg] = 'true';
161
+ }
162
+ const block = Object.entries(map).map(([k, v]) => ` ${k}: ${v}`).join('\n');
163
+ const blockText = `allowBuilds:\n${block}\n`;
164
+ writeFileSync(file, blockMatch !== null ? yaml.replace(blockRe, blockText) : `${yaml.replace(/\n?$/, '\n')}${blockText}`);
165
+ return Object.keys(map);
166
+ }
package/lib/restart.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Self-restart: relaunch the exact DSH invocation that booted this host so
3
+ * pending (non-hot) plugin changes take effect without the user leaving the
4
+ * UI. Contributed in #14 by @ysyyhhh; ported onto the layered architecture.
5
+ *
6
+ * Safety model: the endpoint accepts only direct same-origin loopback
7
+ * requests (no forwarding headers), refuses while a plugin operation runs,
8
+ * and deployments under a supervisor (systemd/launchd/pm2) can disable the
9
+ * whole feature with `allowRestart: false` — the supervisor owns restarts.
10
+ */
11
+ import { spawn } from 'node:child_process';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { dshArgv } from './dsh-cli.js';
15
+ /** Self-restart is enabled by default and disabled only by an explicit false. */
16
+ export function restartAllowed(config) {
17
+ return config.allowRestart !== false;
18
+ }
19
+ /** Whether a process-control request came from this Web host on loopback. */
20
+ export function trustedRestartRequest(request) {
21
+ const address = request.socket.remoteAddress;
22
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
23
+ return false;
24
+ // Any forwarding trace means the loopback peer is a proxy, not the user.
25
+ if (request.headers.forwarded !== undefined
26
+ || request.headers['x-forwarded-for'] !== undefined
27
+ || request.headers['x-real-ip'] !== undefined)
28
+ return false;
29
+ const origin = request.headers.origin;
30
+ const host = request.headers.host;
31
+ if (origin === undefined || host === undefined)
32
+ return false;
33
+ try {
34
+ const parsed = new URL(origin);
35
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /** The exact boot invocation the detached restart helper replays. */
42
+ export function restartLaunch() {
43
+ const launch = dshArgv();
44
+ return {
45
+ ...launch,
46
+ args: [...launch.args, ...process.argv.slice(2)],
47
+ cwd: launch.cwd ?? process.cwd(),
48
+ };
49
+ }
50
+ /**
51
+ * Relaunch this exact DSH entry after a short detached handoff, then stop
52
+ * this process. The helper outlives us (detached + unref), waits for our
53
+ * port to free up, and logs the replacement's output under tmpdir.
54
+ */
55
+ export function scheduleRestart() {
56
+ const launch = restartLaunch();
57
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
58
+ const logOut = join(tmpdir(), `dsh-market-restart-${stamp}.out.log`);
59
+ const logErr = join(tmpdir(), `dsh-market-restart-${stamp}.err.log`);
60
+ const helperCode = [
61
+ "const { spawn } = require('node:child_process')",
62
+ "const fs = require('node:fs')",
63
+ `const file = ${JSON.stringify(launch.file)}`,
64
+ `const args = ${JSON.stringify(launch.args)}`,
65
+ `const cwd = ${JSON.stringify(launch.cwd)}`,
66
+ `const viaShell = ${JSON.stringify(launch.viaShell)}`,
67
+ `const logOut = ${JSON.stringify(logOut)}`,
68
+ `const logErr = ${JSON.stringify(logErr)}`,
69
+ 'setTimeout(() => {',
70
+ ' try {',
71
+ ' const out = fs.openSync(logOut, "a")',
72
+ ' const err = fs.openSync(logErr, "a")',
73
+ ' const child = spawn(file, args, { cwd, detached: true, stdio: ["ignore", out, err], env: process.env, shell: viaShell })',
74
+ ' child.unref()',
75
+ ' } catch {}',
76
+ '}, 1500)',
77
+ ].join('\n');
78
+ const helper = spawn(process.execPath, ['-e', helperCode], {
79
+ detached: true,
80
+ stdio: 'ignore',
81
+ env: process.env,
82
+ });
83
+ helper.unref();
84
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 500);
85
+ return { pid: process.pid, helperPid: helper.pid, logOut, logErr };
86
+ }
package/lib/routes.js CHANGED
@@ -7,17 +7,19 @@
7
7
  * Security: the install route executes a shell command, so it accepts only
8
8
  * same-origin POSTs and only sources present in the curated registry.
9
9
  */
10
- import { readFileSync } from 'node:fs';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
11
12
  import { loadRegistry } from './registry.js';
12
13
  import { cleanHotDir, hotMount, hotUnmount, listHotMounts, mountClientOnlyDeps, readDisabledThemes, } from './hot.js';
13
14
  import { exportLogs, logEvent } from './log.js';
14
- import { BOOT_ID, probePnpm, progress, provisionPnpm, runDshPlugin } from './dsh-cli.js';
15
- import { profileDir, readInstalled, readInstalledVersion, readLockCommits } from './profile.js';
15
+ import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin } from './dsh-cli.js';
16
+ import { profileDir, readInstalled, readInstalledVersion, readLockCommits, setAllowBuilds } from './profile.js';
16
17
  import { findInstalledAlias, installTargetFor } from './sources.js';
17
- import { isStaleUpdate, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
18
+ import { isStaleUpdate, parseIgnoredBuilds, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
18
19
  import { checkUpdates, invalidateUpdates } from './updates.js';
19
20
  import { createThemeManager } from './themes.js';
20
21
  import { readJsonBody, sameOrigin, sendJson } from './http.js';
22
+ import { restartAllowed, scheduleRestart, trustedRestartRequest } from './restart.js';
21
23
  const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
22
24
  /**
23
25
  * Register the market's HTTP routes.
@@ -58,6 +60,21 @@ export function mountMarketRoutes(host, config) {
58
60
  void themes.setEntryDisabled(name, true);
59
61
  });
60
62
  let installing = false;
63
+ let restarting = false;
64
+ /**
65
+ * Drop live hot mounts whose package was removed outside the market
66
+ * (e.g. `dsh plugin remove` in a terminal): the stale mount would keep
67
+ * serving a client bundle that 404s after refresh, wedging the page
68
+ * until a restart (#29 by @SunYanbox).
69
+ */
70
+ async function dropStaleHotMounts() {
71
+ for (const name of listHotMounts()) {
72
+ if (existsSync(join(profileDir(config.profile), 'node_modules', name, 'package.json')))
73
+ continue;
74
+ await hotUnmount(name);
75
+ logEvent('warn', 'hot-sweep', `${name}: package removed outside the market — live mount dropped`);
76
+ }
77
+ }
61
78
  /** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
62
79
  const runPlugin = (profile, args) => withHoistRecovery(runDshPlugin, profile, args);
63
80
  const disposers = [
@@ -82,12 +99,13 @@ export function mountMarketRoutes(host, config) {
82
99
  host.webServer.register({
83
100
  kind: 'exact',
84
101
  path: '/dsh-market/installed',
85
- handler: (request, response) => {
102
+ handler: async (request, response) => {
86
103
  if (request.method !== 'GET') {
87
104
  response.writeHead(405, { allow: 'GET' });
88
105
  response.end();
89
106
  return;
90
107
  }
108
+ await dropStaleHotMounts();
91
109
  sendJson(response, 200, {
92
110
  profile: config.profile,
93
111
  installed: readInstalled(config.profile),
@@ -137,6 +155,7 @@ export function mountMarketRoutes(host, config) {
137
155
  response.end();
138
156
  return;
139
157
  }
158
+ await dropStaleHotMounts();
140
159
  sendJson(response, 200, {
141
160
  active: progress.active,
142
161
  target: progress.target,
@@ -144,6 +163,7 @@ export function mountMarketRoutes(host, config) {
144
163
  lastLine: progress.lastLine,
145
164
  pnpm: await probePnpm(),
146
165
  boot: BOOT_ID,
166
+ restart: restartAllowed(config),
147
167
  installed: readInstalled(config.profile),
148
168
  });
149
169
  },
@@ -236,7 +256,8 @@ export function mountMarketRoutes(host, config) {
236
256
  // default one-day safety wait; scoped to this single command.
237
257
  const addArgs = force ? ['add', '--config.minimumReleaseAge=0', target] : ['add', target];
238
258
  const result = await runPlugin(config.profile, addArgs);
239
- let ok = result.exitCode === 0 && !result.timedOut;
259
+ const cancelled = result.cancelled;
260
+ let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
240
261
  let stale = false;
241
262
  if (ok) {
242
263
  stale = isStaleUpdate({
@@ -254,9 +275,11 @@ export function mountMarketRoutes(host, config) {
254
275
  const staleError = stale
255
276
  ? '这个新版本刚发布不久。为了安全,系统默认会等它发布满一天后再安装——刚发布的版本偶尔会被发现问题然后撤回。可以明天再试,或点「立即更新」不再等待。 / This version was just released; for safety, installs normally wait about a day after a release. Try again tomorrow, or click "Update now" to install it right away.'
256
277
  : null;
257
- logEvent(ok ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${stale ? ' STALE(minimumReleaseAge?)' : ''}${ok ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
258
- sendJson(response, ok ? 200 : 502, {
278
+ logEvent(ok || cancelled ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ' STALE(minimumReleaseAge?)' : ''}${ok || cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
279
+ // A user-cancelled run is a quiet outcome, not an error.
280
+ sendJson(response, ok || cancelled ? 200 : 502, {
259
281
  ok,
282
+ cancelled: cancelled || undefined,
260
283
  stale: stale || undefined,
261
284
  error: staleError ?? undefined,
262
285
  exitCode: result.exitCode,
@@ -299,6 +322,104 @@ export function mountMarketRoutes(host, config) {
299
322
  }
300
323
  },
301
324
  }),
325
+ host.webServer.register({
326
+ kind: 'exact',
327
+ path: '/dsh-market/restart',
328
+ handler: (request, response) => {
329
+ if (request.method !== 'POST') {
330
+ response.writeHead(405, { allow: 'POST' });
331
+ response.end();
332
+ return;
333
+ }
334
+ // One-click restart contributed in #14 by @ysyyhhh.
335
+ if (!restartAllowed(config)) {
336
+ sendJson(response, 403, { error: 'self-restart is disabled for this host' });
337
+ return;
338
+ }
339
+ if (!trustedRestartRequest(request)) {
340
+ sendJson(response, 403, { error: 'restart is limited to same-origin loopback requests' });
341
+ return;
342
+ }
343
+ if (installing) {
344
+ sendJson(response, 409, { error: 'cannot restart while a plugin operation is running' });
345
+ return;
346
+ }
347
+ if (restarting) {
348
+ sendJson(response, 409, { error: 'restart already scheduled' });
349
+ return;
350
+ }
351
+ restarting = true;
352
+ try {
353
+ const result = scheduleRestart();
354
+ logEvent('info', 'restart', `scheduled pid=${String(result.pid)} helper=${String(result.helperPid)}`);
355
+ sendJson(response, 202, { ok: true, boot: BOOT_ID, ...result });
356
+ }
357
+ catch (error) {
358
+ restarting = false;
359
+ const message = error instanceof Error ? error.message : String(error);
360
+ logEvent('error', 'restart', message);
361
+ sendJson(response, 500, { error: message });
362
+ }
363
+ },
364
+ }),
365
+ host.webServer.register({
366
+ kind: 'exact',
367
+ path: '/dsh-market/approve-builds',
368
+ handler: async (request, response) => {
369
+ if (request.method !== 'POST') {
370
+ response.writeHead(405, { allow: 'POST' });
371
+ response.end();
372
+ return;
373
+ }
374
+ if (!sameOrigin(request)) {
375
+ sendJson(response, 403, { error: 'untrusted origin' });
376
+ return;
377
+ }
378
+ try {
379
+ // One-click build-script approval (#6 by @qichuang321): only
380
+ // packages ALREADY installed in the profile can be allowed — the
381
+ // list is not free input.
382
+ const body = (await readJsonBody(request));
383
+ const installed = readInstalled(config.profile);
384
+ const packages = (Array.isArray(body.packages) ? body.packages.map(String) : [])
385
+ .filter(name => installed[name] !== undefined);
386
+ if (packages.length === 0) {
387
+ sendJson(response, 400, { error: 'no installed packages given' });
388
+ return;
389
+ }
390
+ const approved = setAllowBuilds(config.profile, packages);
391
+ logEvent('info', 'approve-builds', `allowed build scripts: ${approved.join(', ')}`);
392
+ sendJson(response, 200, { ok: true, approved });
393
+ }
394
+ catch (error) {
395
+ const message = error instanceof Error ? error.message : String(error);
396
+ logEvent('error', 'approve-builds', `route error: ${message}`);
397
+ sendJson(response, 500, { error: message });
398
+ }
399
+ },
400
+ }),
401
+ host.webServer.register({
402
+ kind: 'exact',
403
+ path: '/dsh-market/cancel',
404
+ handler: async (request, response) => {
405
+ if (request.method !== 'POST') {
406
+ response.writeHead(405, { allow: 'POST' });
407
+ response.end();
408
+ return;
409
+ }
410
+ if (!sameOrigin(request)) {
411
+ sendJson(response, 403, { error: 'untrusted origin' });
412
+ return;
413
+ }
414
+ // Cancel flow contributed in #6 by @qichuang321.
415
+ if (!cancelActive()) {
416
+ sendJson(response, 400, { error: 'no operation is running' });
417
+ return;
418
+ }
419
+ logEvent('info', 'cancel', `cancelled ${progress.target || 'operation'}`);
420
+ sendJson(response, 200, { ok: true, cancelled: true, target: progress.target });
421
+ },
422
+ }),
302
423
  host.webServer.register({
303
424
  kind: 'exact',
304
425
  path: '/dsh-market/uninstall',
@@ -330,15 +451,17 @@ export function mountMarketRoutes(host, config) {
330
451
  installing = true;
331
452
  try {
332
453
  const result = await runPlugin(config.profile, ['remove', name]);
333
- const ok = result.exitCode === 0 && !result.timedOut;
454
+ const cancelled = result.cancelled;
455
+ const ok = result.exitCode === 0 && !result.timedOut && !cancelled;
334
456
  let hot = false;
335
457
  if (ok) {
336
458
  invalidateUpdates();
337
459
  hot = await hotUnmount(name);
338
460
  }
339
- logEvent(ok ? 'info' : 'error', 'uninstall', `${name} exit=${String(result.exitCode)}${ok ? ` live-removed=${String(hot)}` : ` stderr=${result.stderr.slice(-300)}`}`);
340
- sendJson(response, ok ? 200 : 502, {
461
+ logEvent(ok || cancelled ? 'info' : 'error', 'uninstall', `${name} exit=${String(result.exitCode)}${cancelled ? ' CANCELLED' : ''}${ok ? ` live-removed=${String(hot)}` : cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
462
+ sendJson(response, ok || cancelled ? 200 : 502, {
341
463
  ok,
464
+ cancelled: cancelled || undefined,
342
465
  hot,
343
466
  exitCode: result.exitCode,
344
467
  stdout: result.stdout,
@@ -405,7 +528,8 @@ export function mountMarketRoutes(host, config) {
405
528
  try {
406
529
  const before = new Set(Object.keys(readInstalled(config.profile)));
407
530
  const result = await runPlugin(config.profile, ['add', target]);
408
- let ok = result.exitCode === 0 && !result.timedOut;
531
+ const cancelled = result.cancelled;
532
+ let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
409
533
  if (ok)
410
534
  invalidateUpdates();
411
535
  if (ok) {
@@ -420,7 +544,7 @@ export function mountMarketRoutes(host, config) {
420
544
  // must never survive to brick the next boot.
421
545
  let notAPlugin = false;
422
546
  let removedBroken = [];
423
- if (result.exitCode === 0 && !result.timedOut) {
547
+ if (result.exitCode === 0 && !result.timedOut && !cancelled) {
424
548
  const validated = await validateAddedPlugins(runPlugin, config.profile, before);
425
549
  removedBroken = validated.removedBroken;
426
550
  if (removedBroken.length > 0) {
@@ -453,10 +577,12 @@ export function mountMarketRoutes(host, config) {
453
577
  }
454
578
  }
455
579
  }
456
- logEvent(ok ? 'info' : 'error', 'install', `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${ok ? ` hot=${String(hot)}` : ` stderr=${result.stderr.slice(-300)}`}`);
457
- sendJson(response, ok ? 200 : 502, {
580
+ logEvent(ok || cancelled ? 'info' : 'error', 'install', `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${ok ? ` hot=${String(hot)}` : cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
581
+ sendJson(response, ok || cancelled ? 200 : 502, {
458
582
  ok,
583
+ cancelled: cancelled || undefined,
459
584
  hot,
585
+ ignoredBuilds: (() => { const list = parseIgnoredBuilds(result.stdout, result.stderr); return list.length > 0 ? list : undefined; })(),
460
586
  error: notAPlugin ? 'nothing installable: the plugin(s) need a build step (blocked by default, see allowBuilds) or ship no prebuilt artifacts / 没有可安装的内容:插件需要构建授权(allowBuilds,默认拦截)或未附带构建产物,详见导出日志' : undefined,
461
587
  exitCode: result.exitCode,
462
588
  timedOut: result.timedOut,
@@ -29,6 +29,8 @@ export interface InstallResult {
29
29
  timedOut: boolean;
30
30
  stdout: string;
31
31
  stderr: string;
32
+ /** True when the run ended because the user cancelled it. */
33
+ cancelled: boolean;
32
34
  }
33
35
  /** The shape every orchestration function takes to run plugin commands (injectable in tests). */
34
36
  export type PluginRunner = (profile: string, pluginArgs: string[]) => Promise<InstallResult>;
@@ -38,6 +40,11 @@ export type PluginRunner = (profile: string, pluginArgs: string[]) => Promise<In
38
40
  * (Contributed in #7 by @mraing.)
39
41
  */
40
42
  export declare function killChild(child: ChildProcess): void;
43
+ /**
44
+ * Cancel the plugin command currently running.
45
+ * @returns true when there was one to cancel.
46
+ */
47
+ export declare function cancelActive(): boolean;
41
48
  /** Probe `pnpm --version` on PATH. */
42
49
  export declare function probePnpm(): Promise<boolean>;
43
50
  /**
@@ -47,3 +47,9 @@ export declare function isStaleUpdate(check: {
47
47
  beforeCommit: string | null;
48
48
  afterCommit: string | null;
49
49
  }): boolean;
50
+ /**
51
+ * Package names pnpm reported as having their build scripts ignored
52
+ * ("Ignored build scripts: esbuild, koffi."). Empty when none.
53
+ * (#6 by @qichuang321.)
54
+ */
55
+ export declare function parseIgnoredBuilds(stdout: string, stderr: string): string[];
@@ -5,7 +5,7 @@
5
5
  */
6
6
  /** Resolve a profile name to its directory under DSH_HOME (default ~/.dsh). */
7
7
  export declare function profileDir(profile: string): string;
8
- /** Community dependencies of the profile (official in-box scope filtered out). */
8
+ /** Community dependencies of the profile (in-box bundles filtered out). */
9
9
  export declare function readInstalled(profile: string): Record<string, string>;
10
10
  /** The version actually present in the profile's node_modules, or null. */
11
11
  export declare function readInstalledVersion(profile: string, name: string): string | null;
@@ -22,3 +22,11 @@ export declare function hasDshManifest(dir: string): boolean;
22
22
  export declare function entryArtifactExists(dir: string): boolean;
23
23
  /** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
24
24
  export declare function pluginSubdirs(root: string): string[];
25
+ /**
26
+ * Allow the given packages' build scripts in the profile's
27
+ * pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
28
+ * merging with existing entries and leaving the rest of the yaml intact.
29
+ * (#6 by @qichuang321.)
30
+ * @returns every package now allowed.
31
+ */
32
+ export declare function setAllowBuilds(profile: string, packages: string[]): string[];