dshmarket 1.47.0 → 1.48.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/UPDATE-API-V1.md +62 -0
- package/client/client.js +131 -30
- package/lib/dsh-cli.js +45 -1
- package/lib/dsh-install.js +74 -41
- package/lib/install.js +39 -3
- package/lib/pnpm-compat.js +6 -3
- package/lib/routes.js +199 -41
- package/lib/types/dsh-cli.d.ts +34 -0
- package/lib/types/dsh-install.d.ts +2 -2
- package/lib/types/install.d.ts +38 -3
- package/package.json +1 -1
- package/src/client/index.ts +66 -38
- package/src/client/market-element.ts +60 -0
- package/src/client/section-gate.ts +79 -0
- package/src/dsh-cli.ts +45 -1
- package/src/dsh-install.ts +69 -44
- package/src/install.ts +40 -2
- package/src/pnpm-compat.ts +6 -3
- package/src/routes.ts +201 -40
package/UPDATE-API-V1.md
CHANGED
|
@@ -33,6 +33,37 @@ supported features, restart owner and endpoint paths. A client must hide its
|
|
|
33
33
|
restart button when `restart.supported` is false. Desktop and supervised hosts
|
|
34
34
|
normally delegate restart to their owning shell or operator.
|
|
35
35
|
|
|
36
|
+
## Count what can be updated
|
|
37
|
+
|
|
38
|
+
```http
|
|
39
|
+
GET /dsh-market/api/v1/updates/summary
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"schema": "dsh-market/update-api/v1",
|
|
45
|
+
"checked": 12,
|
|
46
|
+
"updatable": 3,
|
|
47
|
+
"packages": [
|
|
48
|
+
{ "name": "dsh-mcp-connector", "source": "npm", "installedVersion": "1.1.0", "latestVersion": "1.2.0" }
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`checked` is the denominator — how many installed plugins the answer looked
|
|
54
|
+
at. Read it, or a badge cannot tell "nothing to update" from "nothing was
|
|
55
|
+
looked at". The two are different answers and only one of them means the
|
|
56
|
+
profile is up to date.
|
|
57
|
+
|
|
58
|
+
`packages` carries the same objects the single-package endpoint below
|
|
59
|
+
returns, and lists only plugins that can be updated. A badge needs
|
|
60
|
+
`updatable`; a panel needs the rows; neither should have to filter the whole
|
|
61
|
+
profile itself.
|
|
62
|
+
|
|
63
|
+
`GET /dsh-market/api/v1/capabilities` reports this endpoint at
|
|
64
|
+
`endpoints.updatesSummary` and sets `features.updatesSummary`. Check that
|
|
65
|
+
rather than probing for the path.
|
|
66
|
+
|
|
36
67
|
## Check one installed package
|
|
37
68
|
|
|
38
69
|
```http
|
|
@@ -123,6 +154,37 @@ same-origin, no forwarding headers, no package mutation in progress, and a Host
|
|
|
123
154
|
whose lifecycle is not owned by Desktop or a supervisor. Clients must feature
|
|
124
155
|
detect it; they must not invent an alternative process-control path.
|
|
125
156
|
|
|
157
|
+
## Rendering the market's panel elsewhere (client-side)
|
|
158
|
+
|
|
159
|
+
A host shell that wants the market inside its own container — rather than in
|
|
160
|
+
the settings page — reads the client service this package publishes:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const market = ctx.reflect.get('market')
|
|
164
|
+
const element = market.render({ preferredSubsectionId: 'installed' })
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`render()` returns the market's own panel wrapped in its error boundary, as a
|
|
168
|
+
React element. Same page, same React instance: this package's client bundle
|
|
169
|
+
resolves react through the host's module table, so the element mounts
|
|
170
|
+
anywhere in that tree.
|
|
171
|
+
|
|
172
|
+
| member | |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `version` | `1` |
|
|
175
|
+
| `render(props?)` | the panel element; `preferredSubsectionId` is optional |
|
|
176
|
+
| `setSettingsVisible(visible)` | register or retract the market's own `settings.section` entry |
|
|
177
|
+
| `settingsVisible()` | whether that entry is registered right now |
|
|
178
|
+
|
|
179
|
+
`ctx.provide(name, value)` and `ctx.reflect.provide(name, value)` are the
|
|
180
|
+
same call — cordis's `Service.provide` delegates to the reflect layer — so
|
|
181
|
+
either idiom reaches this service.
|
|
182
|
+
|
|
183
|
+
What `render()` is **not**: a way to rearrange the market. It hands over the
|
|
184
|
+
whole panel, chrome included. Cutting the market into host-fillable regions
|
|
185
|
+
is a different design, and one host asking is not yet evidence that it fits
|
|
186
|
+
anyone else.
|
|
187
|
+
|
|
126
188
|
## Compatibility policy
|
|
127
189
|
|
|
128
190
|
- New optional response fields may be added within v1.
|
package/client/client.js
CHANGED
|
@@ -10720,6 +10720,86 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
10720
10720
|
});
|
|
10721
10721
|
}
|
|
10722
10722
|
//#endregion
|
|
10723
|
+
//#region src/client/market-element.ts
|
|
10724
|
+
/**
|
|
10725
|
+
* The market's panel, as an element, built from explicit dependencies.
|
|
10726
|
+
*
|
|
10727
|
+
* Two callers want the same thing and must not drift apart:
|
|
10728
|
+
*
|
|
10729
|
+
* - the `settings.section` this package registers, whose slot passes down a
|
|
10730
|
+
* host-chosen `preferredSubsectionId`;
|
|
10731
|
+
* - `market.render()`, for a host shell that renders the market inside its
|
|
10732
|
+
* own container (#602, from the Tauri desktop).
|
|
10733
|
+
*
|
|
10734
|
+
* A module function taking its dependencies rather than a closure over the
|
|
10735
|
+
* cordis context, so the wiring — which locale, which theme, which log
|
|
10736
|
+
* exporter reaches the panel — is something a test can assert instead of
|
|
10737
|
+
* something only a running host can reveal.
|
|
10738
|
+
*/
|
|
10739
|
+
/**
|
|
10740
|
+
* @param props - see {@link MarketElementProps}.
|
|
10741
|
+
* @returns the panel wrapped in its error boundary.
|
|
10742
|
+
*/
|
|
10743
|
+
function marketElement(props) {
|
|
10744
|
+
return (0, react.createElement)(MarketErrorBoundary, {
|
|
10745
|
+
text: props.crashText,
|
|
10746
|
+
actions: (0, react.createElement)("button", {
|
|
10747
|
+
type: "button",
|
|
10748
|
+
onClick: () => {
|
|
10749
|
+
props.exportLog();
|
|
10750
|
+
}
|
|
10751
|
+
}, props.t("exportLog"))
|
|
10752
|
+
}, (0, react.createElement)(MarketSection, {
|
|
10753
|
+
t: props.t,
|
|
10754
|
+
locale: props.locale,
|
|
10755
|
+
theme: props.theme,
|
|
10756
|
+
themeStore: props.themeStore,
|
|
10757
|
+
preferredSubsectionId: props.preferredSubsectionId
|
|
10758
|
+
}));
|
|
10759
|
+
}
|
|
10760
|
+
//#endregion
|
|
10761
|
+
//#region src/client/section-gate.ts
|
|
10762
|
+
/**
|
|
10763
|
+
* @param register - registers the entry and returns its disposer. Called
|
|
10764
|
+
* only when the entry should be visible; may be called again after a
|
|
10765
|
+
* retraction, which is why the disposer is required rather than optional.
|
|
10766
|
+
* @returns the gate.
|
|
10767
|
+
*/
|
|
10768
|
+
function createSectionGate(register) {
|
|
10769
|
+
let ready = false;
|
|
10770
|
+
let wanted = true;
|
|
10771
|
+
let removed = false;
|
|
10772
|
+
let dispose = null;
|
|
10773
|
+
const apply = () => {
|
|
10774
|
+
if (!ready) return;
|
|
10775
|
+
const shouldShow = wanted && !removed;
|
|
10776
|
+
if (shouldShow && dispose === null) {
|
|
10777
|
+
dispose = register();
|
|
10778
|
+
return;
|
|
10779
|
+
}
|
|
10780
|
+
if (!shouldShow && dispose !== null) {
|
|
10781
|
+
const stop = dispose;
|
|
10782
|
+
dispose = null;
|
|
10783
|
+
stop();
|
|
10784
|
+
}
|
|
10785
|
+
};
|
|
10786
|
+
return {
|
|
10787
|
+
available: () => {
|
|
10788
|
+
ready = true;
|
|
10789
|
+
apply();
|
|
10790
|
+
},
|
|
10791
|
+
setVisible: (visible) => {
|
|
10792
|
+
wanted = visible;
|
|
10793
|
+
apply();
|
|
10794
|
+
},
|
|
10795
|
+
visible: () => dispose !== null,
|
|
10796
|
+
retire: () => {
|
|
10797
|
+
removed = true;
|
|
10798
|
+
apply();
|
|
10799
|
+
}
|
|
10800
|
+
};
|
|
10801
|
+
}
|
|
10802
|
+
//#endregion
|
|
10723
10803
|
//#region src/client/SettingsCard.tsx
|
|
10724
10804
|
/**
|
|
10725
10805
|
* The market's card on the plugin configuration page (dsh >= 0.1.0-rc.7).
|
|
@@ -11210,8 +11290,32 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
11210
11290
|
en
|
|
11211
11291
|
}), "dsh-market: dictionaries");
|
|
11212
11292
|
const t = ctx.locale.bind(NS);
|
|
11213
|
-
|
|
11214
|
-
|
|
11293
|
+
/**
|
|
11294
|
+
* The market's own panel, as an element — one builder for the settings
|
|
11295
|
+
* section this package registers and for `market.render()` (#602). Built
|
|
11296
|
+
* per call: the props are live (locale, theme, the host's preferred
|
|
11297
|
+
* subsection), and a cached element would freeze the first caller's.
|
|
11298
|
+
*/
|
|
11299
|
+
const buildMarketElement = (ownerProps = {}) => marketElement({
|
|
11300
|
+
t,
|
|
11301
|
+
locale: ctx.locale,
|
|
11302
|
+
theme: ctx.theme,
|
|
11303
|
+
themeStore: {
|
|
11304
|
+
subscribe: (cb) => ctx.on("theme/change", cb),
|
|
11305
|
+
getSnapshot: () => ctx.theme.getTheme()
|
|
11306
|
+
},
|
|
11307
|
+
crashText: {
|
|
11308
|
+
title: t("crashTitle"),
|
|
11309
|
+
hint: t("crashHint"),
|
|
11310
|
+
reload: t("crashReload"),
|
|
11311
|
+
details: t("crashDetails")
|
|
11312
|
+
},
|
|
11313
|
+
exportLog: () => {
|
|
11314
|
+
exportMarketLog().catch(() => {});
|
|
11315
|
+
},
|
|
11316
|
+
preferredSubsectionId: ownerProps.preferredSubsectionId
|
|
11317
|
+
});
|
|
11318
|
+
const sectionGate = createSectionGate(() => {
|
|
11215
11319
|
const off = ctx.slots.register({
|
|
11216
11320
|
name: "settings.section",
|
|
11217
11321
|
id: "market",
|
|
@@ -11219,32 +11323,31 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
11219
11323
|
label: () => t("nav"),
|
|
11220
11324
|
locale: NS,
|
|
11221
11325
|
inject: () => ({ t })
|
|
11222
|
-
}, (ownerProps = {}) => (
|
|
11223
|
-
|
|
11224
|
-
|
|
11225
|
-
|
|
11226
|
-
|
|
11227
|
-
details: t("crashDetails")
|
|
11228
|
-
},
|
|
11229
|
-
actions: (0, react.createElement)("button", {
|
|
11230
|
-
type: "button",
|
|
11231
|
-
onClick: () => {
|
|
11232
|
-
exportMarketLog().catch(() => {});
|
|
11233
|
-
}
|
|
11234
|
-
}, t("exportLog"))
|
|
11235
|
-
}, (0, react.createElement)(MarketSection, {
|
|
11236
|
-
t,
|
|
11237
|
-
locale: ctx.locale,
|
|
11238
|
-
theme: ctx.theme,
|
|
11239
|
-
themeStore: {
|
|
11240
|
-
subscribe: (cb) => ctx.on("theme/change", cb),
|
|
11241
|
-
getSnapshot: () => ctx.theme.getTheme()
|
|
11242
|
-
},
|
|
11243
|
-
preferredSubsectionId: ownerProps.preferredSubsectionId
|
|
11244
|
-
})));
|
|
11245
|
-
if (typeof off === "function") retireSection = off;
|
|
11246
|
-
return off;
|
|
11326
|
+
}, (ownerProps = {}) => buildMarketElement(ownerProps));
|
|
11327
|
+
return typeof off === "function" ? off : () => {};
|
|
11328
|
+
});
|
|
11329
|
+
ctx.slots.inject("settings.section", () => {
|
|
11330
|
+
sectionGate.available();
|
|
11247
11331
|
});
|
|
11332
|
+
const marketControl = {
|
|
11333
|
+
version: 1,
|
|
11334
|
+
setSettingsVisible: (visible) => {
|
|
11335
|
+
sectionGate.setVisible(visible);
|
|
11336
|
+
},
|
|
11337
|
+
settingsVisible: () => sectionGate.visible(),
|
|
11338
|
+
/**
|
|
11339
|
+
* The market's panel as an element, for a host that renders it inside
|
|
11340
|
+
* its own container. Same page, same React instance — this package's
|
|
11341
|
+
* bundle resolves react through the host's module table, so an element
|
|
11342
|
+
* returned here mounts anywhere in that tree.
|
|
11343
|
+
*
|
|
11344
|
+
* What it is NOT: a way to rearrange the market. It hands over the whole
|
|
11345
|
+
* panel, chrome included. Cutting the market into host-fillable regions
|
|
11346
|
+
* is a different design and has not been asked for by a second host yet.
|
|
11347
|
+
*/
|
|
11348
|
+
render: (props = {}) => buildMarketElement(props)
|
|
11349
|
+
};
|
|
11350
|
+
if (typeof ctx.provide === "function") ctx.provide("market", marketControl);
|
|
11248
11351
|
ctx.inject(["settingsScope"], (scoped) => {
|
|
11249
11352
|
scoped.slots.inject("settings.plugin.item", () => scoped.slots.register({
|
|
11250
11353
|
name: "settings.plugin.item",
|
|
@@ -11254,9 +11357,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
11254
11357
|
}, () => (0, react.createElement)(SettingsCard, {
|
|
11255
11358
|
t,
|
|
11256
11359
|
onRemoved: () => {
|
|
11257
|
-
|
|
11258
|
-
retireSection = null;
|
|
11259
|
-
off?.();
|
|
11360
|
+
sectionGate.retire();
|
|
11260
11361
|
}
|
|
11261
11362
|
})));
|
|
11262
11363
|
});
|
package/lib/dsh-cli.js
CHANGED
|
@@ -192,6 +192,44 @@ export function toolSearchDirs(platform = process.platform, env = process.env, h
|
|
|
192
192
|
// one place a user goes looking for an answer.
|
|
193
193
|
return [...new Set(dirs.filter(dir => dir.trim() !== ''))];
|
|
194
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Stop git asking for credentials down a channel nobody is listening on
|
|
197
|
+
* (#587).
|
|
198
|
+
*
|
|
199
|
+
* `CI=true` below is the same defence one layer up, and pnpm reads it. git
|
|
200
|
+
* does not — it has its own switch, and it was not set. The gap only opens
|
|
201
|
+
* when a spec reaches pnpm's git fetcher instead of the codeload tarball
|
|
202
|
+
* path `accelerate.ts` describes: `github:owner/repo#path:/sub` is one, and
|
|
203
|
+
* pnpm really does shell out to `git` for it, trying HTTPS first.
|
|
204
|
+
*
|
|
205
|
+
* git's credential prompt opens the controlling terminal, not stdin. There
|
|
206
|
+
* is no terminal here, so the question is never seen and never answered:
|
|
207
|
+
* the reporter caught `git.exe` alive for eight minutes having burned 0.05s
|
|
208
|
+
* of CPU, and only the fifteen-minute install timeout ended it. Refusing
|
|
209
|
+
* the prompt turns that into a fast, readable failure.
|
|
210
|
+
*
|
|
211
|
+
* It is a default, not an override: a value the caller set wins, and blank
|
|
212
|
+
* counts as unset because an empty `GIT_TERMINAL_PROMPT` is not a setting
|
|
213
|
+
* git can parse either. Credential helpers and `GIT_ASKPASS` are untouched
|
|
214
|
+
* and still answer first — this closes only the terminal fallback, which is
|
|
215
|
+
* precisely the branch that cannot work from a spawned child.
|
|
216
|
+
*
|
|
217
|
+
* Scope, stated plainly because it is narrower than the issue title
|
|
218
|
+
* suggests: this is the HTTPS half. pnpm falls back to `git@github.com:`
|
|
219
|
+
* when HTTPS fails, and the ssh side needs `GIT_SSH_COMMAND`, which
|
|
220
|
+
* overrides `core.sshCommand` and `GIT_SSH` — the two ordinary ways to
|
|
221
|
+
* choose an identity — and whose `BatchMode=yes` would disable
|
|
222
|
+
* `SSH_ASKPASS`, breaking key-passphrase installs that work today. That
|
|
223
|
+
* half needs a policy decision, so it is not made here. Note also that on
|
|
224
|
+
* POSIX `runDshPlugin` spawns detached, so the subtree has no controlling
|
|
225
|
+
* terminal and the prompt already dies instantly; the hang the issue
|
|
226
|
+
* reports needs Windows, where the spawn is not detached.
|
|
227
|
+
*/
|
|
228
|
+
export function gitEnvForPnpm(env = process.env) {
|
|
229
|
+
if ((env.GIT_TERMINAL_PROMPT ?? '').trim() !== '')
|
|
230
|
+
return {};
|
|
231
|
+
return { GIT_TERMINAL_PROMPT: '0' };
|
|
232
|
+
}
|
|
195
233
|
function spawnEnv() {
|
|
196
234
|
// pnpm v10+ blocks forever on a silent interactive prompt without a TTY;
|
|
197
235
|
// CI mode forces it to act or fail instead of asking.
|
|
@@ -201,7 +239,13 @@ function spawnEnv() {
|
|
|
201
239
|
if (!parts.includes(bin))
|
|
202
240
|
parts.push(bin);
|
|
203
241
|
}
|
|
204
|
-
return {
|
|
242
|
+
return {
|
|
243
|
+
...process.env,
|
|
244
|
+
...proxyEnvForPnpm(process.env, activeRegion()),
|
|
245
|
+
...gitEnvForPnpm(process.env),
|
|
246
|
+
CI: 'true',
|
|
247
|
+
PATH: parts.join(separator),
|
|
248
|
+
};
|
|
205
249
|
}
|
|
206
250
|
const INSTALL_TIMEOUT_MS = Number(process.env.DSH_MARKET_INSTALL_TIMEOUT_MS) || 15 * 60 * 1000;
|
|
207
251
|
/**
|
package/lib/dsh-install.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Locate the DSH host package in CLI and packaged Desktop runtimes. */
|
|
2
2
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
-
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
/** The entry with symlinks resolved, or unchanged when it cannot be read. */
|
|
5
5
|
function realpathOf(entry) {
|
|
6
6
|
try {
|
|
@@ -14,27 +14,77 @@ function realpathOf(entry) {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
const DSH_PACKAGE = '@deepseek-ai/dsh';
|
|
17
|
+
const DESKTOP_PACKAGE = '@deepseek-ai/dsh-desktop';
|
|
18
|
+
const APPLICATION_ROOTS = ['app.asar.unpacked', 'app.asar', 'app'];
|
|
19
|
+
// The split runtime reported in #553. Only these local, identity-checked
|
|
20
|
+
// packages corroborate the shell; never resolve witnesses from a profile.
|
|
21
|
+
const DESKTOP_RUNTIME_PACKAGES = ['dsh-base', 'dsh-web-app', 'dsh-web', 'dsh-settings'];
|
|
17
22
|
/**
|
|
18
|
-
*
|
|
19
|
-
* @returns the parsed manifest of `@deepseek-ai/dsh`, or null.
|
|
23
|
+
* A package's own manifest, or null when its identity cannot be confirmed.
|
|
20
24
|
*/
|
|
21
|
-
function readDshManifest(directory) {
|
|
25
|
+
function readDshManifest(directory, name = DSH_PACKAGE) {
|
|
22
26
|
try {
|
|
23
27
|
const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
|
|
24
|
-
return manifest.name ===
|
|
28
|
+
return manifest.name === name ? { name, version: manifest.version } : null;
|
|
25
29
|
}
|
|
26
30
|
catch {
|
|
27
31
|
return null;
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
|
-
function
|
|
31
|
-
|
|
34
|
+
function entryDirectories(entry) {
|
|
35
|
+
if (entry === undefined)
|
|
36
|
+
return [];
|
|
37
|
+
const directories = [];
|
|
38
|
+
let directory = resolve(dirname(realpathOf(entry)));
|
|
39
|
+
for (let depth = 0; depth < 10; depth += 1) {
|
|
40
|
+
directories.push(directory);
|
|
41
|
+
const parent = dirname(directory);
|
|
42
|
+
if (parent === directory)
|
|
43
|
+
break;
|
|
44
|
+
directory = parent;
|
|
45
|
+
}
|
|
46
|
+
return directories;
|
|
47
|
+
}
|
|
48
|
+
function desktopDirectories() {
|
|
49
|
+
const { resourcesPath } = process;
|
|
50
|
+
if (typeof resourcesPath !== 'string' || resourcesPath.length === 0)
|
|
51
|
+
return [];
|
|
52
|
+
return APPLICATION_ROOTS.map(root => join(resourcesPath, root));
|
|
53
|
+
}
|
|
54
|
+
function declaredVersion(manifest) {
|
|
55
|
+
return typeof manifest?.version === 'string' && manifest.version !== '' ? manifest.version : 'unknown';
|
|
56
|
+
}
|
|
57
|
+
function flatDesktopHost(directory) {
|
|
58
|
+
const shell = readDshManifest(directory, DESKTOP_PACKAGE);
|
|
59
|
+
if (shell === null)
|
|
60
|
+
return null;
|
|
61
|
+
const runtime = DESKTOP_RUNTIME_PACKAGES.map(name => {
|
|
62
|
+
const candidate = join(directory, 'node_modules', '@deepseek-ai', name);
|
|
63
|
+
// Bundled pnpm links are fine; a link to a mutable profile/global package
|
|
64
|
+
// is not evidence about the version of the embedded runtime.
|
|
65
|
+
const path = relative(realpathOf(directory), realpathOf(join(candidate, 'package.json')));
|
|
66
|
+
if (isAbsolute(path) || path === '..' || path.startsWith(`..${sep}`))
|
|
67
|
+
return null;
|
|
68
|
+
return readDshManifest(candidate, `@deepseek-ai/${name}`);
|
|
69
|
+
});
|
|
70
|
+
// A shell alone is not a dependency anchor. Either in-box bundle can
|
|
71
|
+
// establish the directory even if the other version witnesses are broken.
|
|
72
|
+
if (runtime[0] === null && runtime[1] === null)
|
|
73
|
+
return null;
|
|
74
|
+
const version = declaredVersion(shell);
|
|
75
|
+
// DSH split packages share a release line, but a desktop shell need not.
|
|
76
|
+
// Require agreement from every witness; missing/conflicting evidence is
|
|
77
|
+
// unknown, not the shell version or a majority vote among split packages.
|
|
78
|
+
const parsed = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version);
|
|
79
|
+
const corroborated = parsed !== null && !(parsed[1]?.split('.').some(part => /^0\d+$/.test(part)) ?? false)
|
|
80
|
+
&& runtime.every(manifest => manifest?.version === version);
|
|
81
|
+
return { directory, version: corroborated ? version : 'unknown' };
|
|
32
82
|
}
|
|
33
83
|
/**
|
|
34
84
|
* The version of the DSH host this market is running inside.
|
|
35
85
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
86
|
+
* CLI versions come from the host manifest. Flat Desktop shells additionally
|
|
87
|
+
* require agreement with their bundled split runtime, never profile packages.
|
|
38
88
|
*
|
|
39
89
|
* Worth reporting because the host version has repeatedly been the thing
|
|
40
90
|
* neither side could see. #293 turned on it (the reporter was on
|
|
@@ -51,17 +101,20 @@ function isDshPackage(directory) {
|
|
|
51
101
|
* legitimately land here).
|
|
52
102
|
*/
|
|
53
103
|
export function dshHostInfo(entry = process.argv[1]) {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
104
|
+
const ancestors = entryDirectories(entry);
|
|
105
|
+
const applications = desktopDirectories();
|
|
106
|
+
// Keep the entire legacy search order ahead of newly supported shells.
|
|
107
|
+
for (const directory of [...ancestors, ...applications.map(root => join(root, 'node_modules', '@deepseek-ai', 'dsh'))]) {
|
|
108
|
+
const manifest = readDshManifest(directory);
|
|
109
|
+
if (manifest !== null)
|
|
110
|
+
return { directory, version: declaredVersion(manifest) };
|
|
111
|
+
}
|
|
112
|
+
for (const directory of [...ancestors, ...applications]) {
|
|
113
|
+
const host = flatDesktopHost(directory);
|
|
114
|
+
if (host !== null)
|
|
115
|
+
return host;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
65
118
|
}
|
|
66
119
|
/**
|
|
67
120
|
* Walk up from the CLI entry first, then inspect Electron's authoritative
|
|
@@ -84,25 +137,5 @@ export function dshHostInfo(entry = process.argv[1]) {
|
|
|
84
137
|
* why this survived: the case that worked is the one that gets tested.
|
|
85
138
|
*/
|
|
86
139
|
export function findDshInstallDir(entry = process.argv[1]) {
|
|
87
|
-
|
|
88
|
-
let directory = resolve(dirname(realpathOf(entry)));
|
|
89
|
-
for (let depth = 0; depth < 10; depth += 1) {
|
|
90
|
-
if (isDshPackage(directory))
|
|
91
|
-
return directory;
|
|
92
|
-
const parent = dirname(directory);
|
|
93
|
-
if (parent === directory)
|
|
94
|
-
break;
|
|
95
|
-
directory = parent;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
const electronProcess = process;
|
|
99
|
-
if (typeof electronProcess.resourcesPath !== 'string'
|
|
100
|
-
|| electronProcess.resourcesPath.length === 0)
|
|
101
|
-
return null;
|
|
102
|
-
for (const applicationRoot of ['app.asar.unpacked', 'app.asar', 'app']) {
|
|
103
|
-
const candidate = join(electronProcess.resourcesPath, applicationRoot, 'node_modules', '@deepseek-ai', 'dsh');
|
|
104
|
-
if (isDshPackage(candidate))
|
|
105
|
-
return candidate;
|
|
106
|
-
}
|
|
107
|
-
return null;
|
|
140
|
+
return dshHostInfo(entry)?.directory ?? null;
|
|
108
141
|
}
|
package/lib/install.js
CHANGED
|
@@ -10,8 +10,19 @@ import { classifyPnpmFailure, HOST_NAMESPACE_RE } from './pnpm-compat.js';
|
|
|
10
10
|
import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles } from './profile.js';
|
|
11
11
|
import { logEvent } from './log.js';
|
|
12
12
|
import { cleanOrphanedStore } from './store.js';
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* One-shot bypass for pnpm's fresh-release hold; scoped to a single command.
|
|
15
|
+
*
|
|
16
|
+
* Spelled like the .npmrc key, not the camelCase pnpm-workspace.yaml one:
|
|
17
|
+
* from pnpm 12.3.0 (the native CLI) `--config.minimumReleaseAge=0` is
|
|
18
|
+
* silently ignored — no unknown-option error — so the retry ran without the
|
|
19
|
+
* bypass and failed exactly like the first attempt (#600).
|
|
20
|
+
* `--config.minimum-release-age=0` is honoured by pnpm 10, 11 and 12 alike.
|
|
21
|
+
* That is specific to this key, not a rule for `--config.*`: the native CLI
|
|
22
|
+
* ignores `--config.fetch-timeout` in both spellings, which is why
|
|
23
|
+
* FETCH_TIMEOUT_OVERRIDE below is not respelled here (#615).
|
|
24
|
+
*/
|
|
25
|
+
export const RELEASE_AGE_OVERRIDE = '--config.minimum-release-age=0';
|
|
15
26
|
/**
|
|
16
27
|
* Longer per-request fetch timeout for one retried command. pnpm's default
|
|
17
28
|
* 60-second limit aborts large tarball downloads (github: sources fetch the
|
|
@@ -77,8 +88,15 @@ export function isUnpublishedHostPeer(pkg, profile, explicitDir) {
|
|
|
77
88
|
* Any recognized failure that survives gets its bilingual explanation
|
|
78
89
|
* appended to stderr so the UI shows an actionable message instead of a
|
|
79
90
|
* wall of text (#20 bug 3). Cancelled runs are never recovered.
|
|
91
|
+
*
|
|
92
|
+
* The release-age bypass can be declined (`releaseAgeBypass: false`). Its
|
|
93
|
+
* safety argument above assumes the young package is already installed; a
|
|
94
|
+
* fresh install pinned to the registry's latest (#594) is the one case
|
|
95
|
+
* where it is not — there the bypass would be what installs the young
|
|
96
|
+
* version, over a minimumReleaseAge the profile set on purpose — so the
|
|
97
|
+
* install route declines it and falls back to the bare name instead.
|
|
80
98
|
*/
|
|
81
|
-
export async function withHoistRecovery(run, profile, pluginArgs, profileDirectory) {
|
|
99
|
+
export async function withHoistRecovery(run, profile, pluginArgs, profileDirectory, options = {}) {
|
|
82
100
|
let result = await run(profile, pluginArgs);
|
|
83
101
|
const ok = (r) => r.exitCode === 0 && !r.timedOut && !r.cancelled;
|
|
84
102
|
if (!ok(result) && !result.cancelled) {
|
|
@@ -92,6 +110,7 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
92
110
|
result = await run(profile, pluginArgs);
|
|
93
111
|
}
|
|
94
112
|
else if (failure?.code === 'release-age-violation'
|
|
113
|
+
&& options.releaseAgeBypass !== false
|
|
95
114
|
&& (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
|
|
96
115
|
&& !pluginArgs.includes(RELEASE_AGE_OVERRIDE)) {
|
|
97
116
|
logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`);
|
|
@@ -172,6 +191,23 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
172
191
|
export function pnpmNeverStarted(result) {
|
|
173
192
|
return classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code === 'pnpm-unusable';
|
|
174
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Whether pnpm failed because the running host holds the package's files
|
|
196
|
+
* open, so nothing pnpm runs from inside that host can replace them.
|
|
197
|
+
*
|
|
198
|
+
* Worth asking for the same reason as pnpmNeverStarted: the update route
|
|
199
|
+
* answers a failed run by reinstalling the previous build, and that reinstall
|
|
200
|
+
* performs the very rename that just failed, against the same open handles
|
|
201
|
+
* (#608 by @Euezb). It cannot win — pnpm retries the rename for about three
|
|
202
|
+
* minutes before giving up — and the route then told the user their profile
|
|
203
|
+
* might be broken and to inspect it before restarting, when nothing had been
|
|
204
|
+
* reinstalled and the previous build was still there to be checked.
|
|
205
|
+
* @param result - the failed run.
|
|
206
|
+
* @returns true when pnpm was stopped by files the host holds open.
|
|
207
|
+
*/
|
|
208
|
+
export function pnpmBlockedByOpenFiles(result) {
|
|
209
|
+
return classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code === 'windows-file-locked';
|
|
210
|
+
}
|
|
175
211
|
/**
|
|
176
212
|
* The most specific description of a failed run available, for logs.
|
|
177
213
|
*
|
package/lib/pnpm-compat.js
CHANGED
|
@@ -271,7 +271,7 @@ export function classifyPnpmFailure(output, exitCode) {
|
|
|
271
271
|
// before ANY later mutation — uninstalling even an unrelated plugin fails
|
|
272
272
|
// (MINIMUM_RELEASE_AGE_VIOLATION), and a later add can fail re-resolving
|
|
273
273
|
// the young dep (NO_MATURE_MATCHING_VERSION). Recovery is a one-shot
|
|
274
|
-
// --config.
|
|
274
|
+
// --config.minimum-release-age=0 retry, automated in withHoistRecovery.
|
|
275
275
|
if (output.includes('ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION')
|
|
276
276
|
|| output.includes('ERR_PNPM_NO_MATURE_MATCHING_VERSION')) {
|
|
277
277
|
return {
|
|
@@ -389,7 +389,10 @@ export function classifyPnpmFailure(output, exitCode) {
|
|
|
389
389
|
// win, because that process is the thing holding the handles; retrying
|
|
390
390
|
// would only turn one clear failure into several slow ones. So this names
|
|
391
391
|
// the cause and the ways out instead of guessing.
|
|
392
|
-
|
|
392
|
+
// pnpm 12 (the native CLI) says it differently and carries no ERR_PNPM_
|
|
393
|
+
// code: `failed to remove existing directory "…" prior to swap: …` — same
|
|
394
|
+
// refused swap over the open directory, so the same answer.
|
|
395
|
+
if (/ERR_PNPM_EPERM|EPERM: operation not permitted, rename|failed to remove existing directory .* prior to swap/i.test(output)) {
|
|
393
396
|
// Read through the NDJSON reporter like the integrity classifier does:
|
|
394
397
|
// in production this arrives JSON-escaped, so every separator is doubled
|
|
395
398
|
// and a single-character class silently matches nothing.
|
|
@@ -402,7 +405,7 @@ export function classifyPnpmFailure(output, exitCode) {
|
|
|
402
405
|
code: 'windows-file-locked',
|
|
403
406
|
recoverable: false,
|
|
404
407
|
...(pkg === undefined ? {} : { pkg }),
|
|
405
|
-
message: `Windows 不允许替换正在被打开的文件。pnpm 要用新目录替换${zh === '' ? '一个已装好的包' : ` ${pkg}`},而它的文件正被运行中的 DeepSeek Harness
|
|
408
|
+
message: `Windows 不允许替换正在被打开的文件。pnpm 要用新目录替换${zh === '' ? '一个已装好的包' : ` ${pkg}`},而它的文件正被运行中的 DeepSeek Harness 打开着,改名因此失败,这一步没有生效。\n要注意的是:pnpm 在重试改名之前会尽量把目标目录清掉,所以**被占用文件旁边的内容可能已经被删**——不一定只是「没换成」而已。市场遇到这种失败时不再尝试回滚(同一个改名会撞同一批句柄),而是把 package.json 与 pnpm-lock.yaml 恢复成操作前的样子,并检查原构建的入口是否还在——结论以那次检查为准。\n如果这个包带原生模块(.node 文件,例如 node-hid 这类),那么停用插件、甚至卸载插件都不够:原生模块一旦被加载,在进程退出前都不会释放。刚卸载完立刻重装同一个插件在 Windows 上失败,通常就是这个原因。\n可行的做法:完全退出 DeepSeek Harness(不是刷新页面),重新启动后再操作一次;或退出后在命令行执行。杀毒软件或文件索引临时占用目录也会报同样的错,若都不适用可稍后重试。 / Windows will not replace a file that is open. pnpm tried to swap a new directory over${en === '' ? ' an installed package' : en}, whose files the running DeepSeek Harness holds open, so the rename failed and this step did not apply. Note that pnpm clears as much of the target directory as it can before retrying the rename, so content BESIDE the file it could not remove may already be gone — this is not always only "the swap did not happen". On this failure the market no longer attempts a rollback (the same rename would meet the same open handles); it restores package.json and pnpm-lock.yaml to how they were and then checks whether the previous build still has a loadable entry — that check is the answer. If that package ships a native module (a .node file, node-hid and friends), disabling the plugin — even uninstalling it — is not enough: once a native module is loaded it is not released until the process exits, which is the usual reason reinstalling a plugin right after uninstalling it fails on Windows. What works: quit DeepSeek Harness completely (not a page refresh), start it again, and repeat the operation; or run it from the command line with the app closed. Antivirus or a file indexer holding the directory produces the same error, so a later retry is worth trying if neither applies.`,
|
|
406
409
|
};
|
|
407
410
|
}
|
|
408
411
|
// #83: pnpm replays the WHOLE dependency tree on every add/remove, so a
|