dshmarket 1.66.16 → 1.66.17

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/patch.js CHANGED
@@ -421,6 +421,37 @@ function escapeRegExp(value) {
421
421
  function rowBlock(rowId, disabled) {
422
422
  return `- id: ${rowId}\n disabled: ${disabled ? 'true' : 'false'}\n`;
423
423
  }
424
+ /**
425
+ * Every two-line `- id: X` + `disabled: …` block for one row, in file order
426
+ * (#821). A row that goes on with more keys (`config:` and the like) is not
427
+ * one of these blocks: cutting its first two lines would leave the rest
428
+ * hanging off the entry above it. The last block of a file may lack its
429
+ * newline.
430
+ */
431
+ function rowBlocksRe(rowId, disabled) {
432
+ const id = escapeRegExp(rowId);
433
+ const value = disabled === 'any' ? '(?:true|false)' : disabled;
434
+ return new RegExp(`^- id: (?:${id}|'${id}'|"${id}")[ \\t]*\\r?\\n disabled: ${value}[ \\t]*(?:\\r?\\n(?![ \\t])|(?![\\s\\S]))`, 'gmu');
435
+ }
436
+ /**
437
+ * What the patch file finally says about one row: the flag of its last
438
+ * `- id: X` entry that carries one, rows with more keys included. Later
439
+ * entries override earlier ones.
440
+ */
441
+ function lastRowFlag(text, rowId) {
442
+ const id = escapeRegExp(rowId);
443
+ const idLine = new RegExp(`^- id: (?:${id}|'${id}'|"${id}")\\s*$`, 'u');
444
+ const lines = text.split(/\r?\n/u);
445
+ let flag = null;
446
+ for (let index = 0; index < lines.length; index += 1) {
447
+ if (!idLine.test(lines[index] ?? ''))
448
+ continue;
449
+ const value = /^ {2}disabled: (true|false)\s*$/u.exec(lines[index + 1] ?? '');
450
+ if (value !== null)
451
+ flag = value[1];
452
+ }
453
+ return flag;
454
+ }
424
455
  /**
425
456
  * Append one top-level patch entry, refusing when the file is not a valid
426
457
  * entry list. The refusal is the point: a malformed patch layer (the
@@ -481,12 +512,48 @@ function appendPatchEntry(patchPath, block) {
481
512
  writeFileSync(patchPath, `${next}${block}`);
482
513
  return { ok: true, reason: null };
483
514
  }
484
- /** Disable one row: append `- id: X` + `disabled: true` (idempotent). */
515
+ /**
516
+ * Disable one row: `- id: X` + `disabled: true` (idempotent).
517
+ *
518
+ * A force-enable block already there is flipped in place rather than
519
+ * followed by a second block (#821): the pair cancelled out by order alone,
520
+ * and an uninstall that cleared one of them left the other behind to switch
521
+ * a later reinstall off. Duplicates an older version left are folded into
522
+ * the one block at the same time.
523
+ */
485
524
  export function disableRow(patchPath, rowId) {
486
525
  return queuedWrite(async () => {
487
526
  if (!ROW_ID_RE.test(rowId)) {
488
527
  return { ok: false, reason: `行 id 含特殊字符,不支持写入补丁层 / row id ${rowId} cannot be written to the patch layer` };
489
528
  }
529
+ const text = (() => {
530
+ try {
531
+ return readFileSync(patchPath, 'utf8');
532
+ }
533
+ catch {
534
+ return '';
535
+ }
536
+ })();
537
+ const blocks = text.match(rowBlocksRe(rowId, 'any')) ?? [];
538
+ if (blocks.length > 1 || blocks.some(block => /disabled: false/u.test(block))) {
539
+ let kept = false;
540
+ const next = text.replace(rowBlocksRe(rowId, 'any'), block => {
541
+ if (kept)
542
+ return '';
543
+ kept = true;
544
+ const flipped = rowBlock(rowId, true);
545
+ return block.includes('\r\n') ? flipped.replace(/\n/gu, '\r\n') : flipped;
546
+ });
547
+ writeFileSync(patchPath, next);
548
+ // A longer entry for the row further down still has the last word.
549
+ if (lastRowFlag(next, rowId) !== 'true') {
550
+ const result = appendPatchEntry(patchPath, rowBlock(rowId, true));
551
+ if (!result.ok)
552
+ return result;
553
+ }
554
+ logEvent('info', 'patch', `disabled row ${rowId} in ${patchPath}`);
555
+ return { ok: true, reason: null };
556
+ }
490
557
  const state = readUserPatchState(patchPath);
491
558
  if (state.disables.includes(rowId))
492
559
  return { ok: true, reason: null };
@@ -496,7 +563,7 @@ export function disableRow(patchPath, rowId) {
496
563
  return result;
497
564
  });
498
565
  }
499
- /** Enable one row: remove the `disabled: true` block; force-enable with
566
+ /** Enable one row: remove its `disabled: true` blocks; force-enable with
500
567
  * `disabled: false` when a lower layer (bundle/home patch) holds it down. */
501
568
  export function enableRow(patchPath, rowId) {
502
569
  return queuedWrite(async () => {
@@ -504,7 +571,6 @@ export function enableRow(patchPath, rowId) {
504
571
  return { ok: false, reason: `行 id 含特殊字符,不支持写入补丁层 / row id ${rowId} cannot be written to the patch layer` };
505
572
  }
506
573
  const state = readUserPatchState(patchPath);
507
- const blockRe = new RegExp(`^- id: ['\"]?${escapeRegExp(rowId)}['\"]?\\r?\\n disabled: true\\r?\\n`, 'mu');
508
574
  const text = (() => {
509
575
  try {
510
576
  return readFileSync(patchPath, 'utf8');
@@ -513,8 +579,9 @@ export function enableRow(patchPath, rowId) {
513
579
  return '';
514
580
  }
515
581
  })();
516
- if (blockRe.test(text)) {
517
- writeFileSync(patchPath, withPlaceholderRestored(text.replace(blockRe, '')));
582
+ const next = text.replace(rowBlocksRe(rowId, 'true'), '');
583
+ if (next !== text) {
584
+ writeFileSync(patchPath, withPlaceholderRestored(next));
518
585
  logEvent('info', 'patch', `enabled row ${rowId} in ${patchPath}`);
519
586
  return { ok: true, reason: null };
520
587
  }
@@ -555,10 +622,8 @@ export function removeRowBlocks(patchPath, rowIds) {
555
622
  return;
556
623
  }
557
624
  let next = text;
558
- for (const rowId of rowIds) {
559
- const blockRe = new RegExp(`^- id: ['\"]?${escapeRegExp(rowId)}['\"]?\\r?\\n disabled: (?:true|false)\\r?\\n`, 'mu');
560
- next = next.replace(blockRe, '');
561
- }
625
+ for (const rowId of rowIds)
626
+ next = next.replace(rowBlocksRe(rowId, 'any'), '');
562
627
  if (next !== text) {
563
628
  writeFileSync(patchPath, withPlaceholderRestored(next));
564
629
  logEvent('info', 'patch', `removed patch rows for ${rowIds.join(', ')}`);
package/lib/routes.js CHANGED
@@ -7,8 +7,8 @@
7
7
  * Security: the install route executes a shell command, so it accepts only
8
8
  * same-origin POSTs and only sources present in the curated registry.
9
9
  */
10
- import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
11
- import { join } from 'node:path';
10
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
12
  import { Readable } from 'node:stream';
13
13
  import { load as loadYaml } from 'js-yaml';
14
14
  import { forgetCatalog, loadRegistry, pluginCategories } from './registry.js';
@@ -30,7 +30,7 @@ import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validat
30
30
  import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.js';
31
31
  import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
32
32
  import { trialValidate } from './trial.js';
33
- import { catalogRepoKey, codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, sourceFallbackFor, workspaceProtocolDeps } from './sources.js';
33
+ import { catalogRepoKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedAllowBuildsKeys, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, sourceFallbackFor, workspaceProtocolDeps } from './sources.js';
34
34
  import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuildEntries, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
35
35
  import { classifyPnpmFailure } from './pnpm-compat.js';
36
36
  import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
@@ -41,6 +41,7 @@ import { updateNotesFor } from './changelog.js';
41
41
  import { checkUpdates, isUpdatablePlugin, compareVersions, fetchNpmLatest, invalidateUpdates, resolveGitRemoteHead, isUpgrade, latestPublishedRecently, setUpdateRegistry, versionOnChannel } from './updates.js';
42
42
  import { createThemeManager } from './themes.js';
43
43
  import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.js';
44
+ import { resolveDshHome } from './home-paths.js';
44
45
  import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, restartReachableFrom } from './restart.js';
45
46
  import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.js';
46
47
  import { carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
@@ -208,6 +209,52 @@ const PROFILE_HMR_REPLAY_WAIT_MS = 8000;
208
209
  * derived from the profile can answer during an update.
209
210
  */
210
211
  const printedBuildKeys = new Map();
212
+ // #784: pnpm's printed key is the most authoritative spelling of what the
213
+ // PENDING install needs allowlisted, and it used to live only in the map
214
+ // above — a DSH restart between the failure and the "allow build scripts"
215
+ // click dropped it, leaving the button to derive keys from the OLD installed
216
+ // pin and the retry to fail byte-identically. Persisted best-effort under the
217
+ // Harness home; every failure here degrades to the in-memory behavior.
218
+ //
219
+ // The path is resolved lazily (not at module load) so the home in force when
220
+ // the first failure is recorded is the one the file lands under. The load is
221
+ // also lazy — but every reader (blockedBuilds before it records, printedKeysFor
222
+ // before it answers) goes through printedBuildKeysFile() first, so a key from
223
+ // a previous process is on the map before this one either adds to or reads it.
224
+ // Stored keys never overwrite a fresher in-memory one, so the order of the
225
+ // two calls cannot resurrect a stale entry either way.
226
+ let printedBuildKeysHome = null;
227
+ let printedBuildKeysFilePath = null;
228
+ function printedBuildKeysFile() {
229
+ const home = resolveDshHome();
230
+ // Cached per home, not per process: the tests swap DSH_HOME per case, and
231
+ // a restart handoff can change it under a long-lived process too.
232
+ if (printedBuildKeysHome !== home) {
233
+ printedBuildKeysHome = home;
234
+ printedBuildKeysFilePath = join(home, 'dsh-market', 'printed-build-keys.json');
235
+ try {
236
+ const stored = JSON.parse(readFileSync(printedBuildKeysFilePath, 'utf8'));
237
+ for (const [storedName, storedKey] of Object.entries(stored)) {
238
+ if (typeof storedKey === 'string' && !printedBuildKeys.has(storedName))
239
+ printedBuildKeys.set(storedName, storedKey);
240
+ }
241
+ }
242
+ catch { /* absent or unreadable: start empty */ }
243
+ }
244
+ return printedBuildKeysFilePath;
245
+ }
246
+ function persistPrintedBuildKeys() {
247
+ try {
248
+ const file = printedBuildKeysFile();
249
+ // Bounded: one entry per package name that ever failed a build here.
250
+ while (printedBuildKeys.size > 500) {
251
+ printedBuildKeys.delete(printedBuildKeys.keys().next().value);
252
+ }
253
+ mkdirSync(dirname(file), { recursive: true });
254
+ writeFileSync(file, JSON.stringify(Object.fromEntries(printedBuildKeys), null, 2));
255
+ }
256
+ catch { /* best-effort: an unwritable home keeps the in-memory map */ }
257
+ }
211
258
  function blockedBuilds(result) {
212
259
  // Record pnpm's own dep path for every ignored build BEFORE deciding what to
213
260
  // report. Both returns below hand back BARE names, so a git plugin whose
@@ -218,11 +265,15 @@ function blockedBuilds(result) {
218
265
  // back byte-identical.
219
266
  //
220
267
  // Only entries that name a source are worth recording: a registry dep's dep
221
- // path IS its bare name, which the bare entry already authorizes.
268
+ // path IS its bare name, which the bare entry already authorizes. The
269
+ // persisted file is loaded FIRST (PR #822 review): a key recorded before
270
+ // the load ran would otherwise be replaced by the older one in the file.
271
+ printedBuildKeysFile();
222
272
  for (const entry of parseIgnoredBuildEntries(result.stdout, result.stderr)) {
223
273
  if (entry.key !== entry.name)
224
274
  printedBuildKeys.set(entry.name, entry.key);
225
275
  }
276
+ persistPrintedBuildKeys();
226
277
  if (Array.isArray(result.ignoredBuilds) && result.ignoredBuilds.length > 0)
227
278
  return result.ignoredBuilds;
228
279
  const list = parseIgnoredBuilds(result.stdout, result.stderr);
@@ -232,6 +283,7 @@ function blockedBuilds(result) {
232
283
  if (pending === null)
233
284
  return undefined;
234
285
  printedBuildKeys.set(pending, parsePrepareKey(result.stdout, result.stderr));
286
+ persistPrintedBuildKeys();
235
287
  return [pending];
236
288
  }
237
289
  /**
@@ -821,7 +873,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
821
873
  if (!back.ok)
822
874
  logEvent('warn', 'toggle', `${name}: patch row ${rowId} could not be put back — ${back.reason}`);
823
875
  }
824
- else {
876
+ else if (prePatch === null || !prePatch.forced.includes(rowId)) {
877
+ // A row already forced on before this call got nothing from
878
+ // the enable; that block is the user's to keep.
825
879
  removeRowBlocks(userPatchPath, [rowId]);
826
880
  }
827
881
  }
@@ -5160,10 +5214,14 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
5160
5214
  ?? await resolveGitRemoteHead(spec, gitRefOfTarget(spec) ?? undefined);
5161
5215
  if (pinned === null || pinned === undefined)
5162
5216
  return [stable];
5163
- const pinnedKey = repo !== null
5164
- ? codeloadAllowBuildsKey(name, spec, pinned)
5165
- : pinnedGitAllowBuildsKey(name, spec, pinned);
5166
- return pinnedKey === null ? [stable] : [stable, pinnedKey];
5217
+ // Both pinned key forms, for either fetch mode (#784): a codeload
5218
+ // download matches the codeload key (review-measured on 11.7.0,
5219
+ // empty store), while a git clone matches the commit-pinned
5220
+ // git+https key, in the remote's own spelling. Deriving only the
5221
+ // codeload form left a git-clone fetch with no key pnpm would
5222
+ // read.
5223
+ const pinnedKeys = pinnedAllowBuildsKeys(name, spec, pinned);
5224
+ return pinnedKeys.length === 0 ? [stable] : [stable, ...pinnedKeys];
5167
5225
  };
5168
5226
  /**
5169
5227
  * The allowBuilds key pnpm itself printed when it refused this
@@ -5178,6 +5236,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
5178
5236
  * naming the dep path it wants allowlisted.
5179
5237
  */
5180
5238
  const printedKeysFor = (name) => {
5239
+ // Load FIRST (PR #822 review): the in-memory map starts empty in a
5240
+ // new process, and the key pnpm printed before the restart lives
5241
+ // in the persisted file until this call brings it back.
5242
+ printedBuildKeysFile();
5181
5243
  const printed = printedBuildKeys.get(name);
5182
5244
  return printed === null || printed === undefined ? [] : [printed];
5183
5245
  };
package/lib/sources.js CHANGED
@@ -264,6 +264,21 @@ export function gitRefOfTarget(spec) {
264
264
  return null;
265
265
  return refOfFragment(spec);
266
266
  }
267
+ /**
268
+ * True when the spec's `#…` fragment carries a `semver:` range selector.
269
+ *
270
+ * A semver range is a line the user chose on purpose, like a branch: pnpm
271
+ * re-resolves inside that range, so a raw HEAD version ABOVE the range is not
272
+ * installable — asking it would advertise an update forever (#446 shape).
273
+ * `refOfFragment` deliberately answers null for those, so the update check
274
+ * needs this separate probe.
275
+ */
276
+ export function hasSemverSelector(spec) {
277
+ const fragmentAt = spec.indexOf('#');
278
+ if (fragmentAt === -1)
279
+ return false;
280
+ return spec.slice(fragmentAt + 1).split('&').some(s => s.startsWith('semver:'));
281
+ }
267
282
  /** The branch or tag a `#…` fragment selects, or null for the default branch. */
268
283
  function refOfFragment(spec) {
269
284
  const fragmentAt = spec.indexOf('#');
@@ -412,6 +427,74 @@ export function pinnedGitAllowBuildsKey(name, spec, sha) {
412
427
  const remote = gitRemoteSpelling(spec);
413
428
  return remote === null ? null : `${name}@${remote}#${sha}`;
414
429
  }
430
+ /**
431
+ * The repo (`owner/repo`, original case) of a GitHub remote spelled as a URL
432
+ * (`git+https://github.com/owner/repo.git`), or null for anything else.
433
+ *
434
+ * pnpm itself writes this spelling into the manifest for a GitHub source —
435
+ * the market sends `github:owner/repo`, pnpm answers with
436
+ * `git+https://owner/repo.git` (#803) — so the market must recognize it to
437
+ * keep deriving the GitHub keys for deps that only ever appear this way.
438
+ */
439
+ export function githubRepoOfRemote(spec) {
440
+ const remote = gitRemoteSpelling(spec);
441
+ if (remote === null)
442
+ return null;
443
+ const url = /^git\+https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/i.exec(remote);
444
+ if (url === null)
445
+ return null;
446
+ return url[1].endsWith('.git') ? url[1].slice(0, -4) : url[1];
447
+ }
448
+ /**
449
+ * Every commit-pinned allowBuilds key for one git source at one known commit
450
+ * (#784 by @sanqi-normal follow-up).
451
+ *
452
+ * Which pinned key pnpm checks depends on HOW it fetches the dependency,
453
+ * not on how the spec is spelled (review-measured on 11.7.0, empty store:
454
+ * a codeload download passes the codeload key and refuses the git+https#sha
455
+ * one; the git+https#sha refusal seen on dsh-better-sidebar came from a git
456
+ * clone fetch):
457
+ * - pnpm 11.21+ matches the stable unpinned clone-URL key (`gitAllowBuildsKey`).
458
+ * - a codeload download matches the commit-pinned codeload tarball URL.
459
+ * - a git clone matches the commit-pinned `name@git+https://…#<sha>` form,
460
+ * and only when it matches the remote's own spelling (`.git` or not,
461
+ * #665/#695 — pnpm matches the key literally).
462
+ *
463
+ * Writing ONE pinned form was the structural gap: a GitHub source derived
464
+ * only its codeload key, so a git-clone fetch ran with no key pnpm would
465
+ * read and only pnpm's own printed key could save the retry (#784's
466
+ * evidence). When the commit is known, BOTH pinned forms are written — one
467
+ * extra line in a YAML file versus the only button that could have
468
+ * unblocked the user.
469
+ *
470
+ * Non-GitHub remotes keep `pinnedGitAllowBuildsKey`'s single form: one key
471
+ * covers their fetch mode.
472
+ *
473
+ * @param sha - the commit the install will actually fetch.
474
+ * @returns the pinned keys (empty for no known git shape); callers merge the
475
+ * stable key from `gitAllowBuildsKey` alongside.
476
+ */
477
+ export function pinnedAllowBuildsKeys(name, spec, sha) {
478
+ if (!/^[0-9a-f]{40}$/.test(sha))
479
+ return [];
480
+ const githubRepo = repoFromTarget(spec)?.repo ?? githubRepoOfRemote(spec);
481
+ if (githubRepo !== null) {
482
+ // The git+https pinned form keeps the remote's own spelling (`.git` or
483
+ // not), which is what pnpm matches literally (#665/#695): reuse the
484
+ // stable key when one was derived, and only fall back to the canonical
485
+ // `.git` spelling when it was not.
486
+ const stable = gitAllowBuildsKey(name, spec);
487
+ const gitHttps = stable !== null && stable.startsWith(`${name}@git+https://`)
488
+ ? `${stable}#${sha}`
489
+ : `${name}@git+https://github.com/${githubRepo}.git#${sha}`;
490
+ return [
491
+ `${name}@https://codeload.github.com/${githubRepo}/tar.gz/${sha}`,
492
+ gitHttps,
493
+ ];
494
+ }
495
+ const pinned = pinnedGitAllowBuildsKey(name, spec, sha);
496
+ return pinned === null ? [] : [pinned];
497
+ }
415
498
  /**
416
499
  * The pnpm install target for a registry entry. Repo-verified npm packages
417
500
  * win, followed by author-supplied prebuilt GitHub Release tarballs; both avoid
@@ -137,12 +137,20 @@ export declare function packagePatchFlags(host: PatchHost, profileDirectory: str
137
137
  disabled: string[];
138
138
  forced: string[];
139
139
  };
140
- /** Disable one row: append `- id: X` + `disabled: true` (idempotent). */
140
+ /**
141
+ * Disable one row: `- id: X` + `disabled: true` (idempotent).
142
+ *
143
+ * A force-enable block already there is flipped in place rather than
144
+ * followed by a second block (#821): the pair cancelled out by order alone,
145
+ * and an uninstall that cleared one of them left the other behind to switch
146
+ * a later reinstall off. Duplicates an older version left are folded into
147
+ * the one block at the same time.
148
+ */
141
149
  export declare function disableRow(patchPath: string, rowId: string): Promise<{
142
150
  ok: boolean;
143
151
  reason: string | null;
144
152
  }>;
145
- /** Enable one row: remove the `disabled: true` block; force-enable with
153
+ /** Enable one row: remove its `disabled: true` blocks; force-enable with
146
154
  * `disabled: false` when a lower layer (bundle/home patch) holds it down. */
147
155
  export declare function enableRow(patchPath: string, rowId: string): Promise<{
148
156
  ok: boolean;
@@ -103,6 +103,16 @@ export declare function githubRefOfTarget(spec: string): string | null;
103
103
  * never moves. That is #446, reached from a different direction.
104
104
  */
105
105
  export declare function gitRefOfTarget(spec: string): string | null;
106
+ /**
107
+ * True when the spec's `#…` fragment carries a `semver:` range selector.
108
+ *
109
+ * A semver range is a line the user chose on purpose, like a branch: pnpm
110
+ * re-resolves inside that range, so a raw HEAD version ABOVE the range is not
111
+ * installable — asking it would advertise an update forever (#446 shape).
112
+ * `refOfFragment` deliberately answers null for those, so the update check
113
+ * needs this separate probe.
114
+ */
115
+ export declare function hasSemverSelector(spec: string): boolean;
106
116
  /**
107
117
  * An immutable GitHub commit already carried by an install target.
108
118
  *
@@ -171,6 +181,46 @@ export declare function codeloadAllowBuildsKey(name: string, spec: string, sha:
171
181
  * stable key and never instead of it.
172
182
  */
173
183
  export declare function pinnedGitAllowBuildsKey(name: string, spec: string, sha: string): string | null;
184
+ /**
185
+ * The repo (`owner/repo`, original case) of a GitHub remote spelled as a URL
186
+ * (`git+https://github.com/owner/repo.git`), or null for anything else.
187
+ *
188
+ * pnpm itself writes this spelling into the manifest for a GitHub source —
189
+ * the market sends `github:owner/repo`, pnpm answers with
190
+ * `git+https://owner/repo.git` (#803) — so the market must recognize it to
191
+ * keep deriving the GitHub keys for deps that only ever appear this way.
192
+ */
193
+ export declare function githubRepoOfRemote(spec: string): string | null;
194
+ /**
195
+ * Every commit-pinned allowBuilds key for one git source at one known commit
196
+ * (#784 by @sanqi-normal follow-up).
197
+ *
198
+ * Which pinned key pnpm checks depends on HOW it fetches the dependency,
199
+ * not on how the spec is spelled (review-measured on 11.7.0, empty store:
200
+ * a codeload download passes the codeload key and refuses the git+https#sha
201
+ * one; the git+https#sha refusal seen on dsh-better-sidebar came from a git
202
+ * clone fetch):
203
+ * - pnpm 11.21+ matches the stable unpinned clone-URL key (`gitAllowBuildsKey`).
204
+ * - a codeload download matches the commit-pinned codeload tarball URL.
205
+ * - a git clone matches the commit-pinned `name@git+https://…#<sha>` form,
206
+ * and only when it matches the remote's own spelling (`.git` or not,
207
+ * #665/#695 — pnpm matches the key literally).
208
+ *
209
+ * Writing ONE pinned form was the structural gap: a GitHub source derived
210
+ * only its codeload key, so a git-clone fetch ran with no key pnpm would
211
+ * read and only pnpm's own printed key could save the retry (#784's
212
+ * evidence). When the commit is known, BOTH pinned forms are written — one
213
+ * extra line in a YAML file versus the only button that could have
214
+ * unblocked the user.
215
+ *
216
+ * Non-GitHub remotes keep `pinnedGitAllowBuildsKey`'s single form: one key
217
+ * covers their fetch mode.
218
+ *
219
+ * @param sha - the commit the install will actually fetch.
220
+ * @returns the pinned keys (empty for no known git shape); callers merge the
221
+ * stable key from `gitAllowBuildsKey` alongside.
222
+ */
223
+ export declare function pinnedAllowBuildsKeys(name: string, spec: string, sha: string): string[];
174
224
  /**
175
225
  * The pnpm install target for a registry entry. Repo-verified npm packages
176
226
  * win, followed by author-supplied prebuilt GitHub Release tarballs; both avoid
package/lib/updates.js CHANGED
@@ -8,10 +8,10 @@ import { join } from 'node:path';
8
8
  import { DIST_TAG } from './channels.js';
9
9
  import { resolveHeadCommit } from './accelerate.js';
10
10
  import { marketFetch } from './net.js';
11
- import { activeRegion } from './regions.js';
11
+ import { activeRegion, githubRoutesFor, rememberGithubRoute, throughProxy } from './regions.js';
12
12
  import { hasDshManifest, profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits, readProfileBundles } from './profile.js';
13
13
  import { userPatchPackageReferences } from './patch.js';
14
- import { catalogRepoKey, gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, lookupRepoFromUrl, repoOfTarget } from './sources.js';
14
+ import { catalogRepoKey, gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hasSemverSelector, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, lookupRepoFromUrl, parseGitHubRemote, repoOfTarget } from './sources.js';
15
15
  /**
16
16
  * Whether a direct dependency is a plugin the update channel can apply (#793).
17
17
  *
@@ -295,6 +295,108 @@ export async function fetchNpmLatest(name) {
295
295
  return null;
296
296
  }
297
297
  }
298
+ /** How long one raw-file route may take before the next candidate is tried. */
299
+ const RAW_VERSION_TIMEOUT_MS = 6_000;
300
+ /**
301
+ * The GitHub repo a spec installs from, plus the subpath whose `package.json`
302
+ * describes the installed package (#824), or null when the spec is not a
303
+ * GitHub one.
304
+ *
305
+ * Only GitHub gets its version read over the network: `raw.githubusercontent.com`
306
+ * is the raw spelling this market already speaks (a plugin page's README, a
307
+ * restore's manifest) and the only one with a per-region escape route. A
308
+ * gitlab or gitee install keeps the commit comparison, because inventing
309
+ * another host's raw URL would buy a failed request instead of a verdict.
310
+ */
311
+ function githubRawTargetOf(spec) {
312
+ // `github:owner/repo` and `git+https://github.com/…` — the spelling the
313
+ // market itself writes for a floating install, which is the #824 case —
314
+ // both answer here, as does the codeload archive URL older regional
315
+ // installs carry. GitHub matches owner/repo case-insensitively, so the
316
+ // normalized casing is safe in a raw URL.
317
+ const repo = parseGitHubRemote(spec)?.repo.toLowerCase()
318
+ ?? repoOfTarget(spec)?.split('#')[0]
319
+ ?? null;
320
+ if (repo === null)
321
+ return null;
322
+ const hash = spec.indexOf('#');
323
+ const match = hash === -1 ? null : /(?:^|&)path:\/([^&\s]+)/.exec(spec.slice(hash + 1));
324
+ return { repo, subpath: match === null ? null : match[1] };
325
+ }
326
+ /**
327
+ * The `version` the repository publishes at the head of its default branch,
328
+ * or null when it cannot be read (#824).
329
+ *
330
+ * Route handling mirrors `resolveHeadCommit`: the region's candidates in
331
+ * order, the last verified winner first, and that winner remembered for the
332
+ * next check. Every failure — no candidate answers, a repo with no
333
+ * `package.json`, a body that is not JSON — is null, so the caller falls back
334
+ * to comparing commits rather than claiming the install is current.
335
+ */
336
+ async function remoteGitVersion(spec, region) {
337
+ const target = githubRawTargetOf(spec);
338
+ if (target === null)
339
+ return null;
340
+ const file = target.subpath === null ? 'package.json' : `${target.subpath}/package.json`;
341
+ const url = `https://raw.githubusercontent.com/${target.repo}/HEAD/${file}`;
342
+ for (const route of githubRoutesFor('raw', region)) {
343
+ try {
344
+ const res = await marketFetch(throughProxy(route, url), {
345
+ headers: { accept: 'application/json', 'user-agent': 'dsh-market' },
346
+ signal: AbortSignal.timeout(RAW_VERSION_TIMEOUT_MS),
347
+ });
348
+ if (!res.ok)
349
+ continue;
350
+ const doc = await res.json();
351
+ const version = typeof doc.version === 'string' ? doc.version.trim() : '';
352
+ if (version === '')
353
+ continue;
354
+ rememberGithubRoute('raw', route);
355
+ return version;
356
+ }
357
+ catch {
358
+ // This route did not answer; the next candidate may.
359
+ }
360
+ }
361
+ return null;
362
+ }
363
+ /**
364
+ * Whether the repository's own `version` says there is something to install,
365
+ * for a git install that follows whatever the default branch points at (#824).
366
+ *
367
+ * A floating git install has no release boundary: every commit the branch
368
+ * gains makes the commit comparison unequal, so the row advertised "有新版本"
369
+ * forever while pnpm would install the same version the plugin's own updater
370
+ * had already put on disk. That is the mirror image of #446/#597 — those
371
+ * compared commits against a ref the user never chose; this one degrades the
372
+ * ref the user did choose, where "the line moved" is not the same question as
373
+ * "there is something new to install".
374
+ *
375
+ * @returns true/false when both versions are readable and comparable, and
376
+ * null when the question cannot be answered that way: no installed version,
377
+ * a spec that names a branch or tag on purpose (that line IS the user's
378
+ * choice), a non-GitHub host, an unreachable raw file, or a version on
379
+ * either side that is not semver. On null the caller must compare commits —
380
+ * never read null as "current", or a private host would stop reporting
381
+ * updates altogether.
382
+ */
383
+ async function gitVersionUpdate(spec, installedVersion, region) {
384
+ if (installedVersion === null)
385
+ return null;
386
+ // `#branch`, `#tag`, and `#semver:` installs chose a line of their own: a
387
+ // new commit on it is exactly what those users asked to hear about (#446).
388
+ // `refOfFragment` skips `semver:` ranges (they compare against the default
389
+ // branch), so they need their own probe — otherwise a range pinned at ^2
390
+ // with a 3.0.0 at HEAD would advertise an install the range cannot produce.
391
+ const explicitRef = spec.startsWith('github:') ? githubRefOfTarget(spec) : gitRefOfTarget(spec);
392
+ if (explicitRef !== null || hasSemverSelector(spec))
393
+ return null;
394
+ const remoteVersion = await remoteGitVersion(spec, region);
395
+ if (remoteVersion === null)
396
+ return null;
397
+ const compared = compareVersions(remoteVersion, installedVersion);
398
+ return compared === null ? null : compared > 0;
399
+ }
298
400
  /** Per-plugin update checks; a failed check reports no update rather than failing the listing. */
299
401
  export async function checkUpdates(profile, force = false, explicitDir,
300
402
  /**
@@ -397,9 +499,14 @@ catalogNpmByRepo = new Map()) {
397
499
  // default branch for a `#branch` install compares two lines that
398
500
  // never converge, so the row reported an update forever (#446).
399
501
  const latest = await resolveHeadCommit(repo, activeRegion(), process.env, githubRefOfTarget(spec) ?? undefined);
502
+ // A floating install has no release boundary: the commit comparison
503
+ // alone advertised "有新版本" forever on a branch that moved without
504
+ // publishing (#824). The repository's own version decides when both
505
+ // sides are readable; commits decide everything else.
506
+ const byVersion = await gitVersionUpdate(spec, version, activeRegion());
400
507
  result[name] = {
401
508
  kind: 'github', version, current, latest,
402
- updateAvailable: current !== null && latest !== null && current !== latest,
509
+ updateAvailable: byVersion ?? (current !== null && latest !== null && current !== latest),
403
510
  };
404
511
  }
405
512
  else if (isGitHostedSpec(spec)) {
@@ -419,9 +526,12 @@ catalogNpmByRepo = new Map()) {
419
526
  // Ask about the ref the install actually names, exactly as the
420
527
  // github branch above does (#446).
421
528
  const latest = await resolveGitRemoteHead(spec, gitRefOfTarget(spec) ?? undefined);
529
+ // Same rule as the github branch above (#824): the repository's own
530
+ // version decides when both sides are readable, commits otherwise.
531
+ const byVersion = await gitVersionUpdate(spec, version, activeRegion());
422
532
  result[name] = {
423
533
  kind: 'github', version, current, latest,
424
- updateAvailable: current !== null && latest !== null && current !== latest,
534
+ updateAvailable: byVersion ?? (current !== null && latest !== null && current !== latest),
425
535
  };
426
536
  }
427
537
  else {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dshmarket",
3
3
  "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
4
- "version": "1.66.16",
4
+ "version": "1.66.17",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
package/src/patch.ts CHANGED
@@ -428,6 +428,37 @@ function rowBlock(rowId: string, disabled: boolean): string {
428
428
  return `- id: ${rowId}\n disabled: ${disabled ? 'true' : 'false'}\n`
429
429
  }
430
430
 
431
+ /**
432
+ * Every two-line `- id: X` + `disabled: …` block for one row, in file order
433
+ * (#821). A row that goes on with more keys (`config:` and the like) is not
434
+ * one of these blocks: cutting its first two lines would leave the rest
435
+ * hanging off the entry above it. The last block of a file may lack its
436
+ * newline.
437
+ */
438
+ function rowBlocksRe(rowId: string, disabled: 'true' | 'false' | 'any'): RegExp {
439
+ const id = escapeRegExp(rowId)
440
+ const value = disabled === 'any' ? '(?:true|false)' : disabled
441
+ return new RegExp(`^- id: (?:${id}|'${id}'|"${id}")[ \\t]*\\r?\\n disabled: ${value}[ \\t]*(?:\\r?\\n(?![ \\t])|(?![\\s\\S]))`, 'gmu')
442
+ }
443
+
444
+ /**
445
+ * What the patch file finally says about one row: the flag of its last
446
+ * `- id: X` entry that carries one, rows with more keys included. Later
447
+ * entries override earlier ones.
448
+ */
449
+ function lastRowFlag(text: string, rowId: string): 'true' | 'false' | null {
450
+ const id = escapeRegExp(rowId)
451
+ const idLine = new RegExp(`^- id: (?:${id}|'${id}'|"${id}")\\s*$`, 'u')
452
+ const lines = text.split(/\r?\n/u)
453
+ let flag: 'true' | 'false' | null = null
454
+ for (let index = 0; index < lines.length; index += 1) {
455
+ if (!idLine.test(lines[index] ?? '')) continue
456
+ const value = /^ {2}disabled: (true|false)\s*$/u.exec(lines[index + 1] ?? '')
457
+ if (value !== null) flag = value[1] as 'true' | 'false'
458
+ }
459
+ return flag
460
+ }
461
+
431
462
  /**
432
463
  * Append one top-level patch entry, refusing when the file is not a valid
433
464
  * entry list. The refusal is the point: a malformed patch layer (the
@@ -488,12 +519,41 @@ function appendPatchEntry(patchPath: string, block: string): { ok: boolean; reas
488
519
  return { ok: true, reason: null }
489
520
  }
490
521
 
491
- /** Disable one row: append `- id: X` + `disabled: true` (idempotent). */
522
+ /**
523
+ * Disable one row: `- id: X` + `disabled: true` (idempotent).
524
+ *
525
+ * A force-enable block already there is flipped in place rather than
526
+ * followed by a second block (#821): the pair cancelled out by order alone,
527
+ * and an uninstall that cleared one of them left the other behind to switch
528
+ * a later reinstall off. Duplicates an older version left are folded into
529
+ * the one block at the same time.
530
+ */
492
531
  export function disableRow(patchPath: string, rowId: string): Promise<{ ok: boolean; reason: string | null }> {
493
532
  return queuedWrite(async () => {
494
533
  if (!ROW_ID_RE.test(rowId)) {
495
534
  return { ok: false, reason: `行 id 含特殊字符,不支持写入补丁层 / row id ${rowId} cannot be written to the patch layer` }
496
535
  }
536
+ const text = (() => {
537
+ try { return readFileSync(patchPath, 'utf8') } catch { return '' }
538
+ })()
539
+ const blocks = text.match(rowBlocksRe(rowId, 'any')) ?? []
540
+ if (blocks.length > 1 || blocks.some(block => /disabled: false/u.test(block))) {
541
+ let kept = false
542
+ const next = text.replace(rowBlocksRe(rowId, 'any'), block => {
543
+ if (kept) return ''
544
+ kept = true
545
+ const flipped = rowBlock(rowId, true)
546
+ return block.includes('\r\n') ? flipped.replace(/\n/gu, '\r\n') : flipped
547
+ })
548
+ writeFileSync(patchPath, next)
549
+ // A longer entry for the row further down still has the last word.
550
+ if (lastRowFlag(next, rowId) !== 'true') {
551
+ const result = appendPatchEntry(patchPath, rowBlock(rowId, true))
552
+ if (!result.ok) return result
553
+ }
554
+ logEvent('info', 'patch', `disabled row ${rowId} in ${patchPath}`)
555
+ return { ok: true, reason: null }
556
+ }
497
557
  const state = readUserPatchState(patchPath)
498
558
  if (state.disables.includes(rowId)) return { ok: true, reason: null }
499
559
  const result = appendPatchEntry(patchPath, rowBlock(rowId, true))
@@ -502,7 +562,7 @@ export function disableRow(patchPath: string, rowId: string): Promise<{ ok: bool
502
562
  })
503
563
  }
504
564
 
505
- /** Enable one row: remove the `disabled: true` block; force-enable with
565
+ /** Enable one row: remove its `disabled: true` blocks; force-enable with
506
566
  * `disabled: false` when a lower layer (bundle/home patch) holds it down. */
507
567
  export function enableRow(patchPath: string, rowId: string): Promise<{ ok: boolean; reason: string | null }> {
508
568
  return queuedWrite(async () => {
@@ -510,12 +570,12 @@ export function enableRow(patchPath: string, rowId: string): Promise<{ ok: boole
510
570
  return { ok: false, reason: `行 id 含特殊字符,不支持写入补丁层 / row id ${rowId} cannot be written to the patch layer` }
511
571
  }
512
572
  const state = readUserPatchState(patchPath)
513
- const blockRe = new RegExp(`^- id: ['\"]?${escapeRegExp(rowId)}['\"]?\\r?\\n disabled: true\\r?\\n`, 'mu')
514
573
  const text = (() => {
515
574
  try { return readFileSync(patchPath, 'utf8') } catch { return '' }
516
575
  })()
517
- if (blockRe.test(text)) {
518
- writeFileSync(patchPath, withPlaceholderRestored(text.replace(blockRe, '')))
576
+ const next = text.replace(rowBlocksRe(rowId, 'true'), '')
577
+ if (next !== text) {
578
+ writeFileSync(patchPath, withPlaceholderRestored(next))
519
579
  logEvent('info', 'patch', `enabled row ${rowId} in ${patchPath}`)
520
580
  return { ok: true, reason: null }
521
581
  }
@@ -551,10 +611,7 @@ export function removeRowBlocks(patchPath: string, rowIds: readonly string[]): v
551
611
  text = readFileSync(patchPath, 'utf8')
552
612
  } catch { return }
553
613
  let next = text
554
- for (const rowId of rowIds) {
555
- const blockRe = new RegExp(`^- id: ['\"]?${escapeRegExp(rowId)}['\"]?\\r?\\n disabled: (?:true|false)\\r?\\n`, 'mu')
556
- next = next.replace(blockRe, '')
557
- }
614
+ for (const rowId of rowIds) next = next.replace(rowBlocksRe(rowId, 'any'), '')
558
615
  if (next !== text) {
559
616
  writeFileSync(patchPath, withPlaceholderRestored(next))
560
617
  logEvent('info', 'patch', `removed patch rows for ${rowIds.join(', ')}`)
package/src/routes.ts CHANGED
@@ -8,8 +8,8 @@
8
8
  * same-origin POSTs and only sources present in the curated registry.
9
9
  */
10
10
 
11
- import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
12
- import { join } from 'node:path'
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { dirname, join } from 'node:path'
13
13
  import { Readable } from 'node:stream'
14
14
  import type { IncomingMessage, ServerResponse } from 'node:http'
15
15
  import { load as loadYaml } from 'js-yaml'
@@ -39,7 +39,7 @@ import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validat
39
39
  import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.ts'
40
40
  import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.ts'
41
41
  import { trialValidate } from './trial.ts'
42
- import { catalogRepoKey, codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, sourceFallbackFor, workspaceProtocolDeps } from './sources.ts'
42
+ import { catalogRepoKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedAllowBuildsKeys, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, sourceFallbackFor, workspaceProtocolDeps } from './sources.ts'
43
43
  import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuildEntries, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
44
44
  import { classifyPnpmFailure } from './pnpm-compat.ts'
45
45
  import { asChannel, CHANNELS, DIST_TAG, resolveChannel, type Channel } from './channels.ts'
@@ -53,6 +53,7 @@ import { updateNotesFor } from './changelog.ts'
53
53
  import { checkUpdates, isUpdatablePlugin, compareVersions, fetchNpmLatest, invalidateUpdates, resolveGitRemoteHead, isUpgrade, latestPublishedRecently, setUpdateRegistry, versionOnChannel } from './updates.ts'
54
54
  import { createThemeManager, type LoaderEntry } from './themes.ts'
55
55
  import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.ts'
56
+ import { resolveDshHome } from './home-paths.ts'
56
57
  import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, type RecoveryHandoffConfig, restartReachableFrom } from './restart.ts'
57
58
  import type { RecoveryPlugin } from './recovery.ts'
58
59
  import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
@@ -331,6 +332,51 @@ const PROFILE_HMR_REPLAY_WAIT_MS = 8000
331
332
  */
332
333
  const printedBuildKeys = new Map<string, string | null>()
333
334
 
335
+ // #784: pnpm's printed key is the most authoritative spelling of what the
336
+ // PENDING install needs allowlisted, and it used to live only in the map
337
+ // above — a DSH restart between the failure and the "allow build scripts"
338
+ // click dropped it, leaving the button to derive keys from the OLD installed
339
+ // pin and the retry to fail byte-identically. Persisted best-effort under the
340
+ // Harness home; every failure here degrades to the in-memory behavior.
341
+ //
342
+ // The path is resolved lazily (not at module load) so the home in force when
343
+ // the first failure is recorded is the one the file lands under. The load is
344
+ // also lazy — but every reader (blockedBuilds before it records, printedKeysFor
345
+ // before it answers) goes through printedBuildKeysFile() first, so a key from
346
+ // a previous process is on the map before this one either adds to or reads it.
347
+ // Stored keys never overwrite a fresher in-memory one, so the order of the
348
+ // two calls cannot resurrect a stale entry either way.
349
+ let printedBuildKeysHome: string | null = null
350
+ let printedBuildKeysFilePath: string | null = null
351
+ function printedBuildKeysFile(): string {
352
+ const home = resolveDshHome()
353
+ // Cached per home, not per process: the tests swap DSH_HOME per case, and
354
+ // a restart handoff can change it under a long-lived process too.
355
+ if (printedBuildKeysHome !== home) {
356
+ printedBuildKeysHome = home
357
+ printedBuildKeysFilePath = join(home, 'dsh-market', 'printed-build-keys.json')
358
+ try {
359
+ const stored = JSON.parse(readFileSync(printedBuildKeysFilePath, 'utf8')) as Record<string, unknown>
360
+ for (const [storedName, storedKey] of Object.entries(stored)) {
361
+ if (typeof storedKey === 'string' && !printedBuildKeys.has(storedName)) printedBuildKeys.set(storedName, storedKey)
362
+ }
363
+ } catch { /* absent or unreadable: start empty */ }
364
+ }
365
+ return printedBuildKeysFilePath!
366
+ }
367
+
368
+ function persistPrintedBuildKeys(): void {
369
+ try {
370
+ const file = printedBuildKeysFile()
371
+ // Bounded: one entry per package name that ever failed a build here.
372
+ while (printedBuildKeys.size > 500) {
373
+ printedBuildKeys.delete(printedBuildKeys.keys().next().value as string)
374
+ }
375
+ mkdirSync(dirname(file), { recursive: true })
376
+ writeFileSync(file, JSON.stringify(Object.fromEntries(printedBuildKeys), null, 2))
377
+ } catch { /* best-effort: an unwritable home keeps the in-memory map */ }
378
+ }
379
+
334
380
  function blockedBuilds(result: { ignoredBuilds?: unknown; stdout: string; stderr: string }): string[] | undefined {
335
381
  // Record pnpm's own dep path for every ignored build BEFORE deciding what to
336
382
  // report. Both returns below hand back BARE names, so a git plugin whose
@@ -341,16 +387,21 @@ function blockedBuilds(result: { ignoredBuilds?: unknown; stdout: string; stderr
341
387
  // back byte-identical.
342
388
  //
343
389
  // Only entries that name a source are worth recording: a registry dep's dep
344
- // path IS its bare name, which the bare entry already authorizes.
390
+ // path IS its bare name, which the bare entry already authorizes. The
391
+ // persisted file is loaded FIRST (PR #822 review): a key recorded before
392
+ // the load ran would otherwise be replaced by the older one in the file.
393
+ printedBuildKeysFile()
345
394
  for (const entry of parseIgnoredBuildEntries(result.stdout, result.stderr)) {
346
395
  if (entry.key !== entry.name) printedBuildKeys.set(entry.name, entry.key)
347
396
  }
397
+ persistPrintedBuildKeys()
348
398
  if (Array.isArray(result.ignoredBuilds) && result.ignoredBuilds.length > 0) return result.ignoredBuilds as string[]
349
399
  const list = parseIgnoredBuilds(result.stdout, result.stderr)
350
400
  if (list.length > 0) return list
351
401
  const pending = parsePrepareNotAllowed(result.stdout, result.stderr)
352
402
  if (pending === null) return undefined
353
403
  printedBuildKeys.set(pending, parsePrepareKey(result.stdout, result.stderr))
404
+ persistPrintedBuildKeys()
354
405
  return [pending]
355
406
  }
356
407
 
@@ -940,7 +991,9 @@ export function mountMarketRoutes(
940
991
  if (prePatch !== null && prePatch.disables.includes(rowId)) {
941
992
  const back = await disableRow(userPatchPath, rowId)
942
993
  if (!back.ok) logEvent('warn', 'toggle', `${name}: patch row ${rowId} could not be put back — ${back.reason}`)
943
- } else {
994
+ } else if (prePatch === null || !prePatch.forced.includes(rowId)) {
995
+ // A row already forced on before this call got nothing from
996
+ // the enable; that block is the user's to keep.
944
997
  removeRowBlocks(userPatchPath, [rowId])
945
998
  }
946
999
  }
@@ -5386,10 +5439,14 @@ sendJson(response, 200, { updates })
5386
5439
  : gitCommitOfTarget(spec)
5387
5440
  ?? await resolveGitRemoteHead(spec, gitRefOfTarget(spec) ?? undefined)
5388
5441
  if (pinned === null || pinned === undefined) return [stable]
5389
- const pinnedKey = repo !== null
5390
- ? codeloadAllowBuildsKey(name, spec, pinned)
5391
- : pinnedGitAllowBuildsKey(name, spec, pinned)
5392
- return pinnedKey === null ? [stable] : [stable, pinnedKey]
5442
+ // Both pinned key forms, for either fetch mode (#784): a codeload
5443
+ // download matches the codeload key (review-measured on 11.7.0,
5444
+ // empty store), while a git clone matches the commit-pinned
5445
+ // git+https key, in the remote's own spelling. Deriving only the
5446
+ // codeload form left a git-clone fetch with no key pnpm would
5447
+ // read.
5448
+ const pinnedKeys = pinnedAllowBuildsKeys(name, spec, pinned)
5449
+ return pinnedKeys.length === 0 ? [stable] : [stable, ...pinnedKeys]
5393
5450
  }
5394
5451
  /**
5395
5452
  * The allowBuilds key pnpm itself printed when it refused this
@@ -5404,6 +5461,10 @@ sendJson(response, 200, { updates })
5404
5461
  * naming the dep path it wants allowlisted.
5405
5462
  */
5406
5463
  const printedKeysFor = (name: string): string[] => {
5464
+ // Load FIRST (PR #822 review): the in-memory map starts empty in a
5465
+ // new process, and the key pnpm printed before the restart lives
5466
+ // in the persisted file until this call brings it back.
5467
+ printedBuildKeysFile()
5407
5468
  const printed = printedBuildKeys.get(name)
5408
5469
  return printed === null || printed === undefined ? [] : [printed]
5409
5470
  }
package/src/sources.ts CHANGED
@@ -264,6 +264,21 @@ export function gitRefOfTarget(spec: string): string | null {
264
264
  return refOfFragment(spec)
265
265
  }
266
266
 
267
+ /**
268
+ * True when the spec's `#…` fragment carries a `semver:` range selector.
269
+ *
270
+ * A semver range is a line the user chose on purpose, like a branch: pnpm
271
+ * re-resolves inside that range, so a raw HEAD version ABOVE the range is not
272
+ * installable — asking it would advertise an update forever (#446 shape).
273
+ * `refOfFragment` deliberately answers null for those, so the update check
274
+ * needs this separate probe.
275
+ */
276
+ export function hasSemverSelector(spec: string): boolean {
277
+ const fragmentAt = spec.indexOf('#')
278
+ if (fragmentAt === -1) return false
279
+ return spec.slice(fragmentAt + 1).split('&').some(s => s.startsWith('semver:'))
280
+ }
281
+
267
282
  /** The branch or tag a `#…` fragment selects, or null for the default branch. */
268
283
  function refOfFragment(spec: string): string | null {
269
284
  const fragmentAt = spec.indexOf('#')
@@ -406,6 +421,73 @@ export function pinnedGitAllowBuildsKey(name: string, spec: string, sha: string)
406
421
  return remote === null ? null : `${name}@${remote}#${sha}`
407
422
  }
408
423
 
424
+ /**
425
+ * The repo (`owner/repo`, original case) of a GitHub remote spelled as a URL
426
+ * (`git+https://github.com/owner/repo.git`), or null for anything else.
427
+ *
428
+ * pnpm itself writes this spelling into the manifest for a GitHub source —
429
+ * the market sends `github:owner/repo`, pnpm answers with
430
+ * `git+https://owner/repo.git` (#803) — so the market must recognize it to
431
+ * keep deriving the GitHub keys for deps that only ever appear this way.
432
+ */
433
+ export function githubRepoOfRemote(spec: string): string | null {
434
+ const remote = gitRemoteSpelling(spec)
435
+ if (remote === null) return null
436
+ const url = /^git\+https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/i.exec(remote)
437
+ if (url === null) return null
438
+ return url[1]!.endsWith('.git') ? url[1]!.slice(0, -4) : url[1]!
439
+ }
440
+
441
+ /**
442
+ * Every commit-pinned allowBuilds key for one git source at one known commit
443
+ * (#784 by @sanqi-normal follow-up).
444
+ *
445
+ * Which pinned key pnpm checks depends on HOW it fetches the dependency,
446
+ * not on how the spec is spelled (review-measured on 11.7.0, empty store:
447
+ * a codeload download passes the codeload key and refuses the git+https#sha
448
+ * one; the git+https#sha refusal seen on dsh-better-sidebar came from a git
449
+ * clone fetch):
450
+ * - pnpm 11.21+ matches the stable unpinned clone-URL key (`gitAllowBuildsKey`).
451
+ * - a codeload download matches the commit-pinned codeload tarball URL.
452
+ * - a git clone matches the commit-pinned `name@git+https://…#<sha>` form,
453
+ * and only when it matches the remote's own spelling (`.git` or not,
454
+ * #665/#695 — pnpm matches the key literally).
455
+ *
456
+ * Writing ONE pinned form was the structural gap: a GitHub source derived
457
+ * only its codeload key, so a git-clone fetch ran with no key pnpm would
458
+ * read and only pnpm's own printed key could save the retry (#784's
459
+ * evidence). When the commit is known, BOTH pinned forms are written — one
460
+ * extra line in a YAML file versus the only button that could have
461
+ * unblocked the user.
462
+ *
463
+ * Non-GitHub remotes keep `pinnedGitAllowBuildsKey`'s single form: one key
464
+ * covers their fetch mode.
465
+ *
466
+ * @param sha - the commit the install will actually fetch.
467
+ * @returns the pinned keys (empty for no known git shape); callers merge the
468
+ * stable key from `gitAllowBuildsKey` alongside.
469
+ */
470
+ export function pinnedAllowBuildsKeys(name: string, spec: string, sha: string): string[] {
471
+ if (!/^[0-9a-f]{40}$/.test(sha)) return []
472
+ const githubRepo = repoFromTarget(spec)?.repo ?? githubRepoOfRemote(spec)
473
+ if (githubRepo !== null) {
474
+ // The git+https pinned form keeps the remote's own spelling (`.git` or
475
+ // not), which is what pnpm matches literally (#665/#695): reuse the
476
+ // stable key when one was derived, and only fall back to the canonical
477
+ // `.git` spelling when it was not.
478
+ const stable = gitAllowBuildsKey(name, spec)
479
+ const gitHttps = stable !== null && stable.startsWith(`${name}@git+https://`)
480
+ ? `${stable}#${sha}`
481
+ : `${name}@git+https://github.com/${githubRepo}.git#${sha}`
482
+ return [
483
+ `${name}@https://codeload.github.com/${githubRepo}/tar.gz/${sha}`,
484
+ gitHttps,
485
+ ]
486
+ }
487
+ const pinned = pinnedGitAllowBuildsKey(name, spec, sha)
488
+ return pinned === null ? [] : [pinned]
489
+ }
490
+
409
491
  /**
410
492
  * The pnpm install target for a registry entry. Repo-verified npm packages
411
493
  * win, followed by author-supplied prebuilt GitHub Release tarballs; both avoid
package/src/updates.ts CHANGED
@@ -9,10 +9,10 @@ import { join } from 'node:path'
9
9
  import { DIST_TAG, type Channel } from './channels.ts'
10
10
  import { resolveHeadCommit } from './accelerate.ts'
11
11
  import { marketFetch } from './net.ts'
12
- import { activeRegion } from './regions.ts'
12
+ import { activeRegion, githubRoutesFor, rememberGithubRoute, throughProxy, type Region } from './regions.ts'
13
13
  import { hasDshManifest, profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits, readProfileBundles } from './profile.ts'
14
14
  import { userPatchPackageReferences } from './patch.ts'
15
- import { catalogRepoKey, gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, lookupRepoFromUrl, repoOfTarget } from './sources.ts'
15
+ import { catalogRepoKey, gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hasSemverSelector, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, lookupRepoFromUrl, parseGitHubRemote, repoOfTarget } from './sources.ts'
16
16
 
17
17
  export interface UpdateStatus {
18
18
  /**
@@ -345,6 +345,108 @@ export async function fetchNpmLatest(name: string): Promise<string | null> {
345
345
  }
346
346
  }
347
347
 
348
+ /** How long one raw-file route may take before the next candidate is tried. */
349
+ const RAW_VERSION_TIMEOUT_MS = 6_000
350
+
351
+ /**
352
+ * The GitHub repo a spec installs from, plus the subpath whose `package.json`
353
+ * describes the installed package (#824), or null when the spec is not a
354
+ * GitHub one.
355
+ *
356
+ * Only GitHub gets its version read over the network: `raw.githubusercontent.com`
357
+ * is the raw spelling this market already speaks (a plugin page's README, a
358
+ * restore's manifest) and the only one with a per-region escape route. A
359
+ * gitlab or gitee install keeps the commit comparison, because inventing
360
+ * another host's raw URL would buy a failed request instead of a verdict.
361
+ */
362
+ function githubRawTargetOf(spec: string): { repo: string; subpath: string | null } | null {
363
+ // `github:owner/repo` and `git+https://github.com/…` — the spelling the
364
+ // market itself writes for a floating install, which is the #824 case —
365
+ // both answer here, as does the codeload archive URL older regional
366
+ // installs carry. GitHub matches owner/repo case-insensitively, so the
367
+ // normalized casing is safe in a raw URL.
368
+ const repo = parseGitHubRemote(spec)?.repo.toLowerCase()
369
+ ?? repoOfTarget(spec)?.split('#')[0]
370
+ ?? null
371
+ if (repo === null) return null
372
+ const hash = spec.indexOf('#')
373
+ const match = hash === -1 ? null : /(?:^|&)path:\/([^&\s]+)/.exec(spec.slice(hash + 1))
374
+ return { repo, subpath: match === null ? null : match[1]! }
375
+ }
376
+
377
+ /**
378
+ * The `version` the repository publishes at the head of its default branch,
379
+ * or null when it cannot be read (#824).
380
+ *
381
+ * Route handling mirrors `resolveHeadCommit`: the region's candidates in
382
+ * order, the last verified winner first, and that winner remembered for the
383
+ * next check. Every failure — no candidate answers, a repo with no
384
+ * `package.json`, a body that is not JSON — is null, so the caller falls back
385
+ * to comparing commits rather than claiming the install is current.
386
+ */
387
+ async function remoteGitVersion(spec: string, region: Region): Promise<string | null> {
388
+ const target = githubRawTargetOf(spec)
389
+ if (target === null) return null
390
+ const file = target.subpath === null ? 'package.json' : `${target.subpath}/package.json`
391
+ const url = `https://raw.githubusercontent.com/${target.repo}/HEAD/${file}`
392
+ for (const route of githubRoutesFor('raw', region)) {
393
+ try {
394
+ const res = await marketFetch(throughProxy(route, url), {
395
+ headers: { accept: 'application/json', 'user-agent': 'dsh-market' },
396
+ signal: AbortSignal.timeout(RAW_VERSION_TIMEOUT_MS),
397
+ })
398
+ if (!res.ok) continue
399
+ const doc = await res.json() as { version?: unknown }
400
+ const version = typeof doc.version === 'string' ? doc.version.trim() : ''
401
+ if (version === '') continue
402
+ rememberGithubRoute('raw', route)
403
+ return version
404
+ } catch {
405
+ // This route did not answer; the next candidate may.
406
+ }
407
+ }
408
+ return null
409
+ }
410
+
411
+ /**
412
+ * Whether the repository's own `version` says there is something to install,
413
+ * for a git install that follows whatever the default branch points at (#824).
414
+ *
415
+ * A floating git install has no release boundary: every commit the branch
416
+ * gains makes the commit comparison unequal, so the row advertised "有新版本"
417
+ * forever while pnpm would install the same version the plugin's own updater
418
+ * had already put on disk. That is the mirror image of #446/#597 — those
419
+ * compared commits against a ref the user never chose; this one degrades the
420
+ * ref the user did choose, where "the line moved" is not the same question as
421
+ * "there is something new to install".
422
+ *
423
+ * @returns true/false when both versions are readable and comparable, and
424
+ * null when the question cannot be answered that way: no installed version,
425
+ * a spec that names a branch or tag on purpose (that line IS the user's
426
+ * choice), a non-GitHub host, an unreachable raw file, or a version on
427
+ * either side that is not semver. On null the caller must compare commits —
428
+ * never read null as "current", or a private host would stop reporting
429
+ * updates altogether.
430
+ */
431
+ async function gitVersionUpdate(
432
+ spec: string,
433
+ installedVersion: string | null,
434
+ region: Region,
435
+ ): Promise<boolean | null> {
436
+ if (installedVersion === null) return null
437
+ // `#branch`, `#tag`, and `#semver:` installs chose a line of their own: a
438
+ // new commit on it is exactly what those users asked to hear about (#446).
439
+ // `refOfFragment` skips `semver:` ranges (they compare against the default
440
+ // branch), so they need their own probe — otherwise a range pinned at ^2
441
+ // with a 3.0.0 at HEAD would advertise an install the range cannot produce.
442
+ const explicitRef = spec.startsWith('github:') ? githubRefOfTarget(spec) : gitRefOfTarget(spec)
443
+ if (explicitRef !== null || hasSemverSelector(spec)) return null
444
+ const remoteVersion = await remoteGitVersion(spec, region)
445
+ if (remoteVersion === null) return null
446
+ const compared = compareVersions(remoteVersion, installedVersion)
447
+ return compared === null ? null : compared > 0
448
+ }
449
+
348
450
  /** Per-plugin update checks; a failed check reports no update rather than failing the listing. */
349
451
  export async function checkUpdates(
350
452
  profile: string,
@@ -453,9 +555,14 @@ export async function checkUpdates(
453
555
  const latest = await resolveHeadCommit(
454
556
  repo, activeRegion(), process.env, githubRefOfTarget(spec) ?? undefined,
455
557
  )
558
+ // A floating install has no release boundary: the commit comparison
559
+ // alone advertised "有新版本" forever on a branch that moved without
560
+ // publishing (#824). The repository's own version decides when both
561
+ // sides are readable; commits decide everything else.
562
+ const byVersion = await gitVersionUpdate(spec, version, activeRegion())
456
563
  result[name] = {
457
564
  kind: 'github', version, current, latest,
458
- updateAvailable: current !== null && latest !== null && current !== latest,
565
+ updateAvailable: byVersion ?? (current !== null && latest !== null && current !== latest),
459
566
  }
460
567
  } else if (isGitHostedSpec(spec)) {
461
568
  // Private / self-hosted git (Gitea, GitLab, raw git+https). Never ask
@@ -474,9 +581,12 @@ export async function checkUpdates(
474
581
  // Ask about the ref the install actually names, exactly as the
475
582
  // github branch above does (#446).
476
583
  const latest = await resolveGitRemoteHead(spec, gitRefOfTarget(spec) ?? undefined)
584
+ // Same rule as the github branch above (#824): the repository's own
585
+ // version decides when both sides are readable, commits otherwise.
586
+ const byVersion = await gitVersionUpdate(spec, version, activeRegion())
477
587
  result[name] = {
478
588
  kind: 'github', version, current, latest,
479
- updateAvailable: current !== null && latest !== null && current !== latest,
589
+ updateAvailable: byVersion ?? (current !== null && latest !== null && current !== latest),
480
590
  }
481
591
  } else {
482
592
  // A URL install (release archive, self-hosted tarball) has no