dshmarket 1.49.0 → 1.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -1150,7 +1150,44 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1150
1150
  * rather than guessing; declared repo evidence that matches nothing in the
1151
1151
  * catalog must not fall back to a coincidental unique name.
1152
1152
  */
1153
+ /**
1154
+ * Memo for findCatalogEntryForLocal, keyed on the catalog array identity and
1155
+ * then the full lookup input (#589).
1156
+ *
1157
+ * The market client calls this once per rendered card for every link:/file:
1158
+ * dependency, and each call filtered the whole catalog and walked every URL —
1159
+ * measured ~300ms per render at 24 cards against a 3,627-entry catalog, on
1160
+ * every keystroke of the search box. The result is a pure function of the
1161
+ * catalog, the name, the identities, and the hints, so caching by the array
1162
+ * identity never serves a stale answer: a refetched catalog is a new array,
1163
+ * and the old inner map becomes collectable. Null results are cached too —
1164
+ * they are the common answer for same-named forks without evidence.
1165
+ *
1166
+ * The input key is the JSON form of the whole tuple, not a delimiter join:
1167
+ * an empty array and an array holding one empty string serialize
1168
+ * differently, and the matcher treats them differently (`identitySet.size >
1169
+ * 0` chooses the evidence branch), so the key must not conflate them
1170
+ * (#485's lesson: weak signals must never override strong ones).
1171
+ */
1172
+ const localMatchCache = /* @__PURE__ */ new WeakMap();
1153
1173
  function findCatalogEntryForLocal(plugins, name, identities = [], hints = []) {
1174
+ const cacheKey = JSON.stringify([
1175
+ name,
1176
+ identities,
1177
+ hints
1178
+ ]);
1179
+ let byCatalog = localMatchCache.get(plugins);
1180
+ if (byCatalog === void 0) {
1181
+ byCatalog = /* @__PURE__ */ new Map();
1182
+ localMatchCache.set(plugins, byCatalog);
1183
+ }
1184
+ const hit = byCatalog.get(cacheKey);
1185
+ if (hit !== void 0) return hit;
1186
+ const result = findCatalogEntryForLocalUncached(plugins, name, identities, hints);
1187
+ byCatalog.set(cacheKey, result);
1188
+ return result;
1189
+ }
1190
+ function findCatalogEntryForLocalUncached(plugins, name, identities, hints) {
1154
1191
  const nameKey = name.toLowerCase();
1155
1192
  const byName = plugins.filter((plugin) => plugin.name.toLowerCase() === nameKey || typeof plugin.npm === "string" && plugin.npm.toLowerCase() === nameKey);
1156
1193
  const identitySet = new Set(identities.map((value) => value.toLowerCase()));
@@ -1655,56 +1692,6 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1655
1692
  for (const id of entryIdentities(plugin)) if (dep.has(id)) return true;
1656
1693
  return false;
1657
1694
  }
1658
- /**
1659
- * The same memo, for the branch #262 left behind (#589).
1660
- *
1661
- * `looseMatchCount` above covers dependencies installed by version. A
1662
- * `link:` or `file:` dependency takes the other branch, into
1663
- * `findCatalogEntryForLocal`, which walks the whole catalog at least twice
1664
- * per call — once to filter by name, once to collect `/tree/` repos — and
1665
- * up to twice more when there are identities to probe. Both callers below
1666
- * run once per rendered card. The reporter profiled ~300ms per repaint at
1667
- * 24 cards against a 3,627-entry catalog where a version-pinned dependency
1668
- * paid 1.1ms; a local benchmark measured ~38ms per render at that shape,
1669
- * and ~1.4s at 96 cards with eight local dependencies.
1670
- *
1671
- * The inner key carries the EVIDENCE, not just the name. Installing a plugin
1672
- * hands the next render a fresh identities array while the catalog array
1673
- * stays the same, so a name-only key would answer the post-install question
1674
- * with the pre-install result — which is the same-named-fork confusion #485
1675
- * asked this matcher to stop making, reintroduced as a cache bug.
1676
- *
1677
- * A miss is cached as `null`, which is why the "not cached yet" sentinel
1678
- * has to be `undefined`: `null` is a real answer here, and it costs the
1679
- * same full scan to establish as a hit does. It is also the common case —
1680
- * a checkout you are developing is usually not in the catalog at all.
1681
- *
1682
- * The invariant this rests on, stated because the WeakMap cannot enforce it:
1683
- * the catalog array and the entries inside it are frozen once handed here. A
1684
- * refetch replaces the array — which is what the outer key is for — but an
1685
- * in-place `push`, `sort` or `reverse`, or editing a row's `url`, would keep
1686
- * the key and change the answer. Order is load-bearing too: the matcher
1687
- * returns the FIRST row that fits. Nothing in the client does any of this
1688
- * today; `visiblePlugins` and `themePlugins` both sort copies.
1689
- */
1690
- const localMatchCache = /* @__PURE__ */ new WeakMap();
1691
- function cachedEntryForLocal(plugins, name, identities, hints) {
1692
- let byKey = localMatchCache.get(plugins);
1693
- if (byKey === void 0) {
1694
- byKey = /* @__PURE__ */ new Map();
1695
- localMatchCache.set(plugins, byKey);
1696
- }
1697
- const key = JSON.stringify([
1698
- name,
1699
- identities,
1700
- hints
1701
- ]);
1702
- const hit = byKey.get(key);
1703
- if (hit !== void 0) return hit;
1704
- const entry = findCatalogEntryForLocal(plugins, name, identities, hints);
1705
- byKey.set(key, entry);
1706
- return entry;
1707
- }
1708
1695
  /** The installed dependency name a registry entry corresponds to, or null. */
1709
1696
  function matchInstalledName(plugin, installed, repoIdentities = {}, plugins, repoHints = {}) {
1710
1697
  const ids = entryIdentities(plugin);
@@ -1713,7 +1700,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1713
1700
  const repos = repoIdentities[name] ?? [];
1714
1701
  if (/^(?:link|file):/i.test(specStr)) {
1715
1702
  if (plugins === void 0) continue;
1716
- const entry = cachedEntryForLocal(plugins, name, repos, repoHints[name] ?? []);
1703
+ const entry = findCatalogEntryForLocal(plugins, name, repos, repoHints[name] ?? []);
1717
1704
  if (entry !== null && entry.url === plugin.url) return name;
1718
1705
  continue;
1719
1706
  }
@@ -2115,7 +2102,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
2115
2102
  }
2116
2103
  /** Catalog row for an installed dependency — strict for local link:/file: specs. */
2117
2104
  function catalogEntryForInstalled(plugins, name, spec, repoIdentities = [], repoHints = []) {
2118
- if (/^(?:link|file):/i.test(spec)) return cachedEntryForLocal(plugins, name, repoIdentities, repoHints) ?? void 0;
2105
+ if (/^(?:link|file):/i.test(spec)) return findCatalogEntryForLocal(plugins, name, repoIdentities, repoHints) ?? void 0;
2119
2106
  return entryForDep(plugins, name, spec, repoIdentities, repoHints);
2120
2107
  }
2121
2108
  //#endregion
@@ -41,7 +41,41 @@ function catalogEntryMatchesHints(url, hintSet) {
41
41
  * rather than guessing; declared repo evidence that matches nothing in the
42
42
  * catalog must not fall back to a coincidental unique name.
43
43
  */
44
+ /**
45
+ * Memo for findCatalogEntryForLocal, keyed on the catalog array identity and
46
+ * then the full lookup input (#589).
47
+ *
48
+ * The market client calls this once per rendered card for every link:/file:
49
+ * dependency, and each call filtered the whole catalog and walked every URL —
50
+ * measured ~300ms per render at 24 cards against a 3,627-entry catalog, on
51
+ * every keystroke of the search box. The result is a pure function of the
52
+ * catalog, the name, the identities, and the hints, so caching by the array
53
+ * identity never serves a stale answer: a refetched catalog is a new array,
54
+ * and the old inner map becomes collectable. Null results are cached too —
55
+ * they are the common answer for same-named forks without evidence.
56
+ *
57
+ * The input key is the JSON form of the whole tuple, not a delimiter join:
58
+ * an empty array and an array holding one empty string serialize
59
+ * differently, and the matcher treats them differently (`identitySet.size >
60
+ * 0` chooses the evidence branch), so the key must not conflate them
61
+ * (#485's lesson: weak signals must never override strong ones).
62
+ */
63
+ const localMatchCache = new WeakMap();
44
64
  export function findCatalogEntryForLocal(plugins, name, identities = [], hints = []) {
65
+ const cacheKey = JSON.stringify([name, identities, hints]);
66
+ let byCatalog = localMatchCache.get(plugins);
67
+ if (byCatalog === undefined) {
68
+ byCatalog = new Map();
69
+ localMatchCache.set(plugins, byCatalog);
70
+ }
71
+ const hit = byCatalog.get(cacheKey);
72
+ if (hit !== undefined)
73
+ return hit;
74
+ const result = findCatalogEntryForLocalUncached(plugins, name, identities, hints);
75
+ byCatalog.set(cacheKey, result);
76
+ return result;
77
+ }
78
+ function findCatalogEntryForLocalUncached(plugins, name, identities, hints) {
45
79
  const nameKey = name.toLowerCase();
46
80
  const byName = plugins.filter(plugin => plugin.name.toLowerCase() === nameKey
47
81
  || (typeof plugin.npm === 'string' && plugin.npm.toLowerCase() === nameKey));
package/lib/dsh-cli.js CHANGED
@@ -692,7 +692,7 @@ export const BOOT_ID = `${String(process.pid)}-${String(Date.now())}`;
692
692
  */
693
693
  export const TARGET_RE = /^[A-Za-z0-9@:./_#+~^=-]+$/;
694
694
  /** Mutating pnpm commands get the structured reporter appended. */
695
- const NDJSON_COMMANDS = new Set(['add', 'remove', 'install']);
695
+ const NDJSON_COMMANDS = new Set(['add', 'remove', 'install', 'update']);
696
696
  /** Apply profile-specific pnpm compatibility and the structured reporter. */
697
697
  function preparePluginArgs(profileDirectory, pluginArgs) {
698
698
  let args = pluginArgsFor(profileDirectory, [...pluginArgs]);
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Which loader entry belongs to which installed package (#646).
3
+ *
4
+ * The ecosystem does not require a bundle patch's row `name:` to be the
5
+ * package name. Two shapes are ordinary:
6
+ *
7
+ * - id: aegis-method-pack
8
+ * name: aegis/extensions/dsh/index.js ← a SUBPATH inside the package
9
+ * - id: filesystem
10
+ * name: @deepseek-ai/dsh-skill-filesystem ← another package entirely
11
+ *
12
+ * `dsh-chat-import`, `meow-memory` and `dsh-context` all name themselves, so
13
+ * the simple `name === packageName` comparison worked for years. `aegis`
14
+ * names an entry inside itself, and every site that compared the two
15
+ * directly concluded the plugin had no entry — the market reported "no
16
+ * bundle patch … nothing to hot-mount" on enable (#646) and could not see
17
+ * the entry was already live.
18
+ *
19
+ * Three such sites had grown independently (`src/themes.ts`, `src/verify.ts`,
20
+ * `src/routes.ts`), each restating the rule from memory. That is the shape
21
+ * the identity bugs #485→#605 came from: one assumption, several copies,
22
+ * each fixed at a different time. The rule lives here now, once.
23
+ *
24
+ * The carrier case (#156) is NOT decided here. A bundle that mounts someone
25
+ * else's package has no name relation at all, and is recognised by the entry
26
+ * ID its own patch inserts — see `bundlePatchInsertedIds`. The two rules
27
+ * compose; neither replaces the other.
28
+ */
29
+ /**
30
+ * Whether a loader entry name refers to `packageName`.
31
+ *
32
+ * The `/` bound is load-bearing: without it `toolshrink` would match an
33
+ * entry named `toolshrink-extra`, a different installed package.
34
+ *
35
+ * @param name - an entry's `options.name`, in any shape.
36
+ * @param packageName - the installed package name to match against.
37
+ * @returns true for the bare name and for a subpath entry inside it.
38
+ */
39
+ export function nameMatchesPackage(name, packageName) {
40
+ if (typeof name !== 'string' || name === '')
41
+ return false;
42
+ return name === packageName || name.startsWith(`${packageName}/`);
43
+ }
44
+ /**
45
+ * The same rule for a loader entry object.
46
+ *
47
+ * @param entry - anything with `options.name`, as the host's loader stores it.
48
+ * @param packageName - the installed package name to match against.
49
+ * @returns whether this entry is the one `packageName` refers to.
50
+ */
51
+ export function entryMatchesPackage(entry, packageName) {
52
+ return nameMatchesPackage(entry.options?.name, packageName);
53
+ }
54
+ /**
55
+ * The package a subpath entry name belongs to — `aegis/x/y.js` → `aegis`.
56
+ *
57
+ * The inverse of {@link nameMatchesPackage}, for building a set of live
58
+ * packages from a set of live entry names: an entry that is up proves its
59
+ * package is up, and its name alone does not say so.
60
+ *
61
+ * Names that are not npm package names return null rather than a guess: a
62
+ * relative path, an absolute path, a URL or a cordis builtin would otherwise
63
+ * "belong to" a package that does not exist, which is worse than no answer.
64
+ * A scoped name needs three segments (`@scope/pkg/sub`) to have a subpath at
65
+ * all, and a lone `@scope/pkg` has no package to return.
66
+ *
67
+ * @param name - an entry's `options.name`.
68
+ * @returns the package name, or null when the name cannot imply one.
69
+ */
70
+ export function packageOfEntryName(name) {
71
+ if (typeof name !== 'string' || name === '')
72
+ return null;
73
+ if (name.startsWith('.') || name.startsWith('/') || name.startsWith('cordis:'))
74
+ return null;
75
+ if (name.includes('://'))
76
+ return null;
77
+ if (!name.includes('/'))
78
+ return null;
79
+ const parts = name.split('/');
80
+ if (parts[0] === '')
81
+ return null;
82
+ if (parts[0].startsWith('@'))
83
+ return parts.length >= 3 ? `${parts[0]}/${parts[1]}` : null;
84
+ return parts[0];
85
+ }
package/lib/hot.js CHANGED
@@ -30,7 +30,7 @@ import { pathToFileURL } from 'node:url';
30
30
  import { asChannel } from './channels.js';
31
31
  import { asRegion, normalizeGithubProxy } from './regions.js';
32
32
  import { logEvent } from './log.js';
33
- import { entryArtifactExists } from './profile.js';
33
+ import { declaredBundlePatchFile, entryArtifactExists } from './profile.js';
34
34
  /**
35
35
  * Profile-scoped resolution for hot-mount rows: turn a bare package name into
36
36
  * the absolute `file://` entry URL of the package just installed into
@@ -409,12 +409,23 @@ export async function hotMount(ctx, profileDir, packageName) {
409
409
  reason: '宿主不支持热挂载(include 插件不可导入),需重启 / the host cannot hot-mount (include plugin unavailable); restart required',
410
410
  };
411
411
  }
412
- let patchText;
413
- try {
414
- patchText = readFileSync(join(profileDir, 'node_modules', packageName, 'cordis.patch.yml'), 'utf8');
415
- }
416
- catch {
417
- patchText = null;
412
+ // Where the patch lives is the PACKAGE's decision, declared as
413
+ // `dsh.bundle.patch` — `aegis` keeps it in `./extensions/dsh/`. Reading
414
+ // only the package root made a plugin whose patch is declared elsewhere
415
+ // look like one with no bundle at all, and the user was told "no bundle
416
+ // patch … nothing to hot-mount" for a package that plainly has one
417
+ // (#646). The root file stays as the fallback: it is the long-standing
418
+ // convention, and `profile.ts` resolves the declared field for everything
419
+ // else, so the two now agree on where a patch is.
420
+ const packageRoot = join(profileDir, 'node_modules', packageName);
421
+ const declared = declaredBundlePatchFile(packageRoot);
422
+ let patchText = null;
423
+ for (const file of declared !== null ? [declared] : [join(packageRoot, 'cordis.patch.yml')]) {
424
+ try {
425
+ patchText = readFileSync(file, 'utf8');
426
+ break;
427
+ }
428
+ catch { /* try the next location */ }
418
429
  }
419
430
  let rows;
420
431
  if (patchText !== null) {
package/lib/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { createDesktopPluginRuntime } from './dsh-cli.js';
6
6
  import { mountMarketRoutes } from './routes.js';
7
- import { installMarketSettings } from './settings.js';
7
+ import { installDesktopMarketSettings, installMarketSettings } from './settings.js';
8
8
  export const name = 'dsh-market';
9
9
  /**
10
10
  * Register the market against the host context.
@@ -46,10 +46,8 @@ export function apply(ctx, config) {
46
46
  allowRestart: config?.allowRestart,
47
47
  maxSnapshots: config?.maxSnapshots,
48
48
  };
49
- // Offer allowRestart as a switch on the settings page. Deliberately
50
- // NOT in the Desktop branch below: there the shell owns the process
51
- // lifecycle and the value is forced false, so it is not the user's to
52
- // choose. No-ops on a host without a settings service.
49
+ // Web settings may control restart; Desktop only registers the card's
50
+ // namespace below. Both no-op on a host without a settings service.
53
51
  installMarketSettings(ctx, resolved);
54
52
  host.effect(() => mountMarketRoutes(host, resolved, undefined, agentsLookupOf(ctx)), 'dsh-market: http routes');
55
53
  return;
@@ -72,6 +70,7 @@ export function apply(ctx, config) {
72
70
  maxSnapshots: config?.maxSnapshots,
73
71
  };
74
72
  const desktopHost = desktopCtx;
73
+ installDesktopMarketSettings(desktopCtx);
75
74
  desktopHost.effect(() => {
76
75
  const disposeRoutes = mountMarketRoutes(host, resolved, runtime, agentsLookupOf(ctx));
77
76
  return async () => {
package/lib/profile.js CHANGED
@@ -478,11 +478,18 @@ export function readLockCommits(profile, explicitDir) {
478
478
  * Commit recorded for a non-codeload git resolution (`type: git` in pnpm's
479
479
  * lockfile). Matched against the install spec so a Gitea/GitLab URL can
480
480
  * compare HEAD without mistaking a same-named npm package (#525).
481
+ *
482
+ * Two packages of one monorepo resolve from the SAME remote and differ only
483
+ * by pnpm's `path:` selector, so the spec's subpath has to match too — and
484
+ * when the spec names no subpath while several entries of that remote do,
485
+ * there is no answer rather than the first sibling's commit (#632).
481
486
  */
482
487
  export function readGitResolutionCommit(profile, spec, explicitDir) {
483
488
  const want = normalizeGitRepoKey(spec);
484
489
  if (want === null)
485
490
  return null;
491
+ const wantPath = gitSubpathSelector(spec);
492
+ const matches = [];
486
493
  try {
487
494
  const lock = readFileSync(join(profileDir(profile, explicitDir), 'pnpm-lock.yaml'), 'utf8');
488
495
  for (const m of lock.matchAll(/resolution:\s*\{([^}]*)\}/g)) {
@@ -491,12 +498,32 @@ export function readGitResolutionCommit(profile, spec, explicitDir) {
491
498
  const repo = /\brepo:\s*([^\s,}]+)/.exec(body);
492
499
  if (commit === null || repo === null)
493
500
  continue;
494
- if (normalizeGitRepoKey(repo[1]) === want)
495
- return commit[1].toLowerCase();
501
+ if (normalizeGitRepoKey(repo[1]) !== want)
502
+ continue;
503
+ const entryPath = /\bpath:\s*([^\s,}]+)/.exec(body)?.[1]?.replace(/^\/+|\/+$/g, '').toLowerCase() ?? null;
504
+ if (wantPath !== null) {
505
+ if (entryPath === wantPath)
506
+ return commit[1].toLowerCase();
507
+ continue;
508
+ }
509
+ matches.push(commit[1].toLowerCase());
496
510
  }
497
511
  }
498
512
  catch { /* no lockfile */ }
499
- return null;
513
+ // Exactly one entry for this remote is an identity; several are siblings
514
+ // of one monorepo and none of them is THIS package's commit.
515
+ return matches.length === 1 ? matches[0] : null;
516
+ }
517
+ /** The `path:` selector of a git spec, normalized for comparison. */
518
+ function gitSubpathSelector(spec) {
519
+ const hash = spec.indexOf('#');
520
+ if (hash === -1)
521
+ return null;
522
+ const selector = /(?:^|&)path:([^&]*)/.exec(spec.slice(hash + 1))?.[1];
523
+ if (selector === undefined)
524
+ return null;
525
+ const trimmed = selector.replace(/^\/+|\/+$/g, '').toLowerCase();
526
+ return trimmed === '' ? null : trimmed;
500
527
  }
501
528
  /** Lowercased transport-agnostic key for comparing two git remote spellings. */
502
529
  function normalizeGitRepoKey(spec) {
@@ -654,14 +681,38 @@ export function parsePatchRows(text) {
654
681
  return { names, ids, insertedIds };
655
682
  }
656
683
  /** Rows of the patch a package DECLARES through `dsh.bundle.patch`. */
657
- function readBundlePatchRows(dir) {
658
- const empty = { names: [], ids: [], insertedIds: [] };
684
+ /**
685
+ * Where a package's bundle patch lives, according to the package itself.
686
+ *
687
+ * `dsh.bundle.patch` is the package's own declaration and the only place the
688
+ * answer is written down: the path may be a subdirectory (`aegis` declares
689
+ * `./extensions/dsh/cordis.patch.yml`), not just the package root. Callers
690
+ * that assumed the root file made a plugin with a declared patch look like
691
+ * one with none (#646) — so the resolution rule lives here, once.
692
+ *
693
+ * @param dir - the installed package directory.
694
+ * @returns the declared patch file's path, or null when the manifest names
695
+ * none (or the manifest cannot be read).
696
+ */
697
+ export function declaredBundlePatchFile(dir) {
659
698
  try {
660
699
  const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
661
700
  const declared = manifest.dsh?.bundle?.patch;
662
701
  if (typeof declared !== 'string' || declared === '')
663
- return empty;
664
- return parsePatchRows(readFileSync(join(dir, declared), 'utf8'));
702
+ return null;
703
+ return join(dir, declared);
704
+ }
705
+ catch {
706
+ return null;
707
+ }
708
+ }
709
+ function readBundlePatchRows(dir) {
710
+ const empty = { names: [], ids: [], insertedIds: [] };
711
+ const file = declaredBundlePatchFile(dir);
712
+ if (file === null)
713
+ return empty;
714
+ try {
715
+ return parsePatchRows(readFileSync(file, 'utf8'));
665
716
  }
666
717
  catch {
667
718
  return empty;
package/lib/routes.js CHANGED
@@ -20,7 +20,8 @@ import { configurePersistentLog, exportLogs, logEvent, readPersistentLog } from
20
20
  import { marketFetch } from './net.js';
21
21
  import { diagnosePackageManifests } from './diagnostics.js';
22
22
  import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin, TARGET_RE, } from './dsh-cli.js';
23
- import { addProfileBundle, dropFromManifest, hasLoadableEntry, holdsNativeAddon, INBOX_BUNDLES, isDshProfileName, profileDir, readInstalled, readInstalledManifest, readInstalledRepoEvidence, readInstalledVersion, readLockCommits, readProfileBundles, readProfileManifestSnapshot, removeProfileBundle, restoreProfileManifest, setAllowBuilds } from './profile.js';
23
+ import { packageOfEntryName } from './entry-identity.js';
24
+ import { addProfileBundle, dropFromManifest, hasLoadableEntry, holdsNativeAddon, INBOX_BUNDLES, isDshProfileName, profileDir, readGitResolutionCommit, readInstalled, readInstalledManifest, readInstalledRepoEvidence, readInstalledVersion, readLockCommits, readProfileBundles, readProfileManifestSnapshot, removeProfileBundle, restoreProfileManifest, setAllowBuilds } from './profile.js';
24
25
  import { assessProfile, classifyPeer, introducedDuplicateNames, introducedRisks } from './compatibility.js';
25
26
  import { runningAgentIds } from './agents.js';
26
27
  import { analyzeProfile, corePackageNames } from './check.js';
@@ -28,7 +29,7 @@ import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validat
28
29
  import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.js';
29
30
  import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
30
31
  import { trialValidate } from './trial.js';
31
- import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitUpdateTarget, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
32
+ import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitTargetAtCommit, gitUpdateTarget, installTargetFor, isGenerationLink, isLocalSpec, parseGitHubRemote, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
32
33
  import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
33
34
  import { classifyPnpmFailure } from './pnpm-compat.js';
34
35
  import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
@@ -514,8 +515,16 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
514
515
  for (const entry of host.loader.entries()) {
515
516
  if (entry.fiber === undefined)
516
517
  continue;
517
- if (entry.options.name !== undefined)
518
+ if (entry.options.name !== undefined) {
518
519
  live.add(entry.options.name);
520
+ // A SUBPATH entry is up, and its package is therefore up — but the
521
+ // package name never appears as an entry name (#646). Without this,
522
+ // a plugin mounted as `aegis/extensions/dsh/index.js` reads as "not
523
+ // enabled / needs restart" while its entry is visibly live.
524
+ const owner = packageOfEntryName(String(entry.options.name));
525
+ if (owner !== null)
526
+ live.add(owner);
527
+ }
519
528
  // Entry IDS too, under a `#` prefix that cannot collide with a package
520
529
  // name. A CARRIER bundle's row names the package it mounts, not
521
530
  // itself (#156: @tt-a1i/archify-dsh inserts an entry named
@@ -736,12 +745,36 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
736
745
  logEvent('info', 'update-rollback', `${name}: restored npm build v${beforeVersion}`);
737
746
  return { ok: true, detail: null };
738
747
  }
748
+ /**
749
+ * The commit pnpm recorded for a git-sourced install, read the way pnpm
750
+ * wrote it: a `type: git` resolution for a plain remote, and the codeload
751
+ * tarball for a GitHub one — `git+https://github.com/o/r.git` resolves to
752
+ * a tarball, not a git entry, so reading only one of the two would report
753
+ * a rollback that really happened as unverified (#632).
754
+ */
755
+ function gitIdentityCommit(spec) {
756
+ const fromGit = readGitResolutionCommit(config.profile, spec, activeProfileDir);
757
+ if (fromGit !== null)
758
+ return fromGit;
759
+ const github = repoOfTarget(spec)?.split('#')[0] ?? parseGitHubRemote(spec)?.repo ?? null;
760
+ return github === null
761
+ ? null
762
+ : readLockCommits(config.profile, activeProfileDir).get(github.toLowerCase()) ?? null;
763
+ }
739
764
  function exactGitRollbackTarget(target, beforeCommit) {
740
- return githubCommitOfTarget(target) === beforeCommit
765
+ if (repoOfTarget(target) !== null) {
766
+ return githubCommitOfTarget(target) === beforeCommit
767
+ ? target
768
+ : githubTargetAtCommit(target, beforeCommit);
769
+ }
770
+ // A non-GitHub remote (#632): the identity is the URL's own pin or the
771
+ // commit pnpm recorded for it, and the remote as spelled, pinned to that
772
+ // commit, is the exact target.
773
+ return gitCommitOfTarget(target) === beforeCommit
741
774
  ? target
742
- : githubTargetAtCommit(target, beforeCommit);
775
+ : gitTargetAtCommit(target, beforeCommit);
743
776
  }
744
- /** Restore a github: update by re-adding the commit captured before it. */
777
+ /** Restore a git-sourced update by re-adding the commit captured before it. */
745
778
  async function rollbackGitBuild(name, manifestBefore, target, beforeCommit, lockfileBefore, keepRepairedLock) {
746
779
  // Preserve an already immutable durable spelling (including a pinned
747
780
  // codeload URL). Rewriting that source to github: while keeping the
@@ -749,26 +782,25 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
749
782
  // Floating shortcuts still need to be converted to an exact commit.
750
783
  const rollbackTarget = exactGitRollbackTarget(target, beforeCommit);
751
784
  if (rollbackTarget === null) {
752
- return { ok: false, detail: 'the previous github target is invalid; nothing to roll back to' };
785
+ return { ok: false, detail: 'the previous git target is invalid; nothing to roll back to' };
753
786
  }
754
787
  const rollback = await rollbackExactTarget(name, manifestBefore, lockfileBefore, rollbackTarget, keepRepairedLock);
755
788
  if (!rollback.ok)
756
789
  return rollback;
757
- const repoKey = repoOfTarget(rollbackTarget)?.split('#')[0] ?? null;
758
- const restoredCommit = repoKey === null
759
- ? null
760
- : readLockCommits(config.profile, activeProfileDir).get(repoKey.toLowerCase()) ?? null;
790
+ // What pnpm actually resolved, read back the way the identity was
791
+ // captured: the codeload tarball for GitHub, the git resolution otherwise.
792
+ const restoredCommit = gitIdentityCommit(rollbackTarget);
761
793
  if (restoredCommit !== beforeCommit) {
762
794
  return { ok: false, detail: `expected commit ${beforeCommit} after rollback, found ${restoredCommit ?? 'unknown'}` };
763
795
  }
764
- logEvent('info', 'update-rollback', `${name}: restored github build at ${beforeCommit}`);
796
+ logEvent('info', 'update-rollback', `${name}: restored git build at ${beforeCommit}`);
765
797
  return { ok: true, detail: null };
766
798
  }
767
799
  async function executeUpdateRollback(name, manifestBefore, source) {
768
800
  if (source.kind === 'npm') {
769
801
  return rollbackNpmBuild(name, manifestBefore, source.beforeVersion, source.lockfileBefore);
770
802
  }
771
- if (source.kind === 'github') {
803
+ if (source.kind === 'git') {
772
804
  return rollbackGitBuild(name, manifestBefore, source.target, source.beforeCommit, source.lockfileBefore, source.keepRepairedLock);
773
805
  }
774
806
  return rollbackUpdateBuild(name, manifestBefore, true);
@@ -3032,11 +3064,17 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
3032
3064
  : await acceleratedTarget(gitSpec, region);
3033
3065
  const repoIdentity = isGit ? repoOfTarget(spec) : null;
3034
3066
  const repoKey = repoIdentity?.split('#')[0] ?? null;
3067
+ // A non-GitHub remote (#632) has no repo key. Its identity is the
3068
+ // URL's own pin or the commit pnpm recorded for the remote, the
3069
+ // same two reads the update check already trusts for it.
3070
+ const genericGit = isGit && repoKey === null;
3071
+ const sourceKind = repoKey !== null ? 'GitHub' : 'git';
3035
3072
  // dsh-cli's deliberately narrow target grammar rejects the `&`
3036
3073
  // required to combine an exact commit and a monorepo path. Do not
3037
3074
  // weaken that command boundary or offer a rollback action that
3038
3075
  // the real host can never execute.
3039
- const hasGitSubpath = repoIdentity?.includes('#path:/') ?? false;
3076
+ const hasGitSubpath = repoIdentity?.includes('#path:/')
3077
+ ?? (genericGit && /#(?:[^#]*&)?path:/.test(spec));
3040
3078
  // Captured BEFORE pnpm replaces the files: afterwards the loader
3041
3079
  // inventory reads exactly the same, because replacing a package
3042
3080
  // on disk does not unload the module the process already imported.
@@ -3051,19 +3089,31 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
3051
3089
  // that lock and rollback must keep the repair. Floating Git specs
3052
3090
  // still derive identity from the captured lock, so their exact
3053
3091
  // importer bytes remain the authority after rematerialization.
3054
- const manifestPinnedCommit = repoKey !== null ? githubCommitOfTarget(spec) : null;
3055
- const capturedLockCommit = repoKey !== null
3056
- ? readLockCommits(config.profile, activeProfileDir).get(repoKey) ?? null
3057
- : null;
3092
+ const manifestPinnedCommit = repoKey !== null
3093
+ ? githubCommitOfTarget(spec)
3094
+ : genericGit ? gitCommitOfTarget(spec) : null;
3095
+ const capturedLockCommit = isGit ? gitIdentityCommit(spec) : null;
3058
3096
  const beforeCommit = manifestPinnedCommit ?? capturedLockCommit;
3059
3097
  const keepRepairedGitLock = manifestPinnedCommit !== null
3060
3098
  && capturedLockCommit !== manifestPinnedCommit;
3061
3099
  const gitRollbackTarget = beforeCommit === null
3062
3100
  ? null
3063
3101
  : exactGitRollbackTarget(spec, beforeCommit);
3102
+ // A floating git spec makes `add` a no-op: the target is
3103
+ // byte-identical to the specifier already in the manifest, so
3104
+ // pnpm answers "Lockfile is up to date, resolution step is
3105
+ // skipped" and the install never moves (#562). `update <name>`
3106
+ // re-resolves inside the same specifier, which is exactly what a
3107
+ // mutable `github:owner/repo` (or `#branch` / `#semver:`) wants.
3108
+ // Anything whose target differs from the specifier — npm pins,
3109
+ // a de-pinned commit, a rebuilt codeload shortcut, a restore —
3110
+ // keeps `add`, because there the new target IS the change.
3111
+ const reresolveInPlace = isGit && !restore && target === spec;
3064
3112
  // force: the user chose to install a fresh release without the
3065
3113
  // default one-day safety wait; scoped to this single command.
3066
- const addArgs = force ? ['add', RELEASE_AGE_OVERRIDE, target] : ['add', target];
3114
+ const addArgs = reresolveInPlace
3115
+ ? (force ? ['update', RELEASE_AGE_OVERRIDE, name] : ['update', name])
3116
+ : (force ? ['add', RELEASE_AGE_OVERRIDE, target] : ['add', target]);
3067
3117
  // Exact manifest snapshot for failure rollback (#65, #339) — the
3068
3118
  // host can write dependencies AND dsh.profile.bundles before a
3069
3119
  // hard-failed add, leaving residue that breaks the next boot.
@@ -3087,25 +3137,25 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
3087
3137
  ? hasGitSubpath
3088
3138
  ? {
3089
3139
  available: false,
3090
- detail: `更新前的 GitHub 来源使用 monorepo 子目录${beforeCommit === null ? '' : `(提交 ${beforeCommit})`},当前 DSH 命令无法表达该精确目标,因此自动回滚不可用;需要时请手工重新安装该提交。 / The previous GitHub source uses a monorepo subpath${beforeCommit === null ? '' : ` at commit ${beforeCommit}`}; the current DSH command cannot express that exact target, so automatic rollback is unavailable. Reinstall that commit manually if needed.`,
3140
+ detail: `更新前的 ${sourceKind} 来源使用 monorepo 子目录${beforeCommit === null ? '' : `(提交 ${beforeCommit})`},当前 DSH 命令无法表达该精确目标,因此自动回滚不可用;需要时请手工重新安装该提交。 / The previous ${sourceKind} source uses a monorepo subpath${beforeCommit === null ? '' : ` at commit ${beforeCommit}`}; the current DSH command cannot express that exact target, so automatic rollback is unavailable. Reinstall that commit manually if needed.`,
3091
3141
  lockfileBefore: lockfileCapture.snapshot,
3092
3142
  }
3093
3143
  : beforeCommit === null
3094
3144
  ? {
3095
3145
  available: false,
3096
- detail: '未能确认更新前的 GitHub 提交,因此自动回滚不可用;需要时请从可信来源手工重新安装先前版本。 / The previous GitHub commit could not be verified, so automatic rollback is unavailable. Reinstall the prior version manually from a trusted source if needed.',
3146
+ detail: `未能确认更新前的 ${sourceKind} 提交,因此自动回滚不可用;需要时请从可信来源手工重新安装先前版本。 / The previous ${sourceKind} commit could not be verified, so automatic rollback is unavailable. Reinstall the prior version manually from a trusted source if needed.`,
3097
3147
  lockfileBefore: lockfileCapture.snapshot,
3098
3148
  }
3099
3149
  : gitRollbackTarget === null || !supportsExactRollbackTarget(gitRollbackTarget)
3100
3150
  ? {
3101
3151
  available: false,
3102
- detail: `当前宿主无法安装更新前的精确 GitHub 提交 ${beforeCommit},因此自动回滚不可用;需要时请手工重新安装该提交。 / This host cannot install the exact previous GitHub commit ${beforeCommit}, so automatic rollback is unavailable. Reinstall that commit manually if needed.`,
3152
+ detail: `当前宿主无法安装更新前的精确 ${sourceKind} 提交 ${beforeCommit},因此自动回滚不可用;需要时请手工重新安装该提交。 / This host cannot install the exact previous ${sourceKind} commit ${beforeCommit}, so automatic rollback is unavailable. Reinstall that commit manually if needed.`,
3103
3153
  lockfileBefore: lockfileCapture.snapshot,
3104
3154
  }
3105
3155
  : {
3106
3156
  available: true,
3107
3157
  source: {
3108
- kind: 'github',
3158
+ kind: 'git',
3109
3159
  target: spec,
3110
3160
  beforeCommit,
3111
3161
  lockfileBefore: lockfileCapture.snapshot,
@@ -3252,9 +3302,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
3252
3302
  beforeVersion,
3253
3303
  afterVersion: readInstalledVersion(config.profile, name, activeProfileDir),
3254
3304
  beforeCommit,
3255
- afterCommit: repoKey !== null
3256
- ? readLockCommits(config.profile, activeProfileDir).get(repoKey) ?? null
3257
- : null,
3305
+ afterCommit: isGit ? gitIdentityCommit(spec) : null,
3258
3306
  });
3259
3307
  if (stale)
3260
3308
  ok = false;