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.
@@ -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
- // The caller declined the bypass (#594), and the duplicates were not
197
- // what failed: this violation is the profile's own policy doing its
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
- return { ...plugin, category };
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
+ }