dshmarket 1.66.7 → 1.66.8

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/README.md CHANGED
@@ -41,7 +41,7 @@ its own dsh: it may be older than the one `npm` would give you (#139).
41
41
  - **Themes** — a dedicated tab for community themes and skins: install → active immediately, switch with one click (themes are mutually exclusive, your choice survives restarts), uninstall to revert
42
42
  - **One-click install** — confirm the source, watch live progress; most plugins go live after a page refresh, no restart
43
43
  - **Backup & restore** — export your profile's plugin list and configuration as readable JSON, import it on another machine, store it on WebDAV with daily auto-backup, or sync through a private GitHub Gist; restores **merge** (plugins installed after the backup are kept), validate before writing, and roll back on failure
44
- - **Updates** — per-plugin update checks (npm version or pinned commit vs HEAD), one-click update, or update everything at once; the market updates itself the same way. Each row with an update pending carries a **What changed** link — the release notes, or the commits when the version cannot be aligned to one. A notice you are not acting on can be ignored for the rest of the boot instead of being dismissed again on every reload — scoped to the running host, so a restart brings the reminder back and ignoring it is never the same as turning it off
44
+ - **Updates** — per-plugin update checks (npm version or pinned commit vs HEAD), one-click update, or update everything at once; the market updates itself the same way. A plugin installed from an archive link is updated only through its own source: a catalog entry that merely shares the name is never offered as its update, so updating cannot silently replace it with a different plugin. Each row with an update pending carries a **What changed** link — the release notes, or the commits when the version cannot be aligned to one. A notice you are not acting on can be ignored for the rest of the boot instead of being dismissed again on every reload — scoped to the running host, so a restart brings the reminder back and ignoring it is never the same as turning it off
45
45
  - **Resilient GitHub routes** — in the China download region, Git refs, README content, and avatars each keep their own fallback order. The market remembers the last working route, switches only after transport/HTTP/payload validation fails, and rejects proxy error pages disguised as HTTP 200. If every built-in route fails, **Settings → Plugins → Plugin configuration → GitHub acceleration** accepts one persistent custom HTTPS prefix; `DSHM_GITHUB_PROXY` remains the operator-owned override
46
46
  - **Public update API** — plugin-owned settings pages can use the versioned, capability-gated [update API v1](UPDATE-API-V1.md) (beta) instead of copying package-manager logic or depending on private Market UI responses
47
47
  - **Uninstall** — two-step confirm; plugins installed this session are removed live
package/README.zh.md CHANGED
@@ -39,7 +39,7 @@ dsh plugin --profile web add dshmarket
39
39
  - **主题**——独立主题页:装完立即生效,点一下切换(主题互斥、选择跨重启保留),卸载即恢复
40
40
  - **一键安装**——确认来源,实时进度;多数插件刷新页面即可用,无需重启
41
41
  - **备份与恢复**——把 profile 的插件清单与配置导出为可读 JSON,换机导入,存到 WebDAV 并每日自动备份,或通过私有 GitHub Gist 跨机器同步;恢复采用**合并**方式(备份之后新装的插件会保留),写入前校验、失败自动回滚
42
- - **更新**——逐插件检测(npm 版本或锁定 commit 对比 HEAD),一键更新或全部更新;市场自己也走同一通道升级。每个有待更新的行上都有「更新内容」链接——有版本说明就是版本说明,对不上版本时给提交记录。暂时不处理的提醒可以**本次不再提醒**(本次 = 当前宿主进程),重启后提醒会回来——「忽略」永远不等于「关闭」
42
+ - **更新**——逐插件检测(npm 版本或锁定 commit 对比 HEAD),一键更新或全部更新;市场自己也走同一通道升级。从压缩包链接装的插件只认自己的来源:市场里恰好同名的另一个插件不会被当成它的更新,更新也不会悄悄把它换成另一个插件。每个有待更新的行上都有「更新内容」链接——有版本说明就是版本说明,对不上版本时给提交记录。暂时不处理的提醒可以**本次不再提醒**(本次 = 当前宿主进程),重启后提醒会回来——「忽略」永远不等于「关闭」
43
43
  - **GitHub 多线路容错**——中国大陆下载区域会为 Git ref、README 与头像分别维护 fallback 顺序,记住上次可用线路,只在传输、HTTP 状态或响应内容校验失败后换线,并拒绝公共反代伪装成 HTTP 200 的 HTML 错误页。内置线路全部失效时,可到**设置 → 插件 → 插件配置 → GitHub 加速**填写一个持久化的自定义 HTTPS 前缀;运维设置的 `DSHM_GITHUB_PROXY` 始终优先
44
44
  - **公共更新接口**——插件自己的设置页可调用带版本号、能力探测和回滚状态的[更新 API v1](UPDATE-API-V1.md)(beta),无需复制包管理逻辑,也不依赖市场 UI 的私有响应字段
45
45
  - **卸载**——两步确认防误触;本次会话装的插件即点即卸
package/lib/presets.js CHANGED
@@ -149,7 +149,9 @@ function presetMismatch(profileDir, bundleOrder) {
149
149
  * reports the mismatch alongside the still-computable changes (relative
150
150
  * order + enabled/disabled diffs over the intersection).
151
151
  */
152
- export function previewPreset(profileDir, name) {
152
+ export function previewPreset(profileDir, name,
153
+ /** The host's own installation anchor, when it gave one (#781 review). */
154
+ dshInstallDir) {
153
155
  if (typeof name !== 'string')
154
156
  return { ok: false, error: 'invalid preset name / 组合名称无效' };
155
157
  const preset = readPresets(profileDir).find(item => item.name === name);
@@ -166,7 +168,7 @@ export function previewPreset(profileDir, name) {
166
168
  error: `preset is out of date — current profile differs: ${detail} / 组合已过期——当前 profile 的插件列表已变化:${detail}`,
167
169
  };
168
170
  }
169
- const trial = trialValidate(profileDir, preset.bundleOrder);
171
+ const trial = trialValidate(profileDir, preset.bundleOrder, dshInstallDir === undefined ? {} : { dshInstallDir });
170
172
  if (!trial.ok) {
171
173
  const first = trial.errors[0];
172
174
  return {
@@ -198,7 +200,9 @@ export function previewPreset(profileDir, name) {
198
200
  * then write the bundle order and the disable list. The response carries the
199
201
  * change preview so the UI can report exactly what moved.
200
202
  */
201
- export function applyPreset(profileDir, name, maxSnapshots = DEFAULT_MAX_SNAPSHOTS) {
203
+ export function applyPreset(profileDir, name, maxSnapshots = DEFAULT_MAX_SNAPSHOTS,
204
+ /** The host's own installation anchor, when it gave one (#781 review). */
205
+ dshInstallDir) {
202
206
  if (typeof name !== 'string')
203
207
  return { ok: false, error: 'invalid preset name / 组合名称无效' };
204
208
  const preset = readPresets(profileDir).find(item => item.name === name);
@@ -215,7 +219,7 @@ export function applyPreset(profileDir, name, maxSnapshots = DEFAULT_MAX_SNAPSHO
215
219
  error: `preset is out of date — current profile differs: ${detail} / 组合已过期——当前 profile 的插件列表已变化:${detail}`,
216
220
  };
217
221
  }
218
- const trial = trialValidate(profileDir, preset.bundleOrder);
222
+ const trial = trialValidate(profileDir, preset.bundleOrder, dshInstallDir === undefined ? {} : { dshInstallDir });
219
223
  if (!trial.ok) {
220
224
  const first = trial.errors[0];
221
225
  logEvent('warn', 'preset', `apply "${name}" rejected by trial validation: ${first?.message ?? 'unknown'}`);
package/lib/routes.js CHANGED
@@ -30,7 +30,7 @@ import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validat
30
30
  import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.js';
31
31
  import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
32
32
  import { trialValidate } from './trial.js';
33
- import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
33
+ import { catalogRepoKey, codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
34
34
  import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
35
35
  import { classifyPnpmFailure } from './pnpm-compat.js';
36
36
  import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
@@ -228,6 +228,23 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
228
228
  const analyzeActiveProfile = () => analyzeProfile(activeProfileDir, {
229
229
  ...(config.dshInstallDir === undefined ? {} : { dshInstallDir: config.dshInstallDir }),
230
230
  });
231
+ /**
232
+ * The running installation's own anchor, in the shape `trialValidate` wants.
233
+ *
234
+ * The host hands the market this anchor (`profileContext.installAnchor`) and
235
+ * the diagnostics panel has always analysed with it, while every trial
236
+ * validation fell back to `findDshInstallDir()`'s filesystem probe. The two
237
+ * were therefore judging the SAME composition from DIFFERENT inputs, and
238
+ * disagreeing about it: the panel reported a plugin as fine while the update
239
+ * route answered 422 for the identical bundle order (#781 review). On a host
240
+ * whose own detection fails, the probe also resolves the official bundles out
241
+ * of whatever profile tree it can reach, which is how an install/update gets
242
+ * rolled back over a layer the running host never loaded.
243
+ *
244
+ * `undefined` keeps the old fallback behaviour, so this only ever adds
245
+ * information.
246
+ */
247
+ const hostAnchorOption = config.dshInstallDir === undefined ? {} : { dshInstallDir: config.dshInstallDir };
231
248
  const persistentLogFile = join(activeProfileDir, '.dsh-market', 'log.ndjson');
232
249
  const discoveryManifests = new DiscoveryManifestIndex(join(activeProfileDir, '.dsh-market', 'discovery-compatibility-v1.json'));
233
250
  configurePersistentLog(persistentLogFile);
@@ -1384,6 +1401,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
1384
1401
  .filter(name => SELF_NAMES.has(name))
1385
1402
  .map(name => [name, channel]));
1386
1403
  const onlineSourceFor = new Map();
1404
+ // Which registry name — if any — an archive-URL install may be updated
1405
+ // through, keyed by the GitHub repo the archive came from (#768). Same
1406
+ // failure semantics as below: a failed registry load leaves the map
1407
+ // empty, and an empty map authorizes nothing.
1408
+ const catalogNpmByRepo = new Map();
1387
1409
  try {
1388
1410
  const registry = await loadRegistry();
1389
1411
  for (const [name, spec] of Object.entries(installed)) {
@@ -1391,11 +1413,18 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
1391
1413
  if (source !== null)
1392
1414
  onlineSourceFor.set(name, source);
1393
1415
  }
1416
+ for (const plugin of registry.plugins) {
1417
+ if (plugin.npm === null || plugin.npm === undefined)
1418
+ continue;
1419
+ const repoKey = catalogRepoKey(plugin.url);
1420
+ if (repoKey !== null)
1421
+ catalogNpmByRepo.set(repoKey, plugin.npm);
1422
+ }
1394
1423
  }
1395
1424
  catch (error) {
1396
1425
  logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`);
1397
1426
  }
1398
- return { channelFor, onlineSourceFor };
1427
+ return { channelFor, onlineSourceFor, catalogNpmByRepo };
1399
1428
  }
1400
1429
  /**
1401
1430
  * Pre-install host compatibility refusal, shared by the update route and
@@ -1514,8 +1543,8 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
1514
1543
  return;
1515
1544
  }
1516
1545
  try {
1517
- const { channelFor, onlineSourceFor } = await updateCheckInputs();
1518
- const updates = await checkUpdates(config.profile, forceCheckFrom(request), activeProfileDir, channelFor, onlineSourceFor);
1546
+ const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs();
1547
+ const updates = await checkUpdates(config.profile, forceCheckFrom(request), activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo);
1519
1548
  // `packages` carries the same objects the single-package endpoint
1520
1549
  // returns, so one parser serves both. Only updatable ones: a badge
1521
1550
  // wants the count, a panel wants the rows, and neither wants to
@@ -1561,8 +1590,8 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
1561
1590
  // The same inputs the market page builds, so a generation (#497)
1562
1591
  // or a catalog-matched local package answers here with the
1563
1592
  // release it can be compared against rather than with nothing.
1564
- const { channelFor, onlineSourceFor } = await updateCheckInputs();
1565
- const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor))[name];
1593
+ const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs();
1594
+ const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo))[name];
1566
1595
  if (update === undefined) {
1567
1596
  sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' });
1568
1597
  return;
@@ -2185,7 +2214,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
2185
2214
  return;
2186
2215
  }
2187
2216
  }
2188
- const trial = trialValidate(activeProfileDir, order);
2217
+ const trial = trialValidate(activeProfileDir, order, hostAnchorOption);
2189
2218
  if (!trial.ok) {
2190
2219
  const first = trial.errors[0];
2191
2220
  logEvent('warn', 'bundle-order', `rejected by trial validation: ${first?.message ?? 'unknown'}`);
@@ -2267,7 +2296,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
2267
2296
  // a concurrent pnpm run or another direct write must not interleave
2268
2297
  // (issue #98 analysis: write-route mutual exclusion).
2269
2298
  if (body.action === 'preview') {
2270
- const previewed = previewPreset(activeProfileDir, name);
2299
+ const previewed = previewPreset(activeProfileDir, name, config.dshInstallDir);
2271
2300
  sendJson(response, previewed.ok ? 200 : 422, previewed);
2272
2301
  return;
2273
2302
  }
@@ -2280,7 +2309,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
2280
2309
  }
2281
2310
  case 'apply': {
2282
2311
  pendingRollbacks.clear();
2283
- const applied = applyPreset(activeProfileDir, name, maxSnapshots);
2312
+ const applied = applyPreset(activeProfileDir, name, maxSnapshots, config.dshInstallDir);
2284
2313
  if (applied.ok) {
2285
2314
  invalidateUpdates();
2286
2315
  refreshMarketState();
@@ -3212,7 +3241,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3212
3241
  }
3213
3242
  try {
3214
3243
  const force = (request.url ?? '').includes('force=1');
3215
- const { channelFor, onlineSourceFor } = await updateCheckInputs();
3244
+ const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs();
3216
3245
  // Migration hints are this listing's own business: the market page
3217
3246
  // is where "this could come from npm now" is offered, and no other
3218
3247
  // caller acts on it.
@@ -3229,7 +3258,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3229
3258
  catch (error) {
3230
3259
  logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`);
3231
3260
  }
3232
- const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor);
3261
+ const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo);
3233
3262
  for (const [name, migration] of sourceMigrationFor) {
3234
3263
  const status = updates[name];
3235
3264
  if (status !== undefined)
@@ -3397,7 +3426,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3397
3426
  return;
3398
3427
  }
3399
3428
  const stack = readBundleStack(activeProfileDir);
3400
- const trial = trialValidate(activeProfileDir, stack.community);
3429
+ const trial = trialValidate(activeProfileDir, stack.community, hostAnchorOption);
3401
3430
  if (!trial.ok) {
3402
3431
  await failWithRollback(`迁移后的 profile 无法通过启动校验(${trial.errors[0]?.message ?? 'unknown'})。 / The migrated profile failed boot validation (${trial.errors[0]?.message ?? 'unknown'}).`);
3403
3432
  return;
@@ -3617,6 +3646,23 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3617
3646
  // classification chooses rollback mechanics; it must not weaken
3618
3647
  // the existing registry target/downgrade validation.
3619
3648
  const usesNpmUpdateTarget = !restore && !isGit;
3649
+ // #768: an archive URL carries no registry identity, so
3650
+ // name@<tag> would not update this install — it would replace it
3651
+ // with whatever package shares its name. Only proceed when a
3652
+ // catalog entry owns both the repo the archive came from and
3653
+ // this name; anything else keeps the plugin exactly as it is.
3654
+ if (usesNpmUpdateTarget && /^https?:/i.test(spec)) {
3655
+ const repo = lookupRepoFromUrl(spec);
3656
+ const repoKey = repo === null ? null : catalogRepoKey(repo);
3657
+ const { catalogNpmByRepo } = await updateCheckInputs();
3658
+ if (repoKey === null || catalogNpmByRepo.get(repoKey) !== name) {
3659
+ logEvent('warn', 'updates', `refused update for ${name}: no catalog entry owns both the archive's repo and this name (${spec})`);
3660
+ sendJson(response, 400, {
3661
+ error: `不能更新 ${name}:它是从一个压缩包链接装的,市场里的同名插件是另一个包,按名字更新会把它整个换掉。插件没有改动;想换成市场版本,先卸载它,再从市场安装。 / Cannot update ${name}: it was installed from an archive link, and the market's same-named plugin is a different package, so updating by name would replace it. Nothing was changed. To switch, uninstall it first, then install from the market.`,
3662
+ });
3663
+ return;
3664
+ }
3665
+ }
3620
3666
  // `@latest` was hardcoded, so a beta subscriber would have been
3621
3667
  // told an update existed and then handed the stable build. The
3622
3668
  // dist-tag has to follow the same setting the offer came from.
@@ -4229,7 +4275,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
4229
4275
  let trialError = null;
4230
4276
  if (ok) {
4231
4277
  const stack = readBundleStack(activeProfileDir);
4232
- const trial = trialValidate(activeProfileDir, stack.community);
4278
+ const trial = trialValidate(activeProfileDir, stack.community, hostAnchorOption);
4233
4279
  if (!trial.ok) {
4234
4280
  ok = false;
4235
4281
  // Name the LAYER, not only the message: the first error is
package/lib/sources.js CHANGED
@@ -210,6 +210,20 @@ export function lookupRepoFromUrl(url) {
210
210
  return `https://github.com/${releaseAsset[1]}`;
211
211
  return null;
212
212
  }
213
+ /**
214
+ * Repo identity (`owner/repo`, lowercased) of any GitHub URL, ignoring the
215
+ * path after the repo — the plain repo page, monorepo subpaths, and the
216
+ * Release asset URLs `lookupRepoFromUrl` returns all fold to one key.
217
+ * Non-GitHub URLs return null.
218
+ *
219
+ * Update authorization for archive-URL installs (#768): the catalog entry
220
+ * has to own both the repo the archive came from and the registry name the
221
+ * update would use, and this is the repo side of that comparison.
222
+ */
223
+ export function catalogRepoKey(url) {
224
+ const repo = /^https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)/i.exec(url);
225
+ return repo === null ? null : `${repo[1]}/${repo[2]}`.toLowerCase();
226
+ }
213
227
  /**
214
228
  * Normalized identity of an install target, for comparing two targets that
215
229
  * may be spelled differently — lowercased, matching `githubRepoIdentity`.
@@ -77,7 +77,9 @@ export interface PresetMismatch {
77
77
  * reports the mismatch alongside the still-computable changes (relative
78
78
  * order + enabled/disabled diffs over the intersection).
79
79
  */
80
- export declare function previewPreset(profileDir: string, name: unknown): PresetResult & {
80
+ export declare function previewPreset(profileDir: string, name: unknown,
81
+ /** The host's own installation anchor, when it gave one (#781 review). */
82
+ dshInstallDir?: string | null): PresetResult & {
81
83
  changes?: PresetChange;
82
84
  mismatch?: PresetMismatch;
83
85
  };
@@ -87,4 +89,6 @@ export declare function previewPreset(profileDir: string, name: unknown): Preset
87
89
  * then write the bundle order and the disable list. The response carries the
88
90
  * change preview so the UI can report exactly what moved.
89
91
  */
90
- export declare function applyPreset(profileDir: string, name: unknown, maxSnapshots?: number): PresetApplyResult;
92
+ export declare function applyPreset(profileDir: string, name: unknown, maxSnapshots?: number,
93
+ /** The host's own installation anchor, when it gave one (#781 review). */
94
+ dshInstallDir?: string | null): PresetApplyResult;
@@ -64,6 +64,17 @@ export declare function codeloadTarball(repo: string, sha: string, proxy: string
64
64
  * URL can serve different bytes at different times).
65
65
  */
66
66
  export declare function lookupRepoFromUrl(url: string): string | null;
67
+ /**
68
+ * Repo identity (`owner/repo`, lowercased) of any GitHub URL, ignoring the
69
+ * path after the repo — the plain repo page, monorepo subpaths, and the
70
+ * Release asset URLs `lookupRepoFromUrl` returns all fold to one key.
71
+ * Non-GitHub URLs return null.
72
+ *
73
+ * Update authorization for archive-URL installs (#768): the catalog entry
74
+ * has to own both the repo the archive came from and the registry name the
75
+ * update would use, and this is the repo side of that comparison.
76
+ */
77
+ export declare function catalogRepoKey(url: string): string | null;
67
78
  /**
68
79
  * Normalized identity of an install target, for comparing two targets that
69
80
  * may be spelled differently — lowercased, matching `githubRepoIdentity`.
@@ -134,4 +134,11 @@ channelFor?: ReadonlyMap<string, Channel>,
134
134
  * the generations the desktop host links in (#497). Any other `link:` is
135
135
  * a development workspace and is never opted into online updates.
136
136
  */
137
- onlineSourceFor?: ReadonlyMap<string, string>): Promise<Record<string, UpdateStatus>>;
137
+ onlineSourceFor?: ReadonlyMap<string, string>,
138
+ /**
139
+ * Registry names an archive-URL install may be checked against, keyed by
140
+ * the GitHub repo (`owner/repo`) the archive came from (#768). Without an
141
+ * entry that owns both the repo and the name, the npm fallback would
142
+ * compare the install with whatever package merely shares its name.
143
+ */
144
+ catalogNpmByRepo?: ReadonlyMap<string, string>): Promise<Record<string, UpdateStatus>>;
package/lib/updates.js CHANGED
@@ -8,7 +8,7 @@ import { resolveHeadCommit } from './accelerate.js';
8
8
  import { marketFetch } from './net.js';
9
9
  import { activeRegion } from './regions.js';
10
10
  import { profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits } from './profile.js';
11
- import { gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, repoOfTarget } from './sources.js';
11
+ import { catalogRepoKey, gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, lookupRepoFromUrl, repoOfTarget } from './sources.js';
12
12
  const UPDATES_TTL_MS = 30 * 60 * 1000;
13
13
  const GIT_REMOTE_HEAD_TIMEOUT_MS = 6000;
14
14
  let updatesCache = null;
@@ -271,13 +271,20 @@ channelFor = new Map(),
271
271
  * the generations the desktop host links in (#497). Any other `link:` is
272
272
  * a development workspace and is never opted into online updates.
273
273
  */
274
- onlineSourceFor = new Map()) {
274
+ onlineSourceFor = new Map(),
275
+ /**
276
+ * Registry names an archive-URL install may be checked against, keyed by
277
+ * the GitHub repo (`owner/repo`) the archive came from (#768). Without an
278
+ * entry that owns both the repo and the name, the npm fallback would
279
+ * compare the install with whatever package merely shares its name.
280
+ */
281
+ catalogNpmByRepo = new Map()) {
275
282
  const activeProfileDir = profileDir(profile, explicitDir);
276
283
  // The channel is part of the key: switching to betas has to change the
277
284
  // answer immediately, and a cache keyed on the profile alone would serve
278
285
  // the stable verdict for the rest of the TTL — reading as "the setting did
279
286
  // nothing".
280
- const cacheKey = `${activeProfileDir}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}`;
287
+ const cacheKey = `${activeProfileDir}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}\u0000${[...catalogNpmByRepo].map(([n, s]) => `${n}:${s}`).sort().join(',')}`;
281
288
  if (!force && updatesCache?.key === cacheKey && Date.now() - updatesCache.at < UPDATES_TTL_MS) {
282
289
  return updatesCache.data;
283
290
  }
@@ -379,6 +386,19 @@ onlineSourceFor = new Map()) {
379
386
  };
380
387
  }
381
388
  else {
389
+ // A URL install (release archive, self-hosted tarball) has no
390
+ // registry identity of its own. Checking npm by name alone pairs it
391
+ // with whatever package shares that name (#768), so only proceed
392
+ // when a catalog entry owns both the repo the archive came from and
393
+ // this name; otherwise report no update rather than a false one.
394
+ if (/^https?:/i.test(spec)) {
395
+ const repo = lookupRepoFromUrl(spec);
396
+ const repoKey = repo === null ? null : catalogRepoKey(repo);
397
+ if (repoKey === null || catalogNpmByRepo.get(repoKey) !== name) {
398
+ result[name] = { kind: 'npm', version, current: version, latest: null, updateAvailable: false };
399
+ return;
400
+ }
401
+ }
382
402
  const meta = (await fetchJson(npmUrl(`${encodeURIComponent(name)}/latest`)));
383
403
  const stable = typeof meta.version === 'string' ? meta.version : null;
384
404
  const channel = channelFor.get(name);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dshmarket",
3
3
  "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
4
- "version": "1.66.7",
4
+ "version": "1.66.8",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
package/src/presets.ts CHANGED
@@ -211,7 +211,12 @@ function presetMismatch(profileDir: string, bundleOrder: string[]): PresetMismat
211
211
  * reports the mismatch alongside the still-computable changes (relative
212
212
  * order + enabled/disabled diffs over the intersection).
213
213
  */
214
- export function previewPreset(profileDir: string, name: unknown): PresetResult & { changes?: PresetChange; mismatch?: PresetMismatch } {
214
+ export function previewPreset(
215
+ profileDir: string,
216
+ name: unknown,
217
+ /** The host's own installation anchor, when it gave one (#781 review). */
218
+ dshInstallDir?: string | null,
219
+ ): PresetResult & { changes?: PresetChange; mismatch?: PresetMismatch } {
215
220
  if (typeof name !== 'string') return { ok: false, error: 'invalid preset name / 组合名称无效' }
216
221
  const preset = readPresets(profileDir).find(item => item.name === name)
217
222
  if (preset === undefined) return { ok: false, error: 'preset not found / 组合不存在' }
@@ -228,7 +233,7 @@ export function previewPreset(profileDir: string, name: unknown): PresetResult &
228
233
  }
229
234
  }
230
235
 
231
- const trial = trialValidate(profileDir, preset.bundleOrder)
236
+ const trial = trialValidate(profileDir, preset.bundleOrder, dshInstallDir === undefined ? {} : { dshInstallDir })
232
237
  if (!trial.ok) {
233
238
  const first = trial.errors[0]
234
239
  return {
@@ -262,7 +267,13 @@ export function previewPreset(profileDir: string, name: unknown): PresetResult &
262
267
  * then write the bundle order and the disable list. The response carries the
263
268
  * change preview so the UI can report exactly what moved.
264
269
  */
265
- export function applyPreset(profileDir: string, name: unknown, maxSnapshots: number = DEFAULT_MAX_SNAPSHOTS): PresetApplyResult {
270
+ export function applyPreset(
271
+ profileDir: string,
272
+ name: unknown,
273
+ maxSnapshots: number = DEFAULT_MAX_SNAPSHOTS,
274
+ /** The host's own installation anchor, when it gave one (#781 review). */
275
+ dshInstallDir?: string | null,
276
+ ): PresetApplyResult {
266
277
  if (typeof name !== 'string') return { ok: false, error: 'invalid preset name / 组合名称无效' }
267
278
  const preset = readPresets(profileDir).find(item => item.name === name)
268
279
  if (preset === undefined) return { ok: false, error: 'preset not found / 组合不存在' }
@@ -279,7 +290,7 @@ export function applyPreset(profileDir: string, name: unknown, maxSnapshots: num
279
290
  }
280
291
  }
281
292
 
282
- const trial = trialValidate(profileDir, preset.bundleOrder)
293
+ const trial = trialValidate(profileDir, preset.bundleOrder, dshInstallDir === undefined ? {} : { dshInstallDir })
283
294
  if (!trial.ok) {
284
295
  const first = trial.errors[0]
285
296
  logEvent('warn', 'preset', `apply "${name}" rejected by trial validation: ${first?.message ?? 'unknown'}`)
package/src/routes.ts CHANGED
@@ -38,7 +38,7 @@ import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validat
38
38
  import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.ts'
39
39
  import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.ts'
40
40
  import { trialValidate } from './trial.ts'
41
- import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.ts'
41
+ import { catalogRepoKey, codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.ts'
42
42
  import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
43
43
  import { classifyPnpmFailure } from './pnpm-compat.ts'
44
44
  import { asChannel, CHANNELS, DIST_TAG, resolveChannel, type Channel } from './channels.ts'
@@ -326,6 +326,23 @@ export function mountMarketRoutes(
326
326
  const analyzeActiveProfile = () => analyzeProfile(activeProfileDir, {
327
327
  ...(config.dshInstallDir === undefined ? {} : { dshInstallDir: config.dshInstallDir }),
328
328
  })
329
+ /**
330
+ * The running installation's own anchor, in the shape `trialValidate` wants.
331
+ *
332
+ * The host hands the market this anchor (`profileContext.installAnchor`) and
333
+ * the diagnostics panel has always analysed with it, while every trial
334
+ * validation fell back to `findDshInstallDir()`'s filesystem probe. The two
335
+ * were therefore judging the SAME composition from DIFFERENT inputs, and
336
+ * disagreeing about it: the panel reported a plugin as fine while the update
337
+ * route answered 422 for the identical bundle order (#781 review). On a host
338
+ * whose own detection fails, the probe also resolves the official bundles out
339
+ * of whatever profile tree it can reach, which is how an install/update gets
340
+ * rolled back over a layer the running host never loaded.
341
+ *
342
+ * `undefined` keeps the old fallback behaviour, so this only ever adds
343
+ * information.
344
+ */
345
+ const hostAnchorOption = config.dshInstallDir === undefined ? {} : { dshInstallDir: config.dshInstallDir }
329
346
  const persistentLogFile = join(activeProfileDir, '.dsh-market', 'log.ndjson')
330
347
  const discoveryManifests = new DiscoveryManifestIndex(
331
348
  join(activeProfileDir, '.dsh-market', 'discovery-compatibility-v1.json'),
@@ -1528,6 +1545,7 @@ export function mountMarketRoutes(
1528
1545
  async function updateCheckInputs(): Promise<{
1529
1546
  channelFor: Map<string, Channel>
1530
1547
  onlineSourceFor: Map<string, string>
1548
+ catalogNpmByRepo: Map<string, string>
1531
1549
  }> {
1532
1550
  // Only the market itself follows the channel setting (see
1533
1551
  // MarketSettings.channel): a user opting into betas is volunteering to
@@ -1541,16 +1559,26 @@ export function mountMarketRoutes(
1541
1559
  .map(name => [name, channel] as const),
1542
1560
  )
1543
1561
  const onlineSourceFor = new Map<string, string>()
1562
+ // Which registry name — if any — an archive-URL install may be updated
1563
+ // through, keyed by the GitHub repo the archive came from (#768). Same
1564
+ // failure semantics as below: a failed registry load leaves the map
1565
+ // empty, and an empty map authorizes nothing.
1566
+ const catalogNpmByRepo = new Map<string, string>()
1544
1567
  try {
1545
1568
  const registry = await loadRegistry()
1546
1569
  for (const [name, spec] of Object.entries(installed)) {
1547
1570
  const source = onlineSourceOf(registry.plugins, name, spec)
1548
1571
  if (source !== null) onlineSourceFor.set(name, source)
1549
1572
  }
1573
+ for (const plugin of registry.plugins) {
1574
+ if (plugin.npm === null || plugin.npm === undefined) continue
1575
+ const repoKey = catalogRepoKey(plugin.url)
1576
+ if (repoKey !== null) catalogNpmByRepo.set(repoKey, plugin.npm)
1577
+ }
1550
1578
  } catch (error) {
1551
1579
  logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`)
1552
1580
  }
1553
- return { channelFor, onlineSourceFor }
1581
+ return { channelFor, onlineSourceFor, catalogNpmByRepo }
1554
1582
  }
1555
1583
 
1556
1584
  /**
@@ -1681,8 +1709,8 @@ export function mountMarketRoutes(
1681
1709
  return
1682
1710
  }
1683
1711
  try {
1684
- const { channelFor, onlineSourceFor } = await updateCheckInputs()
1685
- const updates = await checkUpdates(config.profile, forceCheckFrom(request), activeProfileDir, channelFor, onlineSourceFor)
1712
+ const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs()
1713
+ const updates = await checkUpdates(config.profile, forceCheckFrom(request), activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo)
1686
1714
  // `packages` carries the same objects the single-package endpoint
1687
1715
  // returns, so one parser serves both. Only updatable ones: a badge
1688
1716
  // wants the count, a panel wants the rows, and neither wants to
@@ -1728,8 +1756,8 @@ export function mountMarketRoutes(
1728
1756
  // The same inputs the market page builds, so a generation (#497)
1729
1757
  // or a catalog-matched local package answers here with the
1730
1758
  // release it can be compared against rather than with nothing.
1731
- const { channelFor, onlineSourceFor } = await updateCheckInputs()
1732
- const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor))[name]
1759
+ const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs()
1760
+ const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo))[name]
1733
1761
  if (update === undefined) {
1734
1762
  sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' })
1735
1763
  return
@@ -2363,7 +2391,7 @@ export function mountMarketRoutes(
2363
2391
  return
2364
2392
  }
2365
2393
  }
2366
- const trial = trialValidate(activeProfileDir, order)
2394
+ const trial = trialValidate(activeProfileDir, order, hostAnchorOption)
2367
2395
  if (!trial.ok) {
2368
2396
  const first = trial.errors[0]
2369
2397
  logEvent('warn', 'bundle-order', `rejected by trial validation: ${first?.message ?? 'unknown'}`)
@@ -2444,7 +2472,7 @@ export function mountMarketRoutes(
2444
2472
  // a concurrent pnpm run or another direct write must not interleave
2445
2473
  // (issue #98 analysis: write-route mutual exclusion).
2446
2474
  if (body.action === 'preview') {
2447
- const previewed = previewPreset(activeProfileDir, name)
2475
+ const previewed = previewPreset(activeProfileDir, name, config.dshInstallDir)
2448
2476
  sendJson(response, previewed.ok ? 200 : 422, previewed)
2449
2477
  return
2450
2478
  }
@@ -2457,7 +2485,7 @@ export function mountMarketRoutes(
2457
2485
  }
2458
2486
  case 'apply': {
2459
2487
  pendingRollbacks.clear()
2460
- const applied = applyPreset(activeProfileDir, name, maxSnapshots)
2488
+ const applied = applyPreset(activeProfileDir, name, maxSnapshots, config.dshInstallDir)
2461
2489
  if (applied.ok) {
2462
2490
  invalidateUpdates()
2463
2491
  refreshMarketState()
@@ -3375,7 +3403,7 @@ export function mountMarketRoutes(
3375
3403
  }
3376
3404
  try {
3377
3405
  const force = (request.url ?? '').includes('force=1')
3378
- const { channelFor, onlineSourceFor } = await updateCheckInputs()
3406
+ const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs()
3379
3407
  // Migration hints are this listing's own business: the market page
3380
3408
  // is where "this could come from npm now" is offered, and no other
3381
3409
  // caller acts on it.
@@ -3390,7 +3418,7 @@ export function mountMarketRoutes(
3390
3418
  } catch (error) {
3391
3419
  logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`)
3392
3420
  }
3393
- const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor)
3421
+ const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo)
3394
3422
  for (const [name, migration] of sourceMigrationFor) {
3395
3423
  const status = updates[name]
3396
3424
  if (status !== undefined) updates[name] = { ...status, sourceMigration: migration }
@@ -3571,7 +3599,7 @@ sendJson(response, 200, { updates })
3571
3599
  }
3572
3600
 
3573
3601
  const stack = readBundleStack(activeProfileDir)
3574
- const trial = trialValidate(activeProfileDir, stack.community)
3602
+ const trial = trialValidate(activeProfileDir, stack.community, hostAnchorOption)
3575
3603
  if (!trial.ok) {
3576
3604
  await failWithRollback(`迁移后的 profile 无法通过启动校验(${trial.errors[0]?.message ?? 'unknown'})。 / The migrated profile failed boot validation (${trial.errors[0]?.message ?? 'unknown'}).`)
3577
3605
  return
@@ -3788,6 +3816,23 @@ sendJson(response, 200, { updates })
3788
3816
  // classification chooses rollback mechanics; it must not weaken
3789
3817
  // the existing registry target/downgrade validation.
3790
3818
  const usesNpmUpdateTarget = !restore && !isGit
3819
+ // #768: an archive URL carries no registry identity, so
3820
+ // name@<tag> would not update this install — it would replace it
3821
+ // with whatever package shares its name. Only proceed when a
3822
+ // catalog entry owns both the repo the archive came from and
3823
+ // this name; anything else keeps the plugin exactly as it is.
3824
+ if (usesNpmUpdateTarget && /^https?:/i.test(spec)) {
3825
+ const repo = lookupRepoFromUrl(spec)
3826
+ const repoKey = repo === null ? null : catalogRepoKey(repo)
3827
+ const { catalogNpmByRepo } = await updateCheckInputs()
3828
+ if (repoKey === null || catalogNpmByRepo.get(repoKey) !== name) {
3829
+ logEvent('warn', 'updates', `refused update for ${name}: no catalog entry owns both the archive's repo and this name (${spec})`)
3830
+ sendJson(response, 400, {
3831
+ error: `不能更新 ${name}:它是从一个压缩包链接装的,市场里的同名插件是另一个包,按名字更新会把它整个换掉。插件没有改动;想换成市场版本,先卸载它,再从市场安装。 / Cannot update ${name}: it was installed from an archive link, and the market's same-named plugin is a different package, so updating by name would replace it. Nothing was changed. To switch, uninstall it first, then install from the market.`,
3832
+ })
3833
+ return
3834
+ }
3835
+ }
3791
3836
  // `@latest` was hardcoded, so a beta subscriber would have been
3792
3837
  // told an update existed and then handed the stable build. The
3793
3838
  // dist-tag has to follow the same setting the offer came from.
@@ -4399,7 +4444,7 @@ sendJson(response, 200, { updates })
4399
4444
  let trialError: string | null = null
4400
4445
  if (ok) {
4401
4446
  const stack = readBundleStack(activeProfileDir)
4402
- const trial = trialValidate(activeProfileDir, stack.community)
4447
+ const trial = trialValidate(activeProfileDir, stack.community, hostAnchorOption)
4403
4448
  if (!trial.ok) {
4404
4449
  ok = false
4405
4450
  // Name the LAYER, not only the message: the first error is
package/src/sources.ts CHANGED
@@ -208,6 +208,21 @@ export function lookupRepoFromUrl(url: string): string | null {
208
208
  return null
209
209
  }
210
210
 
211
+ /**
212
+ * Repo identity (`owner/repo`, lowercased) of any GitHub URL, ignoring the
213
+ * path after the repo — the plain repo page, monorepo subpaths, and the
214
+ * Release asset URLs `lookupRepoFromUrl` returns all fold to one key.
215
+ * Non-GitHub URLs return null.
216
+ *
217
+ * Update authorization for archive-URL installs (#768): the catalog entry
218
+ * has to own both the repo the archive came from and the registry name the
219
+ * update would use, and this is the repo side of that comparison.
220
+ */
221
+ export function catalogRepoKey(url: string): string | null {
222
+ const repo = /^https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)/i.exec(url)
223
+ return repo === null ? null : `${repo[1]}/${repo[2]}`.toLowerCase()
224
+ }
225
+
211
226
  /**
212
227
  * Normalized identity of an install target, for comparing two targets that
213
228
  * may be spelled differently — lowercased, matching `githubRepoIdentity`.
package/src/updates.ts CHANGED
@@ -9,7 +9,7 @@ import { resolveHeadCommit } from './accelerate.ts'
9
9
  import { marketFetch } from './net.ts'
10
10
  import { activeRegion } from './regions.ts'
11
11
  import { profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits } from './profile.ts'
12
- import { gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, repoOfTarget } from './sources.ts'
12
+ import { catalogRepoKey, gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, lookupRepoFromUrl, repoOfTarget } from './sources.ts'
13
13
 
14
14
  export interface UpdateStatus {
15
15
  /**
@@ -317,13 +317,20 @@ export async function checkUpdates(
317
317
  * a development workspace and is never opted into online updates.
318
318
  */
319
319
  onlineSourceFor: ReadonlyMap<string, string> = new Map(),
320
+ /**
321
+ * Registry names an archive-URL install may be checked against, keyed by
322
+ * the GitHub repo (`owner/repo`) the archive came from (#768). Without an
323
+ * entry that owns both the repo and the name, the npm fallback would
324
+ * compare the install with whatever package merely shares its name.
325
+ */
326
+ catalogNpmByRepo: ReadonlyMap<string, string> = new Map(),
320
327
  ): Promise<Record<string, UpdateStatus>> {
321
328
  const activeProfileDir = profileDir(profile, explicitDir)
322
329
  // The channel is part of the key: switching to betas has to change the
323
330
  // answer immediately, and a cache keyed on the profile alone would serve
324
331
  // the stable verdict for the rest of the TTL — reading as "the setting did
325
332
  // nothing".
326
- const cacheKey = `${activeProfileDir}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}`
333
+ const cacheKey = `${activeProfileDir}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}\u0000${[...catalogNpmByRepo].map(([n, s]) => `${n}:${s}`).sort().join(',')}`
327
334
  if (!force && updatesCache?.key === cacheKey && Date.now() - updatesCache.at < UPDATES_TTL_MS) {
328
335
  return updatesCache.data
329
336
  }
@@ -425,6 +432,19 @@ export async function checkUpdates(
425
432
  updateAvailable: current !== null && latest !== null && current !== latest,
426
433
  }
427
434
  } else {
435
+ // A URL install (release archive, self-hosted tarball) has no
436
+ // registry identity of its own. Checking npm by name alone pairs it
437
+ // with whatever package shares that name (#768), so only proceed
438
+ // when a catalog entry owns both the repo the archive came from and
439
+ // this name; otherwise report no update rather than a false one.
440
+ if (/^https?:/i.test(spec)) {
441
+ const repo = lookupRepoFromUrl(spec)
442
+ const repoKey = repo === null ? null : catalogRepoKey(repo)
443
+ if (repoKey === null || catalogNpmByRepo.get(repoKey) !== name) {
444
+ result[name] = { kind: 'npm', version, current: version, latest: null, updateAvailable: false }
445
+ return
446
+ }
447
+ }
428
448
  const meta = (await fetchJson(npmUrl(`${encodeURIComponent(name)}/latest`))) as { version?: string }
429
449
  const stable = typeof meta.version === 'string' ? meta.version : null
430
450
  const channel = channelFor.get(name)