dshmarket 1.70.2 → 1.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/client.js +272 -275
- package/lib/catalog-npm.js +35 -0
- package/lib/hot.js +5 -1
- package/lib/install.js +11 -9
- package/lib/registry.js +39 -1
- package/lib/release-candidates.js +101 -0
- package/lib/routes.js +340 -45
- package/lib/source-migration.js +111 -1
- package/lib/types/catalog-npm.d.ts +15 -0
- package/lib/types/install.d.ts +1 -0
- package/lib/types/registry.d.ts +27 -0
- package/lib/types/release-candidates.d.ts +26 -0
- package/lib/types/source-migration.d.ts +89 -0
- package/lib/types/themes.d.ts +6 -0
- package/lib/types/updates.d.ts +25 -9
- package/lib/types/verify.d.ts +9 -2
- package/lib/updates.js +189 -21
- package/lib/verify.js +21 -1
- package/package.json +1 -1
- package/src/catalog-npm.ts +32 -0
- package/src/client/Market.module.css +7 -36
- package/src/client/MarketSection.tsx +189 -191
- package/src/client/locales.ts +20 -0
- package/src/client/market-data.ts +18 -7
- package/src/client/operations.ts +16 -5
- package/src/hot.ts +6 -1
- package/src/install.ts +12 -10
- package/src/registry.ts +54 -1
- package/src/release-candidates.ts +115 -0
- package/src/routes.ts +342 -44
- package/src/source-migration.ts +135 -1
- package/src/themes.ts +6 -0
- package/src/updates.ts +176 -28
- package/src/verify.ts +21 -1
package/lib/catalog-npm.js
CHANGED
|
@@ -109,3 +109,38 @@ file = 'package/plugins.json') {
|
|
|
109
109
|
throw new Error(`${pkg}@${version} carries no ${file}`);
|
|
110
110
|
return { version, data: JSON.parse(bytes.toString('utf8')) };
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* One file out of a package's newest tarball, or null when it cannot be read
|
|
114
|
+
* (#1012).
|
|
115
|
+
*
|
|
116
|
+
* The install route asks a package what it declares before it installs it: a
|
|
117
|
+
* rename can only be detected from the rows the artifact carries, not from
|
|
118
|
+
* the name it was published under. A failed read is not a verdict — the
|
|
119
|
+
* caller treats null as "not declared", because a registry hiccup must never
|
|
120
|
+
* become a refusal to install.
|
|
121
|
+
*
|
|
122
|
+
* @param registry - registry base, no trailing slash.
|
|
123
|
+
* @param pkg - package name.
|
|
124
|
+
* @param file - exact entry name inside the tarball (`package/cordis.patch.yml`).
|
|
125
|
+
*/
|
|
126
|
+
export async function packageFileFromRegistry(registry, pkg, file) {
|
|
127
|
+
try {
|
|
128
|
+
const metaRes = await marketFetch(`${registry}/${encodeURIComponent(pkg)}/latest`, {
|
|
129
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
130
|
+
headers: { accept: 'application/json', 'user-agent': 'dsh-market' },
|
|
131
|
+
});
|
|
132
|
+
if (!metaRes.ok)
|
|
133
|
+
return null;
|
|
134
|
+
const meta = (await metaRes.json());
|
|
135
|
+
const tarball = typeof meta.dist?.tarball === 'string' ? meta.dist.tarball : null;
|
|
136
|
+
if (tarball === null)
|
|
137
|
+
return null;
|
|
138
|
+
const tarRes = await marketFetch(tarball, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
139
|
+
if (!tarRes.ok)
|
|
140
|
+
return null;
|
|
141
|
+
return fileFromTarball(Buffer.from(await tarRes.arrayBuffer()), file);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
package/lib/hot.js
CHANGED
|
@@ -80,6 +80,10 @@ export function resolveProfileEntry(profileDir, name) {
|
|
|
80
80
|
return name;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
+
// File URLs may contain apostrophes; YAML single-quoted scalars escape them by doubling.
|
|
84
|
+
function yamlSingleQuoted(value) {
|
|
85
|
+
return value.replace(/'/g, "''");
|
|
86
|
+
}
|
|
83
87
|
const HOT_DIR = '.dsh-market';
|
|
84
88
|
/**
|
|
85
89
|
* Ceiling for one hot-mount activation, env-overridable like the install
|
|
@@ -683,7 +687,7 @@ export async function hotMount(ctx, profileDir, packageName) {
|
|
|
683
687
|
// `cleanHotDir` wipes it on every boot and the bundle layer owns
|
|
684
688
|
// persistence, so nothing reads these files back.
|
|
685
689
|
const yml = rows
|
|
686
|
-
.map(row => `- id: 'mkt-${row.id}'\n name: '${resolveProfileEntry(profileDir, row.name)}'\n`)
|
|
690
|
+
.map(row => `- id: '${yamlSingleQuoted(`mkt-${row.id}`)}'\n name: '${yamlSingleQuoted(resolveProfileEntry(profileDir, row.name))}'\n`)
|
|
687
691
|
.join('');
|
|
688
692
|
writeFileSync(file, yml);
|
|
689
693
|
const handle = ctx.plugin(HotTree, { path: pathToFileURL(file).href });
|
package/lib/install.js
CHANGED
|
@@ -119,6 +119,9 @@ export function isUnpublishedHostPeer(pkg, profile, explicitDir) {
|
|
|
119
119
|
*/
|
|
120
120
|
export async function withHoistRecovery(run, profile, pluginArgs, profileDirectory, options = {}) {
|
|
121
121
|
const marketFlags = options.marketFlags !== false;
|
|
122
|
+
// An already verified mature update must not rewrite the user's workspace
|
|
123
|
+
// policy as a side effect of market-level recovery (#991).
|
|
124
|
+
const repairProfilePolicy = options.preserveProfilePolicy !== true;
|
|
122
125
|
/** The option a recovery step needed and this host does not accept (#732). */
|
|
123
126
|
let unavailableOption = null;
|
|
124
127
|
// Before the FIRST run, not after a failure: the shadowed duplicate rule
|
|
@@ -127,7 +130,7 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
127
130
|
// small file read. Every verb that resolves the graph is covered, not just
|
|
128
131
|
// add and remove: an `install` or an in-place `update` reads the same key.
|
|
129
132
|
const verb = pluginArgs.find(argument => !argument.startsWith('-'));
|
|
130
|
-
if (verb === 'add' || verb === 'remove' || verb === 'install' || verb === 'update') {
|
|
133
|
+
if (repairProfilePolicy && (verb === 'add' || verb === 'remove' || verb === 'install' || verb === 'update')) {
|
|
131
134
|
const merged = mergeDuplicateReleaseAgeExcludes(profile, profileDirectory);
|
|
132
135
|
if (merged.length > 0) {
|
|
133
136
|
logEvent('warn', 'install', `minimumReleaseAgeExclude held several rules for ${merged.join(', ')}, and pnpm honours only the FIRST per name (#732) — merged each package's rules into one, before running`);
|
|
@@ -145,7 +148,7 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
145
148
|
// appended stayed dead (@Mlte0907, 1.66.7). The merge is a no-op when
|
|
146
149
|
// nothing is duplicated, so asking it costs one file read.
|
|
147
150
|
let repairedDuringRun = false;
|
|
148
|
-
if (!ok(result) && !result.cancelled && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')) {
|
|
151
|
+
if (repairProfilePolicy && !ok(result) && !result.cancelled && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')) {
|
|
149
152
|
const mergedNow = mergeDuplicateReleaseAgeExcludes(profile, profileDirectory);
|
|
150
153
|
if (mergedNow.length > 0) {
|
|
151
154
|
repairedDuringRun = true;
|
|
@@ -155,7 +158,7 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
155
158
|
}
|
|
156
159
|
if (!ok(result) && !result.cancelled && !repairedDuringRun) {
|
|
157
160
|
const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode);
|
|
158
|
-
if (failure?.code === 'unparseable-build-key') {
|
|
161
|
+
if (failure?.code === 'unparseable-build-key' && repairProfilePolicy) {
|
|
159
162
|
// The profile's own allowBuilds block is what fails, so no retry of the
|
|
160
163
|
// same command can pass until it is repaired (#698). Drop only the
|
|
161
164
|
// source-form keys this pnpm cannot parse — bare names stay, and on
|
|
@@ -187,15 +190,14 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
187
190
|
// (#732) or extending a version union (#733). Either way the same
|
|
188
191
|
// rewrite fixes it, needs no option at all, and so also works on the
|
|
189
192
|
// desktop bridge that refuses options. Retry the SAME argv afterwards.
|
|
190
|
-
const mergedNow = mergeDuplicateReleaseAgeExcludes(profile, profileDirectory);
|
|
193
|
+
const mergedNow = repairProfilePolicy ? mergeDuplicateReleaseAgeExcludes(profile, profileDirectory) : [];
|
|
191
194
|
if (mergedNow.length > 0) {
|
|
192
195
|
logEvent('warn', 'install', `pnpm appended a second minimumReleaseAgeExclude rule for ${mergedNow.join(', ')} and honours only the first, shadowing its own entry (#732) — merged them into one and retrying once`);
|
|
193
196
|
result = await run(profile, pluginArgs);
|
|
194
197
|
}
|
|
195
|
-
else if (options.releaseAgeBypass === false) {
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// job, so the command stands as it is.
|
|
198
|
+
else if (options.releaseAgeBypass === false || options.preserveProfilePolicy === true) {
|
|
199
|
+
// Fresh installs (#594) and mature updates/recovery (#991) never
|
|
200
|
+
// bypass age enforcement. Preserve the native failure and policy.
|
|
199
201
|
}
|
|
200
202
|
else if (!marketFlags) {
|
|
201
203
|
unavailableOption = RELEASE_AGE_OVERRIDE;
|
|
@@ -399,7 +401,7 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
399
401
|
// after a recovery retry or an official Desktop manager install. Repair
|
|
400
402
|
// the final file now, before another plugin operation reads it; a successful
|
|
401
403
|
// command must never be retried just because its exclusions needed merging.
|
|
402
|
-
if (ok(result) && (verb === 'add' || verb === 'remove' || verb === 'install' || verb === 'update')) {
|
|
404
|
+
if (repairProfilePolicy && ok(result) && (verb === 'add' || verb === 'remove' || verb === 'install' || verb === 'update')) {
|
|
403
405
|
try {
|
|
404
406
|
const merged = mergeDuplicateReleaseAgeExcludes(profile, profileDirectory);
|
|
405
407
|
if (merged.length > 0) {
|
package/lib/registry.js
CHANGED
|
@@ -7,6 +7,7 @@ import { logEvent } from './log.js';
|
|
|
7
7
|
import { configuredProxy, marketFetch } from './net.js';
|
|
8
8
|
import { catalogFromPackage } from './catalog-npm.js';
|
|
9
9
|
import { activeRegion, catalogSourcesFor, routesFor } from './regions.js';
|
|
10
|
+
import { NPM_NAME_RE } from './sources.js';
|
|
10
11
|
/**
|
|
11
12
|
* Category ids for one catalog entry, de-duplicated in declaration order.
|
|
12
13
|
*
|
|
@@ -25,6 +26,36 @@ export function pluginCategories(plugin) {
|
|
|
25
26
|
}
|
|
26
27
|
return categories;
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Former npm names for one catalog entry, normalized (#1012).
|
|
31
|
+
*
|
|
32
|
+
* Catalog JSON is external input, so a malformed declaration is dropped
|
|
33
|
+
* rather than trusted: only syntactically valid npm names survive, an entry
|
|
34
|
+
* may not list itself (its own `npm` or `name`), and duplicates collapse in
|
|
35
|
+
* declaration order. Cross-entry rules — a former name that is still live
|
|
36
|
+
* elsewhere, or two entries claiming one former name — cannot be judged per
|
|
37
|
+
* entry; the validator enforces them for the catalog, and
|
|
38
|
+
* `findCatalogRename` fails closed on ambiguity at runtime.
|
|
39
|
+
*/
|
|
40
|
+
export function pluginPreviousNpm(plugin) {
|
|
41
|
+
const declared = plugin.previousNpm;
|
|
42
|
+
const values = Array.isArray(declared) ? declared : declared === undefined || declared === null ? [] : [declared];
|
|
43
|
+
const own = new Set([plugin.name.toLowerCase()]);
|
|
44
|
+
if (typeof plugin.npm === 'string' && plugin.npm !== '')
|
|
45
|
+
own.add(plugin.npm.toLowerCase());
|
|
46
|
+
const names = [];
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
for (const value of values) {
|
|
49
|
+
if (typeof value !== 'string' || value === '' || !NPM_NAME_RE.test(value))
|
|
50
|
+
continue;
|
|
51
|
+
const key = value.toLowerCase();
|
|
52
|
+
if (own.has(key) || seen.has(key))
|
|
53
|
+
continue;
|
|
54
|
+
seen.add(key);
|
|
55
|
+
names.push(value);
|
|
56
|
+
}
|
|
57
|
+
return names;
|
|
58
|
+
}
|
|
28
59
|
/**
|
|
29
60
|
* Where the curated list comes from now lives in the region routing table
|
|
30
61
|
* (src/regions.ts), because it is one of several addresses that move
|
|
@@ -115,7 +146,14 @@ function asRegistry(value) {
|
|
|
115
146
|
const category = pluginCategories(plugin);
|
|
116
147
|
if (category.length === 0)
|
|
117
148
|
throw new Error(`catalog plugin ${String(index)} carries no usable category`);
|
|
118
|
-
|
|
149
|
+
const previousNpm = pluginPreviousNpm(plugin);
|
|
150
|
+
// An entry with nothing usable to declare looks exactly like one that
|
|
151
|
+
// declared nothing, so the field stays absent rather than becoming [].
|
|
152
|
+
if (previousNpm.length === 0) {
|
|
153
|
+
const { previousNpm: _declared, ...rest } = plugin;
|
|
154
|
+
return { ...rest, category };
|
|
155
|
+
}
|
|
156
|
+
return { ...plugin, category, previousNpm };
|
|
119
157
|
});
|
|
120
158
|
return { ...data, plugins };
|
|
121
159
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
function semver(value) {
|
|
2
|
+
if (typeof value !== 'string')
|
|
3
|
+
return null;
|
|
4
|
+
const matched = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(value);
|
|
5
|
+
if (!matched)
|
|
6
|
+
return null;
|
|
7
|
+
const prerelease = matched[4]?.split('.') ?? [];
|
|
8
|
+
if (prerelease.some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0')))
|
|
9
|
+
return null;
|
|
10
|
+
return { core: [BigInt(matched[1]), BigInt(matched[2]), BigInt(matched[3])], prerelease };
|
|
11
|
+
}
|
|
12
|
+
/** Accept stable SemVer, including build metadata, at both API boundaries. */
|
|
13
|
+
export function isStableReleaseVersion(value) {
|
|
14
|
+
const parsed = semver(value);
|
|
15
|
+
return parsed !== null && parsed.prerelease.length === 0;
|
|
16
|
+
}
|
|
17
|
+
function compare(a, b) {
|
|
18
|
+
for (let i = 0; i < 3; i++) {
|
|
19
|
+
if (a.core[i] !== b.core[i])
|
|
20
|
+
return a.core[i] > b.core[i] ? 1 : -1;
|
|
21
|
+
}
|
|
22
|
+
if (a.prerelease.length === 0 || b.prerelease.length === 0)
|
|
23
|
+
return Math.sign(b.prerelease.length - a.prerelease.length);
|
|
24
|
+
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
|
|
25
|
+
const x = a.prerelease[i], y = b.prerelease[i];
|
|
26
|
+
if (x === undefined)
|
|
27
|
+
return -1;
|
|
28
|
+
if (y === undefined)
|
|
29
|
+
return 1;
|
|
30
|
+
if (x === y)
|
|
31
|
+
continue;
|
|
32
|
+
const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
|
|
33
|
+
if (xn && yn)
|
|
34
|
+
return BigInt(x) > BigInt(y) ? 1 : -1;
|
|
35
|
+
if (xn !== yn)
|
|
36
|
+
return xn ? -1 : 1;
|
|
37
|
+
return x > y ? 1 : -1;
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
export function strictPublishTime(value) {
|
|
42
|
+
if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z$/.test(value))
|
|
43
|
+
return null;
|
|
44
|
+
const timestamp = Date.parse(value);
|
|
45
|
+
if (!Number.isFinite(timestamp))
|
|
46
|
+
return null;
|
|
47
|
+
return new Date(timestamp).toISOString().slice(0, 19) === value.slice(0, 19) ? timestamp : null;
|
|
48
|
+
}
|
|
49
|
+
export function selectMatureUpdate(packument, installed, now, policy) {
|
|
50
|
+
const latest = typeof packument?.['dist-tags']?.latest === 'string' ? packument['dist-tags'].latest : null;
|
|
51
|
+
const empty = (reason) => ({ candidate: null, latest, held: null, reason, nextMaturityAt: null, latestMaturityAt: null });
|
|
52
|
+
const top = semver(latest);
|
|
53
|
+
if (!top || top.prerelease.length || !packument.versions || !packument.time ||
|
|
54
|
+
typeof packument.versions !== 'object' || typeof packument.time !== 'object' ||
|
|
55
|
+
!Object.hasOwn(packument.versions, latest) ||
|
|
56
|
+
(policy.latestFromEndpoint != null && policy.latestFromEndpoint !== latest))
|
|
57
|
+
return empty('inconsistent');
|
|
58
|
+
const ceiling = policy.maxVersion === undefined ? top : semver(policy.maxVersion);
|
|
59
|
+
if (!ceiling || ceiling.prerelease.length ||
|
|
60
|
+
(policy.maxVersion !== undefined && !Object.hasOwn(packument.versions, policy.maxVersion)))
|
|
61
|
+
return empty('inconsistent');
|
|
62
|
+
const current = semver(installed);
|
|
63
|
+
if (!current || current.prerelease.length)
|
|
64
|
+
return empty('invalid-installed');
|
|
65
|
+
const ageMs = policy.minimumReleaseAgeMs;
|
|
66
|
+
if (!Number.isSafeInteger(ageMs) || ageMs < 0 || !Number.isFinite(now))
|
|
67
|
+
return empty('policy-unknown');
|
|
68
|
+
let candidate = null;
|
|
69
|
+
let candidateSemver = null;
|
|
70
|
+
let nextMaturityAt = null;
|
|
71
|
+
for (const version of Object.keys(packument.versions)) {
|
|
72
|
+
const parsed = semver(version);
|
|
73
|
+
if (!parsed || parsed.prerelease.length || compare(parsed, current) <= 0 || compare(parsed, top) > 0 || compare(parsed, ceiling) > 0)
|
|
74
|
+
continue;
|
|
75
|
+
const published = strictPublishTime(packument.time[version]);
|
|
76
|
+
const exempt = policy.exemptPackage === true || policy.exemptVersions?.has(version) === true;
|
|
77
|
+
// A declared exemption is not evidence for a fabricated or absent timestamp.
|
|
78
|
+
if (published === null || published > now || (!exempt && published > now - ageMs)) {
|
|
79
|
+
if (published !== null && published <= now && Number.isFinite(published + ageMs)) {
|
|
80
|
+
const at = published + ageMs;
|
|
81
|
+
if (nextMaturityAt === null || at < nextMaturityAt)
|
|
82
|
+
nextMaturityAt = at;
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (candidateSemver === null || compare(parsed, candidateSemver) > 0) {
|
|
87
|
+
candidate = version;
|
|
88
|
+
candidateSemver = parsed;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const latestPublished = strictPublishTime(packument.time[latest]);
|
|
92
|
+
const latestExempt = policy.exemptPackage === true || policy.exemptVersions?.has(latest) === true;
|
|
93
|
+
const latestMature = latestPublished !== null && latestPublished <= now &&
|
|
94
|
+
(latestExempt || latestPublished <= now - ageMs);
|
|
95
|
+
return {
|
|
96
|
+
candidate, latest, held: latestMature ? null : latest,
|
|
97
|
+
reason: candidate ? 'eligible' : latestMature ? 'current' : latestPublished === null ? 'unknown-age' : 'held',
|
|
98
|
+
nextMaturityAt,
|
|
99
|
+
latestMaturityAt: latestPublished === null || latestExempt ? null : latestPublished + ageMs,
|
|
100
|
+
};
|
|
101
|
+
}
|