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 +39 -52
- package/lib/catalog-local-match.js +34 -0
- package/lib/dsh-cli.js +1 -1
- package/lib/entry-identity.js +85 -0
- package/lib/hot.js +18 -7
- package/lib/index.js +4 -5
- package/lib/profile.js +58 -7
- package/lib/routes.js +74 -26
- package/lib/settings.js +12 -0
- package/lib/sources.js +29 -0
- package/lib/themes.js +34 -2
- package/lib/types/catalog-local-match.d.ts +0 -11
- package/lib/types/entry-identity.d.ts +68 -0
- package/lib/types/profile.d.ts +20 -0
- package/lib/types/settings.d.ts +4 -0
- package/lib/types/sources.d.ts +17 -0
- package/lib/verify.js +2 -4
- package/package.json +1 -1
- package/src/catalog-local-match.ts +41 -0
- package/src/client/market-data.ts +2 -62
- package/src/dsh-cli.ts +1 -1
- package/src/entry-identity.ts +84 -0
- package/src/hot.ts +17 -9
- package/src/index.ts +4 -5
- package/src/profile.ts +54 -6
- package/src/routes.ts +77 -27
- package/src/settings.ts +19 -6
- package/src/sources.ts +26 -0
- package/src/themes.ts +37 -2
- package/src/verify.ts +2 -3
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 =
|
|
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
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
//
|
|
50
|
-
//
|
|
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])
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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
|
|
664
|
-
return
|
|
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 {
|
|
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
|
-
|
|
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
|
-
:
|
|
775
|
+
: gitTargetAtCommit(target, beforeCommit);
|
|
743
776
|
}
|
|
744
|
-
/** Restore a
|
|
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
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
|
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 === '
|
|
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:/')
|
|
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
|
|
3055
|
-
|
|
3056
|
-
?
|
|
3057
|
-
|
|
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 =
|
|
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: `更新前的
|
|
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:
|
|
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: `当前宿主无法安装更新前的精确
|
|
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: '
|
|
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:
|
|
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;
|