dshmarket 1.70.2 → 1.70.3

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/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
  }
package/lib/routes.js CHANGED
@@ -11,6 +11,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
11
11
  import { dirname, join } from 'node:path';
12
12
  import { Readable } from 'node:stream';
13
13
  import { load as loadYaml } from 'js-yaml';
14
+ import { packageFileFromRegistry } from './catalog-npm.js';
14
15
  import { forgetCatalog, loadRegistry, pluginCategories } from './registry.js';
15
16
  import { settingsNamespaceState } from './settings.js';
16
17
  import { buildEnvFromUnknown, cleanHotDir, hotMount, hotUnmount, listHotMounts, MAX_BLOCKED, MAX_BLOCKED_NAME, MAX_FAVORITES, MAX_NOTE, MAX_UPDATE_EXEMPT, mountClientOnlyDeps, purgeMarketState, readMarketState, writeMarketState, } from './hot.js';
@@ -23,7 +24,7 @@ import { diagnosePackageManifests } from './diagnostics.js';
23
24
  import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin, setBuildEnvSource, TARGET_RE, } from './dsh-cli.js';
24
25
  import { packageOfEntryName } from './entry-identity.js';
25
26
  import { PendingRowJournal, repairOrphanedRows } from './pending-rows.js';
26
- import { addProfileBundle, bundleOptOutRowIds, bundlePatchInsertedIds, bundlesDroppedFromProfile, declareProfileDependency, dropFromManifest, generationManagedNames, hasLoadableEntry, holdsNativeAddon, INBOX_BUNDLES, isDshProfileName, profileDir, readDependencyOwners, readGitResolutionCommit, readInstalled, readInstalledManifest, readInstalledPackageName, readInstalledRepoEvidence, readInstalledVersion, readLockCommits, readProfileBundles, readProfileManifestSnapshot, removeProfileBundle, restoreProfileManifest, setAllowBuilds } from './profile.js';
27
+ import { addProfileBundle, bundleOptOutRowIds, bundlePatchInsertedIds, bundlesDroppedFromProfile, declareProfileDependency, dropFromManifest, generationManagedNames, hasLoadableEntry, holdsNativeAddon, INBOX_BUNDLES, isDshProfileName, profileDir, readDependencyOwners, readGitResolutionCommit, readInstalled, readInstalledManifest, readInstalledPackageName, readInstalledRepoEvidence, readInstalledVersion, readLockCommits, readProfileBundles, readProfileManifestSnapshot, removeProfileBundle, parsePatchRows, restoreProfileManifest, setAllowBuilds } from './profile.js';
27
28
  import { assessProfile, classifyPeer, introducedDuplicateNames, introducedRisks } from './compatibility.js';
28
29
  import { runningAgentIds } from './agents.js';
29
30
  import { analyzeProfile, corePackageNames } from './check.js';
@@ -50,7 +51,7 @@ import { carrierRowIds, disableRow, enableRow, findUserPatchPath, foreignRowIds,
50
51
  import { createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, unportableDeps, uploadWebdav, WebdavUrlError, } from './backup.js';
51
52
  import { createGist, fitsGistLimit, GistError, gistErrorCode, parseGistId, readGist, resolveGistTokenSource, updateGist, verifyGistToken, } from './gist.js';
52
53
  import { MAX_UPDATE_OPERATIONS_V1, UpdateOperationStoreV1, UPDATE_API_V1_SCHEMA } from './update-api-v1.js';
53
- import { findGitToNpmMigration } from './source-migration.js';
54
+ import { declaredPredecessors, findCatalogRename, findGitToNpmMigration, findNpmTargetEntry, isRegistryRangeSpec, sameLoaderRows } from './source-migration.js';
54
55
  /**
55
56
  * Recognize the documented GitHub Release-download target shape. This does
56
57
  * not authorize a new URL: install trust remains catalog-bound in sources.ts,
@@ -1109,8 +1110,40 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
1109
1110
  }
1110
1111
  return live;
1111
1112
  }
1113
+ /**
1114
+ * The host's own rows for a package, as the loader holds them right now.
1115
+ *
1116
+ * Rows the MARKET created are left out (`mkt-` ids, see `hotMount`): a hot
1117
+ * mount that failed in this session also leaves an entry without a fiber,
1118
+ * and that one really does come back on the next start (#575).
1119
+ */
1120
+ function loaderRowsOf(name) {
1121
+ const entries = [...host.loader.entries()];
1122
+ const ownedRows = new Set(rowIdsForPackage(host, activeProfileDir, name));
1123
+ const mine = (entry) => {
1124
+ const id = typeof entry.options.id === 'string' ? entry.options.id : '';
1125
+ const bare = id.split(':').pop() ?? id;
1126
+ if (bare.startsWith('mkt-'))
1127
+ return false;
1128
+ const entryName = entry.options.name;
1129
+ if (typeof entryName === 'string' && (entryName === name || packageOfEntryName(entryName) === name))
1130
+ return true;
1131
+ return ownedRows.has(bare);
1132
+ };
1133
+ return entries.filter(mine).map(entry => {
1134
+ let disabled = false;
1135
+ try {
1136
+ disabled = entry.disabled === true;
1137
+ }
1138
+ catch { /* an expression that cannot be evaluated is not a switch */ }
1139
+ return { disabled, live: entry.fiber !== undefined };
1140
+ });
1141
+ }
1112
1142
  /** A disabled loader row has no fiber but still makes its package switchable. */
1113
1143
  function loaderEntryObserved(name) {
1144
+ // `loaderRowsOf` is the single reader: two callers disagreeing about what
1145
+ // "observed" means is how a row ends up switchable and not-switchable at
1146
+ // once (#1023).
1114
1147
  const entries = [...host.loader.entries()];
1115
1148
  if (entries.some(entry => {
1116
1149
  const entryName = entry.options.name;
@@ -1123,6 +1156,17 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
1123
1156
  return typeof id === 'string' && ownedRows.has(id.split(':').pop() ?? id);
1124
1157
  });
1125
1158
  }
1159
+ /**
1160
+ * Rows the loaders made for this package and never started (#1023).
1161
+ *
1162
+ * Only a row this START created counts: the market's own hot mounts are
1163
+ * excluded (they retry on the next start), and a package with no row at all
1164
+ * is the ordinary "restart to apply" case, not this one.
1165
+ */
1166
+ function loaderNeverStarted(name) {
1167
+ const rows = loaderRowsOf(name);
1168
+ return rows.length > 0 && rows.every(row => !row.live && !row.disabled);
1169
+ }
1126
1170
  function isNonToggleableLibrary(name) {
1127
1171
  const manifest = readInstalledManifest(config.profile, name, activeProfileDir);
1128
1172
  if (!isDeclaredLibraryDsh(manifest?.dsh))
@@ -1172,6 +1216,45 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
1172
1216
  function cannotRematerializeDetail(name, pinned) {
1173
1217
  return `这台宿主只接受按精确版本重新安装,「${name}」改动前在 profile 里写作 ${pinned ?? '(不在 profile 里)'},市场无法在它上面重建上一版 / this host can only reinstall an exact version, and "${name}" was declared as ${pinned ?? '(not in the profile)'}, so the previous build cannot be rematerialized there`;
1174
1218
  }
1219
+ /**
1220
+ * The installed package this entry replaces under a former name (#1012).
1221
+ *
1222
+ * Identity, not naming. Two packages are one plugin when they insert the
1223
+ * same loader rows, and both sides state theirs: the installed copy in the
1224
+ * patch files it ships, the published copy in its tarball. Comparing those
1225
+ * costs no install and cannot be fooled by a package that merely shares a
1226
+ * name — which is exactly what a rename breaks.
1227
+ *
1228
+ * The catalog's `previousNpm` narrows the candidates and supplies the only
1229
+ * statement of intent; the rows confirm it. A row read that fails proves
1230
+ * nothing, so the catalog's claim stands; rows that ARE readable and
1231
+ * disjoint say these are two plugins after all, and the install proceeds.
1232
+ */
1233
+ async function renamePredecessor(entry, installed, registryBase) {
1234
+ const candidates = declaredPredecessors(entry, installed);
1235
+ if (candidates.length === 0)
1236
+ return null;
1237
+ if (typeof entry.npm !== 'string' || !NPM_NAME_RE.test(entry.npm))
1238
+ return null;
1239
+ if (candidates.length > 1) {
1240
+ logEvent('warn', 'install', `${entry.name}: ${candidates.join(', ')} all answer to a former name — not replacing any of them`);
1241
+ return null;
1242
+ }
1243
+ const current = candidates[0];
1244
+ const installedIds = bundlePatchInsertedIds(join(activeProfileDir, 'node_modules', current));
1245
+ if (installedIds.length === 0)
1246
+ return current;
1247
+ const bytes = await packageFileFromRegistry(registryBase, entry.npm, 'package/cordis.patch.yml');
1248
+ if (bytes === null)
1249
+ return current;
1250
+ const incomingIds = parsePatchRows(bytes.toString('utf8')).insertedIds;
1251
+ if (incomingIds.length === 0)
1252
+ return current;
1253
+ if (sameLoaderRows(installedIds, incomingIds))
1254
+ return current;
1255
+ logEvent('info', 'install', `${entry.name}: ${current} declares no row this release inserts — installing beside it`);
1256
+ return null;
1257
+ }
1175
1258
  /**
1176
1259
  * Undo a clean-exit update whose new build cannot boot. Restoring only the
1177
1260
  * manifest pin (the original #159 behavior) leaves the bad package files
@@ -2572,7 +2655,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
2572
2655
  });
2573
2656
  for (const name of Object.keys(installed)) {
2574
2657
  const manifest = installedManifests.get(name);
2575
- const result = activationAfterReplace(verifyActivation(config.profile, name, live, activeProfileDir, disabled.has(name) || patchFlags.disabled.includes(name) || unbundled.includes(name), undefined, isDeclaredLibraryDsh(manifest?.dsh) && loaderEntryObserved(name)), replacedWhileLive.has(name));
2658
+ const result = activationAfterReplace(verifyActivation(config.profile, name, live, activeProfileDir, disabled.has(name) || patchFlags.disabled.includes(name) || unbundled.includes(name), undefined, isDeclaredLibraryDsh(manifest?.dsh) && loaderEntryObserved(name), loaderNeverStarted(name)), replacedWhileLive.has(name));
2576
2659
  // A package with no dsh surface of its own, outside the bundle
2577
2660
  // layer, that another installed package declares, is that package's
2578
2661
  // library rather than a plugin that failed to start (#634). Saying
@@ -3749,6 +3832,20 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3749
3832
  if (migration !== null)
3750
3833
  sourceMigrationFor.set(name, migration);
3751
3834
  }
3835
+ // A rename declared by the catalog (#1012, P1). This is the only
3836
+ // path that reaches a user whose old range no longer resolves:
3837
+ // their update fails before installing anything, so no offer was
3838
+ // ever recorded. The catalog states the rename up front, and the
3839
+ // row still has to be a plain registry range the transaction can
3840
+ // replace — and the successor must not already be installed.
3841
+ for (const [name, spec] of Object.entries(installed)) {
3842
+ if (sourceMigrationFor.has(name) || !isRegistryRangeSpec(spec))
3843
+ continue;
3844
+ const rename = findCatalogRename(registry.plugins, name);
3845
+ if (rename === null || installed[rename.target] !== undefined)
3846
+ continue;
3847
+ sourceMigrationFor.set(name, { kind: 'npm-rename', from: name, target: rename.target });
3848
+ }
3752
3849
  }
3753
3850
  catch (error) {
3754
3851
  logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`);
@@ -3827,21 +3924,88 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3827
3924
  sendJson(response, 400, { error: 'plugin is not installed' });
3828
3925
  return;
3829
3926
  }
3830
- let migration = null;
3831
- try {
3832
- const registry = await loadRegistry();
3833
- migration = findGitToNpmMigration(registry.plugins, spec);
3834
- }
3835
- catch (error) {
3836
- logEvent('warn', 'source-migration', `${name}: catalog lookup failed — ${error instanceof Error ? error.message : String(error)}`);
3837
- }
3838
- if (migration === null) {
3927
+ // Two ways in, one transaction out (#1012). The discovered
3928
+ // Git -> npm case needs no request body. An npm -> npm rename
3929
+ // cannot be discovered (the old spec names only the old package,
3930
+ // and the registry answers 404 for it), so the client carries the
3931
+ // target from the update failure's own artifact — and it is
3932
+ // re-verified against the catalog here rather than trusted: this
3933
+ // body is user-reachable, and the transaction runs `add <to>@latest`.
3934
+ const kind = body.kind === 'npm-rename' ? 'npm-rename' : 'git-to-npm';
3935
+ if (body.kind !== undefined && body.kind !== 'git-to-npm' && body.kind !== 'npm-rename') {
3839
3936
  sendJson(response, 400, {
3840
- error: '当前 Git 来源无法唯一核验到一个 npm 包,或包含显式 branch/tag/commit/ref;未执行迁移。 / This Git source cannot be uniquely verified to one npm package, or it carries an explicit branch/tag/commit/ref; migration was not performed.',
3937
+ error: '迁移请求无法识别,没有做任何改动。 / This migration request could not be recognised; nothing was changed.',
3841
3938
  });
3842
3939
  return;
3843
3940
  }
3844
- const targetName = migration.target;
3941
+ let targetName;
3942
+ if (kind === 'npm-rename') {
3943
+ const to = typeof body.to === 'string' ? body.to.trim() : '';
3944
+ if (!NPM_NAME_RE.test(to) || to === name) {
3945
+ sendJson(response, 400, {
3946
+ error: '目标包名无效,或仍是当前包名;未执行迁移。 / The target package name is invalid, or is still the current one; migration was not performed.',
3947
+ });
3948
+ return;
3949
+ }
3950
+ // A registry dependency is a bare version range (the same shape
3951
+ // `isNpmRollbackSource` accepts). Everything else carries a
3952
+ // protocol or a path separator — `github:`, `file:`, `link:`,
3953
+ // `workspace:`, `catalog:`, `npm:other@1.0.0`, a tarball URL — and
3954
+ // replacing one of those with `to@latest` is not a rename (#1012).
3955
+ if (!isRegistryRangeSpec(spec)) {
3956
+ sendJson(response, 400, {
3957
+ error: '当前安装不是 npm 依赖,不能按包名迁移。 / The current install is not an npm dependency, so it cannot be migrated by package name.',
3958
+ });
3959
+ return;
3960
+ }
3961
+ // Verified the same way discovery finds it: exactly one catalog
3962
+ // entry declares this package as a former name of ITSELF, and
3963
+ // the target is the one it publishes under (#1012). A name pair
3964
+ // that nobody declares is not a rename, and two declarations are
3965
+ // ambiguity — both refuse, because the transaction removes an
3966
+ // installed package before this is answered.
3967
+ let verified = false;
3968
+ try {
3969
+ const registry = await loadRegistry();
3970
+ const declared = findCatalogRename(registry.plugins, name);
3971
+ const named = findNpmTargetEntry(registry.plugins, to);
3972
+ verified = declared !== null && named !== null
3973
+ && declared.target.toLowerCase() === to.toLowerCase()
3974
+ && declared.entry === named;
3975
+ }
3976
+ catch (error) {
3977
+ logEvent('warn', 'source-migration', `${name}: catalog lookup failed — ${error instanceof Error ? error.message : String(error)}`);
3978
+ }
3979
+ if (!verified) {
3980
+ sendJson(response, 400, {
3981
+ error: `目标包名 ${to} 不在市场目录里,或目录没有声明它是「${name}」的新名字;未执行迁移。 / The target ${to} is not in the market catalog, or the catalog does not declare it as the new name of "${name}"; migration was not performed.`,
3982
+ });
3983
+ return;
3984
+ }
3985
+ targetName = to;
3986
+ }
3987
+ else {
3988
+ let migration = null;
3989
+ try {
3990
+ const registry = await loadRegistry();
3991
+ migration = findGitToNpmMigration(registry.plugins, spec);
3992
+ }
3993
+ catch (error) {
3994
+ logEvent('warn', 'source-migration', `${name}: catalog lookup failed — ${error instanceof Error ? error.message : String(error)}`);
3995
+ }
3996
+ if (migration === null) {
3997
+ sendJson(response, 400, {
3998
+ error: '当前 Git 来源无法唯一核验到一个 npm 包,或包含显式 branch/tag/commit/ref;未执行迁移。 / This Git source cannot be uniquely verified to one npm package, or it carries an explicit branch/tag/commit/ref; migration was not performed.',
3999
+ });
4000
+ return;
4001
+ }
4002
+ targetName = migration.target;
4003
+ }
4004
+ // "The previous source" is the Git ref for a discovered migration
4005
+ // and the old package for a rename; every rollback sentence below
4006
+ // has to name whichever one this run actually restores.
4007
+ const restoredZh = kind === 'npm-rename' ? '原来的安装' : '原 Git 来源';
4008
+ const restoredEn = kind === 'npm-rename' ? 'the original install' : 'the previous Git source';
3845
4009
  if (targetName !== name && manifestCapture.snapshot.dependencies[targetName] !== undefined) {
3846
4010
  sendJson(response, 409, {
3847
4011
  error: `目标 npm 包 ${targetName} 已经作为独立依赖安装;为避免覆盖现有安装,未执行迁移。 / The target npm package ${targetName} is already installed as a separate dependency; migration was not performed to avoid overwriting it.`,
@@ -3888,16 +4052,16 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3888
4052
  }
3889
4053
  return hasLoadableEntry(activeProfileDir, name)
3890
4054
  ? { ok: true, detail: null }
3891
- : { ok: false, detail: 'the previous Git source was restored without a loadable entry' };
4055
+ : { ok: false, detail: `${restoredEn} was restored without a loadable entry` };
3892
4056
  };
3893
4057
  const failWithRollback = async (detail, extra = {}) => {
3894
4058
  const rollback = await rollbackMigration();
3895
- logEvent(rollback.ok ? 'warn' : 'error', 'source-migration', `${name}: migration failed — ${detail}; ${rollback.ok ? 'previous Git source restored' : `rollback failed: ${rollback.detail ?? 'unknown'}`}`);
4059
+ logEvent(rollback.ok ? 'warn' : 'error', 'source-migration', `${name}: migration failed — ${detail}; ${rollback.ok ? `${restoredEn} restored` : `rollback failed: ${rollback.detail ?? 'unknown'}`}`);
3896
4060
  sendJson(response, 500, {
3897
4061
  ok: false,
3898
4062
  error: rollback.ok
3899
- ? `${detail};已恢复原 Git 来源。 / ${detail}; the previous Git source was restored.`
3900
- : `${detail};且原 Git 来源未能验证恢复(${rollback.detail ?? 'unknown'})。 / ${detail}; restoration of the previous Git source could not be verified (${rollback.detail ?? 'unknown'}).`,
4063
+ ? `${detail};已恢复${restoredZh}。 / ${detail}; ${restoredEn} was restored.`
4064
+ : `${detail};且${restoredZh}未能验证恢复(${rollback.detail ?? 'unknown'})。 / ${detail}; restoration of ${restoredEn} could not be verified (${rollback.detail ?? 'unknown'}).`,
3901
4065
  rollback: rollback.ok,
3902
4066
  ...extra,
3903
4067
  });
@@ -3995,11 +4159,12 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3995
4159
  const activation = {
3996
4160
  [targetName]: activationAfterReplace(verifyActivation(config.profile, targetName, liveNames(), activeProfileDir, disabled.has(targetName)), wasLive),
3997
4161
  };
3998
- logEvent('info', 'source-migration', `${name}: ${spec} -> ${targetName}`);
4162
+ logEvent('info', 'source-migration', `${name}: ${spec} -> ${targetName} (${kind})`);
3999
4163
  sendJson(response, 200, {
4000
4164
  ok: true,
4001
4165
  from: { name, source: spec },
4002
4166
  to: { name: targetName, source: 'npm' },
4167
+ kind,
4003
4168
  activation,
4004
4169
  warnings: [stateWarning, ...patchWarnings].filter((value) => value !== null && value !== ''),
4005
4170
  });
@@ -5044,8 +5209,8 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
5044
5209
  // rewriting the profile's bundle list.
5045
5210
  const renamedError = renamedTo === null ? null
5046
5211
  : rollbackOk
5047
- ? `${name} 的上游已把包改名为 ${renamedTo}:新版本会装在旧名字下,DSH 下次启动会拒绝这个 profile,所以本次更新已自动回滚、原版本已恢复。要用新版本,请卸载 ${name} 后按新名字 ${renamedTo} 重新安装。 / ${name} was renamed upstream to ${renamedTo}: the new version would sit under the old name and DSH would refuse to start this profile, so the update was rolled back and the previous build restored. To move to the new version, remove ${name} and install ${renamedTo}.`
5048
- : `${name} 的上游已把包改名为 ${renamedTo},且未能验证恢复原版本(${rollbackDetail ?? 'unknown'});在卸载 ${name} 之前 DSH 会拒绝启动这个 profile,卸载后再按新名字 ${renamedTo} 重新安装。 / ${name} was renamed upstream to ${renamedTo}, and restoration of the previous build could not be verified (${rollbackDetail ?? 'unknown'}); DSH will refuse to start this profile until ${name} is removed — then install ${renamedTo}.`;
5212
+ ? `${name} 的上游已把包改名为 ${renamedTo}:新版本会装在旧名字下,DSH 下次启动会拒绝这个 profile,所以本次更新已自动回滚、原版本已恢复。目录如果声明了这次改名,「已安装」里会出现迁移入口。 / ${name} was renamed upstream to ${renamedTo}: the new version would sit under the old name and DSH would refuse to start this profile, so the update was rolled back and the previous build restored. If the catalog declares this rename, the installed list offers the migration.`
5213
+ : `${name} 的上游已把包改名为 ${renamedTo},且未能验证恢复原版本(${rollbackDetail ?? 'unknown'})。 / ${name} was renamed upstream to ${renamedTo}, and restoration of the previous build could not be verified (${rollbackDetail ?? 'unknown'}).`;
5049
5214
  const cancelDiff = cancelled ? changedSince(beforeInstalled) : null;
5050
5215
  // Build-script blocks hit updates too (#69): a leftover invalid
5051
5216
  // allowBuilds entry (pnpm's placeholder bug, #56) or a newly
@@ -6238,6 +6403,28 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
6238
6403
  return;
6239
6404
  }
6240
6405
  }
6406
+ // Rename guard (#1012): the package this entry used to publish
6407
+ // under may still be in the profile, and a rename re-registers the
6408
+ // same loader rows — installing the successor beside it registers
6409
+ // them twice and bricks the next start (#122). The market does not
6410
+ // answer that with "uninstall, then install": when the old copy is
6411
+ // a registry dependency the migration replaces it in place and
6412
+ // carries switches, groups and notes across, so the response names
6413
+ // both ends and the client continues straight into that
6414
+ // transaction. A Git or path copy cannot be moved by that
6415
+ // transaction, so it is named without promising the dialog.
6416
+ const predecessor = await renamePredecessor(entry, installedNow, routesFor(region).npmRegistry);
6417
+ if (predecessor !== null && typeof entry.npm === 'string') {
6418
+ const movable = isRegistryRangeSpec(String(installedNow[predecessor] ?? ''));
6419
+ logEvent('warn', 'install-rejected', `${entry.name}: predecessor ${predecessor} is installed — ${movable ? 'migration replaces it' : 'not a registry dependency, cannot be migrated here'}`);
6420
+ sendJson(response, 409, {
6421
+ error: movable
6422
+ ? `「${predecessor}」是这个插件改名前用的名字,已经装在 profile 里。装新名字会把同一个插件装成两份、下次启动可能失败;改用迁移把它换过来,开关、分组和备注都会保留。 / "${predecessor}" is already installed under this plugin's former package name. Installing the new name would put two copies of one plugin in the profile and can break the next start — migrate it instead; switches, groups and notes come along.`
6423
+ : `「${predecessor}」是这个插件改名前用的名字,已经装在 profile 里,而它是从 Git 或本地路径装的。装新名字会把同一个插件装成两份、下次启动可能失败;请先把旧的那份换成 npm 包(「已安装」里会给出可用的移动方式)。 / "${predecessor}" is already installed under this plugin's former package name, and it comes from a Git or local path. Installing the new name would put two copies of one plugin in the profile and can break the next start — move the old copy to the npm package first (the installed list shows what can be moved).`,
6424
+ ...(movable ? { migration: { kind: 'npm-rename', from: predecessor, target: entry.npm } } : {}),
6425
+ });
6426
+ return;
6427
+ }
6241
6428
  // Fresh installs ask the host requirement too (#404/#473: the
6242
6429
  // update route refuses a declared-incompatible release before
6243
6430
  // installing; an unguarded fresh install would hit exactly the
@@ -10,7 +10,7 @@
10
10
  * - branch/tag/commit/semver selectors are not migrated in v1;
11
11
  * - more than one matching verified npm entry is ambiguous and fails closed.
12
12
  */
13
- import { NPM_NAME_RE, parseSourceUrl, repoOfTarget } from './sources.js';
13
+ import { NPM_NAME_RE, isGitHostedSpec, isLocalSpec, parseSourceUrl, repoOfTarget } from './sources.js';
14
14
  function migratableGitIdentity(spec) {
15
15
  if (!spec.startsWith('github:'))
16
16
  return null;
@@ -52,3 +52,113 @@ export function findGitToNpmMigration(plugins, spec) {
52
52
  return null;
53
53
  return { kind: 'git-to-npm', repo: identity, target: candidates[0] };
54
54
  }
55
+ /**
56
+ * The catalog entry that publishes `to` on npm, or null when no single entry
57
+ * does.
58
+ *
59
+ * The rename target arrives from an update failure's own artifact, but the
60
+ * `migrate-source` body is user-reachable all the same: this keeps the route
61
+ * from pointing a profile at a package name the catalog does not publish on
62
+ * npm. Two entries claiming one npm name is ambiguous and fails closed, the
63
+ * same rule {@link findGitToNpmMigration} follows.
64
+ */
65
+ export function findNpmTargetEntry(plugins, to) {
66
+ // Compare against the install target, not the display name: that is what the
67
+ // route is about to `add`, and it is what the catalog rules (E10/E11) keep
68
+ // unique. A listing whose npm target is another package cannot satisfy a
69
+ // rename to its display name.
70
+ const matches = plugins.filter(plugin => {
71
+ const target = typeof plugin.npm === 'string' ? plugin.npm : plugin.name;
72
+ return NPM_NAME_RE.test(target) && target === to;
73
+ });
74
+ return matches.length === 1 ? matches[0] : null;
75
+ }
76
+ /**
77
+ * The catalog entry that declares `from` as a package it used to be (#1012).
78
+ *
79
+ * This is the discovery the update failure cannot provide: a user whose old
80
+ * version range no longer resolves never installs anything, so no
81
+ * "came back under another name" evidence is produced. The catalog states the
82
+ * rename up front instead — but it is external input, so two entries claiming
83
+ * one former name is ambiguity, and ambiguity fails closed exactly like
84
+ * {@link findGitToNpmMigration}.
85
+ *
86
+ * Only entries that can actually be installed from npm qualify: an entry whose
87
+ * own target is not a valid npm name has no successor this transaction could
88
+ * install.
89
+ */
90
+ export function findCatalogRename(plugins, from) {
91
+ const wanted = from.toLowerCase();
92
+ const candidates = [];
93
+ for (const plugin of plugins) {
94
+ const previous = Array.isArray(plugin.previousNpm) ? plugin.previousNpm : [];
95
+ if (!previous.some(name => typeof name === 'string' && name.toLowerCase() === wanted))
96
+ continue;
97
+ const target = typeof plugin.npm === 'string' && NPM_NAME_RE.test(plugin.npm) ? plugin.npm : null;
98
+ // An entry may never name itself as its own predecessor; the runtime
99
+ // normalizer drops that, and this keeps a hand-built catalog honest too.
100
+ if (target === null || target.toLowerCase() === wanted)
101
+ continue;
102
+ candidates.push({ entry: plugin, target });
103
+ }
104
+ return candidates.length === 1 ? candidates[0] : null;
105
+ }
106
+ /**
107
+ * Installed packages that answer to one of this entry's former names (#1012).
108
+ *
109
+ * A renamed package usually re-registers the very same bundle ids, so two
110
+ * package names that were one plugin cannot coexist in a profile: the trial
111
+ * boot rejects duplicate loader ids while the two sit side by side. That makes
112
+ * "install the new name as well" the one obvious move that bricks the next
113
+ * start, which is why the install route refuses it before pnpm runs.
114
+ *
115
+ * A name on this list is a claim, not proof: the caller still compares the
116
+ * rows both sides declare before it refuses anything, so a stale catalog
117
+ * field can cost a click at worst and never a wrong refusal.
118
+ */
119
+ export function declaredPredecessors(plugin, installed) {
120
+ const previous = Array.isArray(plugin.previousNpm) ? plugin.previousNpm : [];
121
+ const found = [];
122
+ for (const value of previous) {
123
+ if (typeof value !== 'string' || value === '')
124
+ continue;
125
+ const wanted = value.toLowerCase();
126
+ for (const name of Object.keys(installed)) {
127
+ if (name.toLowerCase() === wanted && !found.includes(name))
128
+ found.push(name);
129
+ }
130
+ }
131
+ return found;
132
+ }
133
+ /**
134
+ * True when the two sides still register at least one of the same loader rows.
135
+ *
136
+ * This is the check that keeps a stale catalog from refusing an install it has
137
+ * no business refusing: two packages that insert different rows can coexist in
138
+ * a profile, so a `previousNpm` that points at one of them is a guess, not the
139
+ * plugin coming back. Only readable, non-empty row sets can prove sameness —
140
+ * a missing side proves nothing and the caller falls back to the declaration.
141
+ */
142
+ export function sameLoaderRows(installed, incoming) {
143
+ if (installed.length === 0 || incoming.length === 0)
144
+ return false;
145
+ const present = new Set(installed);
146
+ return incoming.some(id => present.has(id));
147
+ }
148
+ /**
149
+ * True when a dependency spec is a plain registry version range — the only
150
+ * shape the rename transaction can replace.
151
+ *
152
+ * The route refuses anything carrying a protocol, a path prefix or an alias
153
+ * (`github:`, `file:`, `workspace:`, `npm:` …), because those describe a
154
+ * source this transaction cannot reproduce for the new name. Discovery is
155
+ * kept to the exact same test so the market never offers a migration the
156
+ * route would answer with 400.
157
+ */
158
+ export function isRegistryRangeSpec(spec) {
159
+ if (spec === '')
160
+ return false;
161
+ if (isGitHostedSpec(spec) || isLocalSpec(spec))
162
+ return false;
163
+ return !/[:/\\]/.test(spec);
164
+ }
@@ -45,3 +45,18 @@ file?: string): Promise<{
45
45
  version: string;
46
46
  data: unknown | null;
47
47
  }>;
48
+ /**
49
+ * One file out of a package's newest tarball, or null when it cannot be read
50
+ * (#1012).
51
+ *
52
+ * The install route asks a package what it declares before it installs it: a
53
+ * rename can only be detected from the rows the artifact carries, not from
54
+ * the name it was published under. A failed read is not a verdict — the
55
+ * caller treats null as "not declared", because a registry hiccup must never
56
+ * become a refusal to install.
57
+ *
58
+ * @param registry - registry base, no trailing slash.
59
+ * @param pkg - package name.
60
+ * @param file - exact entry name inside the tarball (`package/cordis.patch.yml`).
61
+ */
62
+ export declare function packageFileFromRegistry(registry: string, pkg: string, file: string): Promise<Buffer | null>;
@@ -47,6 +47,21 @@ export interface RegistryPlugin {
47
47
  deprecated?: boolean;
48
48
  /** Catalog name of the suggested replacement plugin, when deprecated. */
49
49
  replacement?: string;
50
+ /**
51
+ * npm package names this entry used BEFORE its current one (#1012).
52
+ *
53
+ * The catalog is the only place a rename can be stated ahead of time: the
54
+ * old name's npm metadata is gone (no dist-tags, no `deprecated` message),
55
+ * so nothing the market can fetch still knows the two names are one plugin.
56
+ * Same trust boundary as `deprecated`/`replacement` — absent for every
57
+ * normal entry, and a catalog without the field behaves exactly as before.
58
+ *
59
+ * Values are validated where they are consumed (normalized by
60
+ * {@link pluginPreviousNpm}, cross-entry rules in scripts/validate-registry.mjs):
61
+ * a former name must never be a live package in the catalog, and one former
62
+ * name may only have one successor.
63
+ */
64
+ previousNpm?: string[];
50
65
  /**
51
66
  * Capability disclosure (#401): what a static scan of the artifact a user
52
67
  * would install reported touching — `shell`, `network`, `credentials`,
@@ -77,6 +92,18 @@ export interface Registry {
77
92
  categories: Record<string, Record<string, string>>;
78
93
  plugins: RegistryPlugin[];
79
94
  }
95
+ /**
96
+ * Former npm names for one catalog entry, normalized (#1012).
97
+ *
98
+ * Catalog JSON is external input, so a malformed declaration is dropped
99
+ * rather than trusted: only syntactically valid npm names survive, an entry
100
+ * may not list itself (its own `npm` or `name`), and duplicates collapse in
101
+ * declaration order. Cross-entry rules — a former name that is still live
102
+ * elsewhere, or two entries claiming one former name — cannot be judged per
103
+ * entry; the validator enforces them for the catalog, and
104
+ * `findCatalogRename` fails closed on ambiguity at runtime.
105
+ */
106
+ export declare function pluginPreviousNpm(plugin: Pick<RegistryPlugin, 'name' | 'npm' | 'previousNpm'>): string[];
80
107
  /**
81
108
  * Drop what we remember, so the next call is unconditional.
82
109
  *