dshmarket 1.26.0 → 1.27.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/lib/check.js CHANGED
@@ -18,15 +18,17 @@
18
18
  * version (tool calls die, minimal preset fails to mount)?
19
19
  * 4. Are there multiple versions of one core package in the lockfile, and
20
20
  * do plugin peerDependencies ranges match the resolved core version?
21
+ * 5. Do effective user/home patch entries reference npm package roots that
22
+ * are installed in the profile-visible node_modules ancestry?
21
23
  *
22
24
  * The composition step mirrors @deepseek-ai/dsh-app-boot's applyEntryPatches
23
25
  * (same js-yaml dialect incl. `!!js` scalars), so the rows reported here are
24
26
  * what actually mounts at boot.
25
27
  */
26
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
27
- import { createRequire } from 'node:module';
28
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
29
+ import { createRequire, isBuiltin } from 'node:module';
28
30
  import { homedir } from 'node:os';
29
- import { dirname, join, resolve } from 'node:path';
31
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
30
32
  import { JSON_SCHEMA, Type, load } from 'js-yaml';
31
33
  import { INBOX_BUNDLES, readBundleRules, suggestOrder, validateOrder } from './order.js';
32
34
  /** js-yaml dialect for `!!js` scalars — identical to dsh-app-boot's entryListSchema. */
@@ -39,23 +41,75 @@ const entrySchema = JSON_SCHEMA.extend(jsExpr);
39
41
  function isRecord(value) {
40
42
  return value !== null && typeof value === 'object' && !Array.isArray(value);
41
43
  }
42
- /** Parse one entry-list patch file with the dsh dialect; null when unreadable. */
43
- export function parsePatchFile(path) {
44
- let text;
44
+ /** Parse entry-list source with the DSH dialect; null when it is not a list. */
45
+ export function parsePatchText(text) {
45
46
  try {
46
- text = readFileSync(path, 'utf8');
47
+ const value = load(text, { schema: entrySchema });
48
+ return Array.isArray(value) ? value : null;
47
49
  }
48
50
  catch {
49
51
  return null;
50
52
  }
53
+ }
54
+ /** Parse one entry-list patch file with the DSH dialect; null when unreadable. */
55
+ export function parsePatchFile(path) {
51
56
  try {
52
- const value = load(text, { schema: entrySchema });
53
- return Array.isArray(value) ? value : null;
57
+ return parsePatchText(readFileSync(path, 'utf8'));
54
58
  }
55
59
  catch {
56
60
  return null;
57
61
  }
58
62
  }
63
+ /** Whether `name` goes through the Loader's bare-module resolver. */
64
+ function isBareLoaderSpecifier(name) {
65
+ return !name.startsWith('.')
66
+ && !name.startsWith('#')
67
+ && !isAbsolute(name)
68
+ && !/^[a-z][a-z\d+.-]*:/i.test(name);
69
+ }
70
+ /** Npm package root owning one bare Loader specifier. */
71
+ function packageRoot(specifier) {
72
+ const parts = specifier.split('/');
73
+ const segments = specifier.startsWith('@') ? parts.slice(0, 2) : parts.slice(0, 1);
74
+ if (segments.length !== (specifier.startsWith('@') ? 2 : 1))
75
+ return null;
76
+ if (segments.some(segment => segment === '' || segment === '.' || segment === '..'
77
+ || segment.includes('%') || segment.includes('\\')))
78
+ return null;
79
+ if (specifier.startsWith('@') && (segments[0]?.length ?? 0) <= 1)
80
+ return null;
81
+ return segments.join('/');
82
+ }
83
+ /**
84
+ * Package-root presence from the Loader-visible profile ancestry only.
85
+ * An explicit walk avoids CommonJS global lookup directories that
86
+ * `createRequire(...).resolve.paths()` appends but Node ESM does not search.
87
+ */
88
+ function profilePackageInstalled(profileDirectory, name) {
89
+ try {
90
+ const profile = JSON.parse(readFileSync(join(profileDirectory, 'package.json'), 'utf8'));
91
+ if (isRecord(profile) && profile.name === name
92
+ && profile.exports !== undefined && profile.exports !== null)
93
+ return true;
94
+ }
95
+ catch { /* not a self-referencing profile package */ }
96
+ let directory = resolve(profileDirectory);
97
+ while (true) {
98
+ const packageDirectory = join(directory, 'node_modules', name);
99
+ // Node stops at the nearest matching package directory. A partial install
100
+ // there shadows any healthy parent copy and must not be accepted.
101
+ try {
102
+ if (statSync(packageDirectory).isDirectory()) {
103
+ return existsSync(join(packageDirectory, 'package.json'));
104
+ }
105
+ }
106
+ catch { /* keep walking */ }
107
+ const parent = dirname(directory);
108
+ if (parent === directory)
109
+ return false;
110
+ directory = parent;
111
+ }
112
+ }
59
113
  /** Every id in one patch row's insert list, recursively (group configs included). */
60
114
  function collectInsertIds(rows) {
61
115
  const ids = [];
@@ -160,14 +214,14 @@ function readNodeModulesVersion(base, name) {
160
214
  }
161
215
  }
162
216
  /**
163
- * Resolve one bundle package's directory the way the dsh boot does
217
+ * Resolve one package's directory the way the dsh boot does
164
218
  * (dsh-app-boot's resolveBundleDir): probe Node's own node_modules search
165
219
  * paths from the installation anchor first, then the profile directory.
166
220
  * Node resolution walks upward, so this also finds pnpm's workspace-root
167
221
  * hoisting (`<profiles>/node_modules/…` when the profile lives under
168
- * `<profiles>/<name>`) and matches exactly what the Loader would import.
222
+ * `<profiles>/<name>`) and mirrors the Loader's package search roots.
169
223
  */
170
- function resolveBundleDir(anchorPackageJson, name) {
224
+ function resolvePackageDir(anchorPackageJson, name) {
171
225
  let paths = [];
172
226
  try {
173
227
  paths = createRequire(anchorPackageJson).resolve.paths(name) ?? [];
@@ -308,6 +362,26 @@ export function satisfiesRange(version, range) {
308
362
  if (v === null)
309
363
  return null;
310
364
  const versionHasPre = v.pre.length > 0;
365
+ // Mirror pnpm's peer-dependency publish transform. Unlike ordinary
366
+ // dependency specs, a workspace token may sit inside a larger peer range.
367
+ // Operator-only (or bare) tokens receive the linked sibling's version;
368
+ // explicit versions only lose the protocol prefix. Unsupported alias/path
369
+ // forms become an unknown range below rather than a definite mismatch.
370
+ const workspaceSemver = /workspace:([\^~*]|>=|>|<=|<)?((\d+|[xX*])(\.(\d+|[xX*])){0,2})?/;
371
+ let normalizedRange = range;
372
+ if (range.includes('workspace:')) {
373
+ const match = workspaceSemver.exec(range);
374
+ if (match === null) {
375
+ normalizedRange = range.replace('workspace:', '');
376
+ }
377
+ else if (match[2] !== undefined) {
378
+ normalizedRange = range.replace('workspace:', '');
379
+ }
380
+ else {
381
+ const operator = match[1] === '*' ? '' : (match[1] ?? '');
382
+ normalizedRange = range.replace(workspaceSemver, `${operator}${semverStr(v)}`);
383
+ }
384
+ }
311
385
  const single = (part) => {
312
386
  const p = part.trim();
313
387
  if (p === '' || p === '*' || p === 'x' || p === 'X')
@@ -358,7 +432,14 @@ export function satisfiesRange(version, range) {
358
432
  const m = /^(\^|~|>=|<=|>|<)?(.*)$/.exec(p);
359
433
  if (m === null)
360
434
  return null;
361
- return { op: m[1] ?? '', target: (m[2] ?? '').trim() };
435
+ const target = (m[2] ?? '').trim();
436
+ // Enforce this function's documented unknown-range contract before the
437
+ // prerelease admission gate. Otherwise an unknown protocol such as
438
+ // `workspace:^` or `catalog:default` is misreported as a definite false
439
+ // whenever the resolved version happens to carry a prerelease tag.
440
+ if (parseSemver(target) === null)
441
+ return null;
442
+ return { op: m[1] ?? '', target };
362
443
  };
363
444
  /** Evaluate ONE comparator set (a `||` alternative) as a conjunction. */
364
445
  const evaluateSet = (set) => {
@@ -386,22 +467,74 @@ export function satisfiesRange(version, range) {
386
467
  return null;
387
468
  return results.every(r => r === true);
388
469
  };
389
- if (range.includes('||')) {
390
- const outcomes = range.split('||').map(part => evaluateSet(part));
470
+ if (normalizedRange.includes('||')) {
471
+ const outcomes = normalizedRange.split('||').map(part => evaluateSet(part));
391
472
  if (outcomes.some(out => out === true))
392
473
  return true;
393
- return outcomes.every(out => out === null) ? null : false;
474
+ if (outcomes.some(out => out === null))
475
+ return null;
476
+ return false;
394
477
  }
395
- return evaluateSet(range);
478
+ return evaluateSet(normalizedRange);
396
479
  }
397
480
  /** Flatten a tree of entries (group configs included) into row records. */
398
481
  function flattenEntries(nodes) {
399
482
  const rows = [];
400
- const walk = (list) => {
483
+ const walk = (list, inheritedLayer) => {
401
484
  for (const node of list) {
402
- rows.push({ id: node.id, layer: node.layer, kind: 'insert', name: node.name });
403
- if (node.group === true && Array.isArray(node.config))
404
- walk(node.config);
485
+ // Nested group configs come straight from the parsed patch and do not
486
+ // carry the synthetic layer metadata attached to their parent insert.
487
+ const layer = typeof node.layer === 'string' ? node.layer : inheritedLayer;
488
+ if (layer === undefined)
489
+ continue;
490
+ rows.push({ id: node.id, layer, kind: 'insert', name: node.name });
491
+ if (node.group === true && Array.isArray(node.config)) {
492
+ walk(node.config, layer);
493
+ }
494
+ }
495
+ };
496
+ walk(nodes);
497
+ return rows;
498
+ }
499
+ /** Literal values use Loader Boolean semantics; `!!js` stays indeterminate. */
500
+ function disabledState(value) {
501
+ if (isRecord(value) && typeof value.__jsExpr === 'string')
502
+ return 'conditional';
503
+ return Boolean(value) ? 'disabled' : 'active';
504
+ }
505
+ function combineDisabled(parent, own) {
506
+ if (parent === 'disabled' || own === 'disabled')
507
+ return 'disabled';
508
+ if (parent === 'conditional' || own === 'conditional')
509
+ return 'conditional';
510
+ return 'active';
511
+ }
512
+ /**
513
+ * Rows whose specifiers the Loader can attempt to import. Group rows are
514
+ * always imported even when disabled (their disabled state gates children),
515
+ * while expression-gated non-group rows are retained as conditional.
516
+ */
517
+ function resolvableEntries(nodes) {
518
+ const rows = [];
519
+ const walk = (list, inheritedLayer, parentDisabled = 'active') => {
520
+ for (const node of list) {
521
+ const layer = typeof node.layer === 'string' ? node.layer : inheritedLayer;
522
+ if (layer === undefined)
523
+ continue;
524
+ const descendantsDisabled = combineDisabled(parentDisabled, disabledState(node.disabled));
525
+ const activation = node.group === true ? 'required' : descendantsDisabled;
526
+ if (activation !== 'disabled') {
527
+ rows.push({
528
+ id: node.id,
529
+ layer,
530
+ kind: 'insert',
531
+ name: node.name,
532
+ activation: activation === 'conditional' ? 'conditional' : 'required',
533
+ });
534
+ }
535
+ if (node.group === true && Array.isArray(node.config)) {
536
+ walk(node.config, layer, descendantsDisabled);
537
+ }
405
538
  }
406
539
  };
407
540
  walk(nodes);
@@ -457,6 +590,7 @@ export function composeLayers(layers) {
457
590
  layer: layer.label,
458
591
  group: entry.group === true,
459
592
  config: Array.isArray(entry.config) ? entry.config : undefined,
593
+ disabled: entry.disabled,
460
594
  };
461
595
  }).filter((n) => n !== null);
462
596
  if (hasId) {
@@ -512,6 +646,7 @@ export function composeLayers(layers) {
512
646
  }
513
647
  }
514
648
  const rows = flattenEntries(tree);
649
+ const resolvableRows = resolvableEntries(tree);
515
650
  const byId = new Map();
516
651
  for (const row of rows) {
517
652
  const layers = byId.get(row.id) ?? [];
@@ -529,7 +664,7 @@ export function composeLayers(layers) {
529
664
  duplicates.push({ id, layers: byId.get(id) ?? [], count });
530
665
  }
531
666
  duplicates.sort((a, b) => a.id.localeCompare(b.id));
532
- return { rows, duplicates, overrides, orphans };
667
+ return { rows, resolvableRows, duplicates, overrides, orphans };
533
668
  }
534
669
  /** Distinct versions of `@deepseek-ai/{dsh,cordis}*` packages in the lockfile. */
535
670
  function lockfileCoreVersions(profileDir) {
@@ -582,7 +717,7 @@ export function buildBundleLayers(profileDirectory, bundleNames, specs, dshInsta
582
717
  for (const anchor of anchors) {
583
718
  if (anchor === null)
584
719
  continue;
585
- directory = resolveBundleDir(anchor, name);
720
+ directory = resolvePackageDir(anchor, name);
586
721
  if (directory !== null)
587
722
  break;
588
723
  }
@@ -747,6 +882,56 @@ export function analyzeProfile(profileDirectory, options = {}) {
747
882
  if (layer.parseError !== null && layer.kind !== 'bundle')
748
883
  errors.push(`${layer.label}: ${layer.parseError}`);
749
884
  }
885
+ // User and home patches can insert packages independently of a bundle.
886
+ // Only check rows that survived composition: an insert targeting a missing
887
+ // group is skipped by the boot and therefore cannot cause module loading to
888
+ // fail. Normalize package subpaths to their npm root and check the profile's
889
+ // node_modules ancestry; exact exports/subpath validation and relative,
890
+ // absolute, URL, builtin, package-import (#), or cordis: specifiers are
891
+ // outside this check.
892
+ const userLayerLabels = new Set(layers
893
+ .filter(layer => layer.kind === 'user' || layer.kind === 'home')
894
+ .map(layer => layer.label));
895
+ const candidates = new Map();
896
+ for (const row of composed.resolvableRows) {
897
+ if (row.kind !== 'insert' || !userLayerLabels.has(row.layer))
898
+ continue;
899
+ if (row.name === undefined || row.name === '') {
900
+ const message = `${row.layer}: loader entry ${JSON.stringify(row.id)} has no module name`;
901
+ if (row.activation === 'required')
902
+ errors.push(`${message} — the profile will fail to boot`);
903
+ else
904
+ warnings.push(`${message} — boot will fail if its disabled expression enables the entry`);
905
+ continue;
906
+ }
907
+ if (!isBareLoaderSpecifier(row.name))
908
+ continue;
909
+ if (isBuiltin(row.name))
910
+ continue;
911
+ const packageName = packageRoot(row.name);
912
+ if (packageName === null) {
913
+ const message = `${row.layer}: loader specifier ${JSON.stringify(row.name)} is not a valid bare package name`;
914
+ if (row.activation === 'required')
915
+ errors.push(`${message} — the profile will fail to boot`);
916
+ else
917
+ warnings.push(`${message} — boot will fail if its disabled expression enables the entry`);
918
+ continue;
919
+ }
920
+ const key = `${row.layer}\u0000${packageName}`;
921
+ const previous = candidates.get(key);
922
+ if (previous === undefined || previous.row.activation === 'conditional' && row.activation === 'required') {
923
+ candidates.set(key, { row, packageName });
924
+ }
925
+ }
926
+ for (const { row, packageName } of candidates.values()) {
927
+ if (profilePackageInstalled(profileDirectory, packageName))
928
+ continue;
929
+ const message = `${row.layer}: loader package ${packageName} is not installed in the profile`;
930
+ if (row.activation === 'required')
931
+ errors.push(`${message} — the profile will fail to boot`);
932
+ else
933
+ warnings.push(`${message} — boot will fail if its disabled expression enables the entry`);
934
+ }
750
935
  for (const dup of composed.duplicates) {
751
936
  errors.push(`duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows: ${dup.layers.join(', ')})`);
752
937
  }
package/lib/patch.js CHANGED
@@ -28,7 +28,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
28
28
  import { join } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { logEvent } from './log.js';
31
- import { parsePatchFile } from './check.js';
31
+ import { parsePatchFile, parsePatchText } from './check.js';
32
32
  import { bundlePatchInsertedIds, parsePatchRows } from './profile.js';
33
33
  /**
34
34
  * Host infrastructure rows: disabling any of these breaks the very chain
@@ -157,6 +157,81 @@ export function readUserPatchState(patchPath) {
157
157
  }
158
158
  return { disables, forced, inserts };
159
159
  }
160
+ /**
161
+ * User-authored insert rows that still load `packageName` (or one of its
162
+ * exported subpaths). This evidence deliberately stays separate from the
163
+ * package's own bundle declaration: uninstall may clean market-owned rows,
164
+ * but it must never rewrite an insert the user owns.
165
+ *
166
+ * A missing patch is the ordinary empty-profile shape. `null` means the
167
+ * existing file could not be read as DSH's patch dialect; callers must treat
168
+ * that as indeterminate and refuse the destructive step.
169
+ */
170
+ export function userPatchPackageReferences(patchPath, packageName) {
171
+ let source;
172
+ try {
173
+ source = readFileSync(patchPath, 'utf8');
174
+ }
175
+ catch (error) {
176
+ const code = error !== null && typeof error === 'object' && 'code' in error
177
+ ? error.code
178
+ : undefined;
179
+ if (code === 'ENOENT')
180
+ return [];
181
+ return null;
182
+ }
183
+ // Use the same schema as profile composition, including !!js scalars. A
184
+ // line scanner cannot distinguish flow-style entries or nested loader
185
+ // groups from arbitrary config keys in a hand-written user patch.
186
+ const rows = parsePatchText(source);
187
+ if (rows === null)
188
+ return null;
189
+ const insertedNames = new Set();
190
+ const visiting = new Set();
191
+ const visited = new Set();
192
+ const collect = (entries) => {
193
+ if (visited.has(entries))
194
+ return true;
195
+ if (visiting.has(entries))
196
+ return false;
197
+ visiting.add(entries);
198
+ for (const entry of entries) {
199
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
200
+ visiting.delete(entries);
201
+ return false;
202
+ }
203
+ const row = entry;
204
+ if ('name' in row && typeof row.name !== 'string') {
205
+ visiting.delete(entries);
206
+ return false;
207
+ }
208
+ if (typeof row.name === 'string')
209
+ insertedNames.add(row.name);
210
+ // Only group rows treat an array-valued config as child loader rows.
211
+ // Other plugins may use arrays of `{ name: ... }` as ordinary options,
212
+ // and those must not become false package references.
213
+ if (row.group === true && Array.isArray(row.config)) {
214
+ if (!collect(row.config)) {
215
+ visiting.delete(entries);
216
+ return false;
217
+ }
218
+ }
219
+ }
220
+ visiting.delete(entries);
221
+ visited.add(entries);
222
+ return true;
223
+ };
224
+ for (const patch of rows) {
225
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch))
226
+ return null;
227
+ const patchRow = patch;
228
+ if (!('insert' in patchRow))
229
+ continue;
230
+ if (!Array.isArray(patchRow.insert) || !collect(patchRow.insert))
231
+ return null;
232
+ }
233
+ return [...insertedNames].filter(reference => reference === packageName || reference.startsWith(`${packageName}/`));
234
+ }
160
235
  /** The include entry's id prefix (loader entry ids look like `include:X`). */
161
236
  function includePrefix(host) {
162
237
  for (const entry of host.loader.entries()) {
package/lib/routes.js CHANGED
@@ -34,7 +34,7 @@ import { createThemeManager } from './themes.js';
34
34
  import { readJsonBody, sameOrigin, sendJson } from './http.js';
35
35
  import { detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest } from './restart.js';
36
36
  import { activationAfterReplace, brokenClientBundles, checkClientBundle, hasHostHalf, newlyBrokenBundles, verifyActivation } from './verify.js';
37
- import { carrierDisableIds, disableRow, enableRow, findUserPatchPath, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, } from './patch.js';
37
+ import { carrierDisableIds, disableRow, enableRow, findUserPatchPath, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
38
38
  import { createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, unportableDeps, uploadWebdav, } from './backup.js';
39
39
  import { createGist, fitsGistLimit, GistError, gistErrorCode, parseGistId, readGist, resolveGistTokenSource, updateGist, verifyGistToken, } from './gist.js';
40
40
  /**
@@ -2250,6 +2250,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
2250
2250
  await withMutationLock(response, 'install', async () => {
2251
2251
  const body = (await readJsonBody(request));
2252
2252
  const name = typeof body.name === 'string' ? body.name : '';
2253
+ // Only the INDETERMINATE patch case is forceable, below. A patch
2254
+ // that definitely names the package stays refused: there the user
2255
+ // has a concrete thing to go fix, so an override would only help
2256
+ // them break their next boot.
2257
+ const force = body.force === true;
2253
2258
  if (name === 'dsh-market' || name === 'dshmarket') {
2254
2259
  sendJson(response, 400, { error: 'the market cannot uninstall itself; use the dsh CLI' });
2255
2260
  return;
@@ -2258,6 +2263,35 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
2258
2263
  sendJson(response, 400, { error: 'plugin is not installed' });
2259
2264
  return;
2260
2265
  }
2266
+ const userPatchReferences = userPatchPackageReferences(userPatchPath, name);
2267
+ if (userPatchReferences === null && !force) {
2268
+ // Refusing here is right — an unreadable patch might still load
2269
+ // the package, and removing it would break the next boot. But
2270
+ // refusing with NO way through is the wrong shape: the market
2271
+ // cannot say which row to fix, and the moment someone wants to
2272
+ // uninstall is usually the moment something is already broken.
2273
+ // So this one is forceable, and says so.
2274
+ logEvent('warn', 'uninstall-blocked', `${name}: user cordis.patch.yml could not be inspected safely`);
2275
+ sendJson(response, 409, {
2276
+ error: `无法安全卸载 ${name}:当前 profile 的 cordis.patch.yml 无法读取为有效的补丁列表,因此无法排除它仍在引用该包。请先检查补丁文件;确认无关后可强制卸载。 / Cannot safely uninstall ${name}: this profile's cordis.patch.yml could not be read as a valid patch list, so the market cannot rule out a remaining package reference. Check the patch file; you can force the uninstall once you are sure it is unrelated.`,
2277
+ userPatchInspectionFailed: true,
2278
+ forceable: true,
2279
+ });
2280
+ return;
2281
+ }
2282
+ if (userPatchReferences === null) {
2283
+ logEvent('warn', 'uninstall', `${name}: forced past an unreadable user cordis.patch.yml`);
2284
+ }
2285
+ if (userPatchReferences !== null && userPatchReferences.length > 0) {
2286
+ const listed = userPatchReferences.join(', ');
2287
+ logEvent('warn', 'uninstall-blocked', `${name}: user cordis.patch.yml still inserts ${listed}`);
2288
+ sendJson(response, 409, {
2289
+ error: `无法卸载 ${name}:当前 profile 的 cordis.patch.yml 仍通过 insert 引用 ${listed}。请先移除这些用户补丁引用再重试;市场不会自动改写用户补丁。 / Cannot uninstall ${name}: this profile's cordis.patch.yml still inserts ${listed}. Remove those user-owned patch references first and retry; the market will not rewrite the user patch automatically.`,
2290
+ userPatchReferenced: true,
2291
+ patchReferences: userPatchReferences,
2292
+ });
2293
+ return;
2294
+ }
2261
2295
  const busyAgents = runningAgentsForGuard();
2262
2296
  if (busyAgents.length > 0) {
2263
2297
  logEvent('warn', 'uninstall-blocked', `${name}: refused while agents are running — ${busyAgents.join(', ')}`);
@@ -18,6 +18,8 @@
18
18
  * version (tool calls die, minimal preset fails to mount)?
19
19
  * 4. Are there multiple versions of one core package in the lockfile, and
20
20
  * do plugin peerDependencies ranges match the resolved core version?
21
+ * 5. Do effective user/home patch entries reference npm package roots that
22
+ * are installed in the profile-visible node_modules ancestry?
21
23
  *
22
24
  * The composition step mirrors @deepseek-ai/dsh-app-boot's applyEntryPatches
23
25
  * (same js-yaml dialect incl. `!!js` scalars), so the rows reported here are
@@ -187,7 +189,9 @@ export interface CheckOptions {
187
189
  /** Harness home for the home-level patch layer; defaults to $DSH_HOME or ~/.dsh. */
188
190
  homeDir?: string;
189
191
  }
190
- /** Parse one entry-list patch file with the dsh dialect; null when unreadable. */
192
+ /** Parse entry-list source with the DSH dialect; null when it is not a list. */
193
+ export declare function parsePatchText(text: string): unknown[] | null;
194
+ /** Parse one entry-list patch file with the DSH dialect; null when unreadable. */
191
195
  export declare function parsePatchFile(path: string): unknown[] | null;
192
196
  /** DSH host core packages: what the dsh installation ships under @deepseek-ai. */
193
197
  export declare function corePackageNames(dshInstallDir: string | null): Set<string>;
@@ -213,6 +217,9 @@ export declare function compareSemver(a: string, b: string): number;
213
217
  * `>=1.2.3-rc.1 <2.0.0` does match `1.2.3-rc.2` (issue #98 analysis).
214
218
  */
215
219
  export declare function satisfiesRange(version: string, range: string): boolean | null;
220
+ interface ResolvableLoaderRow extends LoaderRow {
221
+ activation: 'required' | 'conditional';
222
+ }
216
223
  export interface LayerInput {
217
224
  label: string;
218
225
  kind: 'bundle' | 'user' | 'home';
@@ -221,6 +228,7 @@ export interface LayerInput {
221
228
  }
222
229
  interface Composed {
223
230
  rows: LoaderRow[];
231
+ resolvableRows: ResolvableLoaderRow[];
224
232
  duplicates: DuplicateId[];
225
233
  overrides: OverrideRow[];
226
234
  orphans: OrphanRow[];
@@ -63,6 +63,17 @@ export interface PatchState {
63
63
  * to know what the user patch layer says.
64
64
  */
65
65
  export declare function readUserPatchState(patchPath: string): PatchState;
66
+ /**
67
+ * User-authored insert rows that still load `packageName` (or one of its
68
+ * exported subpaths). This evidence deliberately stays separate from the
69
+ * package's own bundle declaration: uninstall may clean market-owned rows,
70
+ * but it must never rewrite an insert the user owns.
71
+ *
72
+ * A missing patch is the ordinary empty-profile shape. `null` means the
73
+ * existing file could not be read as DSH's patch dialect; callers must treat
74
+ * that as indeterminate and refuse the destructive step.
75
+ */
76
+ export declare function userPatchPackageReferences(patchPath: string, packageName: string): string[] | null;
66
77
  /**
67
78
  * The user-patch row ids one installed package owns: its bundle patch's
68
79
  * insert rows, plus the loader entries currently carrying its name.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dshmarket",
3
3
  "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
4
- "version": "1.26.0",
4
+ "version": "1.27.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
package/src/check.ts CHANGED
@@ -18,16 +18,18 @@
18
18
  * version (tool calls die, minimal preset fails to mount)?
19
19
  * 4. Are there multiple versions of one core package in the lockfile, and
20
20
  * do plugin peerDependencies ranges match the resolved core version?
21
+ * 5. Do effective user/home patch entries reference npm package roots that
22
+ * are installed in the profile-visible node_modules ancestry?
21
23
  *
22
24
  * The composition step mirrors @deepseek-ai/dsh-app-boot's applyEntryPatches
23
25
  * (same js-yaml dialect incl. `!!js` scalars), so the rows reported here are
24
26
  * what actually mounts at boot.
25
27
  */
26
28
 
27
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
28
- import { createRequire } from 'node:module'
29
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
30
+ import { createRequire, isBuiltin } from 'node:module'
29
31
  import { homedir } from 'node:os'
30
- import { dirname, join, resolve } from 'node:path'
32
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
31
33
  import { JSON_SCHEMA, Type, load } from 'js-yaml'
32
34
  import { INBOX_BUNDLES, readBundleRules, suggestOrder, validateOrder } from './order.ts'
33
35
 
@@ -204,22 +206,71 @@ function isRecord(value: unknown): value is Record<string, unknown> {
204
206
  return value !== null && typeof value === 'object' && !Array.isArray(value)
205
207
  }
206
208
 
207
- /** Parse one entry-list patch file with the dsh dialect; null when unreadable. */
208
- export function parsePatchFile(path: string): unknown[] | null {
209
- let text: string
209
+ /** Parse entry-list source with the DSH dialect; null when it is not a list. */
210
+ export function parsePatchText(text: string): unknown[] | null {
210
211
  try {
211
- text = readFileSync(path, 'utf8')
212
+ const value = load(text, { schema: entrySchema })
213
+ return Array.isArray(value) ? value : null
212
214
  } catch {
213
215
  return null
214
216
  }
217
+ }
218
+
219
+ /** Parse one entry-list patch file with the DSH dialect; null when unreadable. */
220
+ export function parsePatchFile(path: string): unknown[] | null {
215
221
  try {
216
- const value = load(text, { schema: entrySchema })
217
- return Array.isArray(value) ? value : null
222
+ return parsePatchText(readFileSync(path, 'utf8'))
218
223
  } catch {
219
224
  return null
220
225
  }
221
226
  }
222
227
 
228
+ /** Whether `name` goes through the Loader's bare-module resolver. */
229
+ function isBareLoaderSpecifier(name: string): boolean {
230
+ return !name.startsWith('.')
231
+ && !name.startsWith('#')
232
+ && !isAbsolute(name)
233
+ && !/^[a-z][a-z\d+.-]*:/i.test(name)
234
+ }
235
+
236
+ /** Npm package root owning one bare Loader specifier. */
237
+ function packageRoot(specifier: string): string | null {
238
+ const parts = specifier.split('/')
239
+ const segments = specifier.startsWith('@') ? parts.slice(0, 2) : parts.slice(0, 1)
240
+ if (segments.length !== (specifier.startsWith('@') ? 2 : 1)) return null
241
+ if (segments.some(segment => segment === '' || segment === '.' || segment === '..'
242
+ || segment.includes('%') || segment.includes('\\'))) return null
243
+ if (specifier.startsWith('@') && (segments[0]?.length ?? 0) <= 1) return null
244
+ return segments.join('/')
245
+ }
246
+
247
+ /**
248
+ * Package-root presence from the Loader-visible profile ancestry only.
249
+ * An explicit walk avoids CommonJS global lookup directories that
250
+ * `createRequire(...).resolve.paths()` appends but Node ESM does not search.
251
+ */
252
+ function profilePackageInstalled(profileDirectory: string, name: string): boolean {
253
+ try {
254
+ const profile = JSON.parse(readFileSync(join(profileDirectory, 'package.json'), 'utf8')) as unknown
255
+ if (isRecord(profile) && profile.name === name
256
+ && profile.exports !== undefined && profile.exports !== null) return true
257
+ } catch { /* not a self-referencing profile package */ }
258
+ let directory = resolve(profileDirectory)
259
+ while (true) {
260
+ const packageDirectory = join(directory, 'node_modules', name)
261
+ // Node stops at the nearest matching package directory. A partial install
262
+ // there shadows any healthy parent copy and must not be accepted.
263
+ try {
264
+ if (statSync(packageDirectory).isDirectory()) {
265
+ return existsSync(join(packageDirectory, 'package.json'))
266
+ }
267
+ } catch { /* keep walking */ }
268
+ const parent = dirname(directory)
269
+ if (parent === directory) return false
270
+ directory = parent
271
+ }
272
+ }
273
+
223
274
  /** Every id in one patch row's insert list, recursively (group configs included). */
224
275
  function collectInsertIds(rows: unknown[]): string[] {
225
276
  const ids: string[] = []
@@ -315,14 +366,14 @@ function readNodeModulesVersion(base: string, name: string): string | null {
315
366
  }
316
367
 
317
368
  /**
318
- * Resolve one bundle package's directory the way the dsh boot does
369
+ * Resolve one package's directory the way the dsh boot does
319
370
  * (dsh-app-boot's resolveBundleDir): probe Node's own node_modules search
320
371
  * paths from the installation anchor first, then the profile directory.
321
372
  * Node resolution walks upward, so this also finds pnpm's workspace-root
322
373
  * hoisting (`<profiles>/node_modules/…` when the profile lives under
323
- * `<profiles>/<name>`) and matches exactly what the Loader would import.
374
+ * `<profiles>/<name>`) and mirrors the Loader's package search roots.
324
375
  */
325
- function resolveBundleDir(anchorPackageJson: string, name: string): string | null {
376
+ function resolvePackageDir(anchorPackageJson: string, name: string): string | null {
326
377
  let paths: string[] = []
327
378
  try {
328
379
  paths = createRequire(anchorPackageJson).resolve.paths(name) ?? []
@@ -461,6 +512,25 @@ export function satisfiesRange(version: string, range: string): boolean | null {
461
512
  if (v === null) return null
462
513
  const versionHasPre = v.pre.length > 0
463
514
 
515
+ // Mirror pnpm's peer-dependency publish transform. Unlike ordinary
516
+ // dependency specs, a workspace token may sit inside a larger peer range.
517
+ // Operator-only (or bare) tokens receive the linked sibling's version;
518
+ // explicit versions only lose the protocol prefix. Unsupported alias/path
519
+ // forms become an unknown range below rather than a definite mismatch.
520
+ const workspaceSemver = /workspace:([\^~*]|>=|>|<=|<)?((\d+|[xX*])(\.(\d+|[xX*])){0,2})?/
521
+ let normalizedRange = range
522
+ if (range.includes('workspace:')) {
523
+ const match = workspaceSemver.exec(range)
524
+ if (match === null) {
525
+ normalizedRange = range.replace('workspace:', '')
526
+ } else if (match[2] !== undefined) {
527
+ normalizedRange = range.replace('workspace:', '')
528
+ } else {
529
+ const operator = match[1] === '*' ? '' : (match[1] ?? '')
530
+ normalizedRange = range.replace(workspaceSemver, `${operator}${semverStr(v)}`)
531
+ }
532
+ }
533
+
464
534
  const single = (part: string): boolean | null => {
465
535
  const p = part.trim()
466
536
  if (p === '' || p === '*' || p === 'x' || p === 'X') return true
@@ -508,7 +578,13 @@ export function satisfiesRange(version: string, range: string): boolean | null {
508
578
  if (p === '' || p === '*' || p === 'x' || p === 'X') return { op: '', target: '' }
509
579
  const m = /^(\^|~|>=|<=|>|<)?(.*)$/.exec(p)
510
580
  if (m === null) return null
511
- return { op: m[1] ?? '', target: (m[2] ?? '').trim() }
581
+ const target = (m[2] ?? '').trim()
582
+ // Enforce this function's documented unknown-range contract before the
583
+ // prerelease admission gate. Otherwise an unknown protocol such as
584
+ // `workspace:^` or `catalog:default` is misreported as a definite false
585
+ // whenever the resolved version happens to carry a prerelease tag.
586
+ if (parseSemver(target) === null) return null
587
+ return { op: m[1] ?? '', target }
512
588
  }
513
589
 
514
590
  /** Evaluate ONE comparator set (a `||` alternative) as a conjunction. */
@@ -533,12 +609,13 @@ export function satisfiesRange(version: string, range: string): boolean | null {
533
609
  return results.every(r => r === true)
534
610
  }
535
611
 
536
- if (range.includes('||')) {
537
- const outcomes = range.split('||').map(part => evaluateSet(part))
612
+ if (normalizedRange.includes('||')) {
613
+ const outcomes = normalizedRange.split('||').map(part => evaluateSet(part))
538
614
  if (outcomes.some(out => out === true)) return true
539
- return outcomes.every(out => out === null) ? null : false
615
+ if (outcomes.some(out => out === null)) return null
616
+ return false
540
617
  }
541
- return evaluateSet(range)
618
+ return evaluateSet(normalizedRange)
542
619
  }
543
620
 
544
621
  // --- composition (mirrors dsh-app-boot applyEntryPatches) ---
@@ -546,18 +623,78 @@ export function satisfiesRange(version: string, range: string): boolean | null {
546
623
  interface EntryNode {
547
624
  id: string
548
625
  name?: string
549
- layer: string
626
+ layer?: string
550
627
  group?: boolean
551
628
  config?: unknown
629
+ disabled?: unknown
552
630
  }
553
631
 
554
632
  /** Flatten a tree of entries (group configs included) into row records. */
555
633
  function flattenEntries(nodes: EntryNode[]): LoaderRow[] {
556
634
  const rows: LoaderRow[] = []
557
- const walk = (list: EntryNode[]): void => {
635
+ const walk = (list: EntryNode[], inheritedLayer?: string): void => {
636
+ for (const node of list) {
637
+ // Nested group configs come straight from the parsed patch and do not
638
+ // carry the synthetic layer metadata attached to their parent insert.
639
+ const layer = typeof node.layer === 'string' ? node.layer : inheritedLayer
640
+ if (layer === undefined) continue
641
+ rows.push({ id: node.id, layer, kind: 'insert', name: node.name })
642
+ if (node.group === true && Array.isArray(node.config)) {
643
+ walk(node.config as EntryNode[], layer)
644
+ }
645
+ }
646
+ }
647
+ walk(nodes)
648
+ return rows
649
+ }
650
+
651
+ type DisabledState = 'active' | 'disabled' | 'conditional'
652
+
653
+ interface ResolvableLoaderRow extends LoaderRow {
654
+ activation: 'required' | 'conditional'
655
+ }
656
+
657
+ /** Literal values use Loader Boolean semantics; `!!js` stays indeterminate. */
658
+ function disabledState(value: unknown): DisabledState {
659
+ if (isRecord(value) && typeof value.__jsExpr === 'string') return 'conditional'
660
+ return Boolean(value) ? 'disabled' : 'active'
661
+ }
662
+
663
+ function combineDisabled(parent: DisabledState, own: DisabledState): DisabledState {
664
+ if (parent === 'disabled' || own === 'disabled') return 'disabled'
665
+ if (parent === 'conditional' || own === 'conditional') return 'conditional'
666
+ return 'active'
667
+ }
668
+
669
+ /**
670
+ * Rows whose specifiers the Loader can attempt to import. Group rows are
671
+ * always imported even when disabled (their disabled state gates children),
672
+ * while expression-gated non-group rows are retained as conditional.
673
+ */
674
+ function resolvableEntries(nodes: EntryNode[]): ResolvableLoaderRow[] {
675
+ const rows: ResolvableLoaderRow[] = []
676
+ const walk = (
677
+ list: EntryNode[],
678
+ inheritedLayer?: string,
679
+ parentDisabled: DisabledState = 'active',
680
+ ): void => {
558
681
  for (const node of list) {
559
- rows.push({ id: node.id, layer: node.layer, kind: 'insert', name: node.name })
560
- if (node.group === true && Array.isArray(node.config)) walk(node.config as EntryNode[])
682
+ const layer = typeof node.layer === 'string' ? node.layer : inheritedLayer
683
+ if (layer === undefined) continue
684
+ const descendantsDisabled = combineDisabled(parentDisabled, disabledState(node.disabled))
685
+ const activation = node.group === true ? 'required' : descendantsDisabled
686
+ if (activation !== 'disabled') {
687
+ rows.push({
688
+ id: node.id,
689
+ layer,
690
+ kind: 'insert',
691
+ name: node.name,
692
+ activation: activation === 'conditional' ? 'conditional' : 'required',
693
+ })
694
+ }
695
+ if (node.group === true && Array.isArray(node.config)) {
696
+ walk(node.config as EntryNode[], layer, descendantsDisabled)
697
+ }
561
698
  }
562
699
  }
563
700
  walk(nodes)
@@ -573,6 +710,7 @@ export interface LayerInput {
573
710
 
574
711
  interface Composed {
575
712
  rows: LoaderRow[]
713
+ resolvableRows: ResolvableLoaderRow[]
576
714
  duplicates: DuplicateId[]
577
715
  overrides: OverrideRow[]
578
716
  orphans: OrphanRow[]
@@ -624,6 +762,7 @@ export function composeLayers(layers: LayerInput[]): Composed {
624
762
  layer: layer.label,
625
763
  group: entry.group === true,
626
764
  config: Array.isArray(entry.config) ? entry.config : undefined,
765
+ disabled: entry.disabled,
627
766
  }
628
767
  }).filter((n): n is EntryNode => n !== null)
629
768
  if (hasId) {
@@ -675,6 +814,7 @@ export function composeLayers(layers: LayerInput[]): Composed {
675
814
  }
676
815
  }
677
816
  const rows = flattenEntries(tree)
817
+ const resolvableRows = resolvableEntries(tree)
678
818
  const byId = new Map<string, string[]>()
679
819
  for (const row of rows) {
680
820
  const layers = byId.get(row.id) ?? []
@@ -689,7 +829,7 @@ export function composeLayers(layers: LayerInput[]): Composed {
689
829
  duplicates.push({ id, layers: byId.get(id) ?? [], count })
690
830
  }
691
831
  duplicates.sort((a, b) => a.id.localeCompare(b.id))
692
- return { rows, duplicates, overrides, orphans }
832
+ return { rows, resolvableRows, duplicates, overrides, orphans }
693
833
  }
694
834
 
695
835
  /** Distinct versions of `@deepseek-ai/{dsh,cordis}*` packages in the lockfile. */
@@ -745,7 +885,7 @@ export function buildBundleLayers(
745
885
  let directory: string | null = null
746
886
  for (const anchor of anchors) {
747
887
  if (anchor === null) continue
748
- directory = resolveBundleDir(anchor, name)
888
+ directory = resolvePackageDir(anchor, name)
749
889
  if (directory !== null) break
750
890
  }
751
891
  const layer: BundleLayer = {
@@ -911,6 +1051,48 @@ export function analyzeProfile(profileDirectory: string, options: CheckOptions =
911
1051
  for (const layer of layers) {
912
1052
  if (layer.parseError !== null && layer.kind !== 'bundle') errors.push(`${layer.label}: ${layer.parseError}`)
913
1053
  }
1054
+ // User and home patches can insert packages independently of a bundle.
1055
+ // Only check rows that survived composition: an insert targeting a missing
1056
+ // group is skipped by the boot and therefore cannot cause module loading to
1057
+ // fail. Normalize package subpaths to their npm root and check the profile's
1058
+ // node_modules ancestry; exact exports/subpath validation and relative,
1059
+ // absolute, URL, builtin, package-import (#), or cordis: specifiers are
1060
+ // outside this check.
1061
+ const userLayerLabels = new Set(
1062
+ layers
1063
+ .filter(layer => layer.kind === 'user' || layer.kind === 'home')
1064
+ .map(layer => layer.label),
1065
+ )
1066
+ const candidates = new Map<string, { row: ResolvableLoaderRow, packageName: string }>()
1067
+ for (const row of composed.resolvableRows) {
1068
+ if (row.kind !== 'insert' || !userLayerLabels.has(row.layer)) continue
1069
+ if (row.name === undefined || row.name === '') {
1070
+ const message = `${row.layer}: loader entry ${JSON.stringify(row.id)} has no module name`
1071
+ if (row.activation === 'required') errors.push(`${message} — the profile will fail to boot`)
1072
+ else warnings.push(`${message} — boot will fail if its disabled expression enables the entry`)
1073
+ continue
1074
+ }
1075
+ if (!isBareLoaderSpecifier(row.name)) continue
1076
+ if (isBuiltin(row.name)) continue
1077
+ const packageName = packageRoot(row.name)
1078
+ if (packageName === null) {
1079
+ const message = `${row.layer}: loader specifier ${JSON.stringify(row.name)} is not a valid bare package name`
1080
+ if (row.activation === 'required') errors.push(`${message} — the profile will fail to boot`)
1081
+ else warnings.push(`${message} — boot will fail if its disabled expression enables the entry`)
1082
+ continue
1083
+ }
1084
+ const key = `${row.layer}\u0000${packageName}`
1085
+ const previous = candidates.get(key)
1086
+ if (previous === undefined || previous.row.activation === 'conditional' && row.activation === 'required') {
1087
+ candidates.set(key, { row, packageName })
1088
+ }
1089
+ }
1090
+ for (const { row, packageName } of candidates.values()) {
1091
+ if (profilePackageInstalled(profileDirectory, packageName)) continue
1092
+ const message = `${row.layer}: loader package ${packageName} is not installed in the profile`
1093
+ if (row.activation === 'required') errors.push(`${message} — the profile will fail to boot`)
1094
+ else warnings.push(`${message} — boot will fail if its disabled expression enables the entry`)
1095
+ }
914
1096
  for (const dup of composed.duplicates) {
915
1097
  errors.push(`duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows: ${dup.layers.join(', ')})`)
916
1098
  }
@@ -1017,4 +1199,4 @@ export function analyzeProfile(profileDirectory: string, options: CheckOptions =
1017
1199
  warnings,
1018
1200
  },
1019
1201
  }
1020
- }
1202
+ }
package/src/patch.ts CHANGED
@@ -29,7 +29,7 @@ import { readFileSync, writeFileSync } from 'node:fs'
29
29
  import { join } from 'node:path'
30
30
  import { fileURLToPath } from 'node:url'
31
31
  import { logEvent } from './log.ts'
32
- import { parsePatchFile } from './check.ts'
32
+ import { parsePatchFile, parsePatchText } from './check.ts'
33
33
  import { bundlePatchInsertedIds, parsePatchRows } from './profile.ts'
34
34
 
35
35
  /** The slice of the loader tree this module needs. */
@@ -174,6 +174,76 @@ export function readUserPatchState(patchPath: string): PatchState {
174
174
  return { disables, forced, inserts }
175
175
  }
176
176
 
177
+ /**
178
+ * User-authored insert rows that still load `packageName` (or one of its
179
+ * exported subpaths). This evidence deliberately stays separate from the
180
+ * package's own bundle declaration: uninstall may clean market-owned rows,
181
+ * but it must never rewrite an insert the user owns.
182
+ *
183
+ * A missing patch is the ordinary empty-profile shape. `null` means the
184
+ * existing file could not be read as DSH's patch dialect; callers must treat
185
+ * that as indeterminate and refuse the destructive step.
186
+ */
187
+ export function userPatchPackageReferences(patchPath: string, packageName: string): string[] | null {
188
+ let source: string
189
+ try {
190
+ source = readFileSync(patchPath, 'utf8')
191
+ } catch (error) {
192
+ const code = error !== null && typeof error === 'object' && 'code' in error
193
+ ? (error as { code?: unknown }).code
194
+ : undefined
195
+ if (code === 'ENOENT') return []
196
+ return null
197
+ }
198
+
199
+ // Use the same schema as profile composition, including !!js scalars. A
200
+ // line scanner cannot distinguish flow-style entries or nested loader
201
+ // groups from arbitrary config keys in a hand-written user patch.
202
+ const rows = parsePatchText(source)
203
+ if (rows === null) return null
204
+ const insertedNames = new Set<string>()
205
+ const visiting = new Set<unknown[]>()
206
+ const visited = new Set<unknown[]>()
207
+ const collect = (entries: unknown[]): boolean => {
208
+ if (visited.has(entries)) return true
209
+ if (visiting.has(entries)) return false
210
+ visiting.add(entries)
211
+ for (const entry of entries) {
212
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
213
+ visiting.delete(entries)
214
+ return false
215
+ }
216
+ const row = entry as Record<string, unknown>
217
+ if ('name' in row && typeof row.name !== 'string') {
218
+ visiting.delete(entries)
219
+ return false
220
+ }
221
+ if (typeof row.name === 'string') insertedNames.add(row.name)
222
+ // Only group rows treat an array-valued config as child loader rows.
223
+ // Other plugins may use arrays of `{ name: ... }` as ordinary options,
224
+ // and those must not become false package references.
225
+ if (row.group === true && Array.isArray(row.config)) {
226
+ if (!collect(row.config)) {
227
+ visiting.delete(entries)
228
+ return false
229
+ }
230
+ }
231
+ }
232
+ visiting.delete(entries)
233
+ visited.add(entries)
234
+ return true
235
+ }
236
+ for (const patch of rows) {
237
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return null
238
+ const patchRow = patch as Record<string, unknown>
239
+ if (!('insert' in patchRow)) continue
240
+ if (!Array.isArray(patchRow.insert) || !collect(patchRow.insert)) return null
241
+ }
242
+ return [...insertedNames].filter(
243
+ reference => reference === packageName || reference.startsWith(`${packageName}/`),
244
+ )
245
+ }
246
+
177
247
  /** The include entry's id prefix (loader entry ids look like `include:X`). */
178
248
  function includePrefix(host: PatchHost): string {
179
249
  for (const entry of host.loader.entries()) {
@@ -449,4 +519,4 @@ export function removeRowBlocks(patchPath: string, rowIds: readonly string[]): v
449
519
  writeFileSync(patchPath, withPlaceholderRestored(next))
450
520
  logEvent('info', 'patch', `removed patch rows for ${rowIds.join(', ')}`)
451
521
  }
452
- }
522
+ }
package/src/routes.ts CHANGED
@@ -44,7 +44,7 @@ import { detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trust
44
44
  import { activationAfterReplace, brokenClientBundles, checkClientBundle, hasHostHalf, newlyBrokenBundles, verifyActivation } from './verify.ts'
45
45
  import {
46
46
  carrierDisableIds, disableRow, enableRow, findUserPatchPath, isProtectedModule, packagePatchFlags,
47
- readUserPatchState, removeRowBlocks, rowIdsForPackage,
47
+ readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences,
48
48
  } from './patch.ts'
49
49
  import {
50
50
  createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, secretFileCount, unportableDeps, uploadWebdav,
@@ -2304,8 +2304,13 @@ export function mountMarketRoutes(
2304
2304
  }
2305
2305
  try {
2306
2306
  await withMutationLock(response, 'install', async () => {
2307
- const body = (await readJsonBody(request)) as { name?: unknown }
2307
+ const body = (await readJsonBody(request)) as { name?: unknown; force?: unknown }
2308
2308
  const name = typeof body.name === 'string' ? body.name : ''
2309
+ // Only the INDETERMINATE patch case is forceable, below. A patch
2310
+ // that definitely names the package stays refused: there the user
2311
+ // has a concrete thing to go fix, so an override would only help
2312
+ // them break their next boot.
2313
+ const force = body.force === true
2309
2314
  if (name === 'dsh-market' || name === 'dshmarket') {
2310
2315
  sendJson(response, 400, { error: 'the market cannot uninstall itself; use the dsh CLI' })
2311
2316
  return
@@ -2314,6 +2319,35 @@ export function mountMarketRoutes(
2314
2319
  sendJson(response, 400, { error: 'plugin is not installed' })
2315
2320
  return
2316
2321
  }
2322
+ const userPatchReferences = userPatchPackageReferences(userPatchPath, name)
2323
+ if (userPatchReferences === null && !force) {
2324
+ // Refusing here is right — an unreadable patch might still load
2325
+ // the package, and removing it would break the next boot. But
2326
+ // refusing with NO way through is the wrong shape: the market
2327
+ // cannot say which row to fix, and the moment someone wants to
2328
+ // uninstall is usually the moment something is already broken.
2329
+ // So this one is forceable, and says so.
2330
+ logEvent('warn', 'uninstall-blocked', `${name}: user cordis.patch.yml could not be inspected safely`)
2331
+ sendJson(response, 409, {
2332
+ error: `无法安全卸载 ${name}:当前 profile 的 cordis.patch.yml 无法读取为有效的补丁列表,因此无法排除它仍在引用该包。请先检查补丁文件;确认无关后可强制卸载。 / Cannot safely uninstall ${name}: this profile's cordis.patch.yml could not be read as a valid patch list, so the market cannot rule out a remaining package reference. Check the patch file; you can force the uninstall once you are sure it is unrelated.`,
2333
+ userPatchInspectionFailed: true,
2334
+ forceable: true,
2335
+ })
2336
+ return
2337
+ }
2338
+ if (userPatchReferences === null) {
2339
+ logEvent('warn', 'uninstall', `${name}: forced past an unreadable user cordis.patch.yml`)
2340
+ }
2341
+ if (userPatchReferences !== null && userPatchReferences.length > 0) {
2342
+ const listed = userPatchReferences.join(', ')
2343
+ logEvent('warn', 'uninstall-blocked', `${name}: user cordis.patch.yml still inserts ${listed}`)
2344
+ sendJson(response, 409, {
2345
+ error: `无法卸载 ${name}:当前 profile 的 cordis.patch.yml 仍通过 insert 引用 ${listed}。请先移除这些用户补丁引用再重试;市场不会自动改写用户补丁。 / Cannot uninstall ${name}: this profile's cordis.patch.yml still inserts ${listed}. Remove those user-owned patch references first and retry; the market will not rewrite the user patch automatically.`,
2346
+ userPatchReferenced: true,
2347
+ patchReferences: userPatchReferences,
2348
+ })
2349
+ return
2350
+ }
2317
2351
  const busyAgents = runningAgentsForGuard()
2318
2352
  if (busyAgents.length > 0) {
2319
2353
  logEvent('warn', 'uninstall-blocked', `${name}: refused while agents are running — ${busyAgents.join(', ')}`)