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.
- package/client/client.js +144 -200
- package/lib/catalog-npm.js +35 -0
- package/lib/hot.js +5 -1
- package/lib/registry.js +39 -1
- package/lib/routes.js +208 -21
- package/lib/source-migration.js +111 -1
- package/lib/types/catalog-npm.d.ts +15 -0
- package/lib/types/registry.d.ts +27 -0
- package/lib/types/source-migration.d.ts +89 -0
- package/lib/types/themes.d.ts +6 -0
- package/lib/types/updates.d.ts +7 -6
- package/lib/types/verify.d.ts +9 -2
- 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 +121 -160
- package/src/client/locales.ts +16 -0
- package/src/client/market-data.ts +9 -2
- package/src/hot.ts +6 -1
- package/src/registry.ts +54 -1
- package/src/routes.ts +207 -23
- package/src/source-migration.ts +135 -1
- package/src/themes.ts +6 -0
- package/src/updates.ts +7 -2
- 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/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
|
}
|
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
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
if (
|
|
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: '
|
|
3937
|
+
error: '迁移请求无法识别,没有做任何改动。 / This migration request could not be recognised; nothing was changed.',
|
|
3841
3938
|
});
|
|
3842
3939
|
return;
|
|
3843
3940
|
}
|
|
3844
|
-
|
|
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:
|
|
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 ?
|
|
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}
|
|
3900
|
-
: `${detail}
|
|
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
|
|
5048
|
-
: `${name} 的上游已把包改名为 ${renamedTo},且未能验证恢复原版本(${rollbackDetail ?? 'unknown'}
|
|
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
|
package/lib/source-migration.js
CHANGED
|
@@ -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>;
|
package/lib/types/registry.d.ts
CHANGED
|
@@ -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
|
*
|