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/client/client.js +201 -59
- package/client/client.js.map +1 -1
- package/lib/dsh-cli.js +65 -5
- package/lib/install.js +24 -2
- package/lib/profile.js +48 -3
- package/lib/routes.js +98 -15
- package/lib/types/dsh-cli.d.ts +7 -0
- package/lib/types/install.d.ts +6 -0
- package/lib/types/profile.d.ts +9 -1
- package/package.json +1 -1
- package/src/client/Market.module.css +4 -0
- package/src/client/MarketSection.tsx +98 -4
- package/src/client/locales.ts +10 -0
- package/src/dsh-cli.ts +59 -5
- package/src/install.ts +22 -2
- package/src/profile.ts +45 -3
- package/src/routes.ts +102 -18
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|