dshmarket 1.66.30 → 1.66.32

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/lib/profile.js CHANGED
@@ -139,7 +139,13 @@ export function readManifestDeps(profile, explicitDir) {
139
139
  export function readDependencyOwners(profile, names, explicitDir) {
140
140
  const installed = new Set(names);
141
141
  const owners = {};
142
- for (const owner of [...names].sort()) {
142
+ // Who may be an OWNER is not the same question as who gets a card (#892).
143
+ // pnpm hoists a transitive dependency's directory to `node_modules/<name>`
144
+ // as well, so `ws` — a dependency of a plugin, never a profile dependency —
145
+ // is where `bufferutil` and `utf-8-validate` are declared. Scanning only the
146
+ // profile's own entries could therefore never answer "who asked for these",
147
+ // and the two native peers stayed on the list as plugins nobody chose.
148
+ for (const owner of topLevelPackageNames(profile, names, explicitDir)) {
143
149
  const manifest = readInstalledManifest(profile, owner, explicitDir);
144
150
  if (typeof manifest !== 'object' || manifest === null)
145
151
  continue;
@@ -156,6 +162,48 @@ export function readDependencyOwners(profile, names, explicitDir) {
156
162
  }
157
163
  return owners;
158
164
  }
165
+ /**
166
+ * Every package that could be an owner, sorted: the profile's own entries
167
+ * plus every package sitting at the profile's top level.
168
+ *
169
+ * The manifest's keys are the floor, not the ceiling — a missing or
170
+ * unreadable `node_modules` leaves the answer exactly as it was before the
171
+ * top level was scanned, so this can only ever find MORE owners.
172
+ *
173
+ * One manifest read per top-level package, measured at ~30 ms for 300
174
+ * packages on a warm cache, once per `/installed` request — which already
175
+ * reads a manifest, a patch file and an entry artifact for every card.
176
+ */
177
+ function topLevelPackageNames(profile, names, explicitDir) {
178
+ const found = new Set(names);
179
+ const root = join(profileDir(profile, explicitDir), 'node_modules');
180
+ let entries = [];
181
+ try {
182
+ entries = readdirSync(root, { withFileTypes: true })
183
+ // A scope directory is named `@scope`, so the leading `@` belongs here;
184
+ // the package's own namespace is added below.
185
+ .filter(dirent => dirent.isDirectory() && /^@?[A-Za-z0-9._~-]+$/.test(dirent.name))
186
+ .map(dirent => dirent.name);
187
+ }
188
+ catch {
189
+ return [...found].sort();
190
+ }
191
+ for (const entry of entries) {
192
+ if (!entry.startsWith('@')) {
193
+ found.add(entry);
194
+ continue;
195
+ }
196
+ // A scope directory holds the packages themselves.
197
+ try {
198
+ for (const inner of readdirSync(join(root, entry), { withFileTypes: true })) {
199
+ if (inner.isDirectory() && /^[A-Za-z0-9._~-]+$/.test(inner.name))
200
+ found.add(`${entry}/${inner.name}`);
201
+ }
202
+ }
203
+ catch { /* unreadable scope — its packages simply cannot own anything */ }
204
+ }
205
+ return [...found].sort();
206
+ }
159
207
  function objectRecord(value) {
160
208
  return typeof value === 'object' && value !== null && !Array.isArray(value)
161
209
  ? value
package/lib/regions.js CHANGED
@@ -107,6 +107,34 @@ const ROUTES = {
107
107
  ],
108
108
  },
109
109
  };
110
+ /**
111
+ * Every catalog source worth trying for a region: its own list, plus the
112
+ * catalog's published package on that region's own registry when the list
113
+ * does not already name it.
114
+ *
115
+ * The catalog hangs off a SECOND, independently blockable host (#891), and
116
+ * the probe that picks a region measures only the npm registry. A network
117
+ * where npm answers fast and GitHub Pages is blocked therefore measures
118
+ * `global` — whose one source is that very host — and since the region is
119
+ * decided once and persisted, nothing ever reconsiders: "catalog fetch
120
+ * failed" forever, on a machine whose registry is serving the catalog as a
121
+ * package all along.
122
+ *
123
+ * Appending the package closes that blind spot without asking the probe to
124
+ * learn about the second host, without revisiting the decision, and without
125
+ * sending anyone to another region's mirror: it rides the registry the probe
126
+ * already proved reachable. Last, not first — for everyone the official
127
+ * domain serves, this stays a source that is never contacted.
128
+ */
129
+ export function catalogSourcesFor(region) {
130
+ const routes = routesFor(region);
131
+ const sources = [...routes.catalog];
132
+ const served = sources.some(source => source.kind === 'npm'
133
+ && source.registry === routes.npmRegistry && source.pkg === CATALOG_PACKAGE);
134
+ if (!served)
135
+ sources.push({ kind: 'npm', registry: routes.npmRegistry, pkg: CATALOG_PACKAGE });
136
+ return sources;
137
+ }
110
138
  /** Read an environment override, treating blank as unset. */
111
139
  function override(env, name) {
112
140
  const raw = env[name];
package/lib/registry.js CHANGED
@@ -3,9 +3,10 @@
3
3
  * fresh on every request. See `loadRegistry` for why there is nothing
4
4
  * behind it any more.
5
5
  */
6
+ import { logEvent } from './log.js';
6
7
  import { configuredProxy, marketFetch } from './net.js';
7
8
  import { catalogFromPackage } from './catalog-npm.js';
8
- import { activeRegion, routesFor } from './regions.js';
9
+ import { activeRegion, catalogSourcesFor, routesFor } from './regions.js';
9
10
  /**
10
11
  * Category ids for one catalog entry, de-duplicated in declaration order.
11
12
  *
@@ -166,8 +167,23 @@ export async function loadRegistry(region = activeRegion()) {
166
167
  // the FIRST request the market makes, so a mirror that has gone down must
167
168
  // mean a slow market rather than an empty one — the list ends at the
168
169
  // address that has always worked.
169
- for (const source of routesFor(region).catalog) {
170
+ //
171
+ // The list runs one entry past the region's own sources on purpose (#891):
172
+ // the region probe measures the npm registry, so it cannot see a network
173
+ // where npm is fast and the catalog's host is blocked — and the region is
174
+ // decided once and persisted. The catalog's published package, on the
175
+ // registry the probe just proved reachable, is the repair that needs
176
+ // neither the probe nor the decision to change.
177
+ const ownSources = routesFor(region).catalog.length;
178
+ const catalogSources = catalogSourcesFor(region);
179
+ for (const [sourceIndex, source] of catalogSources.entries()) {
170
180
  const key = sourceKey(source);
181
+ const servedBy = (data) => {
182
+ if (sourceIndex >= ownSources) {
183
+ logEvent('warn', 'registry', `catalog: the ${region} sources did not answer; read the catalog package from ${key} instead (#891)`);
184
+ }
185
+ return data;
186
+ };
171
187
  // Two attempts each. A catalog fetch crossing a long, lossy path fails
172
188
  // transiently often enough that one retry is worth more than the second
173
189
  // or two it costs — and with nothing behind this call any more, a
@@ -183,12 +199,12 @@ export async function loadRegistry(region = activeRegion()) {
183
199
  const { version, data } = await catalogFromPackage(source.registry, source.pkg, reusable?.version ?? undefined);
184
200
  // `data === null` means the published version is the one in hand.
185
201
  if (data === null && reusable !== null)
186
- return reusable.data;
202
+ return servedBy(reusable.data);
187
203
  if (data === null)
188
204
  throw new Error('the catalog package reported no change with nothing to reuse');
189
205
  const parsed = asRegistry(data);
190
206
  served = { key, etag: null, modified: null, version, data: parsed };
191
- return parsed;
207
+ return servedBy(parsed);
192
208
  }
193
209
  // ETag first: it is exact, while a date has one-second resolution and
194
210
  // a catalog republished twice within the same second would validate
@@ -221,7 +237,7 @@ export async function loadRegistry(region = activeRegion()) {
221
237
  // otherwise surface as a confusing parse error on an empty body.
222
238
  if (reusable === null)
223
239
  throw new Error('the catalog answered "not modified" with nothing to revalidate');
224
- return reusable.data;
240
+ return servedBy(reusable.data);
225
241
  }
226
242
  if (!res.ok)
227
243
  throw new Error(`HTTP ${String(res.status)}`);
@@ -229,7 +245,7 @@ export async function loadRegistry(region = activeRegion()) {
229
245
  served = {
230
246
  key, etag: res.headers.get('etag'), modified: res.headers.get('last-modified'), version: null, data,
231
247
  };
232
- return data;
248
+ return servedBy(data);
233
249
  }
234
250
  catch (error) {
235
251
  last = error;
@@ -71,6 +71,26 @@ export interface RegionRoutes {
71
71
  */
72
72
  catalog: CatalogSource[];
73
73
  }
74
+ /**
75
+ * Every catalog source worth trying for a region: its own list, plus the
76
+ * catalog's published package on that region's own registry when the list
77
+ * does not already name it.
78
+ *
79
+ * The catalog hangs off a SECOND, independently blockable host (#891), and
80
+ * the probe that picks a region measures only the npm registry. A network
81
+ * where npm answers fast and GitHub Pages is blocked therefore measures
82
+ * `global` — whose one source is that very host — and since the region is
83
+ * decided once and persisted, nothing ever reconsiders: "catalog fetch
84
+ * failed" forever, on a machine whose registry is serving the catalog as a
85
+ * package all along.
86
+ *
87
+ * Appending the package closes that blind spot without asking the probe to
88
+ * learn about the second host, without revisiting the decision, and without
89
+ * sending anyone to another region's mirror: it rides the registry the probe
90
+ * already proved reachable. Last, not first — for everyone the official
91
+ * domain serves, this stays a source that is never contacted.
92
+ */
93
+ export declare function catalogSourcesFor(region: Region): CatalogSource[];
74
94
  /**
75
95
  * Normalize a user-maintained prefix, or reject it.
76
96
  *
package/lib/verify.js CHANGED
@@ -33,7 +33,7 @@ import { join } from 'node:path';
33
33
  import { listHotMounts, parseSimplePatch } from './hot.js';
34
34
  import { userPatchPackageReferences } from './patch.js';
35
35
  import { nameMatchesPackage } from './entry-identity.js';
36
- import { bundlePatchInsertedIds, declaredBundlePatchFiles, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.js';
36
+ import { bundlePatchInsertedIds, declaredBundlePatchFiles, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled } from './profile.js';
37
37
  import { compareSemver, composeLayers, parsePatchFile, satisfiesRange } from './check.js';
38
38
  import { dshHostInfo } from './dsh-install.js';
39
39
  /**
@@ -422,19 +422,33 @@ export function verifyActivation(profile, name, live = new Set(listHotMounts()),
422
422
  hot: false,
423
423
  };
424
424
  }
425
- return inBundles
426
- ? {
425
+ if (inBundles) {
426
+ return {
427
427
  state: 'restart',
428
428
  reasons: ['由你自己的 cordis.patch.yml 按名加载,重启后生效 / loaded by name from your own cordis.patch.yml — live after a restart'],
429
429
  bundle: true,
430
430
  hot: false,
431
- }
432
- : {
433
- state: 'inert',
434
- reasons: ['普通依赖(未声明 dsh 元数据),不是 profile 层插件;若它由某个 bundle patch 按名加载,启动后会显示为已加载 / a plain dependency with no dsh metadata — not a profile-layer plugin; if some bundle patch loads it by name it will read as live once running'],
435
- bundle: false,
436
- hot: false,
437
431
  };
432
+ }
433
+ // A collection repository's ROOT package says nothing about itself and
434
+ // holds the plugins one level down (#892). Installed whole — which the
435
+ // market used to do before it learned to retarget one, and which a user
436
+ // can still do by hand — it lands on this list as "installed, not in
437
+ // effect" with no way out of that sentence. Name the plugins it does
438
+ // carry and the way to install one: the dead end is the bug.
439
+ const carried = pluginSubdirs(dir);
440
+ const shown = carried.slice(0, 3).join('、');
441
+ const more = carried.length > 3 ? `等 ${carried.length} 个` : '';
442
+ const shownEn = carried.slice(0, 3).join(', ');
443
+ const moreEn = carried.length > 3 ? ` and ${carried.length - 3} more` : '';
444
+ return {
445
+ state: 'inert',
446
+ reasons: [carried.length > 0
447
+ ? `这是集合仓库的根包,本身不是插件。可安装的子包:${shown}${more}。请装子包而不是它(仓库地址后加 #path:/子包目录) / this is a collection repository's root package, not a plugin itself; install one of its plugins instead — ${shownEn}${moreEn} — by adding #path:/<subdirectory> to the repository URL`
448
+ : '普通依赖(未声明 dsh 元数据),不是 profile 层插件;若它由某个 bundle patch 按名加载,启动后会显示为已加载 / a plain dependency with no dsh metadata — not a profile-layer plugin; if some bundle patch loads it by name it will read as live once running'],
449
+ bundle: false,
450
+ hot: false,
451
+ };
438
452
  }
439
453
  // Carrier bundles (#103) ship no entry of their own — what they mount is
440
454
  // the point — so judge by "is anything loadable", not by this package's
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.30",
4
+ "version": "1.66.32",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
package/src/profile.ts CHANGED
@@ -151,7 +151,13 @@ export function readDependencyOwners(
151
151
  ): Record<string, string> {
152
152
  const installed = new Set(names)
153
153
  const owners: Record<string, string> = {}
154
- for (const owner of [...names].sort()) {
154
+ // Who may be an OWNER is not the same question as who gets a card (#892).
155
+ // pnpm hoists a transitive dependency's directory to `node_modules/<name>`
156
+ // as well, so `ws` — a dependency of a plugin, never a profile dependency —
157
+ // is where `bufferutil` and `utf-8-validate` are declared. Scanning only the
158
+ // profile's own entries could therefore never answer "who asked for these",
159
+ // and the two native peers stayed on the list as plugins nobody chose.
160
+ for (const owner of topLevelPackageNames(profile, names, explicitDir)) {
155
161
  const manifest = readInstalledManifest(profile, owner, explicitDir)
156
162
  if (typeof manifest !== 'object' || manifest === null) continue
157
163
  const declared = manifest as { dependencies?: unknown; peerDependencies?: unknown }
@@ -166,6 +172,50 @@ export function readDependencyOwners(
166
172
  return owners
167
173
  }
168
174
 
175
+ /**
176
+ * Every package that could be an owner, sorted: the profile's own entries
177
+ * plus every package sitting at the profile's top level.
178
+ *
179
+ * The manifest's keys are the floor, not the ceiling — a missing or
180
+ * unreadable `node_modules` leaves the answer exactly as it was before the
181
+ * top level was scanned, so this can only ever find MORE owners.
182
+ *
183
+ * One manifest read per top-level package, measured at ~30 ms for 300
184
+ * packages on a warm cache, once per `/installed` request — which already
185
+ * reads a manifest, a patch file and an entry artifact for every card.
186
+ */
187
+ function topLevelPackageNames(
188
+ profile: string,
189
+ names: readonly string[],
190
+ explicitDir?: string,
191
+ ): string[] {
192
+ const found = new Set(names)
193
+ const root = join(profileDir(profile, explicitDir), 'node_modules')
194
+ let entries: string[] = []
195
+ try {
196
+ entries = readdirSync(root, { withFileTypes: true })
197
+ // A scope directory is named `@scope`, so the leading `@` belongs here;
198
+ // the package's own namespace is added below.
199
+ .filter(dirent => dirent.isDirectory() && /^@?[A-Za-z0-9._~-]+$/.test(dirent.name))
200
+ .map(dirent => dirent.name)
201
+ } catch {
202
+ return [...found].sort()
203
+ }
204
+ for (const entry of entries) {
205
+ if (!entry.startsWith('@')) {
206
+ found.add(entry)
207
+ continue
208
+ }
209
+ // A scope directory holds the packages themselves.
210
+ try {
211
+ for (const inner of readdirSync(join(root, entry), { withFileTypes: true })) {
212
+ if (inner.isDirectory() && /^[A-Za-z0-9._~-]+$/.test(inner.name)) found.add(`${entry}/${inner.name}`)
213
+ }
214
+ } catch { /* unreadable scope — its packages simply cannot own anything */ }
215
+ }
216
+ return [...found].sort()
217
+ }
218
+
169
219
  /** Exact rollback state owned by one profile package operation. */
170
220
  export interface ProfileManifestSnapshot {
171
221
  dependencies: Record<string, string>
package/src/regions.ts CHANGED
@@ -157,6 +157,34 @@ const ROUTES: Record<Region, RegionRoutes> = {
157
157
  },
158
158
  }
159
159
 
160
+ /**
161
+ * Every catalog source worth trying for a region: its own list, plus the
162
+ * catalog's published package on that region's own registry when the list
163
+ * does not already name it.
164
+ *
165
+ * The catalog hangs off a SECOND, independently blockable host (#891), and
166
+ * the probe that picks a region measures only the npm registry. A network
167
+ * where npm answers fast and GitHub Pages is blocked therefore measures
168
+ * `global` — whose one source is that very host — and since the region is
169
+ * decided once and persisted, nothing ever reconsiders: "catalog fetch
170
+ * failed" forever, on a machine whose registry is serving the catalog as a
171
+ * package all along.
172
+ *
173
+ * Appending the package closes that blind spot without asking the probe to
174
+ * learn about the second host, without revisiting the decision, and without
175
+ * sending anyone to another region's mirror: it rides the registry the probe
176
+ * already proved reachable. Last, not first — for everyone the official
177
+ * domain serves, this stays a source that is never contacted.
178
+ */
179
+ export function catalogSourcesFor(region: Region): CatalogSource[] {
180
+ const routes = routesFor(region)
181
+ const sources = [...routes.catalog]
182
+ const served = sources.some(source => source.kind === 'npm'
183
+ && source.registry === routes.npmRegistry && source.pkg === CATALOG_PACKAGE)
184
+ if (!served) sources.push({ kind: 'npm', registry: routes.npmRegistry, pkg: CATALOG_PACKAGE })
185
+ return sources
186
+ }
187
+
160
188
  /** Read an environment override, treating blank as unset. */
161
189
  function override(env: NodeJS.ProcessEnv, name: string): string | null {
162
190
  const raw = env[name]
package/src/registry.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  * behind it any more.
5
5
  */
6
6
 
7
+ import { logEvent } from './log.ts'
7
8
  import { configuredProxy, marketFetch } from './net.ts'
8
9
  import { catalogFromPackage } from './catalog-npm.ts'
9
- import { activeRegion, routesFor, type CatalogSource, type Region } from './regions.ts'
10
+ import { activeRegion, catalogSourcesFor, routesFor, type CatalogSource, type Region } from './regions.ts'
10
11
 
11
12
  export interface RegistryPlugin {
12
13
  name: string
@@ -245,8 +246,23 @@ export async function loadRegistry(region: Region = activeRegion()): Promise<Reg
245
246
  // the FIRST request the market makes, so a mirror that has gone down must
246
247
  // mean a slow market rather than an empty one — the list ends at the
247
248
  // address that has always worked.
248
- for (const source of routesFor(region).catalog) {
249
+ //
250
+ // The list runs one entry past the region's own sources on purpose (#891):
251
+ // the region probe measures the npm registry, so it cannot see a network
252
+ // where npm is fast and the catalog's host is blocked — and the region is
253
+ // decided once and persisted. The catalog's published package, on the
254
+ // registry the probe just proved reachable, is the repair that needs
255
+ // neither the probe nor the decision to change.
256
+ const ownSources = routesFor(region).catalog.length
257
+ const catalogSources = catalogSourcesFor(region)
258
+ for (const [sourceIndex, source] of catalogSources.entries()) {
249
259
  const key = sourceKey(source)
260
+ const servedBy = <T>(data: T): T => {
261
+ if (sourceIndex >= ownSources) {
262
+ logEvent('warn', 'registry', `catalog: the ${region} sources did not answer; read the catalog package from ${key} instead (#891)`)
263
+ }
264
+ return data
265
+ }
250
266
  // Two attempts each. A catalog fetch crossing a long, lossy path fails
251
267
  // transiently often enough that one retry is worth more than the second
252
268
  // or two it costs — and with nothing behind this call any more, a
@@ -263,11 +279,11 @@ export async function loadRegistry(region: Region = activeRegion()): Promise<Reg
263
279
  source.registry, source.pkg, reusable?.version ?? undefined,
264
280
  )
265
281
  // `data === null` means the published version is the one in hand.
266
- if (data === null && reusable !== null) return reusable.data
282
+ if (data === null && reusable !== null) return servedBy(reusable.data)
267
283
  if (data === null) throw new Error('the catalog package reported no change with nothing to reuse')
268
284
  const parsed = asRegistry(data)
269
285
  served = { key, etag: null, modified: null, version, data: parsed }
270
- return parsed
286
+ return servedBy(parsed)
271
287
  }
272
288
  // ETag first: it is exact, while a date has one-second resolution and
273
289
  // a catalog republished twice within the same second would validate
@@ -300,7 +316,7 @@ export async function loadRegistry(region: Region = activeRegion()): Promise<Reg
300
316
  // Guarded anyway: answering a 304 with nothing to reuse would
301
317
  // otherwise surface as a confusing parse error on an empty body.
302
318
  if (reusable === null) throw new Error('the catalog answered "not modified" with nothing to revalidate')
303
- return reusable.data
319
+ return servedBy(reusable.data)
304
320
  }
305
321
  if (!res.ok) throw new Error(`HTTP ${String(res.status)}`)
306
322
  const data = asRegistry(JSON.parse(new TextDecoder().decode(
@@ -309,7 +325,7 @@ export async function loadRegistry(region: Region = activeRegion()): Promise<Reg
309
325
  served = {
310
326
  key, etag: res.headers.get('etag'), modified: res.headers.get('last-modified'), version: null, data,
311
327
  }
312
- return data
328
+ return servedBy(data)
313
329
  } catch (error) {
314
330
  last = error
315
331
  failures.push({
package/src/verify.ts CHANGED
@@ -34,7 +34,7 @@ import { join } from 'node:path'
34
34
  import { listHotMounts, parseSimplePatch } from './hot.ts'
35
35
  import { userPatchPackageReferences } from './patch.ts'
36
36
  import { nameMatchesPackage } from './entry-identity.ts'
37
- import { bundlePatchInsertedIds, declaredBundlePatchFiles, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.ts'
37
+ import { bundlePatchInsertedIds, declaredBundlePatchFiles, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled } from './profile.ts'
38
38
  import { compareSemver, composeLayers, parsePatchFile, satisfiesRange } from './check.ts'
39
39
  import { dshHostInfo } from './dsh-install.ts'
40
40
 
@@ -498,19 +498,33 @@ export function verifyActivation(
498
498
  hot: false,
499
499
  }
500
500
  }
501
- return inBundles
502
- ? {
503
- state: 'restart',
504
- reasons: ['由你自己的 cordis.patch.yml 按名加载,重启后生效 / loaded by name from your own cordis.patch.yml — live after a restart'],
505
- bundle: true,
506
- hot: false,
507
- }
508
- : {
509
- state: 'inert',
510
- reasons: ['普通依赖(未声明 dsh 元数据),不是 profile 层插件;若它由某个 bundle patch 按名加载,启动后会显示为已加载 / a plain dependency with no dsh metadata — not a profile-layer plugin; if some bundle patch loads it by name it will read as live once running'],
511
- bundle: false,
512
- hot: false,
513
- }
501
+ if (inBundles) {
502
+ return {
503
+ state: 'restart',
504
+ reasons: ['由你自己的 cordis.patch.yml 按名加载,重启后生效 / loaded by name from your own cordis.patch.yml — live after a restart'],
505
+ bundle: true,
506
+ hot: false,
507
+ }
508
+ }
509
+ // A collection repository's ROOT package says nothing about itself and
510
+ // holds the plugins one level down (#892). Installed whole — which the
511
+ // market used to do before it learned to retarget one, and which a user
512
+ // can still do by hand — it lands on this list as "installed, not in
513
+ // effect" with no way out of that sentence. Name the plugins it does
514
+ // carry and the way to install one: the dead end is the bug.
515
+ const carried = pluginSubdirs(dir)
516
+ const shown = carried.slice(0, 3).join('、')
517
+ const more = carried.length > 3 ? `等 ${carried.length} 个` : ''
518
+ const shownEn = carried.slice(0, 3).join(', ')
519
+ const moreEn = carried.length > 3 ? ` and ${carried.length - 3} more` : ''
520
+ return {
521
+ state: 'inert',
522
+ reasons: [carried.length > 0
523
+ ? `这是集合仓库的根包,本身不是插件。可安装的子包:${shown}${more}。请装子包而不是它(仓库地址后加 #path:/子包目录) / this is a collection repository's root package, not a plugin itself; install one of its plugins instead — ${shownEn}${moreEn} — by adding #path:/<subdirectory> to the repository URL`
524
+ : '普通依赖(未声明 dsh 元数据),不是 profile 层插件;若它由某个 bundle patch 按名加载,启动后会显示为已加载 / a plain dependency with no dsh metadata — not a profile-layer plugin; if some bundle patch loads it by name it will read as live once running'],
525
+ bundle: false,
526
+ hot: false,
527
+ }
514
528
  }
515
529
  // Carrier bundles (#103) ship no entry of their own — what they mount is
516
530
  // the point — so judge by "is anything loadable", not by this package's