dshmarket 1.57.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 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,9 +740,10 @@ 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,
746
+ patchPaths: [],
735
747
  error: null,
736
748
  entries: [],
737
749
  parseError: null,
@@ -747,7 +759,7 @@ export function buildBundleLayers(profileDirectory, bundleNames, specs, dshInsta
747
759
  // fatal verdict and rolled back a good update (#369) — while `dsh
748
760
  // --dump-config` on the same profile exited 0. Unknown has to read as
749
761
  // unknown; the profile's own bundles are still judged normally.
750
- if (INBOX_BUNDLES.has(name)) {
762
+ if (hostProvided) {
751
763
  layer.error = null;
752
764
  layer.unresolvedInbox = true;
753
765
  return layer;
@@ -763,23 +775,34 @@ export function buildBundleLayers(profileDirectory, bundleNames, specs, dshInsta
763
775
  layer.error = 'bundle package.json is unreadable';
764
776
  return layer;
765
777
  }
778
+ // A bundle may declare ONE patch file or a LIST of them: dsh 0.1.7's own
779
+ // `@deepseek-ai/dsh-web-app` ships five (a base patch plus four presets),
780
+ // and the official headless template includes that bundle — so requiring
781
+ // a string reported "the profile will fail to boot" for the DEFAULT
782
+ // layout, while `dsh --dump-config` composed it fine (#676).
766
783
  const declared = bundleManifest.dsh?.bundle?.patch;
767
- if (typeof declared !== 'string') {
784
+ const declaredList = typeof declared === 'string'
785
+ ? [declared]
786
+ : Array.isArray(declared) ? declared.filter((item) => typeof item === 'string') : [];
787
+ if (declaredList.length === 0) {
768
788
  layer.error = 'bundle declares no dsh.bundle.patch — the profile will fail to boot';
769
789
  return layer;
770
790
  }
771
- const patchPath = join(directory, declared);
772
- if (!existsSync(patchPath)) {
773
- layer.error = `declared patch ${declared} is missing — the profile will fail to boot`;
791
+ const missing = declaredList.find(relative => !existsSync(join(directory, relative)));
792
+ if (missing !== undefined) {
793
+ layer.error = `declared patch ${missing} is missing — the profile will fail to boot`;
774
794
  return layer;
775
795
  }
776
- layer.patchPath = patchPath;
777
- const patches = parsePatchFile(patchPath);
778
- if (patches === null) {
796
+ // The first declared file is the layer's patch for reporting; every one
797
+ // of them contributes entries, because the composer applies them all.
798
+ layer.patchPath = join(directory, declaredList[0]);
799
+ layer.patchPaths = declaredList.map(relative => join(directory, relative));
800
+ const parsed = declaredList.map(relative => parsePatchFile(join(directory, relative)));
801
+ if (parsed.some(patches => patches === null)) {
779
802
  layer.parseError = 'patch file is not a valid entry list';
780
803
  return layer;
781
804
  }
782
- layer.entries = collectInsertIds(patches);
805
+ layer.entries = parsed.flatMap(patches => collectInsertIds(patches));
783
806
  const order = bundleManifest.dsh?.bundle?.order;
784
807
  if (order !== null && typeof order === 'object' && !Array.isArray(order)) {
785
808
  const listOf = (value) => Array.isArray(value)
@@ -796,7 +819,12 @@ export function buildBundleLayers(profileDirectory, bundleNames, specs, dshInsta
796
819
  const layers = bundles.map((bundle) => ({
797
820
  label: bundle.name,
798
821
  kind: 'bundle',
799
- patches: bundle.patchPath !== null && bundle.parseError === null ? parsePatchFile(bundle.patchPath) ?? [] : [],
822
+ // EVERY declared file, not the first: the composer applies them all, and
823
+ // a composition built from one file would miss the preset rows — so a
824
+ // duplicate id or an orphan in a second file was invisible here (#688).
825
+ patches: bundle.parseError === null
826
+ ? bundle.patchPaths.flatMap(path => parsePatchFile(path) ?? [])
827
+ : [],
800
828
  parseError: bundle.parseError,
801
829
  }));
802
830
  return { bundles, layers };
@@ -946,6 +974,15 @@ export function analyzeProfile(profileDirectory, options = {}) {
946
974
  for (const { row, packageName } of candidates.values()) {
947
975
  if (profilePackageInstalled(profileDirectory, packageName))
948
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
+ }
949
986
  const message = `${row.layer}: loader package ${packageName} is not installed in the profile`;
950
987
  if (row.activation === 'required')
951
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 === '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,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
- * 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
  }