dshmarket 1.66.39 → 1.66.40
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 +1 -1
- package/README.zh.md +1 -1
- package/assets/logo.svg +19 -0
- package/client/client.js +2 -2
- package/lib/install.js +125 -7
- package/lib/official-desktop.js +16 -2
- package/lib/routes.js +25 -3
- package/lib/types/dsh-cli.d.ts +10 -0
- package/lib/types/install.d.ts +28 -0
- package/lib/update-api-v1.js +21 -1
- package/package.json +3 -1
- package/src/client/locales.ts +2 -2
- package/src/dsh-cli.ts +10 -0
- package/src/install.ts +115 -7
- package/src/official-desktop.ts +16 -2
- package/src/routes.ts +25 -3
- package/src/update-api-v1.ts +22 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ English | [中文](README.zh.md)
|
|
|
11
11
|
|
|
12
12
|
The plugin market inside DeepSeek Harness. Open Settings → **Plugin Market** → browse, search, one-click install. Settings sits in the sidebar up to dsh 0.2.0 and in the account menu from 0.2.1.
|
|
13
13
|
|
|
14
|
-
> 📡 **The same catalog is also a plugin market index.** On dsh 0.2.1+, open **Plugins → Discover → Index sources** and add `https://awesome-dsh-plugin.com/index.json`
|
|
14
|
+
> 📡 **The same catalog is also a plugin market index.** On dsh 0.2.1+, open **Plugins → Discover → Index sources** and add `https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json`. That is this list as a format-1 index, served from npm so it loads quickly everywhere — dsh gives a source 15 seconds, and the GitHub Pages copies (`https://awesome-dsh-plugin.com/index.json` and `https://dshmarket.com/index.json`) can exceed that from some networks. The host's Discover tab and the `plugin_market` agent tool then search it.
|
|
15
15
|
|
|
16
16
|

|
|
17
17
|
|
package/README.zh.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
装在 DeepSeek Harness 里的插件市场。打开设置 → **插件市场** → 逛一逛,点一下,装好。设置入口在 dsh 0.2.0 及以前位于侧边栏,0.2.1 起移到了账号菜单里。
|
|
13
13
|
|
|
14
|
-
> 📡 **同一份目录也是一份插件市场索引。** dsh 0.2.1 起,打开 **插件 → 发现(Discover)→ 索引源(Index sources)**,添加 `https://awesome-dsh-plugin.com/index.json
|
|
14
|
+
> 📡 **同一份目录也是一份插件市场索引。** dsh 0.2.1 起,打开 **插件 → 发现(Discover)→ 索引源(Index sources)**,添加 `https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json`。它就是这份列表的 format 1 索引,放在 npm 上分发,所以在哪里都快——dsh 给索引源的时间只有 15 秒,而 GitHub Pages 上的那两份(`https://awesome-dsh-plugin.com/index.json` 与 `https://dshmarket.com/index.json`)在部分网络下会超过。宿主自己的发现页和 `plugin_market` 智能体工具就能搜索它。
|
|
15
15
|
|
|
16
16
|

|
|
17
17
|
|
package/assets/logo.svg
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="t">
|
|
2
|
+
<title id="t">dsh-market</title>
|
|
3
|
+
<!-- Shared brand mark with awesome-dsh-plugin: the block grid with one
|
|
4
|
+
cinnabar block being plugged in. -->
|
|
5
|
+
<rect width="128" height="128" rx="28" fill="#f6f2ea"/>
|
|
6
|
+
<g transform="translate(15.7 16.7) scale(0.3) translate(-112 -78)">
|
|
7
|
+
<g fill="#2b2620">
|
|
8
|
+
<rect x="112" y="112" width="88" height="88" rx="14"/>
|
|
9
|
+
<rect x="212" y="112" width="88" height="88" rx="14"/>
|
|
10
|
+
<rect x="112" y="212" width="88" height="88" rx="14"/>
|
|
11
|
+
<rect x="212" y="212" width="88" height="88" rx="14"/>
|
|
12
|
+
<rect x="112" y="312" width="88" height="88" rx="14"/>
|
|
13
|
+
<rect x="212" y="312" width="88" height="88" rx="14"/>
|
|
14
|
+
<rect x="312" y="212" width="88" height="88" rx="14"/>
|
|
15
|
+
<rect x="312" y="312" width="88" height="88" rx="14"/>
|
|
16
|
+
</g>
|
|
17
|
+
<rect x="346" y="78" width="88" height="88" rx="14" fill="#c0392b" transform="rotate(9 390 122)"/>
|
|
18
|
+
</g>
|
|
19
|
+
</svg>
|
package/client/client.js
CHANGED
|
@@ -211,7 +211,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
211
211
|
setSidebarHint: "在侧栏「插件」下方显示市场。设置里的入口不变。",
|
|
212
212
|
setSidebarOnlyDoorHint: "这个宿主没有市场设置页,这一行就是进入市场的入口。",
|
|
213
213
|
setIndexSource: "官方 Discover 索引",
|
|
214
|
-
setIndexSourceHint: "dsh 0.2.1 起,官方的「插件 → 发现 → 索引源」可以添加 https://
|
|
214
|
+
setIndexSourceHint: "dsh 0.2.1 起,官方的「插件 → 发现 → 索引源」可以添加 https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json —— 官方发现页和 plugin_market 智能体工具会搜索同一份目录。",
|
|
215
215
|
setSidebarOn: "显示",
|
|
216
216
|
setSidebarOff: "不显示",
|
|
217
217
|
sidebarTip: "想更快打开?可在侧栏显示。",
|
|
@@ -951,7 +951,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
951
951
|
setSidebarHint: "Shows the market below Plugins in the sidebar. The Settings entry stays.",
|
|
952
952
|
setSidebarOnlyDoorHint: "This host keeps the market out of Settings, so this row is the way in.",
|
|
953
953
|
setIndexSource: "Discover index",
|
|
954
|
-
setIndexSourceHint: "On dsh 0.2.1 and newer, add https://
|
|
954
|
+
setIndexSourceHint: "On dsh 0.2.1 and newer, add https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json under Plugins → Discover → Index sources — the official Discover tab and the plugin_market agent tool then search the same catalog.",
|
|
955
955
|
setSidebarOn: "Show",
|
|
956
956
|
setSidebarOff: "Hide",
|
|
957
957
|
sidebarTip: "Want a quicker open? Show it in the sidebar.",
|
package/lib/install.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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 { closeSync, existsSync, lstatSync, openSync, readlinkSync, readSync, rmSync, statSync } from 'node:fs';
|
|
7
|
+
import { closeSync, existsSync, lstatSync, openSync, readFileSync, readlinkSync, readSync, rmSync, statSync } from 'node:fs';
|
|
8
8
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
9
9
|
import { findDshInstallDir } from './dsh-install.js';
|
|
10
10
|
import { classifyPnpmFailure, HOST_NAMESPACE_RE } from './pnpm-compat.js';
|
|
@@ -331,12 +331,32 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
331
331
|
//
|
|
332
332
|
// The wait on a fresh release is the one of these where "no retry" leaves
|
|
333
333
|
// the user with nothing to do at all: the plugin IS published, pnpm just
|
|
334
|
-
// will not take it yet
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
334
|
+
// will not take it yet. #852 pointed at plain `dsh web`, but the desktop
|
|
335
|
+
// profile is host-managed — that door is shut (#909). Say the wait, and
|
|
336
|
+
// when pnpm named the publish time, how long is left (#919).
|
|
337
|
+
//
|
|
338
|
+
// Drop the classifier's "market already retried" sentence first: it was
|
|
339
|
+
// appended above and is false on this host, and the client prefers
|
|
340
|
+
// body.error / that classified half over a note tacked on afterwards.
|
|
341
|
+
let note;
|
|
342
|
+
if (unavailableOption === RELEASE_AGE_OVERRIDE) {
|
|
343
|
+
const classified = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode);
|
|
344
|
+
if (classified?.code === 'release-age-violation') {
|
|
345
|
+
const cut = result.stderr.lastIndexOf(classified.message);
|
|
346
|
+
if (cut !== -1) {
|
|
347
|
+
result = { ...result, stderr: result.stderr.slice(0, cut).trimEnd() };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const windowMs = readMinimumReleaseAgeMs(profile, profileDirectory);
|
|
351
|
+
const remaining = windowMs === null
|
|
352
|
+
? null
|
|
353
|
+
: remainingReleaseAgeWait(`${result.stderr}\n${result.stdout}`, Date.now(), windowMs);
|
|
354
|
+
const wait = remaining === null ? { zh: '', en: '' } : formatRemainingReleaseAgeWait(remaining);
|
|
355
|
+
note = `这个插件的新版本比本机的新版本等待期还新,所以现在装不了(改别的插件也会被拦住)。官方桌面端不接受临时放行,所以这里没有自动重试。**等这个版本过了等待期再更新一次即可**${wait.zh}。过了等待期后再在市场点一次更新。 / The plugin's new version is younger than this machine's fresh-release wait, so the install was refused (a change to another plugin too). The official desktop app takes no one-shot bypass, so no automatic retry happens here. **Update again once the version is past the wait**${wait.en}. Try the market update again after the wait.`;
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
note = `这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`;
|
|
359
|
+
}
|
|
340
360
|
result = { ...result, stderr: `${result.stderr}\n\n${note}` };
|
|
341
361
|
}
|
|
342
362
|
// pnpm can append a second rule even on success (#883, #878), including
|
|
@@ -419,6 +439,104 @@ const DIAGNOSTICS_TAIL_BYTES = 8192;
|
|
|
419
439
|
export function pnpmNeverStarted(result) {
|
|
420
440
|
return classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code === 'pnpm-unusable';
|
|
421
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* Whether pnpm refused a too-young release without writing to the profile.
|
|
444
|
+
*
|
|
445
|
+
* Same nature as wroteNothing / pnpmNeverStarted for the update route's
|
|
446
|
+
* rollback (#919): pnpm DID start, read the lockfile, then exited 1 on
|
|
447
|
+
* ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION (or the sibling mature-match /
|
|
448
|
+
* resolution-policy shapes). Nothing was installed or removed, so restoring
|
|
449
|
+
* "the previous build" is alarm about an untouched profile — and on the
|
|
450
|
+
* official desktop bridge it pairs every wait with a false
|
|
451
|
+
* "restoration could not be verified" line that covers the real reason.
|
|
452
|
+
* @param result - the failed run.
|
|
453
|
+
* @returns true when the failure is pnpm's fresh-release gate.
|
|
454
|
+
*/
|
|
455
|
+
export function pnpmRefusedYoungRelease(result) {
|
|
456
|
+
return classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code === 'release-age-violation';
|
|
457
|
+
}
|
|
458
|
+
/** pnpm's default minimumReleaseAge window (1440 minutes). */
|
|
459
|
+
const DEFAULT_RELEASE_AGE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
460
|
+
/**
|
|
461
|
+
* The profile's fresh-release wait window in ms (#919).
|
|
462
|
+
*
|
|
463
|
+
* Reads `minimumReleaseAge` (minutes) from pnpm-workspace.yaml, then
|
|
464
|
+
* `minimum-release-age` from `.npmrc`. A key that is present but not a
|
|
465
|
+
* number returns null — callers must not invent a remaining wait. When
|
|
466
|
+
* neither file names the key, pnpm's default 1440 minutes applies.
|
|
467
|
+
*/
|
|
468
|
+
export function readMinimumReleaseAgeMs(profile, explicitDir) {
|
|
469
|
+
const dir = profileDir(profile, explicitDir);
|
|
470
|
+
const fromWorkspace = readReleaseAgeMinutesFromWorkspace(dir);
|
|
471
|
+
if (fromWorkspace === 'invalid')
|
|
472
|
+
return null;
|
|
473
|
+
if (typeof fromWorkspace === 'number')
|
|
474
|
+
return fromWorkspace * 60 * 1000;
|
|
475
|
+
const fromNpmrc = readReleaseAgeMinutesFromNpmrc(dir);
|
|
476
|
+
if (fromNpmrc === 'invalid')
|
|
477
|
+
return null;
|
|
478
|
+
if (typeof fromNpmrc === 'number')
|
|
479
|
+
return fromNpmrc * 60 * 1000;
|
|
480
|
+
return DEFAULT_RELEASE_AGE_WINDOW_MS;
|
|
481
|
+
}
|
|
482
|
+
/** @returns minutes, `'invalid'` when the key is present but unreadable, or null when absent. */
|
|
483
|
+
function readReleaseAgeMinutesFromWorkspace(dir) {
|
|
484
|
+
let yaml;
|
|
485
|
+
try {
|
|
486
|
+
yaml = readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8');
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
if (!/^minimumReleaseAge\s*:/m.test(yaml))
|
|
492
|
+
return null;
|
|
493
|
+
const match = /^minimumReleaseAge:\s*(\d+)\s*(?:#.*)?$/m.exec(yaml);
|
|
494
|
+
if (match === null)
|
|
495
|
+
return 'invalid';
|
|
496
|
+
const minutes = Number(match[1]);
|
|
497
|
+
return Number.isFinite(minutes) ? minutes : 'invalid';
|
|
498
|
+
}
|
|
499
|
+
/** @returns minutes, `'invalid'` when the key is present but unreadable, or null when absent. */
|
|
500
|
+
function readReleaseAgeMinutesFromNpmrc(dir) {
|
|
501
|
+
let npmrc;
|
|
502
|
+
try {
|
|
503
|
+
npmrc = readFileSync(join(dir, '.npmrc'), 'utf8');
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
if (!/^minimum-release-age\s*=/m.test(npmrc))
|
|
509
|
+
return null;
|
|
510
|
+
const match = /^minimum-release-age\s*=\s*(\d+)\s*(?:#.*)?$/m.exec(npmrc);
|
|
511
|
+
if (match === null)
|
|
512
|
+
return 'invalid';
|
|
513
|
+
const minutes = Number(match[1]);
|
|
514
|
+
return Number.isFinite(minutes) ? minutes : 'invalid';
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* How long is left in the fresh-release wait, when pnpm named the publish
|
|
518
|
+
* time in its refusal (#919). Null when the output has no usable timestamp.
|
|
519
|
+
*/
|
|
520
|
+
export function remainingReleaseAgeWait(output, now = Date.now(), windowMs = DEFAULT_RELEASE_AGE_WINDOW_MS) {
|
|
521
|
+
const match = /published at (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/i.exec(output);
|
|
522
|
+
if (match === null)
|
|
523
|
+
return null;
|
|
524
|
+
const published = Date.parse(match[1]);
|
|
525
|
+
if (!Number.isFinite(published))
|
|
526
|
+
return null;
|
|
527
|
+
return Math.max(0, published + windowMs - now);
|
|
528
|
+
}
|
|
529
|
+
/** Human remaining-wait fragment for the desktop release-age note. */
|
|
530
|
+
function formatRemainingReleaseAgeWait(remainingMs) {
|
|
531
|
+
if (remainingMs <= 0) {
|
|
532
|
+
return { zh: '(现在再试一次即可)', en: ' (try again now)' };
|
|
533
|
+
}
|
|
534
|
+
const hours = Math.max(1, Math.ceil(remainingMs / (60 * 60 * 1000)));
|
|
535
|
+
return {
|
|
536
|
+
zh: `(大约还剩 ${hours} 小时)`,
|
|
537
|
+
en: ` (about ${hours} hour${hours === 1 ? '' : 's'} left)`,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
422
540
|
/**
|
|
423
541
|
* Whether pnpm failed because the running host holds the package's files
|
|
424
542
|
* open, so nothing pnpm runs from inside that host can replace them.
|
package/lib/official-desktop.js
CHANGED
|
@@ -82,7 +82,14 @@ function selfUpdateMessage(spec) {
|
|
|
82
82
|
const at = spec.indexOf('@', 1);
|
|
83
83
|
const version = at === -1 ? '' : spec.slice(at + 1);
|
|
84
84
|
const target = version === '' ? spec : `${registryNameOf(spec) ?? spec}@${version}`;
|
|
85
|
-
|
|
85
|
+
// Two paths that used to be named here do not exist on dsh 0.2.1, and a
|
|
86
|
+
// reporter proved both (#909): `dsh plugin --profile desktop` is refused
|
|
87
|
+
// outright, and the app's Settings → Plugins has no update entry — its
|
|
88
|
+
// manager refuses re-adding a package that is already declared. The only
|
|
89
|
+
// route left is pnpm run against the profile with the app fully quit, which
|
|
90
|
+
// is what the reporter verified. `--config.minimum-release-age=0` is the
|
|
91
|
+
// market's own one-shot flag, spelled so it works in every shell.
|
|
92
|
+
return `市场不能在自己的请求里更新自己:桌面端的移除会先把市场从正在运行的组合里摘掉,补装那一步就执行不到了(#900),而应用内的「设置 → 插件」没有更新入口(#909)。要更新${version === '' ? '' : ` 到 ${version}`}:完全退出 DSH Desktop,在 profile 目录执行 \`pnpm add ${target} --config.minimum-release-age=0\`(没有 pnpm 时用 \`npx pnpm add …\`),然后重新启动。Windows 的 profile 一般在 %USERPROFILE%\\.dsh\\profiles\\desktop。 / The market cannot update itself: the desktop app unloads it from the running composition first, so the reinstall never runs (#900), and the app's Settings → Plugins has no update entry (#909). To update${version === '' ? '' : ` to ${version}`}: quit DSH Desktop completely, run \`pnpm add ${target} --config.minimum-release-age=0\` in the profile directory (use \`npx pnpm add …\` if pnpm is not installed), then start DSH again. On Windows the profile is usually %USERPROFILE%\\.dsh\\profiles\\desktop.`;
|
|
86
93
|
}
|
|
87
94
|
/** The manager's own refusal code, whether it was returned or thrown. */
|
|
88
95
|
function managementCode(error) {
|
|
@@ -244,11 +251,18 @@ _profileDirectory) {
|
|
|
244
251
|
// No package result means the manager stopped before it ran pnpm: the
|
|
245
252
|
// profile is untouched, and the route must not try to "restore" it.
|
|
246
253
|
wroteNothing: !ok && result.packageResult === undefined,
|
|
254
|
+
// The manager's own code, kept machine-readable (#909): a client can
|
|
255
|
+
// tell a self-update refusal from an ordinary failure.
|
|
256
|
+
...(ok ? {} : { refusalCode: managementCode(result.error) }),
|
|
247
257
|
};
|
|
248
258
|
}, (error) => {
|
|
249
259
|
const message = detail(error);
|
|
250
260
|
progress.error = message;
|
|
251
|
-
return {
|
|
261
|
+
return {
|
|
262
|
+
...failure(message, 127),
|
|
263
|
+
wroteNothing: managementCode(error) !== undefined,
|
|
264
|
+
...(managementCode(error) === undefined ? {} : { refusalCode: managementCode(error) }),
|
|
265
|
+
};
|
|
252
266
|
}).finally(() => {
|
|
253
267
|
progress.active = false;
|
|
254
268
|
progress.cancelling = false;
|
package/lib/routes.js
CHANGED
|
@@ -32,7 +32,7 @@ import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnaps
|
|
|
32
32
|
import { readHostRecoveryView, restoreHostRecoveryBackup } from './host-recovery.js';
|
|
33
33
|
import { trialValidate } from './trial.js';
|
|
34
34
|
import { catalogRepoKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedAllowBuildsKeys, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, sourceFallbackFor, workspaceProtocolDeps } from './sources.js';
|
|
35
|
-
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuildEntries, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
|
|
35
|
+
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuildEntries, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, pnpmRefusedYoungRelease, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
|
|
36
36
|
import { classifyPnpmFailure } from './pnpm-compat.js';
|
|
37
37
|
import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
|
|
38
38
|
import { asRegion, githubProxyManaged, normalizeGithubProxy, REGIONS, routesFor, setActiveRegion, setCustomGithubProxy, } from './regions.js';
|
|
@@ -333,6 +333,14 @@ function pnpmFailureMessage(result) {
|
|
|
333
333
|
return undefined;
|
|
334
334
|
if (failure.code === 'ignored-builds' || failure.code === 'git-prepare-not-allowed')
|
|
335
335
|
return undefined;
|
|
336
|
+
// Desktop release-age note is already the actionable sentence (#919). Do
|
|
337
|
+
// not cover it with the classifier's "market already retried" claim —
|
|
338
|
+
// install/uninstall fall through to stderr via failureText when error is
|
|
339
|
+
// undefined.
|
|
340
|
+
if (failure.code === 'release-age-violation'
|
|
341
|
+
&& /没有自动重试|no automatic retry happens here/.test(`${result.stderr}\n${result.stdout}`)) {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
336
344
|
return failure.message;
|
|
337
345
|
}
|
|
338
346
|
/**
|
|
@@ -4606,7 +4614,12 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
4606
4614
|
// verify restoration, inspect this profile" warning this block
|
|
4607
4615
|
// raises on a failed rollback would be alarm about an untouched
|
|
4608
4616
|
// profile.
|
|
4609
|
-
&& result.wroteNothing !== true
|
|
4617
|
+
&& result.wroteNothing !== true
|
|
4618
|
+
// pnpm ran, then refused a too-young release without writing
|
|
4619
|
+
// (#919). Same untouched profile; skipping the rollback keeps
|
|
4620
|
+
// the release-age reason visible instead of covering it with a
|
|
4621
|
+
// false "restoration could not be verified" alarm.
|
|
4622
|
+
&& !pnpmRefusedYoungRelease(result)) {
|
|
4610
4623
|
if (pnpmBlockedByOpenFiles(result)) {
|
|
4611
4624
|
const kept = keepLockedBuild();
|
|
4612
4625
|
rollbackOk = kept.ok;
|
|
@@ -4943,7 +4956,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
4943
4956
|
// ever sees this when something really is unbootable.
|
|
4944
4957
|
...(() => { const orphans = orphanBundles(); return orphans.length > 0 ? { orphanBundles: orphans } : {}; })(),
|
|
4945
4958
|
staleReason: staleReason ?? undefined,
|
|
4946
|
-
|
|
4959
|
+
// The host's own refusal code when it stopped before pnpm
|
|
4960
|
+
// (#909): `market-self-update` tells a client that the button
|
|
4961
|
+
// cannot work on this host, not that the update was broken.
|
|
4962
|
+
failureCode: versionFailureCode ?? result.refusalCode ?? undefined,
|
|
4947
4963
|
renamedTo: renamedTo ?? undefined,
|
|
4948
4964
|
removedDeclaration: removedDeclaration ?? undefined,
|
|
4949
4965
|
...(droppedBundles.length > 0 ? { droppedBundles } : {}),
|
|
@@ -4954,8 +4970,14 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
4954
4970
|
// before running pnpm there is no classifier for its words and
|
|
4955
4971
|
// no rollback behind it, so without this the answer carried no
|
|
4956
4972
|
// reason at all.
|
|
4973
|
+
// Release-age on Desktop (#919): withHoistRecovery already wrote
|
|
4974
|
+
// the wait note into stderr (and stripped the false "already
|
|
4975
|
+
// retried" classifier). Prefer that over pnpmFailureMessage,
|
|
4976
|
+
// which would re-surface the classifier alone — the client
|
|
4977
|
+
// shows body.error first.
|
|
4957
4978
|
error: versionFailureError ?? renamedError ?? trialError ?? brokenEntryError ?? hardFailureRollbackError ?? staleError
|
|
4958
4979
|
?? (result.wroteNothing === true && result.stderr.trim() !== '' ? result.stderr.trim() : undefined)
|
|
4980
|
+
?? (pnpmRefusedYoungRelease(result) && result.stderr.trim() !== '' ? result.stderr.trim() : undefined)
|
|
4959
4981
|
?? pnpmFailureMessage(result) ?? undefined,
|
|
4960
4982
|
exitCode: result.exitCode,
|
|
4961
4983
|
timedOut: result.timedOut,
|
package/lib/types/dsh-cli.d.ts
CHANGED
|
@@ -172,6 +172,16 @@ export interface InstallResult {
|
|
|
172
172
|
* (#897) — and their only true account is "nothing happened".
|
|
173
173
|
*/
|
|
174
174
|
wroteNothing?: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* The host's own refusal code, when it stopped before running pnpm.
|
|
177
|
+
*
|
|
178
|
+
* The desktop manager answers with structured codes — `already-installed`,
|
|
179
|
+
* `bundle-in-use`, `market-self-update` — but they used to survive only as
|
|
180
|
+
* text inside `stderr`. The update route forwards this to `failureCode`,
|
|
181
|
+
* so a client can tell "the market cannot update itself here" (#909) from
|
|
182
|
+
* an ordinary failure without parsing a sentence.
|
|
183
|
+
*/
|
|
184
|
+
refusalCode?: string;
|
|
175
185
|
/** Package names pnpm reported as having ignored build scripts (ndjson). */
|
|
176
186
|
ignoredBuilds?: string[];
|
|
177
187
|
/**
|
package/lib/types/install.d.ts
CHANGED
|
@@ -147,6 +147,34 @@ export declare function diagnosticsTail(result: {
|
|
|
147
147
|
* @returns true when the failure happened before pnpm could run.
|
|
148
148
|
*/
|
|
149
149
|
export declare function pnpmNeverStarted(result: InstallResult): boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Whether pnpm refused a too-young release without writing to the profile.
|
|
152
|
+
*
|
|
153
|
+
* Same nature as wroteNothing / pnpmNeverStarted for the update route's
|
|
154
|
+
* rollback (#919): pnpm DID start, read the lockfile, then exited 1 on
|
|
155
|
+
* ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION (or the sibling mature-match /
|
|
156
|
+
* resolution-policy shapes). Nothing was installed or removed, so restoring
|
|
157
|
+
* "the previous build" is alarm about an untouched profile — and on the
|
|
158
|
+
* official desktop bridge it pairs every wait with a false
|
|
159
|
+
* "restoration could not be verified" line that covers the real reason.
|
|
160
|
+
* @param result - the failed run.
|
|
161
|
+
* @returns true when the failure is pnpm's fresh-release gate.
|
|
162
|
+
*/
|
|
163
|
+
export declare function pnpmRefusedYoungRelease(result: InstallResult): boolean;
|
|
164
|
+
/**
|
|
165
|
+
* The profile's fresh-release wait window in ms (#919).
|
|
166
|
+
*
|
|
167
|
+
* Reads `minimumReleaseAge` (minutes) from pnpm-workspace.yaml, then
|
|
168
|
+
* `minimum-release-age` from `.npmrc`. A key that is present but not a
|
|
169
|
+
* number returns null — callers must not invent a remaining wait. When
|
|
170
|
+
* neither file names the key, pnpm's default 1440 minutes applies.
|
|
171
|
+
*/
|
|
172
|
+
export declare function readMinimumReleaseAgeMs(profile: string, explicitDir?: string): number | null;
|
|
173
|
+
/**
|
|
174
|
+
* How long is left in the fresh-release wait, when pnpm named the publish
|
|
175
|
+
* time in its refusal (#919). Null when the output has no usable timestamp.
|
|
176
|
+
*/
|
|
177
|
+
export declare function remainingReleaseAgeWait(output: string, now?: number, windowMs?: number): number | null;
|
|
150
178
|
/**
|
|
151
179
|
* Whether pnpm failed because the running host holds the package's files
|
|
152
180
|
* open, so nothing pnpm runs from inside that host can replace them.
|
package/lib/update-api-v1.js
CHANGED
|
@@ -18,9 +18,29 @@ function text(value) {
|
|
|
18
18
|
function bool(value) {
|
|
19
19
|
return value === true;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Host refusal codes that may travel through `failureCode` unchanged.
|
|
23
|
+
*
|
|
24
|
+
* The route forwards the desktop manager's own codes (#909); the v1 API only
|
|
25
|
+
* re-publishes the ones a caller can act on, so an internal code cannot leak
|
|
26
|
+
* by accident. `market-self-update` is the one this exists for: the market
|
|
27
|
+
* cannot update itself on a host whose manager refuses re-adds, and a caller
|
|
28
|
+
* should be able to say so instead of retrying.
|
|
29
|
+
*/
|
|
30
|
+
const FORWARDED_FAILURE_CODES = new Set([
|
|
31
|
+
'DOWNGRADE_DETECTED',
|
|
32
|
+
'RESOLVED_VERSION_MISMATCH',
|
|
33
|
+
'market-self-update',
|
|
34
|
+
'already-installed',
|
|
35
|
+
'bundle-in-use',
|
|
36
|
+
'not-removable',
|
|
37
|
+
'stop-profile',
|
|
38
|
+
'management-required',
|
|
39
|
+
'incompatible-version',
|
|
40
|
+
]);
|
|
21
41
|
function failureCode(status, body) {
|
|
22
42
|
const explicit = text(body.failureCode);
|
|
23
|
-
if (explicit
|
|
43
|
+
if (explicit !== null && FORWARDED_FAILURE_CODES.has(explicit))
|
|
24
44
|
return explicit;
|
|
25
45
|
if (body.agentsBusy === true)
|
|
26
46
|
return 'AGENTS_RUNNING';
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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.66.
|
|
4
|
+
"version": "1.66.40",
|
|
5
|
+
"icon": "assets/logo.svg",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"main": "lib/index.js",
|
|
7
8
|
"types": "lib/types/index.d.ts",
|
|
@@ -72,6 +73,7 @@
|
|
|
72
73
|
"./locale/*.json": "./locale/*.json"
|
|
73
74
|
},
|
|
74
75
|
"files": [
|
|
76
|
+
"assets/logo.svg",
|
|
75
77
|
"locale",
|
|
76
78
|
"lib",
|
|
77
79
|
"src",
|
package/src/client/locales.ts
CHANGED
|
@@ -14,7 +14,7 @@ export const zh = {
|
|
|
14
14
|
setSidebarHint: '在侧栏「插件」下方显示市场。设置里的入口不变。',
|
|
15
15
|
setSidebarOnlyDoorHint: '这个宿主没有市场设置页,这一行就是进入市场的入口。',
|
|
16
16
|
setIndexSource: '官方 Discover 索引',
|
|
17
|
-
setIndexSourceHint: 'dsh 0.2.1 起,官方的「插件 → 发现 → 索引源」可以添加 https://
|
|
17
|
+
setIndexSourceHint: 'dsh 0.2.1 起,官方的「插件 → 发现 → 索引源」可以添加 https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json —— 官方发现页和 plugin_market 智能体工具会搜索同一份目录。',
|
|
18
18
|
setSidebarOn: '显示',
|
|
19
19
|
setSidebarOff: '不显示',
|
|
20
20
|
sidebarTip: '想更快打开?可在侧栏显示。',
|
|
@@ -778,7 +778,7 @@ export const en: Record<MarketKey, string> = {
|
|
|
778
778
|
setSidebarHint: 'Shows the market below Plugins in the sidebar. The Settings entry stays.',
|
|
779
779
|
setSidebarOnlyDoorHint: 'This host keeps the market out of Settings, so this row is the way in.',
|
|
780
780
|
setIndexSource: 'Discover index',
|
|
781
|
-
setIndexSourceHint: 'On dsh 0.2.1 and newer, add https://
|
|
781
|
+
setIndexSourceHint: 'On dsh 0.2.1 and newer, add https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json under Plugins → Discover → Index sources — the official Discover tab and the plugin_market agent tool then search the same catalog.',
|
|
782
782
|
setSidebarOn: 'Show',
|
|
783
783
|
setSidebarOff: 'Hide',
|
|
784
784
|
sidebarTip: 'Want a quicker open? Show it in the sidebar.',
|
package/src/dsh-cli.ts
CHANGED
|
@@ -511,6 +511,16 @@ export interface InstallResult {
|
|
|
511
511
|
* (#897) — and their only true account is "nothing happened".
|
|
512
512
|
*/
|
|
513
513
|
wroteNothing?: boolean
|
|
514
|
+
/**
|
|
515
|
+
* The host's own refusal code, when it stopped before running pnpm.
|
|
516
|
+
*
|
|
517
|
+
* The desktop manager answers with structured codes — `already-installed`,
|
|
518
|
+
* `bundle-in-use`, `market-self-update` — but they used to survive only as
|
|
519
|
+
* text inside `stderr`. The update route forwards this to `failureCode`,
|
|
520
|
+
* so a client can tell "the market cannot update itself here" (#909) from
|
|
521
|
+
* an ordinary failure without parsing a sentence.
|
|
522
|
+
*/
|
|
523
|
+
refusalCode?: string
|
|
514
524
|
/** Package names pnpm reported as having ignored build scripts (ndjson). */
|
|
515
525
|
ignoredBuilds?: string[]
|
|
516
526
|
/**
|
package/src/install.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* parameter so tests can substitute a recording fake.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { closeSync, existsSync, lstatSync, openSync, readlinkSync, readSync, rmSync, statSync } from 'node:fs'
|
|
8
|
+
import { closeSync, existsSync, lstatSync, openSync, readFileSync, readlinkSync, readSync, rmSync, statSync } from 'node:fs'
|
|
9
9
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
10
10
|
import type { InstallResult, PluginRunner } from './dsh-cli.ts'
|
|
11
11
|
import { findDshInstallDir } from './dsh-install.ts'
|
|
@@ -337,12 +337,31 @@ export async function withHoistRecovery(
|
|
|
337
337
|
//
|
|
338
338
|
// The wait on a fresh release is the one of these where "no retry" leaves
|
|
339
339
|
// the user with nothing to do at all: the plugin IS published, pnpm just
|
|
340
|
-
// will not take it yet
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
340
|
+
// will not take it yet. #852 pointed at plain `dsh web`, but the desktop
|
|
341
|
+
// profile is host-managed — that door is shut (#909). Say the wait, and
|
|
342
|
+
// when pnpm named the publish time, how long is left (#919).
|
|
343
|
+
//
|
|
344
|
+
// Drop the classifier's "market already retried" sentence first: it was
|
|
345
|
+
// appended above and is false on this host, and the client prefers
|
|
346
|
+
// body.error / that classified half over a note tacked on afterwards.
|
|
347
|
+
let note: string
|
|
348
|
+
if (unavailableOption === RELEASE_AGE_OVERRIDE) {
|
|
349
|
+
const classified = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)
|
|
350
|
+
if (classified?.code === 'release-age-violation') {
|
|
351
|
+
const cut = result.stderr.lastIndexOf(classified.message)
|
|
352
|
+
if (cut !== -1) {
|
|
353
|
+
result = { ...result, stderr: result.stderr.slice(0, cut).trimEnd() }
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const windowMs = readMinimumReleaseAgeMs(profile, profileDirectory)
|
|
357
|
+
const remaining = windowMs === null
|
|
358
|
+
? null
|
|
359
|
+
: remainingReleaseAgeWait(`${result.stderr}\n${result.stdout}`, Date.now(), windowMs)
|
|
360
|
+
const wait = remaining === null ? { zh: '', en: '' } : formatRemainingReleaseAgeWait(remaining)
|
|
361
|
+
note = `这个插件的新版本比本机的新版本等待期还新,所以现在装不了(改别的插件也会被拦住)。官方桌面端不接受临时放行,所以这里没有自动重试。**等这个版本过了等待期再更新一次即可**${wait.zh}。过了等待期后再在市场点一次更新。 / The plugin's new version is younger than this machine's fresh-release wait, so the install was refused (a change to another plugin too). The official desktop app takes no one-shot bypass, so no automatic retry happens here. **Update again once the version is past the wait**${wait.en}. Try the market update again after the wait.`
|
|
362
|
+
} else {
|
|
363
|
+
note = `这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`
|
|
364
|
+
}
|
|
346
365
|
result = { ...result, stderr: `${result.stderr}\n\n${note}` }
|
|
347
366
|
}
|
|
348
367
|
// pnpm can append a second rule even on success (#883, #878), including
|
|
@@ -423,6 +442,95 @@ export function pnpmNeverStarted(result: InstallResult): boolean {
|
|
|
423
442
|
return classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code === 'pnpm-unusable'
|
|
424
443
|
}
|
|
425
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Whether pnpm refused a too-young release without writing to the profile.
|
|
447
|
+
*
|
|
448
|
+
* Same nature as wroteNothing / pnpmNeverStarted for the update route's
|
|
449
|
+
* rollback (#919): pnpm DID start, read the lockfile, then exited 1 on
|
|
450
|
+
* ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION (or the sibling mature-match /
|
|
451
|
+
* resolution-policy shapes). Nothing was installed or removed, so restoring
|
|
452
|
+
* "the previous build" is alarm about an untouched profile — and on the
|
|
453
|
+
* official desktop bridge it pairs every wait with a false
|
|
454
|
+
* "restoration could not be verified" line that covers the real reason.
|
|
455
|
+
* @param result - the failed run.
|
|
456
|
+
* @returns true when the failure is pnpm's fresh-release gate.
|
|
457
|
+
*/
|
|
458
|
+
export function pnpmRefusedYoungRelease(result: InstallResult): boolean {
|
|
459
|
+
return classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code === 'release-age-violation'
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** pnpm's default minimumReleaseAge window (1440 minutes). */
|
|
463
|
+
const DEFAULT_RELEASE_AGE_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The profile's fresh-release wait window in ms (#919).
|
|
467
|
+
*
|
|
468
|
+
* Reads `minimumReleaseAge` (minutes) from pnpm-workspace.yaml, then
|
|
469
|
+
* `minimum-release-age` from `.npmrc`. A key that is present but not a
|
|
470
|
+
* number returns null — callers must not invent a remaining wait. When
|
|
471
|
+
* neither file names the key, pnpm's default 1440 minutes applies.
|
|
472
|
+
*/
|
|
473
|
+
export function readMinimumReleaseAgeMs(profile: string, explicitDir?: string): number | null {
|
|
474
|
+
const dir = profileDir(profile, explicitDir)
|
|
475
|
+
const fromWorkspace = readReleaseAgeMinutesFromWorkspace(dir)
|
|
476
|
+
if (fromWorkspace === 'invalid') return null
|
|
477
|
+
if (typeof fromWorkspace === 'number') return fromWorkspace * 60 * 1000
|
|
478
|
+
const fromNpmrc = readReleaseAgeMinutesFromNpmrc(dir)
|
|
479
|
+
if (fromNpmrc === 'invalid') return null
|
|
480
|
+
if (typeof fromNpmrc === 'number') return fromNpmrc * 60 * 1000
|
|
481
|
+
return DEFAULT_RELEASE_AGE_WINDOW_MS
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** @returns minutes, `'invalid'` when the key is present but unreadable, or null when absent. */
|
|
485
|
+
function readReleaseAgeMinutesFromWorkspace(dir: string): number | 'invalid' | null {
|
|
486
|
+
let yaml: string
|
|
487
|
+
try { yaml = readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8') } catch { return null }
|
|
488
|
+
if (!/^minimumReleaseAge\s*:/m.test(yaml)) return null
|
|
489
|
+
const match = /^minimumReleaseAge:\s*(\d+)\s*(?:#.*)?$/m.exec(yaml)
|
|
490
|
+
if (match === null) return 'invalid'
|
|
491
|
+
const minutes = Number(match[1])
|
|
492
|
+
return Number.isFinite(minutes) ? minutes : 'invalid'
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** @returns minutes, `'invalid'` when the key is present but unreadable, or null when absent. */
|
|
496
|
+
function readReleaseAgeMinutesFromNpmrc(dir: string): number | 'invalid' | null {
|
|
497
|
+
let npmrc: string
|
|
498
|
+
try { npmrc = readFileSync(join(dir, '.npmrc'), 'utf8') } catch { return null }
|
|
499
|
+
if (!/^minimum-release-age\s*=/m.test(npmrc)) return null
|
|
500
|
+
const match = /^minimum-release-age\s*=\s*(\d+)\s*(?:#.*)?$/m.exec(npmrc)
|
|
501
|
+
if (match === null) return 'invalid'
|
|
502
|
+
const minutes = Number(match[1])
|
|
503
|
+
return Number.isFinite(minutes) ? minutes : 'invalid'
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* How long is left in the fresh-release wait, when pnpm named the publish
|
|
508
|
+
* time in its refusal (#919). Null when the output has no usable timestamp.
|
|
509
|
+
*/
|
|
510
|
+
export function remainingReleaseAgeWait(
|
|
511
|
+
output: string,
|
|
512
|
+
now = Date.now(),
|
|
513
|
+
windowMs = DEFAULT_RELEASE_AGE_WINDOW_MS,
|
|
514
|
+
): number | null {
|
|
515
|
+
const match = /published at (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/i.exec(output)
|
|
516
|
+
if (match === null) return null
|
|
517
|
+
const published = Date.parse(match[1]!)
|
|
518
|
+
if (!Number.isFinite(published)) return null
|
|
519
|
+
return Math.max(0, published + windowMs - now)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Human remaining-wait fragment for the desktop release-age note. */
|
|
523
|
+
function formatRemainingReleaseAgeWait(remainingMs: number): { zh: string, en: string } {
|
|
524
|
+
if (remainingMs <= 0) {
|
|
525
|
+
return { zh: '(现在再试一次即可)', en: ' (try again now)' }
|
|
526
|
+
}
|
|
527
|
+
const hours = Math.max(1, Math.ceil(remainingMs / (60 * 60 * 1000)))
|
|
528
|
+
return {
|
|
529
|
+
zh: `(大约还剩 ${hours} 小时)`,
|
|
530
|
+
en: ` (about ${hours} hour${hours === 1 ? '' : 's'} left)`,
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
426
534
|
/**
|
|
427
535
|
* Whether pnpm failed because the running host holds the package's files
|
|
428
536
|
* open, so nothing pnpm runs from inside that host can replace them.
|
package/src/official-desktop.ts
CHANGED
|
@@ -96,7 +96,14 @@ function selfUpdateMessage(spec: string): string {
|
|
|
96
96
|
const at = spec.indexOf('@', 1)
|
|
97
97
|
const version = at === -1 ? '' : spec.slice(at + 1)
|
|
98
98
|
const target = version === '' ? spec : `${registryNameOf(spec) ?? spec}@${version}`
|
|
99
|
-
|
|
99
|
+
// Two paths that used to be named here do not exist on dsh 0.2.1, and a
|
|
100
|
+
// reporter proved both (#909): `dsh plugin --profile desktop` is refused
|
|
101
|
+
// outright, and the app's Settings → Plugins has no update entry — its
|
|
102
|
+
// manager refuses re-adding a package that is already declared. The only
|
|
103
|
+
// route left is pnpm run against the profile with the app fully quit, which
|
|
104
|
+
// is what the reporter verified. `--config.minimum-release-age=0` is the
|
|
105
|
+
// market's own one-shot flag, spelled so it works in every shell.
|
|
106
|
+
return `市场不能在自己的请求里更新自己:桌面端的移除会先把市场从正在运行的组合里摘掉,补装那一步就执行不到了(#900),而应用内的「设置 → 插件」没有更新入口(#909)。要更新${version === '' ? '' : ` 到 ${version}`}:完全退出 DSH Desktop,在 profile 目录执行 \`pnpm add ${target} --config.minimum-release-age=0\`(没有 pnpm 时用 \`npx pnpm add …\`),然后重新启动。Windows 的 profile 一般在 %USERPROFILE%\\.dsh\\profiles\\desktop。 / The market cannot update itself: the desktop app unloads it from the running composition first, so the reinstall never runs (#900), and the app's Settings → Plugins has no update entry (#909). To update${version === '' ? '' : ` to ${version}`}: quit DSH Desktop completely, run \`pnpm add ${target} --config.minimum-release-age=0\` in the profile directory (use \`npx pnpm add …\` if pnpm is not installed), then start DSH again. On Windows the profile is usually %USERPROFILE%\\.dsh\\profiles\\desktop.`
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
/** The manager's own refusal code, whether it was returned or thrown. */
|
|
@@ -262,11 +269,18 @@ export function createOfficialDesktopRuntime(
|
|
|
262
269
|
// No package result means the manager stopped before it ran pnpm: the
|
|
263
270
|
// profile is untouched, and the route must not try to "restore" it.
|
|
264
271
|
wroteNothing: !ok && result.packageResult === undefined,
|
|
272
|
+
// The manager's own code, kept machine-readable (#909): a client can
|
|
273
|
+
// tell a self-update refusal from an ordinary failure.
|
|
274
|
+
...(ok ? {} : { refusalCode: managementCode(result.error) }),
|
|
265
275
|
}
|
|
266
276
|
}, (error): InstallResult => {
|
|
267
277
|
const message = detail(error)
|
|
268
278
|
progress.error = message
|
|
269
|
-
return {
|
|
279
|
+
return {
|
|
280
|
+
...failure(message, 127),
|
|
281
|
+
wroteNothing: managementCode(error) !== undefined,
|
|
282
|
+
...(managementCode(error) === undefined ? {} : { refusalCode: managementCode(error) }),
|
|
283
|
+
}
|
|
270
284
|
}).finally(() => {
|
|
271
285
|
progress.active = false
|
|
272
286
|
progress.cancelling = false
|
package/src/routes.ts
CHANGED
|
@@ -41,7 +41,7 @@ import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnaps
|
|
|
41
41
|
import { readHostRecoveryView, restoreHostRecoveryBackup } from './host-recovery.ts'
|
|
42
42
|
import { trialValidate } from './trial.ts'
|
|
43
43
|
import { catalogRepoKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedAllowBuildsKeys, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, sourceFallbackFor, workspaceProtocolDeps } from './sources.ts'
|
|
44
|
-
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuildEntries, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
|
|
44
|
+
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuildEntries, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, pnpmRefusedYoungRelease, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
|
|
45
45
|
import { classifyPnpmFailure } from './pnpm-compat.ts'
|
|
46
46
|
import { asChannel, CHANNELS, DIST_TAG, resolveChannel, type Channel } from './channels.ts'
|
|
47
47
|
import {
|
|
@@ -451,6 +451,14 @@ function pnpmFailureMessage(result: { exitCode: number | null; stdout: string; s
|
|
|
451
451
|
const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)
|
|
452
452
|
if (failure === null) return undefined
|
|
453
453
|
if (failure.code === 'ignored-builds' || failure.code === 'git-prepare-not-allowed') return undefined
|
|
454
|
+
// Desktop release-age note is already the actionable sentence (#919). Do
|
|
455
|
+
// not cover it with the classifier's "market already retried" claim —
|
|
456
|
+
// install/uninstall fall through to stderr via failureText when error is
|
|
457
|
+
// undefined.
|
|
458
|
+
if (failure.code === 'release-age-violation'
|
|
459
|
+
&& /没有自动重试|no automatic retry happens here/.test(`${result.stderr}\n${result.stdout}`)) {
|
|
460
|
+
return undefined
|
|
461
|
+
}
|
|
454
462
|
return failure.message
|
|
455
463
|
}
|
|
456
464
|
|
|
@@ -4817,7 +4825,12 @@ sendJson(response, 200, { updates })
|
|
|
4817
4825
|
// verify restoration, inspect this profile" warning this block
|
|
4818
4826
|
// raises on a failed rollback would be alarm about an untouched
|
|
4819
4827
|
// profile.
|
|
4820
|
-
&& result.wroteNothing !== true
|
|
4828
|
+
&& result.wroteNothing !== true
|
|
4829
|
+
// pnpm ran, then refused a too-young release without writing
|
|
4830
|
+
// (#919). Same untouched profile; skipping the rollback keeps
|
|
4831
|
+
// the release-age reason visible instead of covering it with a
|
|
4832
|
+
// false "restoration could not be verified" alarm.
|
|
4833
|
+
&& !pnpmRefusedYoungRelease(result)) {
|
|
4821
4834
|
if (pnpmBlockedByOpenFiles(result)) {
|
|
4822
4835
|
const kept = keepLockedBuild()
|
|
4823
4836
|
rollbackOk = kept.ok
|
|
@@ -5172,7 +5185,10 @@ sendJson(response, 200, { updates })
|
|
|
5172
5185
|
// ever sees this when something really is unbootable.
|
|
5173
5186
|
...(() => { const orphans = orphanBundles(); return orphans.length > 0 ? { orphanBundles: orphans } : {} })(),
|
|
5174
5187
|
staleReason: staleReason ?? undefined,
|
|
5175
|
-
|
|
5188
|
+
// The host's own refusal code when it stopped before pnpm
|
|
5189
|
+
// (#909): `market-self-update` tells a client that the button
|
|
5190
|
+
// cannot work on this host, not that the update was broken.
|
|
5191
|
+
failureCode: versionFailureCode ?? result.refusalCode ?? undefined,
|
|
5176
5192
|
renamedTo: renamedTo ?? undefined,
|
|
5177
5193
|
removedDeclaration: removedDeclaration ?? undefined,
|
|
5178
5194
|
...(droppedBundles.length > 0 ? { droppedBundles } : {}),
|
|
@@ -5183,8 +5199,14 @@ sendJson(response, 200, { updates })
|
|
|
5183
5199
|
// before running pnpm there is no classifier for its words and
|
|
5184
5200
|
// no rollback behind it, so without this the answer carried no
|
|
5185
5201
|
// reason at all.
|
|
5202
|
+
// Release-age on Desktop (#919): withHoistRecovery already wrote
|
|
5203
|
+
// the wait note into stderr (and stripped the false "already
|
|
5204
|
+
// retried" classifier). Prefer that over pnpmFailureMessage,
|
|
5205
|
+
// which would re-surface the classifier alone — the client
|
|
5206
|
+
// shows body.error first.
|
|
5186
5207
|
error: versionFailureError ?? renamedError ?? trialError ?? brokenEntryError ?? hardFailureRollbackError ?? staleError
|
|
5187
5208
|
?? (result.wroteNothing === true && result.stderr.trim() !== '' ? result.stderr.trim() : undefined)
|
|
5209
|
+
?? (pnpmRefusedYoungRelease(result) && result.stderr.trim() !== '' ? result.stderr.trim() : undefined)
|
|
5188
5210
|
?? pnpmFailureMessage(result) ?? undefined,
|
|
5189
5211
|
exitCode: result.exitCode,
|
|
5190
5212
|
timedOut: result.timedOut,
|
package/src/update-api-v1.ts
CHANGED
|
@@ -76,9 +76,30 @@ function bool(value: unknown): boolean {
|
|
|
76
76
|
return value === true
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Host refusal codes that may travel through `failureCode` unchanged.
|
|
81
|
+
*
|
|
82
|
+
* The route forwards the desktop manager's own codes (#909); the v1 API only
|
|
83
|
+
* re-publishes the ones a caller can act on, so an internal code cannot leak
|
|
84
|
+
* by accident. `market-self-update` is the one this exists for: the market
|
|
85
|
+
* cannot update itself on a host whose manager refuses re-adds, and a caller
|
|
86
|
+
* should be able to say so instead of retrying.
|
|
87
|
+
*/
|
|
88
|
+
const FORWARDED_FAILURE_CODES = new Set([
|
|
89
|
+
'DOWNGRADE_DETECTED',
|
|
90
|
+
'RESOLVED_VERSION_MISMATCH',
|
|
91
|
+
'market-self-update',
|
|
92
|
+
'already-installed',
|
|
93
|
+
'bundle-in-use',
|
|
94
|
+
'not-removable',
|
|
95
|
+
'stop-profile',
|
|
96
|
+
'management-required',
|
|
97
|
+
'incompatible-version',
|
|
98
|
+
])
|
|
99
|
+
|
|
79
100
|
function failureCode(status: number, body: Record<string, unknown>): string {
|
|
80
101
|
const explicit = text(body.failureCode)
|
|
81
|
-
if (explicit
|
|
102
|
+
if (explicit !== null && FORWARDED_FAILURE_CODES.has(explicit)) return explicit
|
|
82
103
|
if (body.agentsBusy === true) return 'AGENTS_RUNNING'
|
|
83
104
|
if (body.busy === true || status === 409) return 'OPERATION_BUSY'
|
|
84
105
|
if (body.stale === true && body.staleReason === 'release-age') return 'RELEASE_TOO_FRESH'
|