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 +74 -9
- package/lib/routes.js +71 -9
- package/lib/sources.js +83 -0
- package/lib/types/patch.d.ts +10 -2
- package/lib/types/sources.d.ts +50 -0
- package/lib/updates.js +114 -4
- package/package.json +1 -1
- package/src/patch.ts +66 -9
- package/src/routes.ts +70 -9
- package/src/sources.ts +82 -0
- package/src/updates.ts +114 -4
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
517
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
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
|
package/lib/types/patch.d.ts
CHANGED
|
@@ -137,12 +137,20 @@ export declare function packagePatchFlags(host: PatchHost, profileDirectory: str
|
|
|
137
137
|
disabled: string[];
|
|
138
138
|
forced: string[];
|
|
139
139
|
};
|
|
140
|
-
/**
|
|
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
|
|
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;
|
package/lib/types/sources.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
518
|
-
|
|
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,
|
|
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
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
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
|