dshmarket 1.60.0 → 1.62.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/client/client.js +18 -3
- package/lib/dsh-cli.js +72 -14
- package/lib/install.js +159 -6
- package/lib/pnpm-compat.js +15 -0
- package/lib/routes.js +20 -2
- package/lib/types/dsh-cli.d.ts +8 -30
- package/lib/types/install.d.ts +68 -1
- package/lib/types/pnpm-compat.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/MarketSection.tsx +19 -3
- package/src/client/locales.ts +7 -0
- package/src/dsh-cli.ts +71 -13
- package/src/install.ts +155 -6
- package/src/pnpm-compat.ts +16 -1
- package/src/routes.ts +20 -2
package/client/client.js
CHANGED
|
@@ -436,6 +436,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
436
436
|
sortDownloads: "npm 下载量(近 30 天)",
|
|
437
437
|
/** Discover/theme card byline tooltip for catalog `version`. */
|
|
438
438
|
catalogNpmLatest: "npm 当前 latest",
|
|
439
|
+
catalogVersionDated: "目录数据({0} 刷新)里记录的 npm 版本;在那之后发布的版本要等下一次目录刷新才会出现",
|
|
440
|
+
catalogVersionUndated: "目录数据里记录的 npm 版本;目录每日刷新,所以可能滞后于 npm 上的实际 latest",
|
|
439
441
|
sortStars: "Star 数",
|
|
440
442
|
sortAdded: "发布时间",
|
|
441
443
|
sortDesc: "降序",
|
|
@@ -1024,6 +1026,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1024
1026
|
sortDownloads: "npm downloads (30d)",
|
|
1025
1027
|
/** Discover/theme card byline tooltip for catalog `version`. */
|
|
1026
1028
|
catalogNpmLatest: "npm latest",
|
|
1029
|
+
catalogVersionDated: "the npm version as of the catalog's last refresh ({0}); a release published since then appears after the next refresh",
|
|
1030
|
+
catalogVersionUndated: "the npm version as of the catalog's last refresh — the catalog updates daily, so this can lag npm's actual latest",
|
|
1027
1031
|
sortStars: "Stars",
|
|
1028
1032
|
sortAdded: "Release date",
|
|
1029
1033
|
sortDesc: "Descending",
|
|
@@ -7175,6 +7179,15 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
7175
7179
|
* first reply always has to ask which one it was.
|
|
7176
7180
|
*/
|
|
7177
7181
|
const [version, setVersion] = (0, react.useState)(null);
|
|
7182
|
+
/**
|
|
7183
|
+
* The catalog's own build date (#712). The version on a card is the
|
|
7184
|
+
* catalog's copy, not a live npm lookup, so a plugin published after the
|
|
7185
|
+
* last refresh shows the older number — the tooltip has to say so, or
|
|
7186
|
+
* "npm latest" beside a number that is not npm's latest is a bug report
|
|
7187
|
+
* waiting to be filed. Null until the catalog answers.
|
|
7188
|
+
*/
|
|
7189
|
+
const [catalogUpdated, setCatalogUpdated] = (0, react.useState)(null);
|
|
7190
|
+
const catalogVersionTip = (0, react.useMemo)(() => catalogUpdated === null ? t("catalogVersionUndated") : t("catalogVersionDated").replace("{0}", catalogUpdated), [catalogUpdated, t]);
|
|
7178
7191
|
/** Non-live activation results from the last operation, shown as a banner. */
|
|
7179
7192
|
const [activationWarnings, setActivationWarnings] = (0, react.useState)([]);
|
|
7180
7193
|
const [hostDependencyFindings, setHostDependencyFindings] = (0, react.useState)([]);
|
|
@@ -7363,6 +7376,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
7363
7376
|
}
|
|
7364
7377
|
cachedRegistry = body.registry;
|
|
7365
7378
|
setData(body.registry);
|
|
7379
|
+
const catalogDate = body.registry.updated;
|
|
7380
|
+
setCatalogUpdated(typeof catalogDate === "string" && catalogDate.trim() !== "" ? catalogDate : null);
|
|
7366
7381
|
setHostVersion(typeof body.hostVersion === "string" ? body.hostVersion : null);
|
|
7367
7382
|
setLoadError(null);
|
|
7368
7383
|
}).catch((error) => {
|
|
@@ -9119,7 +9134,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
9119
9134
|
}),
|
|
9120
9135
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CatalogVersionMark, {
|
|
9121
9136
|
version: p.version,
|
|
9122
|
-
tip:
|
|
9137
|
+
tip: catalogVersionTip
|
|
9123
9138
|
}),
|
|
9124
9139
|
typeof p.downloads === "number" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
9125
9140
|
label: String(p.downloads),
|
|
@@ -9296,7 +9311,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
9296
9311
|
}),
|
|
9297
9312
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CatalogVersionMark, {
|
|
9298
9313
|
version: p.version,
|
|
9299
|
-
tip:
|
|
9314
|
+
tip: catalogVersionTip
|
|
9300
9315
|
}),
|
|
9301
9316
|
typeof p.downloads === "number" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
9302
9317
|
label: String(p.downloads),
|
|
@@ -11446,7 +11461,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
11446
11461
|
}),
|
|
11447
11462
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CatalogVersionMark, {
|
|
11448
11463
|
version: confirming.version,
|
|
11449
|
-
tip:
|
|
11464
|
+
tip: catalogVersionTip
|
|
11450
11465
|
}),
|
|
11451
11466
|
typeof confirming.downloads === "number" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
11452
11467
|
label: String(confirming.downloads),
|
package/lib/dsh-cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Installs run through node:child_process, not ctx.shell: the shell service is
|
|
7
7
|
* the agent's sandboxed executor and denies writes to the profile directory.
|
|
8
8
|
*/
|
|
9
|
-
import { spawn } from 'node:child_process';
|
|
9
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
@@ -215,20 +215,78 @@ export function toolSearchDirs(platform = process.platform, env = process.env, h
|
|
|
215
215
|
* precisely the branch that cannot work from a spawned child.
|
|
216
216
|
*
|
|
217
217
|
* Scope, stated plainly because it is narrower than the issue title
|
|
218
|
-
* suggests: this is the HTTPS half
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
218
|
+
* suggests: this is the HTTPS half, and the FIRST attempt HTTPS is. Measured
|
|
219
|
+
* with a `GIT_SSH_COMMAND` sentinel that always fails: pnpm's clone of a
|
|
220
|
+
* `github:owner/repo#path:/sub` spec succeeds without ever calling it, so
|
|
221
|
+
* ssh is the fallback that runs after HTTPS fails — which is the private
|
|
222
|
+
* repository case, not the common one.
|
|
223
|
+
*
|
|
224
|
+
* The ssh half (#596, @JINITAIMI121) is a policy decision, and it is made
|
|
225
|
+
* here: `BatchMode=yes`, so a passphrase or host-key question fails fast
|
|
226
|
+
* instead of waiting on a terminal nobody is watching. The cost is real and
|
|
227
|
+
* is why it is conditional — `BatchMode` also disables `SSH_ASKPASS`, so a
|
|
228
|
+
* key that NEEDS a passphrase stops prompting even where something could
|
|
229
|
+
* have answered, and the install fails where it used to work.
|
|
230
|
+
*
|
|
231
|
+
* So it is set only when the user has expressed NO ssh preference at all:
|
|
232
|
+
* `GIT_SSH_COMMAND` and `GIT_SSH` in the environment, and `core.sshCommand`
|
|
233
|
+
* in git config — the three ordinary ways to choose an identity, and
|
|
234
|
+
* `GIT_SSH_COMMAND` would silently override all three (measured: with
|
|
235
|
+
* `core.sshCommand` set, ours wins and theirs is never run). A user who
|
|
236
|
+
* has an agent or a specific key configured keeps exactly what they have;
|
|
237
|
+
* the prompt is closed for everyone else, and `classifyPnpmFailure` says
|
|
238
|
+
* what to do if that turns out to be the passphrase case.
|
|
227
239
|
*/
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
240
|
+
let coreSshCommandProbe;
|
|
241
|
+
/**
|
|
242
|
+
* `core.sshCommand` from the user's git configuration, or null.
|
|
243
|
+
*
|
|
244
|
+
* The third place an ssh identity hides, and the one the environment cannot
|
|
245
|
+
* show: `git config --get` answers it. Memoized for the process — a user
|
|
246
|
+
* does not reconfigure git mid-install, and this runs on every spawn.
|
|
247
|
+
*
|
|
248
|
+
* @returns the configured command, or null when git has none (including
|
|
249
|
+
* when git is absent — an unreadable answer is not a choice).
|
|
250
|
+
*/
|
|
251
|
+
export function probeCoreSshCommand(env) {
|
|
252
|
+
// Memoized only for the caller that has no opinion: a test that supplies
|
|
253
|
+
// its own environment is asking a different question and must get the
|
|
254
|
+
// fresh answer.
|
|
255
|
+
if (env === undefined && coreSshCommandProbe !== undefined)
|
|
256
|
+
return coreSshCommandProbe ?? null;
|
|
257
|
+
try {
|
|
258
|
+
const out = spawnSync('git', ['config', '--get', 'core.sshCommand'], {
|
|
259
|
+
encoding: 'utf8',
|
|
260
|
+
timeout: 5_000,
|
|
261
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
262
|
+
...(env === undefined ? {} : { env }),
|
|
263
|
+
// A user's git may be a shim that prints to a console; nothing here
|
|
264
|
+
// wants a window.
|
|
265
|
+
windowsHide: true,
|
|
266
|
+
});
|
|
267
|
+
const answer = out.status === 0 && typeof out.stdout === 'string' && out.stdout.trim() !== ''
|
|
268
|
+
? out.stdout.trim()
|
|
269
|
+
: null;
|
|
270
|
+
if (env === undefined)
|
|
271
|
+
coreSshCommandProbe = answer;
|
|
272
|
+
return answer;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
if (env === undefined)
|
|
276
|
+
coreSshCommandProbe = null;
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
export function gitEnvForPnpm(env = process.env, coreSshCommand = probeCoreSshCommand()) {
|
|
281
|
+
const out = {};
|
|
282
|
+
// Blank counts as unset: an empty value is not a setting git can parse.
|
|
283
|
+
const speaks = (value) => (value ?? '').trim() !== '';
|
|
284
|
+
if (!speaks(env.GIT_TERMINAL_PROMPT))
|
|
285
|
+
out.GIT_TERMINAL_PROMPT = '0';
|
|
286
|
+
const choseElsewhere = speaks(env.GIT_SSH_COMMAND) || speaks(env.GIT_SSH) || speaks(coreSshCommand ?? '');
|
|
287
|
+
if (!choseElsewhere)
|
|
288
|
+
out.GIT_SSH_COMMAND = 'ssh -oBatchMode=yes';
|
|
289
|
+
return out;
|
|
232
290
|
}
|
|
233
291
|
/**
|
|
234
292
|
* Every `--config.<key>=<value>` override in the argv, repeated as the
|
package/lib/install.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* update staleness detection. Every function takes the plugin runner as a
|
|
5
5
|
* parameter so tests can substitute a recording fake.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync } from 'node:fs';
|
|
8
|
-
import { join } from 'node:path';
|
|
7
|
+
import { closeSync, existsSync, lstatSync, openSync, readlinkSync, readSync, rmSync, statSync } from 'node:fs';
|
|
8
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
9
|
+
import { findDshInstallDir } from './dsh-install.js';
|
|
9
10
|
import { classifyPnpmFailure, HOST_NAMESPACE_RE } from './pnpm-compat.js';
|
|
10
11
|
import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.js';
|
|
11
12
|
import { logEvent } from './log.js';
|
|
@@ -176,6 +177,7 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
176
177
|
// construction: directories are only removed when their owning pid is
|
|
177
178
|
// gone (the name carries it), so a live download is never touched.
|
|
178
179
|
await cleanOrphanedStore(run, profile);
|
|
180
|
+
const diagnostics = diagnosticsTail(result);
|
|
179
181
|
const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode);
|
|
180
182
|
if (failure !== null) {
|
|
181
183
|
result = {
|
|
@@ -184,6 +186,13 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
184
186
|
...(failure.replaceOutput === true ? { stdout: '' } : {}),
|
|
185
187
|
};
|
|
186
188
|
}
|
|
189
|
+
else if (diagnostics !== null) {
|
|
190
|
+
// The dsh CLI redirects the whole pnpm run into a file and leaves one
|
|
191
|
+
// line on stderr: `dsh: pnpm failed; diagnostics: <path>` (its own
|
|
192
|
+
// literal message). Everything a user or a report needs is in that
|
|
193
|
+
// file, so show its tail rather than the one line (#672).
|
|
194
|
+
result = { ...result, stderr: `${result.stderr}\n\n--- dsh diagnostics (${diagnostics.path}) ---\n${diagnostics.text}` };
|
|
195
|
+
}
|
|
187
196
|
else if (result.pnpmError !== undefined && result.pnpmError !== '') {
|
|
188
197
|
// Nothing matched, but pnpm DID say what went wrong — in its ndjson
|
|
189
198
|
// stream, which never reaches stderr. Without this the user is shown
|
|
@@ -200,6 +209,54 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
200
209
|
}
|
|
201
210
|
return result;
|
|
202
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* The tail of the diagnostics file the dsh CLI pointed at, when it pointed at
|
|
214
|
+
* one (#672).
|
|
215
|
+
*
|
|
216
|
+
* `dsh plugin` writes pnpm's entire output to a file and prints only
|
|
217
|
+
* `dsh: pnpm failed; diagnostics: <path>`. For a failure the market cannot
|
|
218
|
+
* classify, that line is all it has — the reasons people reported (#244,
|
|
219
|
+
* #192, #138) were all "the UI shows one unhelpful line" for causes that
|
|
220
|
+
* were written down somewhere the UI never looked.
|
|
221
|
+
*
|
|
222
|
+
* Bounded on purpose: absolute paths only, a regular file, and at most the
|
|
223
|
+
* last {@link DIAGNOSTICS_TAIL_BYTES}. The path comes from our own child, but
|
|
224
|
+
* the market only ever needs the end of a log, and reading an arbitrary
|
|
225
|
+
* amount of an arbitrary file is not worth anything it could add.
|
|
226
|
+
*
|
|
227
|
+
* @returns the path and the text, or null when the output names no readable
|
|
228
|
+
* diagnostics file.
|
|
229
|
+
*/
|
|
230
|
+
export function diagnosticsTail(result) {
|
|
231
|
+
const match = /(?:^|\n)\s*dsh: [^\n]*diagnostics:\s*(\S+)\s*$/m.exec(result.stderr)
|
|
232
|
+
?? /(?:^|\n)\s*dsh: [^\n]*diagnostics:\s*(\S+)\s*$/m.exec(result.stdout);
|
|
233
|
+
if (match === null)
|
|
234
|
+
return null;
|
|
235
|
+
const path = match[1];
|
|
236
|
+
if (!isAbsolute(path))
|
|
237
|
+
return null;
|
|
238
|
+
try {
|
|
239
|
+
if (!statSync(path).isFile())
|
|
240
|
+
return null;
|
|
241
|
+
const size = statSync(path).size;
|
|
242
|
+
const start = Math.max(0, size - DIAGNOSTICS_TAIL_BYTES);
|
|
243
|
+
const handle = openSync(path, 'r');
|
|
244
|
+
try {
|
|
245
|
+
const buffer = Buffer.alloc(Math.min(DIAGNOSTICS_TAIL_BYTES, size));
|
|
246
|
+
readSync(handle, buffer, 0, buffer.length, start);
|
|
247
|
+
const text = buffer.toString('utf8').trim();
|
|
248
|
+
return text === '' ? null : { path, text };
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
closeSync(handle);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** How much of a diagnostics file is worth showing. */
|
|
259
|
+
const DIAGNOSTICS_TAIL_BYTES = 8192;
|
|
203
260
|
/**
|
|
204
261
|
* Whether pnpm never started at all, so the profile cannot have been touched.
|
|
205
262
|
*
|
|
@@ -307,7 +364,7 @@ export async function retargetCollections(run, profile, before, target, explicit
|
|
|
307
364
|
* profile at all, which is a broken plugin-command channel rather than
|
|
308
365
|
* anything wrong with the plugin (#258).
|
|
309
366
|
*/
|
|
310
|
-
export async function validateAddedPlugins(run, profile, before, explicitDir) {
|
|
367
|
+
export async function validateAddedPlugins(run, profile, before, explicitDir, hostDirectory) {
|
|
311
368
|
const dir = profileDir(profile, explicitDir);
|
|
312
369
|
const addedNow = Object.keys(readInstalled(profile, dir)).filter(n => !before.has(n));
|
|
313
370
|
const keep = [];
|
|
@@ -322,7 +379,7 @@ export async function validateAddedPlugins(run, profile, before, explicitDir) {
|
|
|
322
379
|
// ship no entry of their own (#103) and must not be uninstalled here.
|
|
323
380
|
if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
|
|
324
381
|
removedBroken.push(n);
|
|
325
|
-
await removeAndReconcile(run, profile, dir, n);
|
|
382
|
+
await removeAndReconcile(run, profile, dir, n, hostDirectory);
|
|
326
383
|
continue;
|
|
327
384
|
}
|
|
328
385
|
const clash = conflictingEntryIds(dir, n, existingBundles);
|
|
@@ -331,7 +388,7 @@ export async function validateAddedPlugins(run, profile, before, explicitDir) {
|
|
|
331
388
|
conflicts.push(...clash.map(hit => ({ name: n, ...hit })));
|
|
332
389
|
removedBroken.push(n);
|
|
333
390
|
logEvent('error', 'install', `${n}: loader entry id conflict with ${clash[0].owner} (${clash.map(hit => hit.id).join(', ')}) — removing, it would break the next boot`);
|
|
334
|
-
await removeAndReconcile(run, profile, dir, n);
|
|
391
|
+
await removeAndReconcile(run, profile, dir, n, hostDirectory);
|
|
335
392
|
continue;
|
|
336
393
|
}
|
|
337
394
|
keep.push(n);
|
|
@@ -356,11 +413,14 @@ export async function validateAddedPlugins(run, profile, before, explicitDir) {
|
|
|
356
413
|
* @param profile - the profile name for manifest writes.
|
|
357
414
|
* @param dir - the profile directory the validation reads.
|
|
358
415
|
* @param name - the package being removed.
|
|
416
|
+
* @param hostDirectory - the DSH host deployment directory whose node_modules
|
|
417
|
+
* may hold a bridge link for this package (#662); resolved when omitted.
|
|
359
418
|
*/
|
|
360
|
-
async function removeAndReconcile(run, profile, dir, name) {
|
|
419
|
+
async function removeAndReconcile(run, profile, dir, name, hostDirectory = findDshInstallDir()) {
|
|
361
420
|
const result = await run(profile, ['remove', name]);
|
|
362
421
|
const gone = !existsSync(join(dir, 'node_modules', name, 'package.json'));
|
|
363
422
|
if (gone) {
|
|
423
|
+
removeDanglingHostBridge(name, dir, hostDirectory);
|
|
364
424
|
if (dropFromManifest(profile, name, dir)) {
|
|
365
425
|
logEvent('error', 'install', `${name}: remove ${result.exitCode === 0 ? 'skipped the manifest reconcile' : `failed (exit ${String(result.exitCode)})`} but the package is gone from disk — dropped its dependency/bundle rows so the next boot stays loadable`);
|
|
366
426
|
}
|
|
@@ -370,6 +430,99 @@ async function removeAndReconcile(run, profile, dir, name) {
|
|
|
370
430
|
logEvent('error', 'install', `${name}: remove failed (exit ${String(result.exitCode)})${result.timedOut ? ' timed out' : ''}${result.cancelled ? ' cancelled' : ''} and the package is still installed — its rows stay in the manifest; retry the uninstall`);
|
|
371
431
|
}
|
|
372
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* The node_modules root of the DSH host deployment `directory` belongs to.
|
|
435
|
+
*
|
|
436
|
+
* CLI layouts install the host as `<prefix>/node_modules/@deepseek-ai/dsh`,
|
|
437
|
+
* so the shared root is two dirname steps up; a flat Desktop layout keeps
|
|
438
|
+
* the host package at the deployment root (#662's
|
|
439
|
+
* `<desktop-app>\dependencies\dsh`), with its pnpm-managed node_modules
|
|
440
|
+
* directly beside its package.json. `dshHostInfo()` already distinguishes
|
|
441
|
+
* the two — this is pure path arithmetic on whichever directory it returned.
|
|
442
|
+
*/
|
|
443
|
+
export function hostNodeModulesRoot(directory) {
|
|
444
|
+
const segments = directory.split(/[/\\]+/).filter(segment => segment !== '');
|
|
445
|
+
const n = segments.length;
|
|
446
|
+
if (n >= 3 && segments[n - 3].toLowerCase() === 'node_modules' && segments[n - 2].toLowerCase() === '@deepseek-ai') {
|
|
447
|
+
return resolve(dirname(dirname(directory)));
|
|
448
|
+
}
|
|
449
|
+
return resolve(directory, 'node_modules');
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Normalize a link target for path comparison: restore the UNC device form
|
|
453
|
+
* (`\\?\UNC\server\share` back to `\\server\share` — stripped of its prefix
|
|
454
|
+
* it is no longer absolute and resolve() would re-root it against the
|
|
455
|
+
* cwd), then remove the NT device prefixes `\\?\` and the subst-style
|
|
456
|
+
* `\??\` mklink stores. Measured on Node 24/win32: readlinkSync returns
|
|
457
|
+
* the plain absolute path, so these branches only matter for links created
|
|
458
|
+
* outside Node — but a comparison must not silently miss because of them.
|
|
459
|
+
*/
|
|
460
|
+
export function normalizedLinkTarget(target) {
|
|
461
|
+
return target
|
|
462
|
+
.replace(/^\\\\\?\\UNC\\/, '\\\\')
|
|
463
|
+
.replace(/^(?:\\\\\?\\|\\\?\?\\)/, '');
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Whether a link target names the profile's copy of a package. Junction
|
|
467
|
+
* targets keep the case they were created with, so the comparison is
|
|
468
|
+
* case-insensitive on win32, where the filesystem itself is.
|
|
469
|
+
*/
|
|
470
|
+
function pointsAtProfilePackage(target, expected) {
|
|
471
|
+
const left = resolve(normalizedLinkTarget(target));
|
|
472
|
+
const right = resolve(expected);
|
|
473
|
+
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Remove the host-side bridge link a confirmed uninstall leaves dangling
|
|
477
|
+
* (#662). The official boot projects profile packages into the host
|
|
478
|
+
* deployment's node_modules as links (Junction or SymbolicLink — lstat
|
|
479
|
+
* reports both as symlinks) and never reclaims them, and `dsh plugin
|
|
480
|
+
* remove` knows nothing about them, so without this the link outlives the
|
|
481
|
+
* package it pointed at and every tool that lstats its way through
|
|
482
|
+
* node_modules (rg first among them) fails on it.
|
|
483
|
+
*
|
|
484
|
+
* The gate is deliberately total: only `<host node_modules>/<name>` is ever
|
|
485
|
+
* touched, only when that entry is a link whose normalized target is
|
|
486
|
+
* exactly this profile's copy of `name`, and only when that copy is really
|
|
487
|
+
* gone — a live bridge for a package that is still installed must survive.
|
|
488
|
+
* A null `hostDirectory` (no host locatable — a plain `dsh web` from a
|
|
489
|
+
* global install) is a documented no-op.
|
|
490
|
+
*
|
|
491
|
+
* @returns whether a dangling bridge was removed. Never throws: the removal
|
|
492
|
+
* this cleans up after already succeeded, and a cleanup failure must not
|
|
493
|
+
* fail the uninstall that triggered it.
|
|
494
|
+
*/
|
|
495
|
+
export function removeDanglingHostBridge(name, profileDirectory, hostDirectory) {
|
|
496
|
+
if (hostDirectory === null)
|
|
497
|
+
return false;
|
|
498
|
+
const bridge = join(hostNodeModulesRoot(hostDirectory), name);
|
|
499
|
+
const unlinked = join(profileDirectory, 'node_modules', name);
|
|
500
|
+
try {
|
|
501
|
+
if (!lstatSync(bridge).isSymbolicLink())
|
|
502
|
+
return false;
|
|
503
|
+
// The gone-check mirrors removeAndReconcile's manifest truth: a package
|
|
504
|
+
// whose package.json is gone is uninstalled even if an empty directory
|
|
505
|
+
// lingered behind, and its bridge is exactly the dangling link #662 is
|
|
506
|
+
// about.
|
|
507
|
+
if (!pointsAtProfilePackage(readlinkSync(bridge), unlinked) || existsSync(join(unlinked, 'package.json')))
|
|
508
|
+
return false;
|
|
509
|
+
// No `recursive`: rmSync on a link unlinks the link itself only
|
|
510
|
+
// (measured on win32/Node 24 for live and dangling junctions and dir
|
|
511
|
+
// symlinks), and leaving it off keeps a race that swaps the link for a
|
|
512
|
+
// real directory from ever deleting that directory's tree.
|
|
513
|
+
rmSync(bridge, { force: true });
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
// ENOENT is the ordinary "no bridge there" answer (and a lost race
|
|
517
|
+
// while unlinking); anything else is worth a line in the log.
|
|
518
|
+
if (error.code !== 'ENOENT') {
|
|
519
|
+
logEvent('warn', 'uninstall', `${name}: host bridge cleanup skipped — ${error instanceof Error ? error.message : String(error)}`);
|
|
520
|
+
}
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
logEvent('info', 'uninstall', `${name}: removed the dangling host bridge the boot projection left at ${bridge}`);
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
373
526
|
/**
|
|
374
527
|
* Group flat `{id, owner}` conflict hits by the installed plugin that owns
|
|
375
528
|
* them. What the user has to decide is which PLUGINS to uninstall, not which
|
package/lib/pnpm-compat.js
CHANGED
|
@@ -344,6 +344,21 @@ export function classifyPnpmFailure(output, exitCode) {
|
|
|
344
344
|
};
|
|
345
345
|
}
|
|
346
346
|
}
|
|
347
|
+
// #596: the ssh half of #587. git asks for a passphrase (or a host-key
|
|
348
|
+
// confirmation) on a terminal a spawned child does not have; the market
|
|
349
|
+
// closes that prompt with `BatchMode=yes` so the question becomes a fast
|
|
350
|
+
// failure instead of a fifteen-minute hang. This is that failure, and it
|
|
351
|
+
// needs its own message because git's own words send the reader to the
|
|
352
|
+
// wrong place: `Permission denied (publickey)` reads as "your key is
|
|
353
|
+
// wrong", and the key is usually fine — it wants a passphrase, and the
|
|
354
|
+
// channel that would have asked for it is exactly what was shut.
|
|
355
|
+
if (/Permission denied \(publickey\)|Could not read from remote repository/.test(output)) {
|
|
356
|
+
return {
|
|
357
|
+
code: 'ssh-auth-failed',
|
|
358
|
+
recoverable: false,
|
|
359
|
+
message: 'git 无法在无人值守的情况下完成 SSH 认证。如果你的 SSH key 设了密码,请用 ssh-agent(ssh-add),或自己设置 GIT_SSH_COMMAND 指向你的命令,市场不会覆盖它。 / git could not complete SSH authentication unattended. If your SSH key has a passphrase, use ssh-agent (ssh-add), or set GIT_SSH_COMMAND to your own command — the market leaves yours alone.',
|
|
360
|
+
};
|
|
361
|
+
}
|
|
347
362
|
if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
|
|
348
363
|
return {
|
|
349
364
|
code: 'git-prepare-not-allowed',
|
package/lib/routes.js
CHANGED
|
@@ -14,7 +14,7 @@ import { load as loadYaml } from 'js-yaml';
|
|
|
14
14
|
import { forgetCatalog, loadRegistry, pluginCategories } from './registry.js';
|
|
15
15
|
import { cleanHotDir, hotMount, hotUnmount, listHotMounts, MAX_FAVORITES, MAX_NOTE, mountClientOnlyDeps, purgeMarketState, readMarketState, writeMarketState, } from './hot.js';
|
|
16
16
|
import { createGroup, deleteGroup, removeFromGroups, renameGroup, setGroupMembers } from './groups.js';
|
|
17
|
-
import { dshHostInfo } from './dsh-install.js';
|
|
17
|
+
import { dshHostInfo, findDshInstallDir } from './dsh-install.js';
|
|
18
18
|
import { deriveHostCompatibility, DiscoveryManifestIndex } from './discovery-compatibility.js';
|
|
19
19
|
import { configurePersistentLog, exportLogs, logEvent, readPersistentLog } from './log.js';
|
|
20
20
|
import { marketFetch } from './net.js';
|
|
@@ -30,7 +30,7 @@ import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } fro
|
|
|
30
30
|
import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
|
|
31
31
|
import { trialValidate } from './trial.js';
|
|
32
32
|
import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
|
|
33
|
-
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
|
|
33
|
+
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
|
|
34
34
|
import { classifyPnpmFailure } from './pnpm-compat.js';
|
|
35
35
|
import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
|
|
36
36
|
import { asRegion, githubProxyManaged, normalizeGithubProxy, REGIONS, routesFor, setActiveRegion, setCustomGithubProxy, } from './regions.js';
|
|
@@ -966,6 +966,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
966
966
|
// with two activation sources must not have the second one skipped
|
|
967
967
|
// because the first succeeded.
|
|
968
968
|
const unmounted = await hotUnmount(name);
|
|
969
|
+
// #662: the removal is confirmed — drop the host bridge link the boot
|
|
970
|
+
// projection may have left pointing at the now-gone package.
|
|
971
|
+
removeDanglingHostBridge(name, activeProfileDir, config.dshInstallDir ?? findDshInstallDir());
|
|
969
972
|
const entryDisabled = await themes.setEntryDisabled(name, true);
|
|
970
973
|
const hot = (unmounted || entryDisabled) && !native;
|
|
971
974
|
if (native) {
|
|
@@ -3998,6 +4001,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
3998
4001
|
// has just been removed. Only rows belonging to packages on the
|
|
3999
4002
|
// market's own disable list are touched — a hand-written row is
|
|
4000
4003
|
// the user's, not ours.
|
|
4004
|
+
// #662 first, while the filesystem is the only thing touched:
|
|
4005
|
+
// the market's own host bridge dangles the moment the remove
|
|
4006
|
+
// succeeds, and this route is the last code of ours to run.
|
|
4007
|
+
removeDanglingHostBridge(selfName, activeProfileDir, config.dshInstallDir ?? findDshInstallDir());
|
|
4001
4008
|
const purge = body.purge === true;
|
|
4002
4009
|
const restored = [];
|
|
4003
4010
|
if (purge) {
|
|
@@ -4287,6 +4294,13 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
4287
4294
|
sendJson(response, 400, { error: 'the market cannot uninstall itself; use the dsh CLI' });
|
|
4288
4295
|
return;
|
|
4289
4296
|
}
|
|
4297
|
+
// The bridge cleanup this route performs joins the package name
|
|
4298
|
+
// into a host node_modules path (#662); a hand-edited manifest
|
|
4299
|
+
// carrying `../../evil` must not escape that join.
|
|
4300
|
+
if (!NPM_NAME_RE.test(name)) {
|
|
4301
|
+
sendJson(response, 400, { error: 'plugin is not installed' });
|
|
4302
|
+
return;
|
|
4303
|
+
}
|
|
4290
4304
|
if (readInstalled(config.profile, activeProfileDir)[name] === undefined) {
|
|
4291
4305
|
sendJson(response, 400, { error: 'plugin is not installed' });
|
|
4292
4306
|
return;
|
|
@@ -4372,6 +4386,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
4372
4386
|
let hot = false;
|
|
4373
4387
|
if (ok || halfGone) {
|
|
4374
4388
|
invalidateUpdates();
|
|
4389
|
+
// #662: the removal is final (confirmed exit or reconciled
|
|
4390
|
+
// from disk truth) — the host node_modules bridge the boot
|
|
4391
|
+
// projection left for this package must not outlive it.
|
|
4392
|
+
removeDanglingHostBridge(name, activeProfileDir, config.dshInstallDir ?? findDshInstallDir());
|
|
4375
4393
|
hot = await hotUnmount(name);
|
|
4376
4394
|
// Bundle-layer plugins never hot-mount, but their loader entry
|
|
4377
4395
|
// is still LIVE in this process — after the remove deleted the
|
package/lib/types/dsh-cli.d.ts
CHANGED
|
@@ -79,39 +79,17 @@ export declare function proxyEnvForPnpm(env?: NodeJS.ProcessEnv, region?: Region
|
|
|
79
79
|
*/
|
|
80
80
|
export declare function toolSearchDirs(platform?: string, env?: NodeJS.ProcessEnv, home?: string): string[];
|
|
81
81
|
/**
|
|
82
|
-
*
|
|
83
|
-
* (#587).
|
|
82
|
+
* `core.sshCommand` from the user's git configuration, or null.
|
|
84
83
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* path `accelerate.ts` describes: `github:owner/repo#path:/sub` is one, and
|
|
89
|
-
* pnpm really does shell out to `git` for it, trying HTTPS first.
|
|
84
|
+
* The third place an ssh identity hides, and the one the environment cannot
|
|
85
|
+
* show: `git config --get` answers it. Memoized for the process — a user
|
|
86
|
+
* does not reconfigure git mid-install, and this runs on every spawn.
|
|
90
87
|
*
|
|
91
|
-
*
|
|
92
|
-
* is
|
|
93
|
-
* the reporter caught `git.exe` alive for eight minutes having burned 0.05s
|
|
94
|
-
* of CPU, and only the fifteen-minute install timeout ended it. Refusing
|
|
95
|
-
* the prompt turns that into a fast, readable failure.
|
|
96
|
-
*
|
|
97
|
-
* It is a default, not an override: a value the caller set wins, and blank
|
|
98
|
-
* counts as unset because an empty `GIT_TERMINAL_PROMPT` is not a setting
|
|
99
|
-
* git can parse either. Credential helpers and `GIT_ASKPASS` are untouched
|
|
100
|
-
* and still answer first — this closes only the terminal fallback, which is
|
|
101
|
-
* precisely the branch that cannot work from a spawned child.
|
|
102
|
-
*
|
|
103
|
-
* Scope, stated plainly because it is narrower than the issue title
|
|
104
|
-
* suggests: this is the HTTPS half. pnpm falls back to `git@github.com:`
|
|
105
|
-
* when HTTPS fails, and the ssh side needs `GIT_SSH_COMMAND`, which
|
|
106
|
-
* overrides `core.sshCommand` and `GIT_SSH` — the two ordinary ways to
|
|
107
|
-
* choose an identity — and whose `BatchMode=yes` would disable
|
|
108
|
-
* `SSH_ASKPASS`, breaking key-passphrase installs that work today. That
|
|
109
|
-
* half needs a policy decision, so it is not made here. Note also that on
|
|
110
|
-
* POSIX `runDshPlugin` spawns detached, so the subtree has no controlling
|
|
111
|
-
* terminal and the prompt already dies instantly; the hang the issue
|
|
112
|
-
* reports needs Windows, where the spawn is not detached.
|
|
88
|
+
* @returns the configured command, or null when git has none (including
|
|
89
|
+
* when git is absent — an unreadable answer is not a choice).
|
|
113
90
|
*/
|
|
114
|
-
export declare function
|
|
91
|
+
export declare function probeCoreSshCommand(env?: NodeJS.ProcessEnv): string | null;
|
|
92
|
+
export declare function gitEnvForPnpm(env?: NodeJS.ProcessEnv, coreSshCommand?: string | null): NodeJS.ProcessEnv;
|
|
115
93
|
/**
|
|
116
94
|
* Every `--config.<key>=<value>` override in the argv, repeated as the
|
|
117
95
|
* `PNPM_CONFIG_<KEY>` environment variable that pnpm 12 still reads.
|
package/lib/types/install.d.ts
CHANGED
|
@@ -102,6 +102,31 @@ export declare function isUnpublishedHostPeer(pkg: string | undefined, profile:
|
|
|
102
102
|
export declare function withHoistRecovery(run: PluginRunner, profile: string, pluginArgs: string[], profileDirectory?: string, options?: {
|
|
103
103
|
releaseAgeBypass?: boolean;
|
|
104
104
|
}): Promise<InstallResult>;
|
|
105
|
+
/**
|
|
106
|
+
* The tail of the diagnostics file the dsh CLI pointed at, when it pointed at
|
|
107
|
+
* one (#672).
|
|
108
|
+
*
|
|
109
|
+
* `dsh plugin` writes pnpm's entire output to a file and prints only
|
|
110
|
+
* `dsh: pnpm failed; diagnostics: <path>`. For a failure the market cannot
|
|
111
|
+
* classify, that line is all it has — the reasons people reported (#244,
|
|
112
|
+
* #192, #138) were all "the UI shows one unhelpful line" for causes that
|
|
113
|
+
* were written down somewhere the UI never looked.
|
|
114
|
+
*
|
|
115
|
+
* Bounded on purpose: absolute paths only, a regular file, and at most the
|
|
116
|
+
* last {@link DIAGNOSTICS_TAIL_BYTES}. The path comes from our own child, but
|
|
117
|
+
* the market only ever needs the end of a log, and reading an arbitrary
|
|
118
|
+
* amount of an arbitrary file is not worth anything it could add.
|
|
119
|
+
*
|
|
120
|
+
* @returns the path and the text, or null when the output names no readable
|
|
121
|
+
* diagnostics file.
|
|
122
|
+
*/
|
|
123
|
+
export declare function diagnosticsTail(result: {
|
|
124
|
+
stdout: string;
|
|
125
|
+
stderr: string;
|
|
126
|
+
}): {
|
|
127
|
+
path: string;
|
|
128
|
+
text: string;
|
|
129
|
+
} | null;
|
|
105
130
|
/**
|
|
106
131
|
* Whether pnpm never started at all, so the profile cannot have been touched.
|
|
107
132
|
*
|
|
@@ -164,7 +189,7 @@ export declare function retargetCollections(run: PluginRunner, profile: string,
|
|
|
164
189
|
* profile at all, which is a broken plugin-command channel rather than
|
|
165
190
|
* anything wrong with the plugin (#258).
|
|
166
191
|
*/
|
|
167
|
-
export declare function validateAddedPlugins(run: PluginRunner, profile: string, before: Set<string>, explicitDir?: string): Promise<{
|
|
192
|
+
export declare function validateAddedPlugins(run: PluginRunner, profile: string, before: Set<string>, explicitDir?: string, hostDirectory?: string | null): Promise<{
|
|
168
193
|
added: string[];
|
|
169
194
|
keep: string[];
|
|
170
195
|
removedBroken: string[];
|
|
@@ -174,6 +199,48 @@ export declare function validateAddedPlugins(run: PluginRunner, profile: string,
|
|
|
174
199
|
owner: string;
|
|
175
200
|
}[];
|
|
176
201
|
}>;
|
|
202
|
+
/**
|
|
203
|
+
* The node_modules root of the DSH host deployment `directory` belongs to.
|
|
204
|
+
*
|
|
205
|
+
* CLI layouts install the host as `<prefix>/node_modules/@deepseek-ai/dsh`,
|
|
206
|
+
* so the shared root is two dirname steps up; a flat Desktop layout keeps
|
|
207
|
+
* the host package at the deployment root (#662's
|
|
208
|
+
* `<desktop-app>\dependencies\dsh`), with its pnpm-managed node_modules
|
|
209
|
+
* directly beside its package.json. `dshHostInfo()` already distinguishes
|
|
210
|
+
* the two — this is pure path arithmetic on whichever directory it returned.
|
|
211
|
+
*/
|
|
212
|
+
export declare function hostNodeModulesRoot(directory: string): string;
|
|
213
|
+
/**
|
|
214
|
+
* Normalize a link target for path comparison: restore the UNC device form
|
|
215
|
+
* (`\\?\UNC\server\share` back to `\\server\share` — stripped of its prefix
|
|
216
|
+
* it is no longer absolute and resolve() would re-root it against the
|
|
217
|
+
* cwd), then remove the NT device prefixes `\\?\` and the subst-style
|
|
218
|
+
* `\??\` mklink stores. Measured on Node 24/win32: readlinkSync returns
|
|
219
|
+
* the plain absolute path, so these branches only matter for links created
|
|
220
|
+
* outside Node — but a comparison must not silently miss because of them.
|
|
221
|
+
*/
|
|
222
|
+
export declare function normalizedLinkTarget(target: string): string;
|
|
223
|
+
/**
|
|
224
|
+
* Remove the host-side bridge link a confirmed uninstall leaves dangling
|
|
225
|
+
* (#662). The official boot projects profile packages into the host
|
|
226
|
+
* deployment's node_modules as links (Junction or SymbolicLink — lstat
|
|
227
|
+
* reports both as symlinks) and never reclaims them, and `dsh plugin
|
|
228
|
+
* remove` knows nothing about them, so without this the link outlives the
|
|
229
|
+
* package it pointed at and every tool that lstats its way through
|
|
230
|
+
* node_modules (rg first among them) fails on it.
|
|
231
|
+
*
|
|
232
|
+
* The gate is deliberately total: only `<host node_modules>/<name>` is ever
|
|
233
|
+
* touched, only when that entry is a link whose normalized target is
|
|
234
|
+
* exactly this profile's copy of `name`, and only when that copy is really
|
|
235
|
+
* gone — a live bridge for a package that is still installed must survive.
|
|
236
|
+
* A null `hostDirectory` (no host locatable — a plain `dsh web` from a
|
|
237
|
+
* global install) is a documented no-op.
|
|
238
|
+
*
|
|
239
|
+
* @returns whether a dangling bridge was removed. Never throws: the removal
|
|
240
|
+
* this cleans up after already succeeded, and a cleanup failure must not
|
|
241
|
+
* fail the uninstall that triggered it.
|
|
242
|
+
*/
|
|
243
|
+
export declare function removeDanglingHostBridge(name: string, profileDirectory: string, hostDirectory: string | null): boolean;
|
|
177
244
|
/**
|
|
178
245
|
* Group flat `{id, owner}` conflict hits by the installed plugin that owns
|
|
179
246
|
* them. What the user has to decide is which PLUGINS to uninstall, not which
|
|
@@ -33,7 +33,7 @@ export declare function pluginArgsFor(profileDir: string, pluginArgs: string[]):
|
|
|
33
33
|
*/
|
|
34
34
|
export declare const HOST_NAMESPACE_RE: RegExp;
|
|
35
35
|
export interface PnpmFailure {
|
|
36
|
-
code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom';
|
|
36
|
+
code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed';
|
|
37
37
|
/** Bilingual, actionable message shown to the user instead of the raw wall of text. */
|
|
38
38
|
message: string;
|
|
39
39
|
/** True when re-running `pnpm install` in the profile is the documented recovery. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshmarket",
|
|
3
3
|
"description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.62.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -1527,6 +1527,20 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
1527
1527
|
* first reply always has to ask which one it was.
|
|
1528
1528
|
*/
|
|
1529
1529
|
const [version, setVersion] = useState<string | null>(null)
|
|
1530
|
+
/**
|
|
1531
|
+
* The catalog's own build date (#712). The version on a card is the
|
|
1532
|
+
* catalog's copy, not a live npm lookup, so a plugin published after the
|
|
1533
|
+
* last refresh shows the older number — the tooltip has to say so, or
|
|
1534
|
+
* "npm latest" beside a number that is not npm's latest is a bug report
|
|
1535
|
+
* waiting to be filed. Null until the catalog answers.
|
|
1536
|
+
*/
|
|
1537
|
+
const [catalogUpdated, setCatalogUpdated] = useState<string | null>(null)
|
|
1538
|
+
const catalogVersionTip = useMemo(
|
|
1539
|
+
() => (catalogUpdated === null
|
|
1540
|
+
? t('catalogVersionUndated')
|
|
1541
|
+
: t('catalogVersionDated').replace('{0}', catalogUpdated)),
|
|
1542
|
+
[catalogUpdated, t],
|
|
1543
|
+
)
|
|
1530
1544
|
/** Non-live activation results from the last operation, shown as a banner. */
|
|
1531
1545
|
const [activationWarnings, setActivationWarnings] = useState<{ name: string; info: ActivationInfo }[]>([])
|
|
1532
1546
|
const [hostDependencyFindings, setHostDependencyFindings] = useState<SharedHostPackageDependencyFinding[]>([])
|
|
@@ -1739,6 +1753,8 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
1739
1753
|
}
|
|
1740
1754
|
cachedRegistry = body.registry
|
|
1741
1755
|
setData(body.registry)
|
|
1756
|
+
const catalogDate = body.registry.updated
|
|
1757
|
+
setCatalogUpdated(typeof catalogDate === 'string' && catalogDate.trim() !== '' ? catalogDate : null)
|
|
1742
1758
|
setHostVersion(typeof body.hostVersion === 'string' ? body.hostVersion : null)
|
|
1743
1759
|
setLoadError(null)
|
|
1744
1760
|
})
|
|
@@ -3579,7 +3595,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
3579
3595
|
<div className={css.byline}>
|
|
3580
3596
|
<OwnerAvatar name={p.name} owner={p.owner || ''} />
|
|
3581
3597
|
<span className={css.owner} title={p.owner}>{p.owner}</span>
|
|
3582
|
-
<CatalogVersionMark version={p.version} tip={
|
|
3598
|
+
<CatalogVersionMark version={p.version} tip={catalogVersionTip} />
|
|
3583
3599
|
{typeof p.downloads === 'number' && (
|
|
3584
3600
|
<Tooltip label={String(p.downloads)} side="top">
|
|
3585
3601
|
<span className={css.star}>{'· ↓ ' + formatCount(p.downloads)}</span>
|
|
@@ -3721,7 +3737,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
3721
3737
|
<div className={css.byline}>
|
|
3722
3738
|
<OwnerAvatar name={p.name} owner={p.owner || ''} />
|
|
3723
3739
|
<span className={css.owner} title={p.owner}>{p.owner}</span>
|
|
3724
|
-
<CatalogVersionMark version={p.version} tip={
|
|
3740
|
+
<CatalogVersionMark version={p.version} tip={catalogVersionTip} />
|
|
3725
3741
|
{typeof p.downloads === 'number' && (
|
|
3726
3742
|
<Tooltip label={String(p.downloads)} side="top">
|
|
3727
3743
|
<span className={css.star}>{'· ↓ ' + formatCount(p.downloads)}</span>
|
|
@@ -5473,7 +5489,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
5473
5489
|
<div className={css.byline}>
|
|
5474
5490
|
<OwnerAvatar name={confirming.name} owner={confirming.owner || ''} />
|
|
5475
5491
|
<span className={css.owner} title={confirming.owner}>{confirming.owner}</span>
|
|
5476
|
-
<CatalogVersionMark version={confirming.version} tip={
|
|
5492
|
+
<CatalogVersionMark version={confirming.version} tip={catalogVersionTip} />
|
|
5477
5493
|
{typeof confirming.downloads === 'number' && (
|
|
5478
5494
|
<Tooltip label={String(confirming.downloads)} side="top">
|
|
5479
5495
|
<span className={css.star}>{'· ↓ ' + formatCount(confirming.downloads)}</span>
|
package/src/client/locales.ts
CHANGED
|
@@ -245,6 +245,11 @@ export const zh = {
|
|
|
245
245
|
sortDownloads: 'npm 下载量(近 30 天)',
|
|
246
246
|
/** Discover/theme card byline tooltip for catalog `version`. */
|
|
247
247
|
catalogNpmLatest: 'npm 当前 latest',
|
|
248
|
+
// #712: the number is the CATALOG's copy, refreshed daily — not a live npm
|
|
249
|
+
// lookup. Saying "npm latest" beside a number that is not npm's latest is
|
|
250
|
+
// how a stale display turns into a bug report.
|
|
251
|
+
catalogVersionDated: '目录数据({0} 刷新)里记录的 npm 版本;在那之后发布的版本要等下一次目录刷新才会出现',
|
|
252
|
+
catalogVersionUndated: '目录数据里记录的 npm 版本;目录每日刷新,所以可能滞后于 npm 上的实际 latest',
|
|
248
253
|
sortStars: 'Star 数',
|
|
249
254
|
sortAdded: '发布时间',
|
|
250
255
|
sortDesc: '降序',
|
|
@@ -841,6 +846,8 @@ export const en: Record<MarketKey, string> = {
|
|
|
841
846
|
sortDownloads: 'npm downloads (30d)',
|
|
842
847
|
/** Discover/theme card byline tooltip for catalog `version`. */
|
|
843
848
|
catalogNpmLatest: 'npm latest',
|
|
849
|
+
catalogVersionDated: "the npm version as of the catalog's last refresh ({0}); a release published since then appears after the next refresh",
|
|
850
|
+
catalogVersionUndated: "the npm version as of the catalog's last refresh — the catalog updates daily, so this can lag npm's actual latest",
|
|
844
851
|
sortStars: 'Stars',
|
|
845
852
|
sortAdded: 'Release date',
|
|
846
853
|
sortDesc: 'Descending',
|
package/src/dsh-cli.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the agent's sandboxed executor and denies writes to the profile directory.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { spawn } from 'node:child_process'
|
|
10
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
11
11
|
import type { ChildProcess, SpawnOptions } from 'node:child_process'
|
|
12
12
|
import { existsSync } from 'node:fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
@@ -217,19 +217,77 @@ export function toolSearchDirs(
|
|
|
217
217
|
* precisely the branch that cannot work from a spawned child.
|
|
218
218
|
*
|
|
219
219
|
* Scope, stated plainly because it is narrower than the issue title
|
|
220
|
-
* suggests: this is the HTTPS half
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
220
|
+
* suggests: this is the HTTPS half, and the FIRST attempt HTTPS is. Measured
|
|
221
|
+
* with a `GIT_SSH_COMMAND` sentinel that always fails: pnpm's clone of a
|
|
222
|
+
* `github:owner/repo#path:/sub` spec succeeds without ever calling it, so
|
|
223
|
+
* ssh is the fallback that runs after HTTPS fails — which is the private
|
|
224
|
+
* repository case, not the common one.
|
|
225
|
+
*
|
|
226
|
+
* The ssh half (#596, @JINITAIMI121) is a policy decision, and it is made
|
|
227
|
+
* here: `BatchMode=yes`, so a passphrase or host-key question fails fast
|
|
228
|
+
* instead of waiting on a terminal nobody is watching. The cost is real and
|
|
229
|
+
* is why it is conditional — `BatchMode` also disables `SSH_ASKPASS`, so a
|
|
230
|
+
* key that NEEDS a passphrase stops prompting even where something could
|
|
231
|
+
* have answered, and the install fails where it used to work.
|
|
232
|
+
*
|
|
233
|
+
* So it is set only when the user has expressed NO ssh preference at all:
|
|
234
|
+
* `GIT_SSH_COMMAND` and `GIT_SSH` in the environment, and `core.sshCommand`
|
|
235
|
+
* in git config — the three ordinary ways to choose an identity, and
|
|
236
|
+
* `GIT_SSH_COMMAND` would silently override all three (measured: with
|
|
237
|
+
* `core.sshCommand` set, ours wins and theirs is never run). A user who
|
|
238
|
+
* has an agent or a specific key configured keeps exactly what they have;
|
|
239
|
+
* the prompt is closed for everyone else, and `classifyPnpmFailure` says
|
|
240
|
+
* what to do if that turns out to be the passphrase case.
|
|
241
|
+
*/
|
|
242
|
+
let coreSshCommandProbe: string | null | undefined
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* `core.sshCommand` from the user's git configuration, or null.
|
|
246
|
+
*
|
|
247
|
+
* The third place an ssh identity hides, and the one the environment cannot
|
|
248
|
+
* show: `git config --get` answers it. Memoized for the process — a user
|
|
249
|
+
* does not reconfigure git mid-install, and this runs on every spawn.
|
|
250
|
+
*
|
|
251
|
+
* @returns the configured command, or null when git has none (including
|
|
252
|
+
* when git is absent — an unreadable answer is not a choice).
|
|
229
253
|
*/
|
|
230
|
-
export function
|
|
231
|
-
|
|
232
|
-
|
|
254
|
+
export function probeCoreSshCommand(env?: NodeJS.ProcessEnv): string | null {
|
|
255
|
+
// Memoized only for the caller that has no opinion: a test that supplies
|
|
256
|
+
// its own environment is asking a different question and must get the
|
|
257
|
+
// fresh answer.
|
|
258
|
+
if (env === undefined && coreSshCommandProbe !== undefined) return coreSshCommandProbe ?? null
|
|
259
|
+
try {
|
|
260
|
+
const out = spawnSync('git', ['config', '--get', 'core.sshCommand'], {
|
|
261
|
+
encoding: 'utf8',
|
|
262
|
+
timeout: 5_000,
|
|
263
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
264
|
+
...(env === undefined ? {} : { env }),
|
|
265
|
+
// A user's git may be a shim that prints to a console; nothing here
|
|
266
|
+
// wants a window.
|
|
267
|
+
windowsHide: true,
|
|
268
|
+
})
|
|
269
|
+
const answer = out.status === 0 && typeof out.stdout === 'string' && out.stdout.trim() !== ''
|
|
270
|
+
? out.stdout.trim()
|
|
271
|
+
: null
|
|
272
|
+
if (env === undefined) coreSshCommandProbe = answer
|
|
273
|
+
return answer
|
|
274
|
+
} catch {
|
|
275
|
+
if (env === undefined) coreSshCommandProbe = null
|
|
276
|
+
return null
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function gitEnvForPnpm(
|
|
281
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
282
|
+
coreSshCommand: string | null = probeCoreSshCommand(),
|
|
283
|
+
): NodeJS.ProcessEnv {
|
|
284
|
+
const out: NodeJS.ProcessEnv = {}
|
|
285
|
+
// Blank counts as unset: an empty value is not a setting git can parse.
|
|
286
|
+
const speaks = (value: string | undefined): boolean => (value ?? '').trim() !== ''
|
|
287
|
+
if (!speaks(env.GIT_TERMINAL_PROMPT)) out.GIT_TERMINAL_PROMPT = '0'
|
|
288
|
+
const choseElsewhere = speaks(env.GIT_SSH_COMMAND) || speaks(env.GIT_SSH) || speaks(coreSshCommand ?? '')
|
|
289
|
+
if (!choseElsewhere) out.GIT_SSH_COMMAND = 'ssh -oBatchMode=yes'
|
|
290
|
+
return out
|
|
233
291
|
}
|
|
234
292
|
|
|
235
293
|
/**
|
package/src/install.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* parameter so tests can substitute a recording fake.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync } from 'node:fs'
|
|
9
|
-
import { join } from 'node:path'
|
|
8
|
+
import { closeSync, existsSync, lstatSync, openSync, readlinkSync, readSync, rmSync, statSync } from 'node:fs'
|
|
9
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
10
10
|
import type { InstallResult, PluginRunner } from './dsh-cli.ts'
|
|
11
|
+
import { findDshInstallDir } from './dsh-install.ts'
|
|
11
12
|
import { classifyPnpmFailure, HOST_NAMESPACE_RE, isTransientPnpmFailure } from './pnpm-compat.ts'
|
|
12
13
|
import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.ts'
|
|
13
14
|
import { logEvent } from './log.ts'
|
|
@@ -194,6 +195,7 @@ export async function withHoistRecovery(
|
|
|
194
195
|
// construction: directories are only removed when their owning pid is
|
|
195
196
|
// gone (the name carries it), so a live download is never touched.
|
|
196
197
|
await cleanOrphanedStore(run, profile)
|
|
198
|
+
const diagnostics = diagnosticsTail(result)
|
|
197
199
|
const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)
|
|
198
200
|
if (failure !== null) {
|
|
199
201
|
result = {
|
|
@@ -201,6 +203,12 @@ export async function withHoistRecovery(
|
|
|
201
203
|
stderr: failure.replaceOutput === true ? failure.message : `${result.stderr}\n\n${failure.message}`,
|
|
202
204
|
...(failure.replaceOutput === true ? { stdout: '' } : {}),
|
|
203
205
|
}
|
|
206
|
+
} else if (diagnostics !== null) {
|
|
207
|
+
// The dsh CLI redirects the whole pnpm run into a file and leaves one
|
|
208
|
+
// line on stderr: `dsh: pnpm failed; diagnostics: <path>` (its own
|
|
209
|
+
// literal message). Everything a user or a report needs is in that
|
|
210
|
+
// file, so show its tail rather than the one line (#672).
|
|
211
|
+
result = { ...result, stderr: `${result.stderr}\n\n--- dsh diagnostics (${diagnostics.path}) ---\n${diagnostics.text}` }
|
|
204
212
|
} else if (result.pnpmError !== undefined && result.pnpmError !== '') {
|
|
205
213
|
// Nothing matched, but pnpm DID say what went wrong — in its ndjson
|
|
206
214
|
// stream, which never reaches stderr. Without this the user is shown
|
|
@@ -218,6 +226,51 @@ export async function withHoistRecovery(
|
|
|
218
226
|
return result
|
|
219
227
|
}
|
|
220
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The tail of the diagnostics file the dsh CLI pointed at, when it pointed at
|
|
231
|
+
* one (#672).
|
|
232
|
+
*
|
|
233
|
+
* `dsh plugin` writes pnpm's entire output to a file and prints only
|
|
234
|
+
* `dsh: pnpm failed; diagnostics: <path>`. For a failure the market cannot
|
|
235
|
+
* classify, that line is all it has — the reasons people reported (#244,
|
|
236
|
+
* #192, #138) were all "the UI shows one unhelpful line" for causes that
|
|
237
|
+
* were written down somewhere the UI never looked.
|
|
238
|
+
*
|
|
239
|
+
* Bounded on purpose: absolute paths only, a regular file, and at most the
|
|
240
|
+
* last {@link DIAGNOSTICS_TAIL_BYTES}. The path comes from our own child, but
|
|
241
|
+
* the market only ever needs the end of a log, and reading an arbitrary
|
|
242
|
+
* amount of an arbitrary file is not worth anything it could add.
|
|
243
|
+
*
|
|
244
|
+
* @returns the path and the text, or null when the output names no readable
|
|
245
|
+
* diagnostics file.
|
|
246
|
+
*/
|
|
247
|
+
export function diagnosticsTail(result: { stdout: string; stderr: string }): { path: string; text: string } | null {
|
|
248
|
+
const match = /(?:^|\n)\s*dsh: [^\n]*diagnostics:\s*(\S+)\s*$/m.exec(result.stderr)
|
|
249
|
+
?? /(?:^|\n)\s*dsh: [^\n]*diagnostics:\s*(\S+)\s*$/m.exec(result.stdout)
|
|
250
|
+
if (match === null) return null
|
|
251
|
+
const path = match[1]!
|
|
252
|
+
if (!isAbsolute(path)) return null
|
|
253
|
+
try {
|
|
254
|
+
if (!statSync(path).isFile()) return null
|
|
255
|
+
const size = statSync(path).size
|
|
256
|
+
const start = Math.max(0, size - DIAGNOSTICS_TAIL_BYTES)
|
|
257
|
+
const handle = openSync(path, 'r')
|
|
258
|
+
try {
|
|
259
|
+
const buffer = Buffer.alloc(Math.min(DIAGNOSTICS_TAIL_BYTES, size))
|
|
260
|
+
readSync(handle, buffer, 0, buffer.length, start)
|
|
261
|
+
const text = buffer.toString('utf8').trim()
|
|
262
|
+
return text === '' ? null : { path, text }
|
|
263
|
+
} finally {
|
|
264
|
+
closeSync(handle)
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** How much of a diagnostics file is worth showing. */
|
|
272
|
+
const DIAGNOSTICS_TAIL_BYTES = 8192
|
|
273
|
+
|
|
221
274
|
/**
|
|
222
275
|
* Whether pnpm never started at all, so the profile cannot have been touched.
|
|
223
276
|
*
|
|
@@ -330,7 +383,7 @@ export async function retargetCollections(
|
|
|
330
383
|
* anything wrong with the plugin (#258).
|
|
331
384
|
*/
|
|
332
385
|
export async function validateAddedPlugins(
|
|
333
|
-
run: PluginRunner, profile: string, before: Set<string>, explicitDir?: string,
|
|
386
|
+
run: PluginRunner, profile: string, before: Set<string>, explicitDir?: string, hostDirectory?: string | null,
|
|
334
387
|
): Promise<{ added: string[]; keep: string[]; removedBroken: string[]; conflicts: { name: string; id: string; owner: string }[] }> {
|
|
335
388
|
const dir = profileDir(profile, explicitDir)
|
|
336
389
|
const addedNow = Object.keys(readInstalled(profile, dir)).filter(n => !before.has(n))
|
|
@@ -346,7 +399,7 @@ export async function validateAddedPlugins(
|
|
|
346
399
|
// ship no entry of their own (#103) and must not be uninstalled here.
|
|
347
400
|
if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
|
|
348
401
|
removedBroken.push(n)
|
|
349
|
-
await removeAndReconcile(run, profile, dir, n)
|
|
402
|
+
await removeAndReconcile(run, profile, dir, n, hostDirectory)
|
|
350
403
|
continue
|
|
351
404
|
}
|
|
352
405
|
const clash = conflictingEntryIds(dir, n, existingBundles)
|
|
@@ -356,7 +409,7 @@ export async function validateAddedPlugins(
|
|
|
356
409
|
removedBroken.push(n)
|
|
357
410
|
logEvent('error', 'install',
|
|
358
411
|
`${n}: loader entry id conflict with ${clash[0].owner} (${clash.map(hit => hit.id).join(', ')}) — removing, it would break the next boot`)
|
|
359
|
-
await removeAndReconcile(run, profile, dir, n)
|
|
412
|
+
await removeAndReconcile(run, profile, dir, n, hostDirectory)
|
|
360
413
|
continue
|
|
361
414
|
}
|
|
362
415
|
keep.push(n)
|
|
@@ -382,11 +435,14 @@ export async function validateAddedPlugins(
|
|
|
382
435
|
* @param profile - the profile name for manifest writes.
|
|
383
436
|
* @param dir - the profile directory the validation reads.
|
|
384
437
|
* @param name - the package being removed.
|
|
438
|
+
* @param hostDirectory - the DSH host deployment directory whose node_modules
|
|
439
|
+
* may hold a bridge link for this package (#662); resolved when omitted.
|
|
385
440
|
*/
|
|
386
|
-
async function removeAndReconcile(run: PluginRunner, profile: string, dir: string, name: string): Promise<void> {
|
|
441
|
+
async function removeAndReconcile(run: PluginRunner, profile: string, dir: string, name: string, hostDirectory: string | null = findDshInstallDir()): Promise<void> {
|
|
387
442
|
const result = await run(profile, ['remove', name])
|
|
388
443
|
const gone = !existsSync(join(dir, 'node_modules', name, 'package.json'))
|
|
389
444
|
if (gone) {
|
|
445
|
+
removeDanglingHostBridge(name, dir, hostDirectory)
|
|
390
446
|
if (dropFromManifest(profile, name, dir)) {
|
|
391
447
|
logEvent('error', 'install',
|
|
392
448
|
`${name}: remove ${result.exitCode === 0 ? 'skipped the manifest reconcile' : `failed (exit ${String(result.exitCode)})`} but the package is gone from disk — dropped its dependency/bundle rows so the next boot stays loadable`)
|
|
@@ -399,6 +455,99 @@ async function removeAndReconcile(run: PluginRunner, profile: string, dir: strin
|
|
|
399
455
|
}
|
|
400
456
|
}
|
|
401
457
|
|
|
458
|
+
/**
|
|
459
|
+
* The node_modules root of the DSH host deployment `directory` belongs to.
|
|
460
|
+
*
|
|
461
|
+
* CLI layouts install the host as `<prefix>/node_modules/@deepseek-ai/dsh`,
|
|
462
|
+
* so the shared root is two dirname steps up; a flat Desktop layout keeps
|
|
463
|
+
* the host package at the deployment root (#662's
|
|
464
|
+
* `<desktop-app>\dependencies\dsh`), with its pnpm-managed node_modules
|
|
465
|
+
* directly beside its package.json. `dshHostInfo()` already distinguishes
|
|
466
|
+
* the two — this is pure path arithmetic on whichever directory it returned.
|
|
467
|
+
*/
|
|
468
|
+
export function hostNodeModulesRoot(directory: string): string {
|
|
469
|
+
const segments = directory.split(/[/\\]+/).filter(segment => segment !== '')
|
|
470
|
+
const n = segments.length
|
|
471
|
+
if (n >= 3 && segments[n - 3].toLowerCase() === 'node_modules' && segments[n - 2].toLowerCase() === '@deepseek-ai') {
|
|
472
|
+
return resolve(dirname(dirname(directory)))
|
|
473
|
+
}
|
|
474
|
+
return resolve(directory, 'node_modules')
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Normalize a link target for path comparison: restore the UNC device form
|
|
479
|
+
* (`\\?\UNC\server\share` back to `\\server\share` — stripped of its prefix
|
|
480
|
+
* it is no longer absolute and resolve() would re-root it against the
|
|
481
|
+
* cwd), then remove the NT device prefixes `\\?\` and the subst-style
|
|
482
|
+
* `\??\` mklink stores. Measured on Node 24/win32: readlinkSync returns
|
|
483
|
+
* the plain absolute path, so these branches only matter for links created
|
|
484
|
+
* outside Node — but a comparison must not silently miss because of them.
|
|
485
|
+
*/
|
|
486
|
+
export function normalizedLinkTarget(target: string): string {
|
|
487
|
+
return target
|
|
488
|
+
.replace(/^\\\\\?\\UNC\\/, '\\\\')
|
|
489
|
+
.replace(/^(?:\\\\\?\\|\\\?\?\\)/, '')
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Whether a link target names the profile's copy of a package. Junction
|
|
494
|
+
* targets keep the case they were created with, so the comparison is
|
|
495
|
+
* case-insensitive on win32, where the filesystem itself is.
|
|
496
|
+
*/
|
|
497
|
+
function pointsAtProfilePackage(target: string, expected: string): boolean {
|
|
498
|
+
const left = resolve(normalizedLinkTarget(target))
|
|
499
|
+
const right = resolve(expected)
|
|
500
|
+
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Remove the host-side bridge link a confirmed uninstall leaves dangling
|
|
505
|
+
* (#662). The official boot projects profile packages into the host
|
|
506
|
+
* deployment's node_modules as links (Junction or SymbolicLink — lstat
|
|
507
|
+
* reports both as symlinks) and never reclaims them, and `dsh plugin
|
|
508
|
+
* remove` knows nothing about them, so without this the link outlives the
|
|
509
|
+
* package it pointed at and every tool that lstats its way through
|
|
510
|
+
* node_modules (rg first among them) fails on it.
|
|
511
|
+
*
|
|
512
|
+
* The gate is deliberately total: only `<host node_modules>/<name>` is ever
|
|
513
|
+
* touched, only when that entry is a link whose normalized target is
|
|
514
|
+
* exactly this profile's copy of `name`, and only when that copy is really
|
|
515
|
+
* gone — a live bridge for a package that is still installed must survive.
|
|
516
|
+
* A null `hostDirectory` (no host locatable — a plain `dsh web` from a
|
|
517
|
+
* global install) is a documented no-op.
|
|
518
|
+
*
|
|
519
|
+
* @returns whether a dangling bridge was removed. Never throws: the removal
|
|
520
|
+
* this cleans up after already succeeded, and a cleanup failure must not
|
|
521
|
+
* fail the uninstall that triggered it.
|
|
522
|
+
*/
|
|
523
|
+
export function removeDanglingHostBridge(name: string, profileDirectory: string, hostDirectory: string | null): boolean {
|
|
524
|
+
if (hostDirectory === null) return false
|
|
525
|
+
const bridge = join(hostNodeModulesRoot(hostDirectory), name)
|
|
526
|
+
const unlinked = join(profileDirectory, 'node_modules', name)
|
|
527
|
+
try {
|
|
528
|
+
if (!lstatSync(bridge).isSymbolicLink()) return false
|
|
529
|
+
// The gone-check mirrors removeAndReconcile's manifest truth: a package
|
|
530
|
+
// whose package.json is gone is uninstalled even if an empty directory
|
|
531
|
+
// lingered behind, and its bridge is exactly the dangling link #662 is
|
|
532
|
+
// about.
|
|
533
|
+
if (!pointsAtProfilePackage(readlinkSync(bridge), unlinked) || existsSync(join(unlinked, 'package.json'))) return false
|
|
534
|
+
// No `recursive`: rmSync on a link unlinks the link itself only
|
|
535
|
+
// (measured on win32/Node 24 for live and dangling junctions and dir
|
|
536
|
+
// symlinks), and leaving it off keeps a race that swaps the link for a
|
|
537
|
+
// real directory from ever deleting that directory's tree.
|
|
538
|
+
rmSync(bridge, { force: true })
|
|
539
|
+
} catch (error) {
|
|
540
|
+
// ENOENT is the ordinary "no bridge there" answer (and a lost race
|
|
541
|
+
// while unlinking); anything else is worth a line in the log.
|
|
542
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
543
|
+
logEvent('warn', 'uninstall', `${name}: host bridge cleanup skipped — ${error instanceof Error ? error.message : String(error)}`)
|
|
544
|
+
}
|
|
545
|
+
return false
|
|
546
|
+
}
|
|
547
|
+
logEvent('info', 'uninstall', `${name}: removed the dangling host bridge the boot projection left at ${bridge}`)
|
|
548
|
+
return true
|
|
549
|
+
}
|
|
550
|
+
|
|
402
551
|
/**
|
|
403
552
|
* Group flat `{id, owner}` conflict hits by the installed plugin that owns
|
|
404
553
|
* them. What the user has to decide is which PLUGINS to uninstall, not which
|
package/src/pnpm-compat.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface PnpmFailure {
|
|
|
48
48
|
| 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch'
|
|
49
49
|
| 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout'
|
|
50
50
|
| 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked'
|
|
51
|
-
| 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom'
|
|
51
|
+
| 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed'
|
|
52
52
|
/** Bilingual, actionable message shown to the user instead of the raw wall of text. */
|
|
53
53
|
message: string
|
|
54
54
|
/** True when re-running `pnpm install` in the profile is the documented recovery. */
|
|
@@ -381,6 +381,21 @@ export function classifyPnpmFailure(output: string, exitCode?: number | null): P
|
|
|
381
381
|
}
|
|
382
382
|
}
|
|
383
383
|
}
|
|
384
|
+
// #596: the ssh half of #587. git asks for a passphrase (or a host-key
|
|
385
|
+
// confirmation) on a terminal a spawned child does not have; the market
|
|
386
|
+
// closes that prompt with `BatchMode=yes` so the question becomes a fast
|
|
387
|
+
// failure instead of a fifteen-minute hang. This is that failure, and it
|
|
388
|
+
// needs its own message because git's own words send the reader to the
|
|
389
|
+
// wrong place: `Permission denied (publickey)` reads as "your key is
|
|
390
|
+
// wrong", and the key is usually fine — it wants a passphrase, and the
|
|
391
|
+
// channel that would have asked for it is exactly what was shut.
|
|
392
|
+
if (/Permission denied \(publickey\)|Could not read from remote repository/.test(output)) {
|
|
393
|
+
return {
|
|
394
|
+
code: 'ssh-auth-failed',
|
|
395
|
+
recoverable: false,
|
|
396
|
+
message: 'git 无法在无人值守的情况下完成 SSH 认证。如果你的 SSH key 设了密码,请用 ssh-agent(ssh-add),或自己设置 GIT_SSH_COMMAND 指向你的命令,市场不会覆盖它。 / git could not complete SSH authentication unattended. If your SSH key has a passphrase, use ssh-agent (ssh-add), or set GIT_SSH_COMMAND to your own command — the market leaves yours alone.',
|
|
397
|
+
}
|
|
398
|
+
}
|
|
384
399
|
if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
|
|
385
400
|
return {
|
|
386
401
|
code: 'git-prepare-not-allowed',
|
package/src/routes.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
mountClientOnlyDeps, purgeMarketState, readMarketState, writeMarketState,
|
|
20
20
|
} from './hot.ts'
|
|
21
21
|
import { createGroup, deleteGroup, removeFromGroups, renameGroup, setGroupMembers } from './groups.ts'
|
|
22
|
-
import { dshHostInfo } from './dsh-install.ts'
|
|
22
|
+
import { dshHostInfo, findDshInstallDir } from './dsh-install.ts'
|
|
23
23
|
import { deriveHostCompatibility, DiscoveryManifestIndex } from './discovery-compatibility.ts'
|
|
24
24
|
import { configurePersistentLog, exportLogs, logEvent, readPersistentLog } from './log.ts'
|
|
25
25
|
import { marketFetch } from './net.ts'
|
|
@@ -38,7 +38,7 @@ import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } fro
|
|
|
38
38
|
import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.ts'
|
|
39
39
|
import { trialValidate } from './trial.ts'
|
|
40
40
|
import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.ts'
|
|
41
|
-
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
|
|
41
|
+
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
|
|
42
42
|
import { classifyPnpmFailure } from './pnpm-compat.ts'
|
|
43
43
|
import { asChannel, CHANNELS, DIST_TAG, resolveChannel, type Channel } from './channels.ts'
|
|
44
44
|
import {
|
|
@@ -1094,6 +1094,9 @@ export function mountMarketRoutes(
|
|
|
1094
1094
|
// with two activation sources must not have the second one skipped
|
|
1095
1095
|
// because the first succeeded.
|
|
1096
1096
|
const unmounted = await hotUnmount(name)
|
|
1097
|
+
// #662: the removal is confirmed — drop the host bridge link the boot
|
|
1098
|
+
// projection may have left pointing at the now-gone package.
|
|
1099
|
+
removeDanglingHostBridge(name, activeProfileDir, config.dshInstallDir ?? findDshInstallDir())
|
|
1097
1100
|
const entryDisabled = await themes.setEntryDisabled(name, true)
|
|
1098
1101
|
const hot = (unmounted || entryDisabled) && !native
|
|
1099
1102
|
if (native) {
|
|
@@ -4158,6 +4161,10 @@ sendJson(response, 200, { updates })
|
|
|
4158
4161
|
// has just been removed. Only rows belonging to packages on the
|
|
4159
4162
|
// market's own disable list are touched — a hand-written row is
|
|
4160
4163
|
// the user's, not ours.
|
|
4164
|
+
// #662 first, while the filesystem is the only thing touched:
|
|
4165
|
+
// the market's own host bridge dangles the moment the remove
|
|
4166
|
+
// succeeds, and this route is the last code of ours to run.
|
|
4167
|
+
removeDanglingHostBridge(selfName, activeProfileDir, config.dshInstallDir ?? findDshInstallDir())
|
|
4161
4168
|
const purge = body.purge === true
|
|
4162
4169
|
const restored: string[] = []
|
|
4163
4170
|
if (purge) {
|
|
@@ -4447,6 +4454,13 @@ sendJson(response, 200, { updates })
|
|
|
4447
4454
|
sendJson(response, 400, { error: 'the market cannot uninstall itself; use the dsh CLI' })
|
|
4448
4455
|
return
|
|
4449
4456
|
}
|
|
4457
|
+
// The bridge cleanup this route performs joins the package name
|
|
4458
|
+
// into a host node_modules path (#662); a hand-edited manifest
|
|
4459
|
+
// carrying `../../evil` must not escape that join.
|
|
4460
|
+
if (!NPM_NAME_RE.test(name)) {
|
|
4461
|
+
sendJson(response, 400, { error: 'plugin is not installed' })
|
|
4462
|
+
return
|
|
4463
|
+
}
|
|
4450
4464
|
if (readInstalled(config.profile, activeProfileDir)[name] === undefined) {
|
|
4451
4465
|
sendJson(response, 400, { error: 'plugin is not installed' })
|
|
4452
4466
|
return
|
|
@@ -4532,6 +4546,10 @@ sendJson(response, 200, { updates })
|
|
|
4532
4546
|
let hot = false
|
|
4533
4547
|
if (ok || halfGone) {
|
|
4534
4548
|
invalidateUpdates()
|
|
4549
|
+
// #662: the removal is final (confirmed exit or reconciled
|
|
4550
|
+
// from disk truth) — the host node_modules bridge the boot
|
|
4551
|
+
// projection left for this package must not outlive it.
|
|
4552
|
+
removeDanglingHostBridge(name, activeProfileDir, config.dshInstallDir ?? findDshInstallDir())
|
|
4535
4553
|
hot = await hotUnmount(name)
|
|
4536
4554
|
// Bundle-layer plugins never hot-mount, but their loader entry
|
|
4537
4555
|
// is still LIVE in this process — after the remove deleted the
|