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 +208 -23
- package/lib/patch.js +76 -1
- package/lib/routes.js +35 -1
- package/lib/types/check.d.ts +9 -1
- package/lib/types/patch.d.ts +11 -0
- package/package.json +1 -1
- package/src/check.ts +206 -24
- package/src/patch.ts +72 -2
- package/src/routes.ts +36 -2
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
|
|
43
|
-
export function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
222
|
+
* `<profiles>/<name>`) and mirrors the Loader's package search roots.
|
|
169
223
|
*/
|
|
170
|
-
function
|
|
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
|
-
|
|
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 (
|
|
390
|
-
const outcomes =
|
|
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
|
-
|
|
474
|
+
if (outcomes.some(out => out === null))
|
|
475
|
+
return null;
|
|
476
|
+
return false;
|
|
394
477
|
}
|
|
395
|
-
return evaluateSet(
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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 =
|
|
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(', ')}`);
|
package/lib/types/check.d.ts
CHANGED
|
@@ -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
|
|
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[];
|
package/lib/types/patch.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
208
|
-
export function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
374
|
+
* `<profiles>/<name>`) and mirrors the Loader's package search roots.
|
|
324
375
|
*/
|
|
325
|
-
function
|
|
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
|
-
|
|
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 (
|
|
537
|
-
const outcomes =
|
|
612
|
+
if (normalizedRange.includes('||')) {
|
|
613
|
+
const outcomes = normalizedRange.split('||').map(part => evaluateSet(part))
|
|
538
614
|
if (outcomes.some(out => out === true)) return true
|
|
539
|
-
|
|
615
|
+
if (outcomes.some(out => out === null)) return null
|
|
616
|
+
return false
|
|
540
617
|
}
|
|
541
|
-
return evaluateSet(
|
|
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
|
|
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
|
-
|
|
560
|
-
if (
|
|
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 =
|
|
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(', ')}`)
|