dshmarket 1.58.0 → 1.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -7139,6 +7139,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7139
7139
  * real switch state so hand-edited cordis.patch.yml toggles are visible.
7140
7140
  */
7141
7141
  const [patchDisabledNames, setPatchDisabledNames] = (0, react.useState)([]);
7142
+ /** Bundle packages DSH's own plugin page turned off by leaving them out of dsh.profile.bundles (#696). */
7143
+ const [unbundledNames, setUnbundledNames] = (0, react.useState)([]);
7142
7144
  const [groups, setGroups] = (0, react.useState)({});
7143
7145
  const [groupOrder, setGroupOrder] = (0, react.useState)([]);
7144
7146
  /** Installed-tab sub-view: flat list or groups (All-plugins was removed —
@@ -7309,6 +7311,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7309
7311
  }
7310
7312
  if (body.notes !== null && typeof body.notes === "object" && !Array.isArray(body.notes)) setNotes(body.notes);
7311
7313
  if (Array.isArray(body.patchDisabled)) setPatchDisabledNames(body.patchDisabled);
7314
+ if (Array.isArray(body.unbundled)) setUnbundledNames(body.unbundled);
7312
7315
  if (body.groups && typeof body.groups === "object") setGroups(body.groups);
7313
7316
  if (Array.isArray(body.groupOrder)) setGroupOrder(body.groupOrder);
7314
7317
  if (Array.isArray(body.favorites)) setFavoriteUrls(body.favorites.filter((url) => typeof url === "string"));
@@ -7324,7 +7327,15 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7324
7327
  (0, react.useMemo)(() => new Set(disabledNames), [disabledNames]);
7325
7328
  const favoriteUrlSet = (0, react.useMemo)(() => new Set(favoriteUrls), [favoriteUrls]);
7326
7329
  /** Effective switch state: market disable list ∪ user-patch-layer disables. */
7327
- const effectiveDisabledSet = (0, react.useMemo)(() => /* @__PURE__ */ new Set([...disabledNames, ...patchDisabledNames]), [disabledNames, patchDisabledNames]);
7330
+ const effectiveDisabledSet = (0, react.useMemo)(() => /* @__PURE__ */ new Set([
7331
+ ...disabledNames,
7332
+ ...patchDisabledNames,
7333
+ ...unbundledNames
7334
+ ]), [
7335
+ disabledNames,
7336
+ patchDisabledNames,
7337
+ unbundledNames
7338
+ ]);
7328
7339
  (0, react.useEffect)(() => {
7329
7340
  if (tab !== "themes" && themesFullscreen) setThemesFullscreen(false);
7330
7341
  }, [tab, themesFullscreen]);
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 && INBOX_BUNDLES.has(name)
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: INBOX_BUNDLES.has(name) ? 'official' : 'community',
743
+ kind: hostProvided ? 'official' : 'community',
733
744
  directory,
734
745
  patchPath: null,
735
746
  patchPaths: [],
@@ -748,7 +759,19 @@ 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 (INBOX_BUNDLES.has(name)) {
762
+ //
763
+ // The same holds for any official bundle while the installation itself
764
+ // cannot be located (#676): a desktop build ships more in-box bundles
765
+ // than the three named in INBOX_BUNDLES —
766
+ // `@deepseek-ai/dsh-experimental-agent-team-profile` among them — and a
767
+ // fixed list, or an install-path shape, cannot know which. Two
768
+ // reporters saw that bundle called "not installed — will fail to boot"
769
+ // while its three entries were active in the running host. Only
770
+ // DeepSeek publishes under `@deepseek-ai/`, so while the installation
771
+ // is out of sight such a bundle is unknown, not missing. When the
772
+ // installation IS located and the bundle is in neither it nor the
773
+ // profile, the fatal verdict below still applies.
774
+ if (hostProvided || (dshInstallDir === null && name.startsWith('@deepseek-ai/'))) {
752
775
  layer.error = null;
753
776
  layer.unresolvedInbox = true;
754
777
  return layer;
@@ -963,6 +986,15 @@ export function analyzeProfile(profileDirectory, options = {}) {
963
986
  for (const { row, packageName } of candidates.values()) {
964
987
  if (profilePackageInstalled(profileDirectory, packageName))
965
988
  continue;
989
+ // Official Electron keeps built-in packages beside app.asar, not inside
990
+ // the writable profile. The loader can resolve them from this anchor.
991
+ if (dshInstall !== null && core.has(packageName)
992
+ && resolvePackageDir(join(dshInstall, 'package.json'), packageName) !== null)
993
+ continue;
994
+ if (isPackagedDesktopInstall(dshInstall) && DESKTOP_HOST_LOADERS.has(packageName)) {
995
+ warnings.push(`${row.layer}: bundled Desktop loader ${packageName} could not be independently resolved from app.asar`);
996
+ continue;
997
+ }
966
998
  const message = `${row.layer}: loader package ${packageName} is not installed in the profile`;
967
999
  if (row.activation === 'required')
968
1000
  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 === 'hoist-pattern-diff') {
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
+ }
@@ -313,6 +313,37 @@ 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
+ // #701: pnpm 12's native engine (pnpm-native) aborting on an allocation it
317
+ // cannot get — "memory allocation of 5368709120 bytes failed", Windows exit
318
+ // 3221226505 (0xC0000409, how a Rust abort ends there). Reported with an
319
+ // A/B: on pnpm 12.5.1 every run with `autoInstallPeers: false` in the
320
+ // workspace file (DSH writes it into every profile) aborted at ~5 GB peak
321
+ // RSS, every run without it passed at ~80 MB, with free memory to spare.
322
+ // Nothing about the plugin being installed; not something a retry of the
323
+ // same command changes on the reporter's data, so none is attempted.
324
+ if (/memory allocation of \d+ bytes failed/.test(output) || exitCode === 3221226505) {
325
+ return {
326
+ code: 'native-oom',
327
+ recoverable: false,
328
+ message: 'pnpm 12 的原生引擎在处理这个 profile 时耗尽了内存并中止——和要安装的插件无关。在 pnpm 修复之前,请改用 pnpm 11(npm install -g pnpm@11)后再试。 / pnpm 12\'s native engine ran out of memory on this profile and aborted — the plugin being installed is not the cause. Until pnpm fixes it, switch to pnpm 11 (npm install -g pnpm@11) and try again.',
329
+ };
330
+ }
331
+ // #698: pnpm 10.26+ and 11.0–11.5 read an allowBuilds key as
332
+ // `name@<version union>`, so a git or archive source there fails the WHOLE
333
+ // workspace file — every pnpm command in the profile, not the one plugin.
334
+ // Named separately from any install failure because the cure is in the
335
+ // profile's own pnpm-workspace.yaml, which withHoistRecovery repairs.
336
+ {
337
+ const found = /ERR_PNPM_INVALID_VERSION_UNION[\s\S]*?Found: \\?"([^"\\]+)\\?"/.exec(output);
338
+ if (found !== null && /@(?:git\+|https?:)/.test(found[1].slice(1))) {
339
+ return {
340
+ code: 'unparseable-build-key',
341
+ recoverable: false,
342
+ pkg: found[1],
343
+ 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`,
344
+ };
345
+ }
346
+ }
316
347
  if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
317
348
  return {
318
349
  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
- * Allow the given packages' build scripts in the profile's
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
- const GIT_KEY_RE = new RegExp(`^${NAME}@git\\+https://[A-Za-z0-9_.-]+(?::\\d{1,5})?/${SEG}(?:/${SEG})*\\.git(?:#${SHA})?$`);
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
- // Write back in the file's OWN line ending. Rewriting a CRLF workspace
1109
- // file with LF would leave it mixed, which is the same class of mess this
1110
- // fix exists to clean up.
1111
- const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
1112
- const block = Object.entries(map).map(([k, v]) => ` ${quoteYamlKey(k)}: ${v}`).join(eol);
1113
- const blockText = `allowBuilds:${eol}${block}${eol}`;
1114
- let next;
1115
- if (blockMatch === null) {
1116
- next = `${yaml.replace(/\r?\n?$/, eol)}${blockText}`;
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
- else {
1119
- // The merged block replaces the first occurrence; any further ones are
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
- writeFileSync(file, next);
1126
- return Object.keys(map);
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
  }