dshmarket 1.2.3 → 1.2.4

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/src/dsh-cli.ts CHANGED
@@ -14,7 +14,9 @@ import { logEvent } from './log.ts'
14
14
  import { pluginArgsFor } from './pnpm-compat.ts'
15
15
  import { profileDir } from './profile.ts'
16
16
 
17
- const INSTALL_TIMEOUT_MS = 5 * 60 * 1000
17
+ // 15 min default (slow networks + git installs), overridable for CI/tests.
18
+ // (#6 by @qichuang321.)
19
+ const INSTALL_TIMEOUT_MS = Number(process.env.DSH_MARKET_INSTALL_TIMEOUT_MS) || 15 * 60 * 1000
18
20
 
19
21
  /**
20
22
  * Windows npm/corepack/pnpm are `.cmd` shims. Node's `spawn` without a shell
@@ -47,6 +49,8 @@ export interface InstallResult {
47
49
  timedOut: boolean
48
50
  stdout: string
49
51
  stderr: string
52
+ /** True when the run ended because the user cancelled it. */
53
+ cancelled: boolean
50
54
  }
51
55
 
52
56
  /** The shape every orchestration function takes to run plugin commands (injectable in tests). */
@@ -67,6 +71,49 @@ export function killChild(child: ChildProcess): void {
67
71
  child.kill('SIGKILL')
68
72
  }
69
73
 
74
+ /** The child of the operation currently running, for /dsh-market/cancel. */
75
+ let activeChild: ChildProcess | null = null
76
+ let cancelRequested = false
77
+
78
+ /**
79
+ * Kill a child and its whole tree, gracefully where the platform allows:
80
+ * taskkill /T /F on Windows (plain kill() leaves pnpm children running),
81
+ * SIGTERM with a 5s SIGKILL escalation elsewhere so pnpm can clean up.
82
+ * (Cancel flow contributed in #6 by @qichuang321.)
83
+ */
84
+ function killTree(child: ChildProcess): void {
85
+ if (process.platform === 'win32' && child.pid !== undefined) {
86
+ try {
87
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' })
88
+ return
89
+ } catch { /* fall through */ }
90
+ }
91
+ // POSIX: the dsh wrapper runs pnpm as a grandchild (spawnSync), which a
92
+ // plain child.kill() leaves running — it keeps our stdio pipes open, so
93
+ // the close event never fires and the market looks stuck "installing".
94
+ // The child is spawned detached as its own process GROUP; kill the group.
95
+ const signalTree = (signal: NodeJS.Signals): void => {
96
+ if (child.pid === undefined) return
97
+ try { process.kill(-child.pid, signal) } catch {
98
+ try { child.kill(signal) } catch { /* already gone */ }
99
+ }
100
+ }
101
+ signalTree('SIGTERM')
102
+ const escalate = setTimeout(() => signalTree('SIGKILL'), 5000)
103
+ escalate.unref?.()
104
+ }
105
+
106
+ /**
107
+ * Cancel the plugin command currently running.
108
+ * @returns true when there was one to cancel.
109
+ */
110
+ export function cancelActive(): boolean {
111
+ if (activeChild === null) return false
112
+ cancelRequested = true
113
+ killTree(activeChild)
114
+ return true
115
+ }
116
+
70
117
  /** Whether `pnpm` resolves on PATH; success is cached, absence is re-probed. */
71
118
  let pnpmReady = false
72
119
 
@@ -147,7 +194,7 @@ export function runDshPlugin(profile: string, pluginArgs: string[]): Promise<Ins
147
194
  const target = pluginArgs[pluginArgs.length - 1] ?? ''
148
195
  if (!TARGET_RE.test(target)) {
149
196
  logEvent('error', 'install', `unsafe plugin target rejected: ${JSON.stringify(target)}`)
150
- return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: `unsafe plugin target rejected: ${JSON.stringify(target)}` })
197
+ return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: `unsafe plugin target rejected: ${JSON.stringify(target)}`, cancelled: false })
151
198
  }
152
199
  progress.active = true
153
200
  progress.target = target
@@ -162,13 +209,18 @@ export function runDshPlugin(profile: string, pluginArgs: string[]): Promise<Ins
162
209
  env: { ...process.env, CI: 'true' },
163
210
  stdio: ['ignore', 'pipe', 'pipe'],
164
211
  shell: viaShell,
212
+ // Own process group on POSIX so cancel/timeout can kill the whole
213
+ // tree (dsh wrapper + pnpm grandchild) with one group signal.
214
+ detached: process.platform !== 'win32',
165
215
  })
216
+ activeChild = child
217
+ cancelRequested = false
166
218
  let stdout = ''
167
219
  let stderr = ''
168
220
  let timedOut = false
169
221
  const timer = setTimeout(() => {
170
222
  timedOut = true
171
- killChild(child)
223
+ killTree(child)
172
224
  }, INSTALL_TIMEOUT_MS)
173
225
  child.stdout.on('data', (chunk: Buffer) => {
174
226
  const text = chunk.toString()
@@ -183,12 +235,14 @@ export function runDshPlugin(profile: string, pluginArgs: string[]): Promise<Ins
183
235
  child.on('error', (error) => {
184
236
  clearTimeout(timer)
185
237
  progress.active = false
186
- resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}` })
238
+ if (activeChild === child) activeChild = null
239
+ resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}`, cancelled: false })
187
240
  })
188
241
  child.on('close', (code) => {
189
242
  clearTimeout(timer)
190
243
  progress.active = false
191
- resolvePromise({ exitCode: code, timedOut, stdout, stderr })
244
+ if (activeChild === child) activeChild = null
245
+ resolvePromise({ exitCode: code, timedOut, stdout, stderr, cancelled: cancelRequested })
192
246
  })
193
247
  })
194
248
  }
package/src/install.ts CHANGED
@@ -22,7 +22,7 @@ import { logEvent } from './log.ts'
22
22
  */
23
23
  export async function withHoistRecovery(run: PluginRunner, profile: string, pluginArgs: string[]): Promise<InstallResult> {
24
24
  let result = await run(profile, pluginArgs)
25
- const ok = (r: InstallResult): boolean => r.exitCode === 0 && !r.timedOut
25
+ const ok = (r: InstallResult): boolean => r.exitCode === 0 && !r.timedOut && !r.cancelled
26
26
  if (!ok(result) && classifyPnpmFailure(`${result.stderr}\n${result.stdout}`)?.code === 'hoist-pattern-diff') {
27
27
  logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`)
28
28
  // --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
@@ -30,7 +30,7 @@ export async function withHoistRecovery(run: PluginRunner, profile: string, plug
30
30
  const rebuild = await run(profile, ['install', '--no-frozen-lockfile'])
31
31
  if (ok(rebuild)) result = await run(profile, pluginArgs)
32
32
  }
33
- if (!ok(result)) {
33
+ if (!ok(result) && !result.cancelled) {
34
34
  const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`)
35
35
  if (failure !== null) result = { ...result, stderr: `${result.stderr}\n\n${failure.message}` }
36
36
  }
@@ -119,3 +119,23 @@ export function isStaleUpdate(check: {
119
119
  ? check.beforeCommit !== null && check.afterCommit === check.beforeCommit
120
120
  : check.beforeVersion !== null && check.afterVersion === check.beforeVersion
121
121
  }
122
+
123
+ /**
124
+ * Package names pnpm reported as having their build scripts ignored
125
+ * ("Ignored build scripts: esbuild, koffi."). Empty when none.
126
+ * (#6 by @qichuang321.)
127
+ */
128
+ export function parseIgnoredBuilds(stdout: string, stderr: string): string[] {
129
+ const m = /Ignored build scripts:?\s*([^\n]+)/i.exec(`${stdout}\n${stderr}`)
130
+ if (m === null) return []
131
+ const found: string[] = []
132
+ for (const chunk of m[1].split(',')) {
133
+ // Entries may carry a version suffix and the sentence's final period.
134
+ const trimmed = chunk.trim().replace(/\.$/, '')
135
+ if (trimmed === '') continue
136
+ const at = trimmed.lastIndexOf('@')
137
+ const name = at > 0 ? trimmed.slice(0, at) : trimmed
138
+ if (name !== '' && !found.includes(name)) found.push(name)
139
+ }
140
+ return found
141
+ }
package/src/profile.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * functions of the directory contents; no processes, no network.
5
5
  */
6
6
 
7
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
7
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
8
8
  import { homedir } from 'node:os'
9
9
  import { join } from 'node:path'
10
10
 
@@ -14,7 +14,20 @@ export function profileDir(profile: string): string {
14
14
  return join(home, 'profiles', profile)
15
15
  }
16
16
 
17
- /** Community dependencies of the profile (official in-box scope filtered out). */
17
+ /**
18
+ * The in-box bundles dsh's profile templates install themselves — the ONLY
19
+ * names the market hides from the installed list. Community plugins may
20
+ * legitimately publish under the official scope (#28), so a whole-scope
21
+ * filter would make them invisible and fail install validation.
22
+ * (Diagnosis and fix proposed in #28 by @Lograthmic.)
23
+ */
24
+ const INBOX_BUNDLES = new Set([
25
+ '@deepseek-ai/dsh-base',
26
+ '@deepseek-ai/dsh-web-app',
27
+ '@deepseek-ai/dsh-headless',
28
+ ])
29
+
30
+ /** Community dependencies of the profile (in-box bundles filtered out). */
18
31
  export function readInstalled(profile: string): Record<string, string> {
19
32
  try {
20
33
  const manifest = JSON.parse(readFileSync(join(profileDir(profile), 'package.json'), 'utf8')) as {
@@ -22,7 +35,7 @@ export function readInstalled(profile: string): Record<string, string> {
22
35
  }
23
36
  const installed: Record<string, string> = {}
24
37
  for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
25
- if (!name.startsWith('@deepseek-ai/')) installed[name] = spec
38
+ if (!INBOX_BUNDLES.has(name)) installed[name] = spec
26
39
  }
27
40
  return installed
28
41
  } catch {
@@ -118,3 +131,32 @@ export function pluginSubdirs(root: string): string[] {
118
131
  }
119
132
  return found.slice(0, 8)
120
133
  }
134
+
135
+ /**
136
+ * Allow the given packages' build scripts in the profile's
137
+ * pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
138
+ * merging with existing entries and leaving the rest of the yaml intact.
139
+ * (#6 by @qichuang321.)
140
+ * @returns every package now allowed.
141
+ */
142
+ export function setAllowBuilds(profile: string, packages: string[]): string[] {
143
+ const file = join(profileDir(profile), 'pnpm-workspace.yaml')
144
+ let yaml = ''
145
+ try { yaml = readFileSync(file, 'utf8') } catch { /* created below */ }
146
+ const blockRe = /allowBuilds:\n((?:[ \t]+[^\n]*\n?)*)/
147
+ const map: Record<string, string> = {}
148
+ const blockMatch = blockRe.exec(yaml)
149
+ if (blockMatch !== null) {
150
+ for (const line of blockMatch[1].split('\n')) {
151
+ const m = /^[ \t]+([^:\s]+(?:\/[^:\s]+)?)\s*:\s*(\S.*)?$/.exec(line)
152
+ if (m !== null) map[m[1]] = m[2] ?? 'true'
153
+ }
154
+ }
155
+ for (const pkg of packages) {
156
+ if (/^[A-Za-z0-9@/_.-]+$/.test(pkg)) map[pkg] = 'true'
157
+ }
158
+ const block = Object.entries(map).map(([k, v]) => ` ${k}: ${v}`).join('\n')
159
+ const blockText = `allowBuilds:\n${block}\n`
160
+ writeFileSync(file, blockMatch !== null ? yaml.replace(blockRe, blockText) : `${yaml.replace(/\n?$/, '\n')}${blockText}`)
161
+ return Object.keys(map)
162
+ }
package/src/routes.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  * same-origin POSTs and only sources present in the curated registry.
9
9
  */
10
10
 
11
- import { readFileSync } from 'node:fs'
11
+ import { existsSync, readFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
12
13
  import type { IncomingMessage, ServerResponse } from 'node:http'
13
14
  import { loadRegistry } from './registry.ts'
14
15
  import {
@@ -16,10 +17,10 @@ import {
16
17
  mountClientOnlyDeps, readDisabledThemes,
17
18
  } from './hot.ts'
18
19
  import { exportLogs, logEvent } from './log.ts'
19
- import { BOOT_ID, probePnpm, progress, provisionPnpm, runDshPlugin } from './dsh-cli.ts'
20
- import { profileDir, readInstalled, readInstalledVersion, readLockCommits } from './profile.ts'
20
+ import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin } from './dsh-cli.ts'
21
+ import { profileDir, readInstalled, readInstalledVersion, readLockCommits, setAllowBuilds } from './profile.ts'
21
22
  import { findInstalledAlias, installTargetFor } from './sources.ts'
22
- import { isStaleUpdate, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
23
+ import { isStaleUpdate, parseIgnoredBuilds, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
23
24
  import { checkUpdates, invalidateUpdates } from './updates.ts'
24
25
  import { createThemeManager, type LoaderEntry } from './themes.ts'
25
26
  import { readJsonBody, sameOrigin, sendJson } from './http.ts'
@@ -89,6 +90,20 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
89
90
  })
90
91
  let installing = false
91
92
 
93
+ /**
94
+ * Drop live hot mounts whose package was removed outside the market
95
+ * (e.g. `dsh plugin remove` in a terminal): the stale mount would keep
96
+ * serving a client bundle that 404s after refresh, wedging the page
97
+ * until a restart (#29 by @SunYanbox).
98
+ */
99
+ async function dropStaleHotMounts(): Promise<void> {
100
+ for (const name of listHotMounts()) {
101
+ if (existsSync(join(profileDir(config.profile), 'node_modules', name, 'package.json'))) continue
102
+ await hotUnmount(name)
103
+ logEvent('warn', 'hot-sweep', `${name}: package removed outside the market — live mount dropped`)
104
+ }
105
+ }
106
+
92
107
  /** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
93
108
  const runPlugin = (profile: string, args: string[]) => withHoistRecovery(runDshPlugin, profile, args)
94
109
 
@@ -114,12 +129,13 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
114
129
  host.webServer.register({
115
130
  kind: 'exact',
116
131
  path: '/dsh-market/installed',
117
- handler: (request, response) => {
132
+ handler: async (request, response) => {
118
133
  if (request.method !== 'GET') {
119
134
  response.writeHead(405, { allow: 'GET' })
120
135
  response.end()
121
136
  return
122
137
  }
138
+ await dropStaleHotMounts()
123
139
  sendJson(response, 200, {
124
140
  profile: config.profile,
125
141
  installed: readInstalled(config.profile),
@@ -170,6 +186,7 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
170
186
  response.end()
171
187
  return
172
188
  }
189
+ await dropStaleHotMounts()
173
190
  sendJson(response, 200, {
174
191
  active: progress.active,
175
192
  target: progress.target,
@@ -270,7 +287,8 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
270
287
  // default one-day safety wait; scoped to this single command.
271
288
  const addArgs = force ? ['add', '--config.minimumReleaseAge=0', target] : ['add', target]
272
289
  const result = await runPlugin(config.profile, addArgs)
273
- let ok = result.exitCode === 0 && !result.timedOut
290
+ const cancelled = result.cancelled
291
+ let ok = result.exitCode === 0 && !result.timedOut && !cancelled
274
292
  let stale = false
275
293
  if (ok) {
276
294
  stale = isStaleUpdate({
@@ -286,10 +304,12 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
286
304
  const staleError = stale
287
305
  ? '这个新版本刚发布不久。为了安全,系统默认会等它发布满一天后再安装——刚发布的版本偶尔会被发现问题然后撤回。可以明天再试,或点「立即更新」不再等待。 / 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.'
288
306
  : null
289
- logEvent(ok ? 'info' : 'error', 'update',
290
- `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${stale ? ' STALE(minimumReleaseAge?)' : ''}${ok ? '' : ` stderr=${result.stderr.slice(-300)}`}`)
291
- sendJson(response, ok ? 200 : 502, {
307
+ logEvent(ok || cancelled ? 'info' : 'error', 'update',
308
+ `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ' STALE(minimumReleaseAge?)' : ''}${ok || cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`)
309
+ // A user-cancelled run is a quiet outcome, not an error.
310
+ sendJson(response, ok || cancelled ? 200 : 502, {
292
311
  ok,
312
+ cancelled: cancelled || undefined,
293
313
  stale: stale || undefined,
294
314
  error: staleError ?? undefined,
295
315
  exitCode: result.exitCode,
@@ -331,6 +351,65 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
331
351
  },
332
352
  }),
333
353
 
354
+ host.webServer.register({
355
+ kind: 'exact',
356
+ path: '/dsh-market/approve-builds',
357
+ handler: async (request, response) => {
358
+ if (request.method !== 'POST') {
359
+ response.writeHead(405, { allow: 'POST' })
360
+ response.end()
361
+ return
362
+ }
363
+ if (!sameOrigin(request)) {
364
+ sendJson(response, 403, { error: 'untrusted origin' })
365
+ return
366
+ }
367
+ try {
368
+ // One-click build-script approval (#6 by @qichuang321): only
369
+ // packages ALREADY installed in the profile can be allowed — the
370
+ // list is not free input.
371
+ const body = (await readJsonBody(request)) as { packages?: unknown }
372
+ const installed = readInstalled(config.profile)
373
+ const packages = (Array.isArray(body.packages) ? body.packages.map(String) : [])
374
+ .filter(name => installed[name] !== undefined)
375
+ if (packages.length === 0) {
376
+ sendJson(response, 400, { error: 'no installed packages given' })
377
+ return
378
+ }
379
+ const approved = setAllowBuilds(config.profile, packages)
380
+ logEvent('info', 'approve-builds', `allowed build scripts: ${approved.join(', ')}`)
381
+ sendJson(response, 200, { ok: true, approved })
382
+ } catch (error) {
383
+ const message = error instanceof Error ? error.message : String(error)
384
+ logEvent('error', 'approve-builds', `route error: ${message}`)
385
+ sendJson(response, 500, { error: message })
386
+ }
387
+ },
388
+ }),
389
+
390
+ host.webServer.register({
391
+ kind: 'exact',
392
+ path: '/dsh-market/cancel',
393
+ handler: async (request, response) => {
394
+ if (request.method !== 'POST') {
395
+ response.writeHead(405, { allow: 'POST' })
396
+ response.end()
397
+ return
398
+ }
399
+ if (!sameOrigin(request)) {
400
+ sendJson(response, 403, { error: 'untrusted origin' })
401
+ return
402
+ }
403
+ // Cancel flow contributed in #6 by @qichuang321.
404
+ if (!cancelActive()) {
405
+ sendJson(response, 400, { error: 'no operation is running' })
406
+ return
407
+ }
408
+ logEvent('info', 'cancel', `cancelled ${progress.target || 'operation'}`)
409
+ sendJson(response, 200, { ok: true, cancelled: true, target: progress.target })
410
+ },
411
+ }),
412
+
334
413
  host.webServer.register({
335
414
  kind: 'exact',
336
415
  path: '/dsh-market/uninstall',
@@ -362,16 +441,18 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
362
441
  installing = true
363
442
  try {
364
443
  const result = await runPlugin(config.profile, ['remove', name])
365
- const ok = result.exitCode === 0 && !result.timedOut
444
+ const cancelled = result.cancelled
445
+ const ok = result.exitCode === 0 && !result.timedOut && !cancelled
366
446
  let hot = false
367
447
  if (ok) {
368
448
  invalidateUpdates()
369
449
  hot = await hotUnmount(name)
370
450
  }
371
- logEvent(ok ? 'info' : 'error', 'uninstall',
372
- `${name} exit=${String(result.exitCode)}${ok ? ` live-removed=${String(hot)}` : ` stderr=${result.stderr.slice(-300)}`}`)
373
- sendJson(response, ok ? 200 : 502, {
451
+ logEvent(ok || cancelled ? 'info' : 'error', 'uninstall',
452
+ `${name} exit=${String(result.exitCode)}${cancelled ? ' CANCELLED' : ''}${ok ? ` live-removed=${String(hot)}` : cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`)
453
+ sendJson(response, ok || cancelled ? 200 : 502, {
374
454
  ok,
455
+ cancelled: cancelled || undefined,
375
456
  hot,
376
457
  exitCode: result.exitCode,
377
458
  stdout: result.stdout,
@@ -437,7 +518,8 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
437
518
  try {
438
519
  const before = new Set(Object.keys(readInstalled(config.profile)))
439
520
  const result = await runPlugin(config.profile, ['add', target])
440
- let ok = result.exitCode === 0 && !result.timedOut
521
+ const cancelled = result.cancelled
522
+ let ok = result.exitCode === 0 && !result.timedOut && !cancelled
441
523
  if (ok) invalidateUpdates()
442
524
  if (ok) {
443
525
  // Collection repos (e.g. skin monorepos) install as a junk
@@ -451,7 +533,7 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
451
533
  // must never survive to brick the next boot.
452
534
  let notAPlugin = false
453
535
  let removedBroken: string[] = []
454
- if (result.exitCode === 0 && !result.timedOut) {
536
+ if (result.exitCode === 0 && !result.timedOut && !cancelled) {
455
537
  const validated = await validateAddedPlugins(runPlugin, config.profile, before)
456
538
  removedBroken = validated.removedBroken
457
539
  if (removedBroken.length > 0) {
@@ -482,11 +564,13 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
482
564
  }
483
565
  }
484
566
  }
485
- logEvent(ok ? 'info' : 'error', 'install',
486
- `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${ok ? ` hot=${String(hot)}` : ` stderr=${result.stderr.slice(-300)}`}`)
487
- sendJson(response, ok ? 200 : 502, {
567
+ logEvent(ok || cancelled ? 'info' : 'error', 'install',
568
+ `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${ok ? ` hot=${String(hot)}` : cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`)
569
+ sendJson(response, ok || cancelled ? 200 : 502, {
488
570
  ok,
571
+ cancelled: cancelled || undefined,
489
572
  hot,
573
+ ignoredBuilds: (() => { const list = parseIgnoredBuilds(result.stdout, result.stderr); return list.length > 0 ? list : undefined })(),
490
574
  error: notAPlugin ? 'nothing installable: the plugin(s) need a build step (blocked by default, see allowBuilds) or ship no prebuilt artifacts / 没有可安装的内容:插件需要构建授权(allowBuilds,默认拦截)或未附带构建产物,详见导出日志' : undefined,
491
575
  exitCode: result.exitCode,
492
576
  timedOut: result.timedOut,