dshmarket 1.58.0 → 1.59.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/lib/check.js +23 -3
- package/lib/index.js +38 -0
- package/lib/install.js +38 -2
- package/lib/official-desktop.js +135 -0
- package/lib/pnpm-compat.js +16 -0
- package/lib/profile.js +111 -38
- package/lib/routes.js +77 -15
- package/lib/settings.js +36 -7
- package/lib/sources.js +8 -7
- package/lib/types/install.d.ts +19 -0
- package/lib/types/official-desktop.d.ts +26 -0
- package/lib/types/pnpm-compat.d.ts +1 -1
- package/lib/types/profile.d.ts +27 -0
- package/lib/types/routes.d.ts +2 -0
- package/lib/types/settings.d.ts +14 -7
- package/lib/types/sources.d.ts +3 -2
- package/package.json +1 -1
- package/src/check.ts +24 -3
- package/src/index.ts +43 -0
- package/src/install.ts +38 -2
- package/src/official-desktop.ts +148 -0
- package/src/pnpm-compat.ts +17 -1
- package/src/profile.ts +116 -35
- package/src/routes.ts +81 -15
- package/src/settings.ts +35 -7
- package/src/sources.ts +8 -7
package/lib/check.js
CHANGED
|
@@ -32,6 +32,15 @@ import { JSON_SCHEMA, Type, load } from 'js-yaml';
|
|
|
32
32
|
import { findDshInstallDir } from './dsh-install.js';
|
|
33
33
|
import { resolveDshHome } from './home-paths.js';
|
|
34
34
|
import { INBOX_BUNDLES, readBundleRules, suggestOrder, validateOrder } from './order.js';
|
|
35
|
+
// Electron's app.asar packages can be loadable by the host while invisible to
|
|
36
|
+
// filesystem probes made from a profile plugin. Keep this fallback limited to
|
|
37
|
+
// packages confirmed to ship with the official Desktop app, and only use it
|
|
38
|
+
// when the install anchor is the packaged app.
|
|
39
|
+
const DESKTOP_HOST_BUNDLES = new Set(['@deepseek-ai/dsh-experimental-agent-team-profile']);
|
|
40
|
+
const DESKTOP_HOST_LOADERS = new Set(['@deepseek-ai/dsh-mcp-client']);
|
|
41
|
+
function isPackagedDesktopInstall(dshInstallDir) {
|
|
42
|
+
return dshInstallDir !== null && /[/\\]app\.asar[/\\]dsh$/iu.test(dshInstallDir);
|
|
43
|
+
}
|
|
35
44
|
export { findDshInstallDir } from './dsh-install.js';
|
|
36
45
|
/** js-yaml dialect for `!!js` scalars — identical to dsh-app-boot's entryListSchema. */
|
|
37
46
|
const jsExpr = new Type('tag:yaml.org,2002:js', {
|
|
@@ -702,13 +711,15 @@ function lockfileCoreVersions(profileDir) {
|
|
|
702
711
|
*/
|
|
703
712
|
export function buildBundleLayers(profileDirectory, bundleNames, specs, dshInstallDir) {
|
|
704
713
|
const bundles = bundleNames.map((name) => {
|
|
714
|
+
const hostProvided = INBOX_BUNDLES.has(name)
|
|
715
|
+
|| (isPackagedDesktopInstall(dshInstallDir) && DESKTOP_HOST_BUNDLES.has(name));
|
|
705
716
|
// The real loader gives the DSH installation first refusal for in-box
|
|
706
717
|
// bundles. Desktop keeps that installation private from plugins, so a
|
|
707
718
|
// DIRECT profile-local copy with the same official name is only a stale
|
|
708
719
|
// shadow, never evidence for the layer the running host loaded (#371).
|
|
709
720
|
// Keep walking the profile anchor's parent search paths: Desktop heals an
|
|
710
721
|
// authoritative host fallback at <profiles>/node_modules.
|
|
711
|
-
const ignoredProfilePackage = dshInstallDir === null &&
|
|
722
|
+
const ignoredProfilePackage = dshInstallDir === null && hostProvided
|
|
712
723
|
? join(profileDirectory, 'node_modules', name)
|
|
713
724
|
: undefined;
|
|
714
725
|
const anchors = [
|
|
@@ -729,7 +740,7 @@ export function buildBundleLayers(profileDirectory, bundleNames, specs, dshInsta
|
|
|
729
740
|
const layer = {
|
|
730
741
|
name,
|
|
731
742
|
source: specs[name] ?? '(not a direct dependency)',
|
|
732
|
-
kind:
|
|
743
|
+
kind: hostProvided ? 'official' : 'community',
|
|
733
744
|
directory,
|
|
734
745
|
patchPath: null,
|
|
735
746
|
patchPaths: [],
|
|
@@ -748,7 +759,7 @@ export function buildBundleLayers(profileDirectory, bundleNames, specs, dshInsta
|
|
|
748
759
|
// fatal verdict and rolled back a good update (#369) — while `dsh
|
|
749
760
|
// --dump-config` on the same profile exited 0. Unknown has to read as
|
|
750
761
|
// unknown; the profile's own bundles are still judged normally.
|
|
751
|
-
if (
|
|
762
|
+
if (hostProvided) {
|
|
752
763
|
layer.error = null;
|
|
753
764
|
layer.unresolvedInbox = true;
|
|
754
765
|
return layer;
|
|
@@ -963,6 +974,15 @@ export function analyzeProfile(profileDirectory, options = {}) {
|
|
|
963
974
|
for (const { row, packageName } of candidates.values()) {
|
|
964
975
|
if (profilePackageInstalled(profileDirectory, packageName))
|
|
965
976
|
continue;
|
|
977
|
+
// Official Electron keeps built-in packages beside app.asar, not inside
|
|
978
|
+
// the writable profile. The loader can resolve them from this anchor.
|
|
979
|
+
if (dshInstall !== null && core.has(packageName)
|
|
980
|
+
&& resolvePackageDir(join(dshInstall, 'package.json'), packageName) !== null)
|
|
981
|
+
continue;
|
|
982
|
+
if (isPackagedDesktopInstall(dshInstall) && DESKTOP_HOST_LOADERS.has(packageName)) {
|
|
983
|
+
warnings.push(`${row.layer}: bundled Desktop loader ${packageName} could not be independently resolved from app.asar`);
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
966
986
|
const message = `${row.layer}: loader package ${packageName} is not installed in the profile`;
|
|
967
987
|
if (row.activation === 'required')
|
|
968
988
|
errors.push(`${message} — the profile will fail to boot`);
|
package/lib/index.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* dsh-market host entry: mounts the market's HTTP routes once the profile
|
|
3
3
|
* composes the webServer and shell services.
|
|
4
4
|
*/
|
|
5
|
+
import { dirname, isAbsolute } from 'node:path';
|
|
5
6
|
import { createDesktopPluginRuntime, setHostPackageManager } from './dsh-cli.js';
|
|
7
|
+
import { createOfficialDesktopRuntime } from './official-desktop.js';
|
|
6
8
|
import { isDshProfileName } from './profile.js';
|
|
7
9
|
import { mountMarketRoutes } from './routes.js';
|
|
8
10
|
import { installDesktopMarketSettings, installMarketSettings } from './settings.js';
|
|
@@ -98,6 +100,42 @@ export function apply(ctx, config) {
|
|
|
98
100
|
// its name, and never with somebody else's.
|
|
99
101
|
const profileContext = ctx.get('profileContext');
|
|
100
102
|
const launched = launchedProfile(profileContext);
|
|
103
|
+
// The official Electron app owns this profile. Its CLI explicitly
|
|
104
|
+
// refuses `--profile desktop`; use the app's pluginManager service.
|
|
105
|
+
// Looking it up at request time lets the market mount before the
|
|
106
|
+
// service while still failing closed if it never becomes available.
|
|
107
|
+
// The dsh CLI refuses a profile by NAME — `profile.toLowerCase() ===
|
|
108
|
+
// "desktop"` (@deepseek-ai/dsh 0.1.7-alpha.2) — so a launched profile
|
|
109
|
+
// with that name can never be changed through `dsh plugin`, whatever
|
|
110
|
+
// the install layout. Detection follows the same rule rather than the
|
|
111
|
+
// app.asar anchor shape an earlier draft keyed on: the official desktop
|
|
112
|
+
// host is not published, its layout could not be checked, and a host
|
|
113
|
+
// that missed the anchor test fell straight back to the CLI and failed
|
|
114
|
+
// every install (#702). A third-party shell announces itself through
|
|
115
|
+
// `desktopProfiles`, and this whole block runs only when that service
|
|
116
|
+
// is absent, so this never takes a third-party shell's profile.
|
|
117
|
+
const officialElectron = launched !== undefined && launched.name.toLowerCase() === 'desktop';
|
|
118
|
+
if (officialElectron && config?.profile === undefined) {
|
|
119
|
+
const runtime = createOfficialDesktopRuntime(() => hostCtx.get('pluginManager'), launched.name, launched.dir);
|
|
120
|
+
const resolved = {
|
|
121
|
+
profile: launched.name,
|
|
122
|
+
profileDirectory: launched.dir,
|
|
123
|
+
desktopHost: true,
|
|
124
|
+
allowRestart: false,
|
|
125
|
+
maxSnapshots: config?.maxSnapshots,
|
|
126
|
+
...(typeof profileContext?.installAnchor === 'string' && isAbsolute(profileContext.installAnchor)
|
|
127
|
+
? { dshInstallDir: dirname(profileContext.installAnchor) } : {}),
|
|
128
|
+
};
|
|
129
|
+
installDesktopMarketSettings(ctx);
|
|
130
|
+
host.effect(() => {
|
|
131
|
+
const disposeRoutes = mountMarketRoutes(host, resolved, runtime, agentsLookupOf(ctx));
|
|
132
|
+
return async () => {
|
|
133
|
+
disposeRoutes();
|
|
134
|
+
await runtime.dispose();
|
|
135
|
+
};
|
|
136
|
+
}, 'dsh-market: official Desktop routes and package operations');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
101
139
|
// The launcher's own package manager, when it publishes one. Registering
|
|
102
140
|
// it here covers both the pnpm probe and every install spawn, since the
|
|
103
141
|
// invocation's environment reaches both through spawnEnv (#653).
|
package/lib/install.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { existsSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { classifyPnpmFailure, HOST_NAMESPACE_RE } from './pnpm-compat.js';
|
|
10
|
-
import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles } from './profile.js';
|
|
10
|
+
import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.js';
|
|
11
11
|
import { logEvent } from './log.js';
|
|
12
12
|
import { cleanOrphanedStore } from './store.js';
|
|
13
13
|
/**
|
|
@@ -113,7 +113,19 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
113
113
|
const ok = (r) => r.exitCode === 0 && !r.timedOut && !r.cancelled;
|
|
114
114
|
if (!ok(result) && !result.cancelled) {
|
|
115
115
|
const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode);
|
|
116
|
-
if (failure?.code === '
|
|
116
|
+
if (failure?.code === 'unparseable-build-key') {
|
|
117
|
+
// The profile's own allowBuilds block is what fails, so no retry of the
|
|
118
|
+
// same command can pass until it is repaired (#698). Drop only the
|
|
119
|
+
// source-form keys this pnpm cannot parse — bare names stay, and on
|
|
120
|
+
// these versions a bare name is what authorizes a git dependency — then
|
|
121
|
+
// run the command once more.
|
|
122
|
+
const removed = dropUnparseableBuildKeys(profile, profileDirectory);
|
|
123
|
+
if (removed.length > 0) {
|
|
124
|
+
logEvent('warn', 'install', `this pnpm cannot parse git-source allowBuilds keys; removed ${removed.join(', ')} from pnpm-workspace.yaml and retrying once (#698)`);
|
|
125
|
+
result = await run(profile, pluginArgs);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (failure?.code === 'hoist-pattern-diff') {
|
|
117
129
|
logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`);
|
|
118
130
|
// --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
|
|
119
131
|
// where a lockfile written by the old major would otherwise be refused.
|
|
@@ -411,6 +423,30 @@ export function parsePrepareNotAllowed(stdout, stderr) {
|
|
|
411
423
|
const at = raw.lastIndexOf('@');
|
|
412
424
|
return at > 0 ? raw.slice(0, at) : raw;
|
|
413
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* The allowBuilds key pnpm itself printed for a prepare refusal, when it
|
|
428
|
+
* printed one (#698).
|
|
429
|
+
*
|
|
430
|
+
* pnpm 11 ends ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED with the exact line to
|
|
431
|
+
* add — measured on 11.8.0 against the reported plugin:
|
|
432
|
+
*
|
|
433
|
+
* For example:
|
|
434
|
+
* allowBuilds:
|
|
435
|
+
* @dsh-external/dsh-super-injector@https://codeload.github.com/…/tar.gz/<sha>: true
|
|
436
|
+
*
|
|
437
|
+
* For a TRANSITIVE git dependency that key is the only knowledge anyone has
|
|
438
|
+
* of its source: the package is in neither node_modules, package.json nor
|
|
439
|
+
* the catalog. pnpm 10 prints an `onlyBuiltDependencies` example with the
|
|
440
|
+
* bare name instead, and gets null here — the bare name is what it needs.
|
|
441
|
+
*
|
|
442
|
+
* @returns the key, or null when the output carries no allowBuilds example.
|
|
443
|
+
*/
|
|
444
|
+
export function parsePrepareKey(stdout, stderr) {
|
|
445
|
+
// ndjson carries this inside a JSON string: quotes and newlines escaped.
|
|
446
|
+
const text = `${stdout}\n${stderr}`.replace(/\\"/g, '"').replace(/\\n/g, '\n');
|
|
447
|
+
const m = /For example:\s*\n\s*allowBuilds:\s*\n[ \t]+("?)([^\s"]+)\1:\s*true/.exec(text);
|
|
448
|
+
return m === null ? null : m[2];
|
|
449
|
+
}
|
|
414
450
|
/**
|
|
415
451
|
* Package names pnpm reported as having their build scripts ignored
|
|
416
452
|
* ("Ignored build scripts: esbuild, koffi."). Empty when none.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** The official Electron profile is owned by DSH's in-process plugin manager. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { progress, TARGET_RE } from './dsh-cli.js';
|
|
4
|
+
/**
|
|
5
|
+
* What a user can actually do when the market cannot perform an operation on
|
|
6
|
+
* the official Desktop profile: the app's own Plugins page runs the same
|
|
7
|
+
* managed pipeline. Bilingual, because it is read in the operations panel.
|
|
8
|
+
*/
|
|
9
|
+
export const OFFICIAL_PAGE_HINT = '官方桌面客户端的这个插件操作需要在「设置 → 插件」里完成;市场无法通过命令行修改桌面端的 profile。 / On the official desktop app, do this from Settings → Plugins; the market cannot change the desktop profile through the command line.';
|
|
10
|
+
function failure(message, exitCode = 1) {
|
|
11
|
+
return { exitCode, timedOut: false, stdout: '', stderr: message, cancelled: false };
|
|
12
|
+
}
|
|
13
|
+
function detail(error) {
|
|
14
|
+
if (typeof error === 'string')
|
|
15
|
+
return error;
|
|
16
|
+
if (error instanceof Error)
|
|
17
|
+
return error.message;
|
|
18
|
+
if (error === undefined)
|
|
19
|
+
return 'plugin manager did not explain the failure';
|
|
20
|
+
return JSON.stringify(error);
|
|
21
|
+
}
|
|
22
|
+
/** Never fall back to `dsh plugin --profile desktop`: that CLI is forbidden. */
|
|
23
|
+
export function createOfficialDesktopRuntime(managerLookup, profileName, _profileDirectory) {
|
|
24
|
+
let disposed = false;
|
|
25
|
+
let active;
|
|
26
|
+
const runPlugin = (profile, argv) => {
|
|
27
|
+
if (disposed)
|
|
28
|
+
return Promise.resolve(failure('official desktop plugin runtime is disposed', 127));
|
|
29
|
+
if (profile !== profileName)
|
|
30
|
+
return Promise.resolve(failure('refusing to change a different profile', 127));
|
|
31
|
+
if (active !== undefined)
|
|
32
|
+
return Promise.resolve({ ...failure('another plugin operation is running', 127), busy: true });
|
|
33
|
+
const manager = managerLookup();
|
|
34
|
+
if (manager === undefined
|
|
35
|
+
|| typeof manager.installBundle !== 'function'
|
|
36
|
+
|| typeof manager.removeBundle !== 'function'
|
|
37
|
+
|| typeof manager.cancelInstall !== 'function') {
|
|
38
|
+
return Promise.resolve(failure(OFFICIAL_PAGE_HINT, 127));
|
|
39
|
+
}
|
|
40
|
+
// Market sends pnpm argv. Only operations representable by the official
|
|
41
|
+
// manager are accepted; silently dropping pnpm flags would change intent.
|
|
42
|
+
const [command, target, ...extra] = argv;
|
|
43
|
+
if (extra.length !== 0 || typeof target !== 'string' || !TARGET_RE.test(target)) {
|
|
44
|
+
return Promise.resolve(failure('this desktop operation is not supported by the official plugin manager', 127));
|
|
45
|
+
}
|
|
46
|
+
const spec = target;
|
|
47
|
+
// Only `add` and `remove` map onto the manager. `update` reached here
|
|
48
|
+
// only for the #564 in-place re-resolve of a floating git spec; turning
|
|
49
|
+
// it into `name@latest` (as a first draft did) would cross the installed
|
|
50
|
+
// range and ignore the release channel, which is a different operation.
|
|
51
|
+
if (command !== 'add' && command !== 'remove') {
|
|
52
|
+
return Promise.resolve(failure(OFFICIAL_PAGE_HINT, 127));
|
|
53
|
+
}
|
|
54
|
+
const requestId = command === 'remove' ? undefined : randomUUID();
|
|
55
|
+
progress.active = true;
|
|
56
|
+
progress.target = target;
|
|
57
|
+
progress.startedAt = Date.now();
|
|
58
|
+
progress.lastLine = 'Using the official desktop plugin manager';
|
|
59
|
+
progress.error = null;
|
|
60
|
+
progress.cancelling = false;
|
|
61
|
+
let operation;
|
|
62
|
+
try {
|
|
63
|
+
operation = command === 'remove'
|
|
64
|
+
? manager.removeBundle(target)
|
|
65
|
+
: manager.installBundle(spec, { requestId });
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
progress.active = false;
|
|
69
|
+
return Promise.resolve(failure(detail(error), 127));
|
|
70
|
+
}
|
|
71
|
+
const current = { requestId, cancelled: false, done: Promise.resolve(failure('not started')) };
|
|
72
|
+
active = current;
|
|
73
|
+
current.done = operation.then((result) => {
|
|
74
|
+
// `overridden` is a change the manager KEPT (`changed: true`) whose live
|
|
75
|
+
// state differs because another layer — a user patch — overrides it.
|
|
76
|
+
// It is not a failed install: reporting it as one sent the update route
|
|
77
|
+
// into a rollback of a change the manager had already kept.
|
|
78
|
+
const ok = result.application === 'applied'
|
|
79
|
+
|| result.application === 'restart-required'
|
|
80
|
+
|| result.application === 'overridden';
|
|
81
|
+
const output = [
|
|
82
|
+
result.packageResult?.output ?? '',
|
|
83
|
+
result.application === 'overridden' ? 'the plugin manager kept this change, but another layer overrides whether it is enabled' : '',
|
|
84
|
+
].filter(Boolean).join('\n');
|
|
85
|
+
const message = ok ? '' : detail(result.error);
|
|
86
|
+
if (!ok)
|
|
87
|
+
progress.error = message;
|
|
88
|
+
return {
|
|
89
|
+
// `||`, not `??`: a failure at stage `enable` follows a SUCCESSFUL
|
|
90
|
+
// pnpm step, so packageResult.exitCode is 0 there — and every route
|
|
91
|
+
// reads exit 0 as success. A failed application must never be 0.
|
|
92
|
+
exitCode: ok ? 0 : (result.packageResult?.exitCode || 1),
|
|
93
|
+
timedOut: false,
|
|
94
|
+
stdout: output,
|
|
95
|
+
stderr: message,
|
|
96
|
+
cancelled: current.cancelled || result.application === 'cancelled',
|
|
97
|
+
};
|
|
98
|
+
}, (error) => {
|
|
99
|
+
const message = detail(error);
|
|
100
|
+
progress.error = message;
|
|
101
|
+
return failure(message, 127);
|
|
102
|
+
}).finally(() => {
|
|
103
|
+
progress.active = false;
|
|
104
|
+
progress.cancelling = false;
|
|
105
|
+
if (active === current)
|
|
106
|
+
active = undefined;
|
|
107
|
+
});
|
|
108
|
+
return current.done;
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
runPlugin,
|
|
112
|
+
probePnpm: async () => managerLookup() !== undefined,
|
|
113
|
+
provisionPnpm: async () => ({ ok: managerLookup() !== undefined, hint: 'Use the official desktop plugin manager' }),
|
|
114
|
+
cancelActive: () => {
|
|
115
|
+
if (active?.requestId === undefined)
|
|
116
|
+
return false;
|
|
117
|
+
active.cancelled = true;
|
|
118
|
+
progress.cancelling = true;
|
|
119
|
+
const manager = managerLookup();
|
|
120
|
+
if (manager !== undefined)
|
|
121
|
+
void manager.cancelInstall(active.requestId).catch(() => { });
|
|
122
|
+
return true;
|
|
123
|
+
},
|
|
124
|
+
supportsExactRollbackTarget: target => TARGET_RE.test(target),
|
|
125
|
+
dispose: async () => {
|
|
126
|
+
disposed = true;
|
|
127
|
+
if (active?.requestId !== undefined) {
|
|
128
|
+
const manager = managerLookup();
|
|
129
|
+
if (manager !== undefined)
|
|
130
|
+
void manager.cancelInstall(active.requestId).catch(() => { });
|
|
131
|
+
}
|
|
132
|
+
await active?.done;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
package/lib/pnpm-compat.js
CHANGED
|
@@ -313,6 +313,22 @@ export function classifyPnpmFailure(output, exitCode) {
|
|
|
313
313
|
// pnpm's FETCHER, before anything lands in node_modules — so the package
|
|
314
314
|
// the user must approve is not installed yet, and pnpm's own hint names a
|
|
315
315
|
// commit-pinned codeload URL that changes on every push.
|
|
316
|
+
// #698: pnpm 10.26+ and 11.0–11.5 read an allowBuilds key as
|
|
317
|
+
// `name@<version union>`, so a git or archive source there fails the WHOLE
|
|
318
|
+
// workspace file — every pnpm command in the profile, not the one plugin.
|
|
319
|
+
// Named separately from any install failure because the cure is in the
|
|
320
|
+
// profile's own pnpm-workspace.yaml, which withHoistRecovery repairs.
|
|
321
|
+
{
|
|
322
|
+
const found = /ERR_PNPM_INVALID_VERSION_UNION[\s\S]*?Found: \\?"([^"\\]+)\\?"/.exec(output);
|
|
323
|
+
if (found !== null && /@(?:git\+|https?:)/.test(found[1].slice(1))) {
|
|
324
|
+
return {
|
|
325
|
+
code: 'unparseable-build-key',
|
|
326
|
+
recoverable: false,
|
|
327
|
+
pkg: found[1],
|
|
328
|
+
message: `这个版本的 pnpm 读不懂 allowBuilds 里的 git 来源键(${found[1]}),整个 profile 的包操作都会因此失败 / this pnpm version cannot read a git-source key in allowBuilds (${found[1]}), which fails every package operation in the profile`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
316
332
|
if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
|
|
317
333
|
return {
|
|
318
334
|
code: 'git-prepare-not-allowed',
|
package/lib/profile.js
CHANGED
|
@@ -261,6 +261,19 @@ export function readInstalledVersion(profile, name, explicitDir) {
|
|
|
261
261
|
return null;
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* The `name` in the package.json of the directory a dependency is installed
|
|
266
|
+
* under, or null. DSH Desktop requires it to equal the dependency key (#694).
|
|
267
|
+
*/
|
|
268
|
+
export function readInstalledPackageName(profile, name, explicitDir) {
|
|
269
|
+
try {
|
|
270
|
+
const manifest = JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'node_modules', name, 'package.json'), 'utf8'));
|
|
271
|
+
return typeof manifest.name === 'string' ? manifest.name : null;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
264
277
|
/** The installed package manifest, or null when absent or malformed. */
|
|
265
278
|
export function readInstalledManifest(profile, name, explicitDir) {
|
|
266
279
|
try {
|
|
@@ -1025,26 +1038,8 @@ function quoteYamlKey(key) {
|
|
|
1025
1038
|
}
|
|
1026
1039
|
return key;
|
|
1027
1040
|
}
|
|
1028
|
-
/**
|
|
1029
|
-
|
|
1030
|
-
* pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
|
|
1031
|
-
* merging with existing entries and leaving the rest of the yaml intact.
|
|
1032
|
-
* (#6 by @qichuang321.)
|
|
1033
|
-
* @returns every package now allowed.
|
|
1034
|
-
*/
|
|
1035
|
-
export function setAllowBuilds(profile, packages, explicitDir) {
|
|
1036
|
-
const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml');
|
|
1037
|
-
let yaml = '';
|
|
1038
|
-
try {
|
|
1039
|
-
yaml = readFileSync(file, 'utf8');
|
|
1040
|
-
}
|
|
1041
|
-
catch { /* created below */ }
|
|
1042
|
-
// `\r?\n`, not `\n`: a CRLF pnpm-workspace.yaml (every Windows editor, and
|
|
1043
|
-
// git with core.autocrlf=true) put a `\r` between `allowBuilds:` and the
|
|
1044
|
-
// newline, so the old pattern never matched an EXISTING block and appended
|
|
1045
|
-
// a second one. Two top-level `allowBuilds:` keys is invalid YAML, and pnpm
|
|
1046
|
-
// then refuses every install in that profile — not just the one that
|
|
1047
|
-
// triggered it (#231 by @MichengAI).
|
|
1041
|
+
/** The allowBuilds block(s) of a pnpm-workspace.yaml, read into one map. */
|
|
1042
|
+
function readAllowBuildsMap(yaml) {
|
|
1048
1043
|
const blockRe = /allowBuilds:[ \t]*\r?\n((?:[ \t]+[^\r\n]*\r?\n?)*)/g;
|
|
1049
1044
|
const map = {};
|
|
1050
1045
|
// Every block, not just the first: a profile already broken by the bug
|
|
@@ -1075,6 +1070,50 @@ export function setAllowBuilds(profile, packages, explicitDir) {
|
|
|
1075
1070
|
map[key] = m[2] ?? 'true';
|
|
1076
1071
|
}
|
|
1077
1072
|
}
|
|
1073
|
+
return { map, blockRe, blockMatch };
|
|
1074
|
+
}
|
|
1075
|
+
/** Write `map` back as the file's single allowBuilds block, in its own line endings. */
|
|
1076
|
+
function writeAllowBuildsMap(file, yaml, map, blockRe, blockMatch) {
|
|
1077
|
+
// Write back in the file's OWN line ending. Rewriting a CRLF workspace
|
|
1078
|
+
// file with LF would leave it mixed, which is the same class of mess this
|
|
1079
|
+
// fix exists to clean up.
|
|
1080
|
+
const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
|
|
1081
|
+
const block = Object.entries(map).map(([k, v]) => ` ${quoteYamlKey(k)}: ${v}`).join(eol);
|
|
1082
|
+
const blockText = `allowBuilds:${eol}${block}${eol}`;
|
|
1083
|
+
let next;
|
|
1084
|
+
if (blockMatch === null) {
|
|
1085
|
+
next = `${yaml.replace(/\r?\n?$/, eol)}${blockText}`;
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
// The merged block replaces the first occurrence; any further ones are
|
|
1089
|
+
// the duplicates this bug created and are dropped — their entries are
|
|
1090
|
+
// already folded into `map` above, so nothing is lost.
|
|
1091
|
+
let seen = 0;
|
|
1092
|
+
next = yaml.replace(blockRe, () => (seen++ === 0 ? blockText : ''));
|
|
1093
|
+
}
|
|
1094
|
+
writeFileSync(file, next);
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Allow the given packages' build scripts in the profile's
|
|
1098
|
+
* pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
|
|
1099
|
+
* merging with existing entries and leaving the rest of the yaml intact.
|
|
1100
|
+
* (#6 by @qichuang321.)
|
|
1101
|
+
* @returns every package now allowed.
|
|
1102
|
+
*/
|
|
1103
|
+
export function setAllowBuilds(profile, packages, explicitDir) {
|
|
1104
|
+
const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml');
|
|
1105
|
+
let yaml = '';
|
|
1106
|
+
try {
|
|
1107
|
+
yaml = readFileSync(file, 'utf8');
|
|
1108
|
+
}
|
|
1109
|
+
catch { /* created below */ }
|
|
1110
|
+
// `\r?\n`, not `\n`: a CRLF pnpm-workspace.yaml (every Windows editor, and
|
|
1111
|
+
// git with core.autocrlf=true) put a `\r` between `allowBuilds:` and the
|
|
1112
|
+
// newline, so the old pattern never matched an EXISTING block and appended
|
|
1113
|
+
// a second one. Two top-level `allowBuilds:` keys is invalid YAML, and pnpm
|
|
1114
|
+
// then refuses every install in that profile — not just the one that
|
|
1115
|
+
// triggered it (#231 by @MichengAI).
|
|
1116
|
+
const { map, blockRe, blockMatch } = readAllowBuildsMap(yaml);
|
|
1078
1117
|
// What may be written, and nothing else. The allowlist is not a host
|
|
1079
1118
|
// trust boundary — every key here is derived from the profile's OWN
|
|
1080
1119
|
// manifest or the curated catalog — it is what stops a caller writing
|
|
@@ -1091,7 +1130,13 @@ export function setAllowBuilds(profile, packages, explicitDir) {
|
|
|
1091
1130
|
// serves it. Optionally pinned to a commit, which is the form pnpm 11.8.0
|
|
1092
1131
|
// names for a plain remote. https only: the market installs from https
|
|
1093
1132
|
// remotes, and an http key would authorize a source it never writes.
|
|
1094
|
-
|
|
1133
|
+
//
|
|
1134
|
+
// The `.git` suffix is optional because pnpm keys the remote exactly as it
|
|
1135
|
+
// was spelled: measured on 12.4.1, a remote installed without `.git`
|
|
1136
|
+
// authorizes under `name@git+…/repo` and NOT under `…/repo.git`, so
|
|
1137
|
+
// requiring the suffix dropped a correctly derived key on its way out, and
|
|
1138
|
+
// appending it would have written one pnpm never reads.
|
|
1139
|
+
const GIT_KEY_RE = new RegExp(`^${NAME}@git\\+https://[A-Za-z0-9_.-]+(?::\\d{1,5})?/${SEG}(?:/${SEG})*(?:\\.git)?(?:#${SHA})?$`);
|
|
1095
1140
|
// The commit-pinned download a host serves, which is what pnpm below 11.21
|
|
1096
1141
|
// matches instead (#285): codeload for github, the project archive for
|
|
1097
1142
|
// gitlab.com and bitbucket.org (#637). Each branch names its host and its
|
|
@@ -1105,23 +1150,51 @@ export function setAllowBuilds(profile, packages, explicitDir) {
|
|
|
1105
1150
|
if (/^[A-Za-z0-9@/_.-]+$/.test(pkg) || GIT_KEY_RE.test(pkg) || ARCHIVE_KEY_RE.test(pkg))
|
|
1106
1151
|
map[pkg] = 'true';
|
|
1107
1152
|
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1153
|
+
writeAllowBuildsMap(file, yaml, map, blockRe, blockMatch);
|
|
1154
|
+
return Object.keys(map);
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Remove the allowBuilds keys pnpm cannot parse as a version range, and say
|
|
1158
|
+
* which (#698).
|
|
1159
|
+
*
|
|
1160
|
+
* pnpm reads an allowBuilds key as `name@<version union>`, and on 10.26 to
|
|
1161
|
+
* the latest 10.x and on 11.0 to 11.5 a git or archive source there —
|
|
1162
|
+
* `name@git+https://…`, `name@https://codeload…` — is rejected as
|
|
1163
|
+
* "Invalid versions union … Use exact versions only". Not the one entry: the
|
|
1164
|
+
* whole workspace file, so EVERY later pnpm command in the profile fails,
|
|
1165
|
+
* including installs that have nothing to do with it. Measured on 9.15,
|
|
1166
|
+
* 10.0 through 10.29, 11.0 through 11.8, 11.21 and 12.4; 10.25 and below
|
|
1167
|
+
* ignore allowBuilds and 11.6 and above accept these keys.
|
|
1168
|
+
*
|
|
1169
|
+
* Those are exactly the keys the market writes for a git source (#68, #285,
|
|
1170
|
+
* #637), because the pnpm versions that need them to authorize anything
|
|
1171
|
+
* read them fine. On the versions in between, a bare name is what
|
|
1172
|
+
* authorizes a git dependency — measured on 10.29 — and it is kept.
|
|
1173
|
+
*
|
|
1174
|
+
* @returns the keys removed; empty when nothing matched, in which case the
|
|
1175
|
+
* file is left untouched.
|
|
1176
|
+
*/
|
|
1177
|
+
export function dropUnparseableBuildKeys(profile, explicitDir) {
|
|
1178
|
+
const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml');
|
|
1179
|
+
let yaml;
|
|
1180
|
+
try {
|
|
1181
|
+
yaml = readFileSync(file, 'utf8');
|
|
1117
1182
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
// the duplicates this bug created and are dropped — their entries are
|
|
1121
|
-
// already folded into `map` above, so nothing is lost.
|
|
1122
|
-
let seen = 0;
|
|
1123
|
-
next = yaml.replace(blockRe, () => (seen++ === 0 ? blockText : ''));
|
|
1183
|
+
catch {
|
|
1184
|
+
return [];
|
|
1124
1185
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1186
|
+
const { map, blockRe, blockMatch } = readAllowBuildsMap(yaml);
|
|
1187
|
+
// A key is a source rather than a version when what follows the name's own
|
|
1188
|
+
// `@` starts a URL scheme. Scoped names begin with `@`, so the separator is
|
|
1189
|
+
// the first `@` after position 0.
|
|
1190
|
+
const removed = Object.keys(map).filter(key => {
|
|
1191
|
+
const at = key.indexOf('@', 1);
|
|
1192
|
+
return at > 0 && /^(?:git\+|https?:)/.test(key.slice(at + 1));
|
|
1193
|
+
});
|
|
1194
|
+
if (removed.length === 0)
|
|
1195
|
+
return [];
|
|
1196
|
+
for (const key of removed)
|
|
1197
|
+
delete map[key];
|
|
1198
|
+
writeAllowBuildsMap(file, yaml, map, blockRe, blockMatch);
|
|
1199
|
+
return removed;
|
|
1127
1200
|
}
|