dshmarket 1.2.2 → 1.2.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/README.md +8 -0
- package/README.zh.md +8 -0
- package/client/client.js +106 -71
- package/client/client.js.map +1 -1
- package/lib/dsh-cli.js +164 -0
- package/lib/http.js +39 -0
- package/lib/install.js +110 -0
- package/lib/pnpm-compat.js +72 -0
- package/lib/profile.js +121 -0
- package/lib/routes.js +63 -552
- package/lib/sources.js +85 -0
- package/lib/themes.js +102 -0
- package/lib/types/dsh-cli.d.ts +61 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/install.d.ts +49 -0
- package/lib/types/pnpm-compat.d.ts +42 -0
- package/lib/types/profile.d.ts +24 -0
- package/lib/types/routes.d.ts +7 -21
- package/lib/types/sources.d.ts +42 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/updates.d.ts +16 -0
- package/lib/updates.js +61 -0
- package/package.json +5 -2
- package/src/client/Market.module.css +4 -1
- package/src/client/MarketSection.tsx +23 -27
- package/src/client/locales.ts +2 -0
- package/src/client/market-data.ts +60 -4
- package/src/dsh-cli.ts +194 -0
- package/src/http.ts +41 -0
- package/src/install.ts +121 -0
- package/src/pnpm-compat.ts +83 -0
- package/src/profile.ts +120 -0
- package/src/routes.ts +68 -583
- package/src/sources.ts +84 -0
- package/src/themes.ts +120 -0
- package/src/updates.ts +70 -0
package/src/routes.ts
CHANGED
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP routes bridging the browser market UI to the host
|
|
3
|
-
*
|
|
2
|
+
* HTTP routes bridging the browser market UI to the host. This layer only
|
|
3
|
+
* parses requests, calls the service modules, and serializes responses —
|
|
4
|
+
* process spawning lives in dsh-cli.ts, filesystem reads in profile.ts,
|
|
5
|
+
* orchestration in install.ts / themes.ts / updates.ts.
|
|
4
6
|
*
|
|
5
7
|
* Security: the install route executes a shell command, so it accepts only
|
|
6
8
|
* same-origin POSTs and only sources present in the curated registry.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import {
|
|
10
|
-
import type { ChildProcess } from 'node:child_process'
|
|
11
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
12
|
-
import { homedir } from 'node:os'
|
|
13
|
-
import { dirname, join, resolve } from 'node:path'
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
14
12
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
15
13
|
import { loadRegistry } from './registry.ts'
|
|
16
14
|
import {
|
|
17
15
|
cleanHotDir, hotMount, hotUnmount, listHotMounts,
|
|
18
|
-
mountClientOnlyDeps, readDisabledThemes,
|
|
16
|
+
mountClientOnlyDeps, readDisabledThemes,
|
|
19
17
|
} from './hot.ts'
|
|
20
18
|
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'
|
|
21
|
+
import { findInstalledAlias, installTargetFor } from './sources.ts'
|
|
22
|
+
import { isStaleUpdate, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
|
|
23
|
+
import { checkUpdates, invalidateUpdates } from './updates.ts'
|
|
24
|
+
import { createThemeManager, type LoaderEntry } from './themes.ts'
|
|
25
|
+
import { readJsonBody, sameOrigin, sendJson } from './http.ts'
|
|
26
|
+
|
|
27
|
+
export type { LoaderEntry } from './themes.ts'
|
|
28
|
+
export type { UpdateStatus } from './updates.ts'
|
|
21
29
|
|
|
22
30
|
export interface WebServerService {
|
|
23
31
|
register(route: {
|
|
@@ -27,13 +35,6 @@ export interface WebServerService {
|
|
|
27
35
|
}): () => void
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
/** The slice of a cordis loader entry the market needs for live enable/disable. */
|
|
31
|
-
export interface LoaderEntry {
|
|
32
|
-
options: { id?: string; name?: string; disabled?: boolean | null }
|
|
33
|
-
fiber?: unknown
|
|
34
|
-
update(options: { disabled: boolean | null }, create?: boolean, force?: boolean): Promise<void>
|
|
35
|
-
}
|
|
36
|
-
|
|
37
38
|
export interface MarketHost {
|
|
38
39
|
webServer: WebServerService
|
|
39
40
|
loader: { entries(): Iterable<LoaderEntry> }
|
|
@@ -48,441 +49,6 @@ export interface MarketConfig {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
const PROFILE_RE = /^[A-Za-z0-9_-]+$/
|
|
51
|
-
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
|
|
52
|
-
const INSTALL_TIMEOUT_MS = 5 * 60 * 1000
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Argv re-invoking the CLI that launched this host process, so installs work
|
|
56
|
-
* whether dsh runs from a global bin, a local install, or repo source
|
|
57
|
-
* (`node --import tsx/esm .../bin.ts`). Falls back to a PATH `dsh`.
|
|
58
|
-
*
|
|
59
|
-
* Installs run through node:child_process, not ctx.shell: the shell service is
|
|
60
|
-
* the agent's sandboxed executor and denies writes to the profile directory.
|
|
61
|
-
*/
|
|
62
|
-
function dshArgv(): { file: string; args: string[]; cwd: string | undefined; viaShell: boolean } {
|
|
63
|
-
const entry = process.argv[1]
|
|
64
|
-
if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
|
|
65
|
-
// Absolute paths are required: source launches (`pnpm dsh`) pass a
|
|
66
|
-
// relative entry, which the child resolves against its OWN cwd and dies
|
|
67
|
-
// with MODULE_NOT_FOUND (#13). cwd near the entry keeps execArgv imports
|
|
68
|
-
// (tsx/esm) resolvable on source launches.
|
|
69
|
-
const abs = resolve(entry)
|
|
70
|
-
return { file: process.execPath, args: [...process.execArgv, abs], cwd: dirname(abs), viaShell: false }
|
|
71
|
-
}
|
|
72
|
-
// Bare `dsh` is a .cmd shim on Windows that only a shell can start (#13).
|
|
73
|
-
return { file: 'dsh', args: [], cwd: undefined, viaShell: winCmdShim }
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
interface InstallResult {
|
|
77
|
-
exitCode: number | null
|
|
78
|
-
timedOut: boolean
|
|
79
|
-
stdout: string
|
|
80
|
-
stderr: string
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Whether `pnpm` resolves on PATH; success is cached, absence is re-probed. */
|
|
84
|
-
let pnpmReady = false
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Windows npm/corepack/pnpm are `.cmd` shims. Node's `spawn` without a shell
|
|
88
|
-
* cannot start them (ENOENT / EINVAL). Same pattern as dsh's `plugin` forwarder.
|
|
89
|
-
*/
|
|
90
|
-
const winCmdShim = process.platform === 'win32'
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Kill a spawned child and, on Windows, its whole process tree — `kill()`
|
|
94
|
-
* there only terminates the wrapper, leaving pnpm children running.
|
|
95
|
-
* (Contributed in #7 by @mraing.)
|
|
96
|
-
*/
|
|
97
|
-
function killChild(child: ChildProcess): void {
|
|
98
|
-
if (process.platform === 'win32' && child.pid !== undefined) {
|
|
99
|
-
try {
|
|
100
|
-
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' })
|
|
101
|
-
return
|
|
102
|
-
} catch { /* fall through */ }
|
|
103
|
-
}
|
|
104
|
-
child.kill('SIGKILL')
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function probePnpm(): Promise<boolean> {
|
|
108
|
-
if (pnpmReady) return Promise.resolve(true)
|
|
109
|
-
return new Promise((resolvePromise) => {
|
|
110
|
-
const child = spawn('pnpm', ['--version'], { stdio: 'ignore', shell: winCmdShim })
|
|
111
|
-
child.on('error', () => resolvePromise(false))
|
|
112
|
-
child.on('close', (code) => {
|
|
113
|
-
pnpmReady = code === 0
|
|
114
|
-
resolvePromise(pnpmReady)
|
|
115
|
-
})
|
|
116
|
-
})
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function runQuiet(file: string, args: string[], timeoutMs: number): Promise<{ code: number | null; output: string }> {
|
|
120
|
-
return new Promise((resolvePromise) => {
|
|
121
|
-
const child = spawn(file, args, {
|
|
122
|
-
env: { ...process.env, CI: 'true' },
|
|
123
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
124
|
-
shell: winCmdShim,
|
|
125
|
-
})
|
|
126
|
-
let output = ''
|
|
127
|
-
const timer = setTimeout(() => killChild(child), timeoutMs)
|
|
128
|
-
const collect = (chunk: Buffer): void => { output = (output + chunk.toString()).slice(-8 * 1024) }
|
|
129
|
-
child.stdout.on('data', collect)
|
|
130
|
-
child.stderr.on('data', collect)
|
|
131
|
-
child.on('error', (error) => { clearTimeout(timer); resolvePromise({ code: 127, output: error.message }) })
|
|
132
|
-
child.on('close', (code) => { clearTimeout(timer); resolvePromise({ code, output }) })
|
|
133
|
-
})
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Provision pnpm without user involvement: corepack (ships with Node) first,
|
|
138
|
-
* a global npm install as fallback.
|
|
139
|
-
* @returns true when `pnpm --version` succeeds afterwards.
|
|
140
|
-
*/
|
|
141
|
-
async function provisionPnpm(): Promise<boolean> {
|
|
142
|
-
const corepack = await runQuiet('corepack', ['enable', 'pnpm'], 60 * 1000)
|
|
143
|
-
logEvent(corepack.code === 0 ? 'info' : 'warn', 'setup-pnpm', `corepack enable: exit=${String(corepack.code)} ${corepack.output.slice(-200)}`)
|
|
144
|
-
if (await probePnpm()) return true
|
|
145
|
-
const npm = await runQuiet('npm', ['install', '-g', 'pnpm'], 3 * 60 * 1000)
|
|
146
|
-
logEvent(npm.code === 0 ? 'info' : 'error', 'setup-pnpm', `npm -g: exit=${String(npm.code)} ${npm.output.slice(-200)}`)
|
|
147
|
-
return probePnpm()
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Live progress of the running plugin command, for the status route. */
|
|
151
|
-
interface InstallProgress {
|
|
152
|
-
active: boolean
|
|
153
|
-
target: string
|
|
154
|
-
startedAt: number
|
|
155
|
-
lastLine: string
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const progress: InstallProgress = { active: false, target: '', startedAt: 0, lastLine: '' }
|
|
159
|
-
|
|
160
|
-
/** Identifies this host process; the client scopes its pending-restart flags to it. */
|
|
161
|
-
const BOOT_ID = `${String(process.pid)}-${String(Date.now())}`
|
|
162
|
-
|
|
163
|
-
function trackProgress(chunk: string): void {
|
|
164
|
-
const lines = chunk.split('\n').map(l => l.trim()).filter(l => l !== '')
|
|
165
|
-
if (lines.length > 0) progress.lastLine = lines[lines.length - 1].slice(0, 200)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Central allowlist for every spawn target, regardless of which route built
|
|
170
|
-
* it (defense in depth on top of per-route validation — the win32 bare-dsh
|
|
171
|
-
* fallback runs through a shell). Suggested in #16 by @anupamme.
|
|
172
|
-
*/
|
|
173
|
-
const TARGET_RE = /^[A-Za-z0-9@:./_#+-]+$/
|
|
174
|
-
|
|
175
|
-
function runDshPlugin(profile: string, pluginArgs: string[]): Promise<InstallResult> {
|
|
176
|
-
const { file, args, cwd, viaShell } = dshArgv()
|
|
177
|
-
// pnpm 9 refuses to add at a workspace root without -w (#17); pnpm 10/11
|
|
178
|
-
// accept the flag as a no-op there, so it is safe to pass always.
|
|
179
|
-
if (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove') {
|
|
180
|
-
pluginArgs = [pluginArgs[0], '-w', ...pluginArgs.slice(1)]
|
|
181
|
-
}
|
|
182
|
-
const target = pluginArgs[pluginArgs.length - 1] ?? ''
|
|
183
|
-
if (!TARGET_RE.test(target)) {
|
|
184
|
-
logEvent('error', 'install', `unsafe plugin target rejected: ${JSON.stringify(target)}`)
|
|
185
|
-
return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: `unsafe plugin target rejected: ${JSON.stringify(target)}` })
|
|
186
|
-
}
|
|
187
|
-
progress.active = true
|
|
188
|
-
progress.target = target
|
|
189
|
-
progress.startedAt = Date.now()
|
|
190
|
-
progress.lastLine = ''
|
|
191
|
-
return new Promise((resolvePromise) => {
|
|
192
|
-
const child = spawn(file, [...args, 'plugin', '--profile', profile, ...pluginArgs], {
|
|
193
|
-
cwd,
|
|
194
|
-
// pnpm v10 blocks forever on a silent interactive prompt without a TTY
|
|
195
|
-
// (observed on re-add over a pinned git spec); CI mode forces it to act
|
|
196
|
-
// or fail instead of asking.
|
|
197
|
-
env: { ...process.env, CI: 'true' },
|
|
198
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
199
|
-
shell: viaShell,
|
|
200
|
-
})
|
|
201
|
-
let stdout = ''
|
|
202
|
-
let stderr = ''
|
|
203
|
-
let timedOut = false
|
|
204
|
-
const timer = setTimeout(() => {
|
|
205
|
-
timedOut = true
|
|
206
|
-
killChild(child)
|
|
207
|
-
}, INSTALL_TIMEOUT_MS)
|
|
208
|
-
child.stdout.on('data', (chunk: Buffer) => {
|
|
209
|
-
const text = chunk.toString()
|
|
210
|
-
stdout = (stdout + text).slice(-256 * 1024)
|
|
211
|
-
trackProgress(text)
|
|
212
|
-
})
|
|
213
|
-
child.stderr.on('data', (chunk: Buffer) => {
|
|
214
|
-
const text = chunk.toString()
|
|
215
|
-
stderr = (stderr + text).slice(-64 * 1024)
|
|
216
|
-
trackProgress(text)
|
|
217
|
-
})
|
|
218
|
-
child.on('error', (error) => {
|
|
219
|
-
clearTimeout(timer)
|
|
220
|
-
progress.active = false
|
|
221
|
-
resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}` })
|
|
222
|
-
})
|
|
223
|
-
child.on('close', (code) => {
|
|
224
|
-
clearTimeout(timer)
|
|
225
|
-
progress.active = false
|
|
226
|
-
resolvePromise({ exitCode: code, timedOut, stdout, stderr })
|
|
227
|
-
})
|
|
228
|
-
})
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function sendJson(response: ServerResponse, status: number, payload: unknown): void {
|
|
232
|
-
response.writeHead(status, {
|
|
233
|
-
'cache-control': 'no-store',
|
|
234
|
-
'content-type': 'application/json; charset=utf-8',
|
|
235
|
-
})
|
|
236
|
-
response.end(JSON.stringify(payload))
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function sameOrigin(request: IncomingMessage): boolean {
|
|
240
|
-
const origin = request.headers.origin
|
|
241
|
-
const host = request.headers.host
|
|
242
|
-
if (origin === undefined || host === undefined) return false
|
|
243
|
-
try {
|
|
244
|
-
return new URL(origin).host === host
|
|
245
|
-
} catch {
|
|
246
|
-
return false
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
async function readJsonBody(request: IncomingMessage): Promise<unknown> {
|
|
251
|
-
const chunks: Buffer[] = []
|
|
252
|
-
let size = 0
|
|
253
|
-
for await (const chunk of request) {
|
|
254
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
255
|
-
size += buffer.length
|
|
256
|
-
if (size > 4096) throw new Error('request body too large')
|
|
257
|
-
chunks.push(buffer)
|
|
258
|
-
}
|
|
259
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function profileDir(profile: string): string {
|
|
263
|
-
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
264
|
-
return join(home, 'profiles', profile)
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Community dependencies of the profile (official in-box scope filtered out). */
|
|
268
|
-
function readInstalled(profile: string): Record<string, string> {
|
|
269
|
-
try {
|
|
270
|
-
const manifest = JSON.parse(readFileSync(join(profileDir(profile), 'package.json'), 'utf8')) as {
|
|
271
|
-
dependencies?: Record<string, string>
|
|
272
|
-
}
|
|
273
|
-
const installed: Record<string, string> = {}
|
|
274
|
-
for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
|
|
275
|
-
if (!name.startsWith('@deepseek-ai/')) installed[name] = spec
|
|
276
|
-
}
|
|
277
|
-
return installed
|
|
278
|
-
} catch {
|
|
279
|
-
return {}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/** GitHub `owner/repo` for a registry URL, or null when it is not a GitHub repo URL. */
|
|
284
|
-
/**
|
|
285
|
-
* Parse a registry source url: a github repo, optionally with a
|
|
286
|
-
* `/tree/<branch>/<subpath>` suffix (how the curated list links monorepo
|
|
287
|
-
* subpackages, e.g. dsh-plugins#theme-gallery).
|
|
288
|
-
*/
|
|
289
|
-
function parseSourceUrl(url: string): { repo: string; subpath: string | null } | null {
|
|
290
|
-
const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(url)
|
|
291
|
-
if (m === null || !REPO_RE.test(m[1])) return null
|
|
292
|
-
const subpath = m[2] ?? null
|
|
293
|
-
if (subpath !== null && !/^[A-Za-z0-9_./-]+$/.test(subpath)) return null
|
|
294
|
-
return { repo: m[1], subpath }
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function repoOf(url: string): string | null {
|
|
298
|
-
return parseSourceUrl(url)?.repo ?? null
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Pinned commit per `owner/repo` from the profile lockfile's codeload tarball URLs. */
|
|
302
|
-
function readLockCommits(profile: string): Map<string, string> {
|
|
303
|
-
const commits = new Map<string, string>()
|
|
304
|
-
try {
|
|
305
|
-
const lock = readFileSync(join(profileDir(profile), 'pnpm-lock.yaml'), 'utf8')
|
|
306
|
-
for (const m of lock.matchAll(/codeload\.github\.com\/([^/\s]+\/[^/\s]+)\/tar\.gz\/([0-9a-f]{40})/g)) {
|
|
307
|
-
commits.set(m[1].toLowerCase(), m[2])
|
|
308
|
-
}
|
|
309
|
-
} catch { /* no lockfile — no git installs to report */ }
|
|
310
|
-
return commits
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Some registry entries point at collection repos whose actual plugin lives
|
|
315
|
-
* in a subdirectory — the root has no package.json, and pnpm installs the
|
|
316
|
-
* bare fileset with exit 0. Detect that junk install, drop it, and re-add
|
|
317
|
-
* each plugin subdirectory through pnpm's `#path:` selector.
|
|
318
|
-
* @returns overall success (true when nothing needed retargeting).
|
|
319
|
-
*/
|
|
320
|
-
/**
|
|
321
|
-
* True when the package's declared entry artifact actually exists — github
|
|
322
|
-
* source checkouts of build-required plugins ship no lib/, and promoting one
|
|
323
|
-
* into the bundle layer bricks the next boot (ERR_MODULE_NOT_FOUND kills the
|
|
324
|
-
* whole profile, #18).
|
|
325
|
-
*/
|
|
326
|
-
function entryArtifactExists(dir: string): boolean {
|
|
327
|
-
try {
|
|
328
|
-
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
|
|
329
|
-
main?: string
|
|
330
|
-
exports?: Record<string, unknown> | string
|
|
331
|
-
}
|
|
332
|
-
const candidates: string[] = []
|
|
333
|
-
if (typeof manifest.main === 'string') candidates.push(manifest.main)
|
|
334
|
-
const rootExport = typeof manifest.exports === 'string'
|
|
335
|
-
? manifest.exports
|
|
336
|
-
: (manifest.exports as Record<string, unknown> | undefined)?.['.']
|
|
337
|
-
if (typeof rootExport === 'string') candidates.push(rootExport)
|
|
338
|
-
else if (rootExport !== null && typeof rootExport === 'object') {
|
|
339
|
-
for (const value of Object.values(rootExport)) if (typeof value === 'string') candidates.push(value)
|
|
340
|
-
}
|
|
341
|
-
if (candidates.length === 0) candidates.push('index.js')
|
|
342
|
-
return candidates.some(rel => existsSync(join(dir, rel)))
|
|
343
|
-
} catch {
|
|
344
|
-
return false
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** True when the installed package's manifest declares a dsh plugin surface. */
|
|
349
|
-
function hasDshManifest(dir: string): boolean {
|
|
350
|
-
try {
|
|
351
|
-
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { dsh?: unknown }
|
|
352
|
-
return manifest.dsh !== undefined
|
|
353
|
-
} catch {
|
|
354
|
-
return false
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
|
|
359
|
-
function pluginSubdirs(root: string): string[] {
|
|
360
|
-
const found: string[] = []
|
|
361
|
-
let level1: string[] = []
|
|
362
|
-
try {
|
|
363
|
-
level1 = readdirSync(root, { withFileTypes: true })
|
|
364
|
-
.filter(dirent => dirent.isDirectory() && /^[A-Za-z0-9_.-]+$/.test(dirent.name) && dirent.name !== 'node_modules')
|
|
365
|
-
.map(dirent => dirent.name)
|
|
366
|
-
} catch {
|
|
367
|
-
return found
|
|
368
|
-
}
|
|
369
|
-
for (const sub of level1) {
|
|
370
|
-
if (hasDshManifest(join(root, sub))) {
|
|
371
|
-
found.push(sub)
|
|
372
|
-
continue
|
|
373
|
-
}
|
|
374
|
-
try {
|
|
375
|
-
for (const inner of readdirSync(join(root, sub), { withFileTypes: true })) {
|
|
376
|
-
if (!inner.isDirectory() || !/^[A-Za-z0-9_.-]+$/.test(inner.name) || inner.name === 'node_modules') continue
|
|
377
|
-
if (hasDshManifest(join(root, sub, inner.name))) found.push(`${sub}/${inner.name}`)
|
|
378
|
-
}
|
|
379
|
-
} catch { /* unreadable level — skip */ }
|
|
380
|
-
if (found.length >= 8) break
|
|
381
|
-
}
|
|
382
|
-
return found.slice(0, 8)
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
async function retargetCollections(profile: string, before: Set<string>, target: string): Promise<boolean> {
|
|
386
|
-
if (!target.startsWith('github:')) return true
|
|
387
|
-
// A collection checkout: no root package.json at all, or a root manifest
|
|
388
|
-
// that declares no dsh surface (workspace roots are usually also private
|
|
389
|
-
// with no entry point — #18).
|
|
390
|
-
const junk = Object.keys(readInstalled(profile)).filter((name) => {
|
|
391
|
-
if (before.has(name)) return false
|
|
392
|
-
const root = join(profileDir(profile), 'node_modules', name)
|
|
393
|
-
if (!existsSync(join(root, 'package.json'))) return true
|
|
394
|
-
return !hasDshManifest(root)
|
|
395
|
-
})
|
|
396
|
-
let allOk = true
|
|
397
|
-
for (const name of junk) {
|
|
398
|
-
const root = join(profileDir(profile), 'node_modules', name)
|
|
399
|
-
const candidates = pluginSubdirs(root)
|
|
400
|
-
logEvent('info', 'install', `${name}: collection repo (root declares no dsh manifest); plugins inside: ${candidates.join(', ') || 'none'}`)
|
|
401
|
-
await runDshPlugin(profile, ['remove', name])
|
|
402
|
-
if (candidates.length === 0) {
|
|
403
|
-
allOk = false
|
|
404
|
-
continue
|
|
405
|
-
}
|
|
406
|
-
for (const sub of candidates) {
|
|
407
|
-
const result = await runDshPlugin(profile, ['add', `${target}#path:/${sub}`])
|
|
408
|
-
if (result.exitCode !== 0 || result.timedOut) {
|
|
409
|
-
allOk = false
|
|
410
|
-
logEvent('error', 'install',
|
|
411
|
-
`${target}#path:/${sub}: exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''} — ${(result.stderr || result.stdout).slice(-220)}`)
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
return allOk
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function readInstalledVersion(profile: string, name: string): string | null {
|
|
419
|
-
try {
|
|
420
|
-
const manifest = JSON.parse(
|
|
421
|
-
readFileSync(join(profileDir(profile), 'node_modules', name, 'package.json'), 'utf8'),
|
|
422
|
-
) as { version?: string }
|
|
423
|
-
return manifest.version ?? null
|
|
424
|
-
} catch {
|
|
425
|
-
return null
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
export interface UpdateStatus {
|
|
430
|
-
kind: 'github' | 'npm' | 'linked'
|
|
431
|
-
version: string | null
|
|
432
|
-
current: string | null
|
|
433
|
-
latest: string | null
|
|
434
|
-
updateAvailable: boolean
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const UPDATES_TTL_MS = 30 * 60 * 1000
|
|
438
|
-
let updatesCache: { at: number; data: Record<string, UpdateStatus> } | null = null
|
|
439
|
-
|
|
440
|
-
async function fetchJson(url: string): Promise<unknown> {
|
|
441
|
-
const res = await fetch(url, {
|
|
442
|
-
headers: { accept: 'application/json', 'user-agent': 'dsh-market' },
|
|
443
|
-
signal: AbortSignal.timeout(4000),
|
|
444
|
-
})
|
|
445
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
446
|
-
return res.json() as unknown
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/** Per-plugin update checks; a failed check reports no update rather than failing the listing. */
|
|
450
|
-
async function checkUpdates(profile: string, force = false): Promise<Record<string, UpdateStatus>> {
|
|
451
|
-
if (!force && updatesCache && Date.now() - updatesCache.at < UPDATES_TTL_MS) return updatesCache.data
|
|
452
|
-
const installed = readInstalled(profile)
|
|
453
|
-
const lockCommits = readLockCommits(profile)
|
|
454
|
-
const result: Record<string, UpdateStatus> = {}
|
|
455
|
-
await Promise.all(Object.entries(installed).map(async ([name, spec]) => {
|
|
456
|
-
const version = readInstalledVersion(profile, name)
|
|
457
|
-
if (spec.startsWith('link:') || spec.startsWith('file:')) {
|
|
458
|
-
result[name] = { kind: 'linked', version, current: null, latest: null, updateAvailable: false }
|
|
459
|
-
return
|
|
460
|
-
}
|
|
461
|
-
const gh = /^(?:github:)?([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:#.*)?$/.exec(spec)
|
|
462
|
-
try {
|
|
463
|
-
if (spec.startsWith('github:') && gh !== null) {
|
|
464
|
-
const current = lockCommits.get(gh[1].toLowerCase()) ?? null
|
|
465
|
-
const head = (await fetchJson(`https://api.github.com/repos/${gh[1]}/commits/HEAD`)) as { sha?: string }
|
|
466
|
-
const latest = typeof head.sha === 'string' ? head.sha : null
|
|
467
|
-
result[name] = {
|
|
468
|
-
kind: 'github', version, current, latest,
|
|
469
|
-
updateAvailable: current !== null && latest !== null && current !== latest,
|
|
470
|
-
}
|
|
471
|
-
} else {
|
|
472
|
-
const meta = (await fetchJson(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`)) as { version?: string }
|
|
473
|
-
const latest = typeof meta.version === 'string' ? meta.version : null
|
|
474
|
-
result[name] = {
|
|
475
|
-
kind: 'npm', version, current: version, latest,
|
|
476
|
-
updateAvailable: version !== null && latest !== null && version !== latest,
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
} catch {
|
|
480
|
-
result[name] = { kind: spec.startsWith('github:') ? 'github' : 'npm', version, current: null, latest: null, updateAvailable: false }
|
|
481
|
-
}
|
|
482
|
-
}))
|
|
483
|
-
updatesCache = { at: Date.now(), data: result }
|
|
484
|
-
return result
|
|
485
|
-
}
|
|
486
52
|
|
|
487
53
|
/**
|
|
488
54
|
* Register the market's HTTP routes.
|
|
@@ -497,19 +63,20 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
|
|
|
497
63
|
// Boot-time wipe: stale hot-mount inputs from a previous session must never
|
|
498
64
|
// survive into a composition where the bundle layer already covers them.
|
|
499
65
|
cleanHotDir(profileDir(config.profile))
|
|
500
|
-
// Client-only packages (dsh.client without dsh.bundle) are invisible to the
|
|
501
|
-
// bundle layer in every boot; the market shim-mounts them so their client
|
|
502
|
-
// bundles are actually served.
|
|
503
66
|
// The user's persisted theme choice; activateTheme mutates and writes it.
|
|
504
67
|
const disabledThemes = readDisabledThemes(profileDir(config.profile))
|
|
68
|
+
const themes = createThemeManager(host, config.profile, disabledThemes)
|
|
505
69
|
|
|
70
|
+
// Client-only packages (dsh.client without dsh.bundle) are invisible to the
|
|
71
|
+
// bundle layer in every boot; the market shim-mounts them so their client
|
|
72
|
+
// bundles are actually served.
|
|
506
73
|
void mountClientOnlyDeps(host, profileDir(config.profile)).then(async (mounted) => {
|
|
507
74
|
if (mounted.length > 0) logEvent('info', 'boot', `client-only shims mounted: ${mounted.join(', ')}`)
|
|
508
75
|
// Replay the user's theme choice: bundle-layer themes they switched away
|
|
509
76
|
// from get live-disabled again (bundle trees are in-memory, so the
|
|
510
77
|
// disable never persists on its own).
|
|
511
78
|
for (const name of disabledThemes) {
|
|
512
|
-
if (await setEntryDisabled(name, true)) logEvent('info', 'boot', `theme kept off: ${name}`)
|
|
79
|
+
if (await themes.setEntryDisabled(name, true)) logEvent('info', 'boot', `theme kept off: ${name}`)
|
|
513
80
|
}
|
|
514
81
|
})
|
|
515
82
|
|
|
@@ -518,88 +85,12 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
|
|
|
518
85
|
// for a theme the user switched off, put it back down.
|
|
519
86
|
host.on?.('internal/plugin', (fiber) => {
|
|
520
87
|
const name = fiber.entry?.options?.name
|
|
521
|
-
if (name !== undefined && disabledThemes.has(name)) void setEntryDisabled(name, true)
|
|
88
|
+
if (name !== undefined && disabledThemes.has(name)) void themes.setEntryDisabled(name, true)
|
|
522
89
|
})
|
|
523
90
|
let installing = false
|
|
524
91
|
|
|
525
|
-
/**
|
|
526
|
-
|
|
527
|
-
const names = new Set<string>()
|
|
528
|
-
try {
|
|
529
|
-
const { registry } = await loadRegistry()
|
|
530
|
-
const themeEntries = registry.plugins.filter(p => p.category === 'theme')
|
|
531
|
-
const themeNames = new Set(themeEntries.map(p => p.name))
|
|
532
|
-
const themeRepos = new Set(
|
|
533
|
-
themeEntries.map(p => repoOf(p.url)).filter((r): r is string => r !== null).map(r => r.toLowerCase()),
|
|
534
|
-
)
|
|
535
|
-
for (const [name, spec] of Object.entries(readInstalled(profile))) {
|
|
536
|
-
if (themeNames.has(name)) {
|
|
537
|
-
names.add(name)
|
|
538
|
-
continue
|
|
539
|
-
}
|
|
540
|
-
const match = /github:([^#\s]+)/.exec(String(spec).toLowerCase())
|
|
541
|
-
if (match !== null && themeRepos.has(match[1])) names.add(name)
|
|
542
|
-
}
|
|
543
|
-
} catch { /* registry unavailable — nothing classifies as a theme */ }
|
|
544
|
-
return names
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/**
|
|
548
|
-
* Live-toggle a bundle-layer plugin through its loader entry. Bundle trees
|
|
549
|
-
* are in-memory (write is a no-op), so this never touches any file — the
|
|
550
|
-
* market persists the choice itself and replays it at boot.
|
|
551
|
-
* @returns true when a matching live entry was found and updated.
|
|
552
|
-
*/
|
|
553
|
-
async function setEntryDisabled(name: string, disabledFlag: boolean): Promise<boolean> {
|
|
554
|
-
let found = false
|
|
555
|
-
for (const entry of host.loader.entries()) {
|
|
556
|
-
if (entry.options.name !== name) continue
|
|
557
|
-
// A disable can land while the entry's init is still in flight: the
|
|
558
|
-
// options flip but the finishing init brings the fiber up anyway, and a
|
|
559
|
-
// plain re-update no-ops on the empty diff. Force the update and verify
|
|
560
|
-
// the live state, retrying until reality matches the flag.
|
|
561
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
562
|
-
try {
|
|
563
|
-
await entry.update({ disabled: disabledFlag ? true : null }, false, true)
|
|
564
|
-
found = true
|
|
565
|
-
} catch (error) {
|
|
566
|
-
logEvent('warn', 'toggle', `${name}: entry update failed — ${error instanceof Error ? error.message : String(error)}`)
|
|
567
|
-
break
|
|
568
|
-
}
|
|
569
|
-
const live = entry.fiber !== undefined
|
|
570
|
-
if (live !== disabledFlag) break
|
|
571
|
-
await new Promise(resolvePromise => setTimeout(resolvePromise, 200))
|
|
572
|
-
}
|
|
573
|
-
logEvent('info', 'toggle',
|
|
574
|
-
`${name} -> ${disabledFlag ? 'off' : 'on'}: fiber=${String(entry.fiber !== undefined)}`)
|
|
575
|
-
}
|
|
576
|
-
if (!found) logEvent('info', 'toggle', `${name}: no loader entry matched`)
|
|
577
|
-
return found
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/**
|
|
581
|
-
* Make `name` the one active theme: deactivate every other installed theme
|
|
582
|
-
* (market hot mounts unmount; bundle-layer entries live-disable) and bring
|
|
583
|
-
* it up. The choice persists in state.json and is replayed at boot.
|
|
584
|
-
*/
|
|
585
|
-
async function activateTheme(name: string): Promise<boolean> {
|
|
586
|
-
const dir = profileDir(config.profile)
|
|
587
|
-
const themes = await installedThemeNames(config.profile)
|
|
588
|
-
for (const other of themes) {
|
|
589
|
-
if (other === name) continue
|
|
590
|
-
if (listHotMounts().includes(other)) {
|
|
591
|
-
await hotUnmount(other)
|
|
592
|
-
disabledThemes.add(other)
|
|
593
|
-
} else if (await setEntryDisabled(other, true)) {
|
|
594
|
-
disabledThemes.add(other)
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
disabledThemes.delete(name)
|
|
598
|
-
writeDisabledThemes(dir, disabledThemes)
|
|
599
|
-
if (listHotMounts().includes(name)) return true
|
|
600
|
-
if (await setEntryDisabled(name, false)) return true
|
|
601
|
-
return hotMount(host, dir, name)
|
|
602
|
-
}
|
|
92
|
+
/** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
|
|
93
|
+
const runPlugin = (profile: string, args: string[]) => withHoistRecovery(runDshPlugin, profile, args)
|
|
603
94
|
|
|
604
95
|
const disposers = [
|
|
605
96
|
host.webServer.register({
|
|
@@ -654,12 +145,12 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
|
|
|
654
145
|
const body = (await readJsonBody(request)) as { name?: unknown }
|
|
655
146
|
const name = typeof body.name === 'string' ? body.name : ''
|
|
656
147
|
const installed = readInstalled(config.profile)
|
|
657
|
-
const
|
|
658
|
-
if (installed[name] === undefined || !
|
|
148
|
+
const themeNames = await themes.installedThemeNames()
|
|
149
|
+
if (installed[name] === undefined || !themeNames.has(name)) {
|
|
659
150
|
sendJson(response, 400, { error: 'not an installed theme' })
|
|
660
151
|
return
|
|
661
152
|
}
|
|
662
|
-
const activated = await activateTheme(name)
|
|
153
|
+
const activated = await themes.activateTheme(name)
|
|
663
154
|
logEvent(activated ? 'info' : 'error', 'use-skin', `${name}: ${activated ? 'active' : 'failed'}`)
|
|
664
155
|
sendJson(response, activated ? 200 : 502, { ok: activated, live: listHotMounts() })
|
|
665
156
|
} catch (error) {
|
|
@@ -754,8 +245,9 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
|
|
|
754
245
|
return
|
|
755
246
|
}
|
|
756
247
|
try {
|
|
757
|
-
const body = (await readJsonBody(request)) as { name?: unknown }
|
|
248
|
+
const body = (await readJsonBody(request)) as { name?: unknown; force?: unknown }
|
|
758
249
|
const name = typeof body.name === 'string' ? body.name : ''
|
|
250
|
+
const force = body.force === true
|
|
759
251
|
const spec = readInstalled(config.profile)[name]
|
|
760
252
|
if (spec === undefined) {
|
|
761
253
|
sendJson(response, 400, { error: 'plugin is not installed' })
|
|
@@ -774,28 +266,31 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
|
|
|
774
266
|
const beforeCommit = repoKey !== null ? readLockCommits(config.profile).get(repoKey) ?? null : null
|
|
775
267
|
installing = true
|
|
776
268
|
try {
|
|
777
|
-
|
|
269
|
+
// force: the user chose to install a fresh release without the
|
|
270
|
+
// default one-day safety wait; scoped to this single command.
|
|
271
|
+
const addArgs = force ? ['add', '--config.minimumReleaseAge=0', target] : ['add', target]
|
|
272
|
+
const result = await runPlugin(config.profile, addArgs)
|
|
778
273
|
let ok = result.exitCode === 0 && !result.timedOut
|
|
779
274
|
let stale = false
|
|
780
275
|
if (ok) {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
: beforeVersion !== null && afterVersion === beforeVersion
|
|
276
|
+
stale = isStaleUpdate({
|
|
277
|
+
isGit,
|
|
278
|
+
beforeVersion,
|
|
279
|
+
afterVersion: readInstalledVersion(config.profile, name),
|
|
280
|
+
beforeCommit,
|
|
281
|
+
afterCommit: repoKey !== null ? readLockCommits(config.profile).get(repoKey) ?? null : null,
|
|
282
|
+
})
|
|
789
283
|
if (stale) ok = false
|
|
790
284
|
}
|
|
791
|
-
if (ok)
|
|
285
|
+
if (ok) invalidateUpdates()
|
|
792
286
|
const staleError = stale
|
|
793
|
-
?
|
|
287
|
+
? '这个新版本刚发布不久。为了安全,系统默认会等它发布满一天后再安装——刚发布的版本偶尔会被发现问题然后撤回。可以明天再试,或点「立即更新」不再等待。 / 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.'
|
|
794
288
|
: null
|
|
795
289
|
logEvent(ok ? 'info' : 'error', 'update',
|
|
796
290
|
`${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${stale ? ' STALE(minimumReleaseAge?)' : ''}${ok ? '' : ` stderr=${result.stderr.slice(-300)}`}`)
|
|
797
291
|
sendJson(response, ok ? 200 : 502, {
|
|
798
292
|
ok,
|
|
293
|
+
stale: stale || undefined,
|
|
799
294
|
error: staleError ?? undefined,
|
|
800
295
|
exitCode: result.exitCode,
|
|
801
296
|
timedOut: result.timedOut,
|
|
@@ -866,11 +361,11 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
|
|
|
866
361
|
}
|
|
867
362
|
installing = true
|
|
868
363
|
try {
|
|
869
|
-
const result = await
|
|
364
|
+
const result = await runPlugin(config.profile, ['remove', name])
|
|
870
365
|
const ok = result.exitCode === 0 && !result.timedOut
|
|
871
366
|
let hot = false
|
|
872
367
|
if (ok) {
|
|
873
|
-
|
|
368
|
+
invalidateUpdates()
|
|
874
369
|
hot = await hotUnmount(name)
|
|
875
370
|
}
|
|
876
371
|
logEvent(ok ? 'info' : 'error', 'uninstall',
|
|
@@ -922,57 +417,47 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
|
|
|
922
417
|
sendJson(response, 400, { error: 'plugin is not in the curated registry' })
|
|
923
418
|
return
|
|
924
419
|
}
|
|
925
|
-
const
|
|
926
|
-
if (
|
|
420
|
+
const target = installTargetFor(entry)
|
|
421
|
+
if (target === null) {
|
|
927
422
|
sendJson(response, 400, { error: 'unsupported source url' })
|
|
928
423
|
return
|
|
929
424
|
}
|
|
930
|
-
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
425
|
+
// Duplicate guard (#27): the same plugin listed under another name
|
|
426
|
+
// (an alias entry pointing at the same repo) must never install
|
|
427
|
+
// twice — two loader entries with one id brick the next boot.
|
|
428
|
+
// Monorepo subpath entries (distinct plugins in one repo) pass:
|
|
429
|
+
// their entry urls differ by subpath and identity is name-based.
|
|
430
|
+
const aliasOf = findInstalledAlias(entry, readInstalled(config.profile))
|
|
431
|
+
if (aliasOf !== null) {
|
|
432
|
+
logEvent('warn', 'install-rejected', `${entry.name}: same plugin already installed as ${aliasOf}`)
|
|
433
|
+
sendJson(response, 400, { error: `已以「${aliasOf}」安装过同一个插件,无需重复安装 / this plugin is already installed as "${aliasOf}"` })
|
|
434
|
+
return
|
|
435
|
+
}
|
|
940
436
|
installing = true
|
|
941
437
|
try {
|
|
942
438
|
const before = new Set(Object.keys(readInstalled(config.profile)))
|
|
943
|
-
const result = await
|
|
439
|
+
const result = await runPlugin(config.profile, ['add', target])
|
|
944
440
|
let ok = result.exitCode === 0 && !result.timedOut
|
|
945
|
-
if (ok)
|
|
441
|
+
if (ok) invalidateUpdates()
|
|
946
442
|
if (ok) {
|
|
947
443
|
// Collection repos (e.g. skin monorepos) install as a junk
|
|
948
444
|
// fileset with no root package.json; retarget to the real
|
|
949
445
|
// plugin subdirectories via pnpm's #path: selector.
|
|
950
|
-
ok = await retargetCollections(config.profile, before, target)
|
|
446
|
+
ok = await retargetCollections(runPlugin, config.profile, before, target)
|
|
951
447
|
}
|
|
952
448
|
// Fake-success guard (#18): a clean exit that added nothing
|
|
953
|
-
// installable must not read as success
|
|
954
|
-
//
|
|
955
|
-
// brick the next boot
|
|
449
|
+
// installable must not read as success. Runs even when
|
|
450
|
+
// retargeting partially failed — a broken piece that slipped in
|
|
451
|
+
// must never survive to brick the next boot.
|
|
956
452
|
let notAPlugin = false
|
|
957
453
|
let removedBroken: string[] = []
|
|
958
|
-
// Runs even when retargeting partially failed — a broken piece
|
|
959
|
-
// that slipped in must never survive to brick the next boot.
|
|
960
454
|
if (result.exitCode === 0 && !result.timedOut) {
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
for (const n of addedNow) {
|
|
964
|
-
const dir = join(profileDir(config.profile), 'node_modules', n)
|
|
965
|
-
if (hasDshManifest(dir) && entryArtifactExists(dir)) {
|
|
966
|
-
keep.push(n)
|
|
967
|
-
} else {
|
|
968
|
-
removedBroken.push(n)
|
|
969
|
-
await runDshPlugin(config.profile, ['remove', n])
|
|
970
|
-
}
|
|
971
|
-
}
|
|
455
|
+
const validated = await validateAddedPlugins(runPlugin, config.profile, before)
|
|
456
|
+
removedBroken = validated.removedBroken
|
|
972
457
|
if (removedBroken.length > 0) {
|
|
973
458
|
logEvent('warn', 'install', `${target}: removed uninstallable pieces (no dsh manifest or missing build artifacts): ${removedBroken.join(', ')}`)
|
|
974
459
|
}
|
|
975
|
-
if (keep.length === 0) {
|
|
460
|
+
if (validated.keep.length === 0) {
|
|
976
461
|
ok = false
|
|
977
462
|
notAPlugin = true
|
|
978
463
|
logEvent('error', 'install', `${target}: nothing installable survived validation`)
|
|
@@ -991,7 +476,7 @@ export function mountMarketRoutes(host: MarketHost, config: MarketConfig): () =>
|
|
|
991
476
|
hot = true
|
|
992
477
|
for (const name of added) {
|
|
993
478
|
const live = entry.category === 'theme'
|
|
994
|
-
? await activateTheme(name)
|
|
479
|
+
? await themes.activateTheme(name)
|
|
995
480
|
: await hotMount(host, profileDir(config.profile), name)
|
|
996
481
|
if (!live) hot = false
|
|
997
482
|
}
|