dshmarket 1.66.19 → 1.66.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/client.js +57 -0
- package/lib/profile.js +61 -40
- package/lib/recovery.js +10 -0
- package/lib/regions.js +27 -1
- package/lib/restart.js +22 -2
- package/lib/routes.js +78 -2
- package/lib/themes.js +59 -5
- package/lib/types/profile.d.ts +11 -0
- package/lib/types/verify.d.ts +37 -0
- package/lib/verify.js +118 -26
- package/package.json +1 -1
- package/src/client/Diagnostics.tsx +39 -0
- package/src/client/MarketSection.tsx +27 -0
- package/src/client/locales.ts +14 -0
- package/src/profile.ts +58 -36
- package/src/recovery.ts +10 -0
- package/src/regions.ts +28 -1
- package/src/restart.ts +22 -2
- package/src/routes.ts +86 -2
- package/src/themes.ts +55 -6
- package/src/verify.ts +130 -23
package/lib/types/verify.d.ts
CHANGED
|
@@ -180,6 +180,43 @@ export interface BundleCheck {
|
|
|
180
180
|
/** Populated only when the file was found AND failed to parse. */
|
|
181
181
|
reason: string | null;
|
|
182
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Every namespace a client bundle asks its config from (#810).
|
|
185
|
+
*
|
|
186
|
+
* One level of constant propagation, as asked for in the report: the plugin
|
|
187
|
+
* that started this writes `var NAMESPACE = "dsh-comfyui-canvas"` and calls
|
|
188
|
+
* `configForms.get(NAMESPACE)`, so a literal-only scan would miss the very
|
|
189
|
+
* case the check exists for. A name assigned twice is dropped rather than
|
|
190
|
+
* guessed at — which value it holds at the call site is not decidable from
|
|
191
|
+
* text alone, and a wrong guess here would be reported as a plugin defect.
|
|
192
|
+
*/
|
|
193
|
+
export declare function configFormNamespaces(source: string): string[];
|
|
194
|
+
/** A package whose client asks for a namespace nothing in the composition serves (#810). */
|
|
195
|
+
export interface UnservedConfigNamespace {
|
|
196
|
+
/** The installed package whose client bundle asks. */
|
|
197
|
+
name: string;
|
|
198
|
+
/** The namespaces it asks for that no loader entry id answers to. */
|
|
199
|
+
namespaces: string[];
|
|
200
|
+
/** Entry ids this package's own patch declares — the value it meant. */
|
|
201
|
+
ownEntryIds: string[];
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Installed plugins whose client reads its config from a name nothing serves
|
|
205
|
+
* (#810).
|
|
206
|
+
*
|
|
207
|
+
* `served` is the entry id set of the WHOLE composition — deliberately not
|
|
208
|
+
* this package's own ids: a package may legitimately mount another one
|
|
209
|
+
* (carrier form), and judging by own ids alone would flag those. `ownIdsOf`
|
|
210
|
+
* answers what the package itself declares, which is the value to write
|
|
211
|
+
* instead — the report asked for the correct spelling to be shown, not just
|
|
212
|
+
* the mistake.
|
|
213
|
+
*
|
|
214
|
+
* The scan reads one bundle per installed plugin. Measured against the same
|
|
215
|
+
* cost the syntax sweep above already pays (0.40ms per plugin on a 385KB
|
|
216
|
+
* bundle), so a 30-plugin profile is ~12ms — this runs on a request a user
|
|
217
|
+
* made, not in a loop.
|
|
218
|
+
*/
|
|
219
|
+
export declare function findUnservedConfigNamespaces(profile: string, served: ReadonlySet<string>, ownIdsOf: (name: string) => string[], explicitDir?: string): UnservedConfigNamespace[];
|
|
183
220
|
/**
|
|
184
221
|
* Every installed plugin whose client bundle will not parse (#222 by
|
|
185
222
|
* @MicroMilo).
|
package/lib/verify.js
CHANGED
|
@@ -532,6 +532,117 @@ export function clientBundlePath(exportsField, depth = 0) {
|
|
|
532
532
|
}
|
|
533
533
|
return null;
|
|
534
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* One assignment of a string literal to a name, and the `configForms.get`
|
|
537
|
+
* call sites (#810).
|
|
538
|
+
*
|
|
539
|
+
* The namespace a client asks its config from is a PROFILE ENTRY ID, not a
|
|
540
|
+
* package name. A client that writes the package name reads nothing: the
|
|
541
|
+
* settings document has no entry under it, the call returns empty, and the
|
|
542
|
+
* plugin degrades silently — blank or unwritable settings forms, and in the
|
|
543
|
+
* reported case a panel stuck on a hardcoded fallback port. Nothing logs it,
|
|
544
|
+
* and nothing on the market side noticed.
|
|
545
|
+
*
|
|
546
|
+
* `configForms.get` is matched by name, so an aliased receiver
|
|
547
|
+
* (`const forms = ctx.configForms; forms.get("x")`) is not seen — the same
|
|
548
|
+
* honesty as the syntax check above: this reports what it can prove, and
|
|
549
|
+
* says nothing otherwise.
|
|
550
|
+
*/
|
|
551
|
+
const STRING_ASSIGNMENT = /(?:^|[^\w$.])(?:var|let|const)?\s*([A-Za-z_$][\w$]*)\s*=\s*(['"])([^'"\\\n]*)\2/gu;
|
|
552
|
+
const CONFIG_FORMS_GET = /configForms\s*\.\s*get\s*\(\s*(?:([A-Za-z_$][\w$]*)|(['"])([^'"\\\n]*)\2)\s*\)/gu;
|
|
553
|
+
/** A value that is a plain loader entry id: a backslash means it is not one. */
|
|
554
|
+
function plainNamespace(value) {
|
|
555
|
+
return value !== '' && value.length <= 200 && !value.includes('\\') && /^[\w@/.:-]+$/u.test(value) ? value : null;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Every namespace a client bundle asks its config from (#810).
|
|
559
|
+
*
|
|
560
|
+
* One level of constant propagation, as asked for in the report: the plugin
|
|
561
|
+
* that started this writes `var NAMESPACE = "dsh-comfyui-canvas"` and calls
|
|
562
|
+
* `configForms.get(NAMESPACE)`, so a literal-only scan would miss the very
|
|
563
|
+
* case the check exists for. A name assigned twice is dropped rather than
|
|
564
|
+
* guessed at — which value it holds at the call site is not decidable from
|
|
565
|
+
* text alone, and a wrong guess here would be reported as a plugin defect.
|
|
566
|
+
*/
|
|
567
|
+
export function configFormNamespaces(source) {
|
|
568
|
+
// Measured on a 750KB bundle: these scans cost ~9ms, because both walk
|
|
569
|
+
// every character, and this runs on a request the market polls. Almost no
|
|
570
|
+
// client mentions configForms at all, and — an order of magnitude rarer —
|
|
571
|
+
// almost none of those calls a method on it: for both, this pair of plain
|
|
572
|
+
// substring searches IS the whole cost (0.23ms measured), and only a client
|
|
573
|
+
// that actually asks for a config pays for a scan.
|
|
574
|
+
if (!source.includes('configForms') || !source.includes('configForms.get'))
|
|
575
|
+
return [];
|
|
576
|
+
const constants = new Map();
|
|
577
|
+
for (const match of source.matchAll(STRING_ASSIGNMENT)) {
|
|
578
|
+
const name = match[1];
|
|
579
|
+
const value = plainNamespace(match[3]);
|
|
580
|
+
constants.set(name, constants.has(name) || value === null ? null : value);
|
|
581
|
+
}
|
|
582
|
+
const namespaces = new Set();
|
|
583
|
+
for (const match of source.matchAll(CONFIG_FORMS_GET)) {
|
|
584
|
+
// A namespace is a loader entry id: no escapes, no whitespace runs, no
|
|
585
|
+
// arbitrary length. Anything else is not this pattern and stays out.
|
|
586
|
+
const value = plainNamespace(match[3] ?? '') ?? constants.get(match[1]) ?? null;
|
|
587
|
+
if (value !== null)
|
|
588
|
+
namespaces.add(value);
|
|
589
|
+
}
|
|
590
|
+
return [...namespaces];
|
|
591
|
+
}
|
|
592
|
+
/** The client bundle of an installed package, read; null when it has none to read. */
|
|
593
|
+
function readClientBundle(profile, name, explicitDir) {
|
|
594
|
+
const root = join(profileDir(profile, explicitDir), 'node_modules', name);
|
|
595
|
+
let manifest;
|
|
596
|
+
try {
|
|
597
|
+
manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
if (manifest.dsh?.client === undefined)
|
|
603
|
+
return null;
|
|
604
|
+
const exportsField = manifest.exports;
|
|
605
|
+
const relative = exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)
|
|
606
|
+
? clientBundlePath(exportsField['./client'])
|
|
607
|
+
: null;
|
|
608
|
+
if (relative === null)
|
|
609
|
+
return null;
|
|
610
|
+
try {
|
|
611
|
+
return readFileSync(join(root, relative), 'utf8');
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Installed plugins whose client reads its config from a name nothing serves
|
|
619
|
+
* (#810).
|
|
620
|
+
*
|
|
621
|
+
* `served` is the entry id set of the WHOLE composition — deliberately not
|
|
622
|
+
* this package's own ids: a package may legitimately mount another one
|
|
623
|
+
* (carrier form), and judging by own ids alone would flag those. `ownIdsOf`
|
|
624
|
+
* answers what the package itself declares, which is the value to write
|
|
625
|
+
* instead — the report asked for the correct spelling to be shown, not just
|
|
626
|
+
* the mistake.
|
|
627
|
+
*
|
|
628
|
+
* The scan reads one bundle per installed plugin. Measured against the same
|
|
629
|
+
* cost the syntax sweep above already pays (0.40ms per plugin on a 385KB
|
|
630
|
+
* bundle), so a 30-plugin profile is ~12ms — this runs on a request a user
|
|
631
|
+
* made, not in a loop.
|
|
632
|
+
*/
|
|
633
|
+
export function findUnservedConfigNamespaces(profile, served, ownIdsOf, explicitDir) {
|
|
634
|
+
const found = [];
|
|
635
|
+
for (const name of Object.keys(readInstalled(profile, explicitDir))) {
|
|
636
|
+
const source = readClientBundle(profile, name, explicitDir);
|
|
637
|
+
if (source === null)
|
|
638
|
+
continue;
|
|
639
|
+
const unserved = configFormNamespaces(source).filter(namespace => !served.has(namespace));
|
|
640
|
+
if (unserved.length === 0)
|
|
641
|
+
continue;
|
|
642
|
+
found.push({ name, namespaces: unserved, ownEntryIds: ownIdsOf(name) });
|
|
643
|
+
}
|
|
644
|
+
return found;
|
|
645
|
+
}
|
|
535
646
|
/**
|
|
536
647
|
* Whether a package's client bundle still parses as JavaScript (#222).
|
|
537
648
|
*
|
|
@@ -598,34 +709,15 @@ export function newlyBrokenBundles(before, after) {
|
|
|
598
709
|
return after.filter(entry => !seen.has(entry.name));
|
|
599
710
|
}
|
|
600
711
|
export function checkClientBundle(profile, name, explicitDir) {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
return { ok: true, reason: null };
|
|
608
|
-
}
|
|
609
|
-
if (manifest.dsh?.client === undefined)
|
|
712
|
+
// Declared but absent is verifyActivation's territory (a missing entry
|
|
713
|
+
// artifact is already `broken` there); duplicating it here would report one
|
|
714
|
+
// problem twice in two different vocabularies — so the reader's null covers
|
|
715
|
+
// both "no client bundle" and "declared but unreadable" with the same ok.
|
|
716
|
+
const source = readClientBundle(profile, name, explicitDir);
|
|
717
|
+
if (source === null)
|
|
610
718
|
return { ok: true, reason: null };
|
|
611
|
-
const exportsField = manifest.exports;
|
|
612
|
-
const relative = exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)
|
|
613
|
-
? clientBundlePath(exportsField['./client'])
|
|
614
|
-
: null;
|
|
615
|
-
if (relative === null)
|
|
616
|
-
return { ok: true, reason: null };
|
|
617
|
-
let source;
|
|
618
|
-
try {
|
|
619
|
-
source = readFileSync(join(root, relative), 'utf8');
|
|
620
|
-
}
|
|
621
|
-
catch {
|
|
622
|
-
// Declared but absent is verifyActivation's territory (a missing entry
|
|
623
|
-
// artifact is already `broken` there); duplicating it here would report
|
|
624
|
-
// one problem twice in two different vocabularies.
|
|
625
|
-
return { ok: true, reason: null };
|
|
626
|
-
}
|
|
627
719
|
try {
|
|
628
|
-
new Script(source, { filename:
|
|
720
|
+
new Script(source, { filename: `${name}/client` });
|
|
629
721
|
return { ok: true, reason: null };
|
|
630
722
|
}
|
|
631
723
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshmarket",
|
|
3
3
|
"description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
|
|
4
|
-
"version": "1.66.
|
|
4
|
+
"version": "1.66.21",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -135,6 +135,16 @@ interface CheckReport {
|
|
|
135
135
|
* failure — informational, and the only place that names what is on disk.
|
|
136
136
|
*/
|
|
137
137
|
residuals?: ResidualDirectory[]
|
|
138
|
+
/**
|
|
139
|
+
* Installed plugins whose client reads its config from a name no loader
|
|
140
|
+
* entry serves (#810). Attached by the /check route, which is the only
|
|
141
|
+
* caller that pays for reading one client bundle per installed plugin.
|
|
142
|
+
*/
|
|
143
|
+
configNamespaces?: Array<{
|
|
144
|
+
name: string
|
|
145
|
+
namespaces: string[]
|
|
146
|
+
ownEntryIds: string[]
|
|
147
|
+
}>
|
|
138
148
|
}
|
|
139
149
|
|
|
140
150
|
/**
|
|
@@ -840,6 +850,35 @@ export function Diagnostics(props: { t: Translate }) {
|
|
|
840
850
|
</div>
|
|
841
851
|
</Section>
|
|
842
852
|
|
|
853
|
+
{/* A client that reads its config from a name no loader entry serves
|
|
854
|
+
(#810). This one IS a defect — the plugin's settings are blank or
|
|
855
|
+
unwritable right now — but it is the plugin's own, not the
|
|
856
|
+
profile's, so it stays out of the summary warnings: nothing the user
|
|
857
|
+
can do to this profile fixes it. The section title says whose
|
|
858
|
+
problem it is and the hint says who has to change it. */}
|
|
859
|
+
<Section
|
|
860
|
+
title={t('checkConfigNamespaces')}
|
|
861
|
+
count={report.configNamespaces?.length ?? 0}
|
|
862
|
+
empty={t('checkConfigNamespacesEmpty')}
|
|
863
|
+
overview={report.configNamespaces?.[0]?.name}
|
|
864
|
+
>
|
|
865
|
+
<p className={css.panelNote}>{t('checkConfigNamespacesHint')}</p>
|
|
866
|
+
<div className={css.diagList}>
|
|
867
|
+
{(report.configNamespaces ?? []).map(finding => (
|
|
868
|
+
<div key={finding.name} className={css.diagRow}>
|
|
869
|
+
<span className={css.nm}>{finding.name}</span>
|
|
870
|
+
<span className={css.spec}>
|
|
871
|
+
{finding.namespaces.map(namespace => t('checkConfigNamespaceRow').replace('{0}', namespace)).join(';')}
|
|
872
|
+
{' — '}
|
|
873
|
+
{finding.ownEntryIds.length > 0
|
|
874
|
+
? `${t('checkConfigNamespaceOwn')}: ${finding.ownEntryIds.join(', ')}`
|
|
875
|
+
: t('checkConfigNamespaceNone')}
|
|
876
|
+
</span>
|
|
877
|
+
</div>
|
|
878
|
+
))}
|
|
879
|
+
</div>
|
|
880
|
+
</Section>
|
|
881
|
+
|
|
843
882
|
{/* issue #98 phase 2 (step 1): community-bundle ordering */}
|
|
844
883
|
<CollapsibleSection title={t('orderSection')} count={order.length} open={orderOpen} onToggle={() => setOrderOpen(o => !o)}>
|
|
845
884
|
<p className={css.panelNote}>{t('orderDragHint')}</p>
|
|
@@ -2207,6 +2207,12 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
2207
2207
|
)
|
|
2208
2208
|
/** Non-live activation results from the last operation, shown as a banner. */
|
|
2209
2209
|
const [activationWarnings, setActivationWarnings] = useState<{ name: string; info: ActivationInfo }[]>([])
|
|
2210
|
+
/**
|
|
2211
|
+
* Installed plugins whose client asks for a config namespace nothing serves
|
|
2212
|
+
* (#810). Server-computed, because the answer needs one client bundle read
|
|
2213
|
+
* per plugin and the client cannot see a bundle's contents.
|
|
2214
|
+
*/
|
|
2215
|
+
const [configNamespaceIssues, setConfigNamespaceIssues] = useState<Array<{ name: string; namespaces: string[]; ownEntryIds: string[] }>>([])
|
|
2210
2216
|
const [hostDependencyFindings, setHostDependencyFindings] = useState<SharedHostPackageDependencyFinding[]>([])
|
|
2211
2217
|
/** Plugin name awaiting uninstall confirmation (Modal). */
|
|
2212
2218
|
const [removeConfirm, setRemoveConfirm] = useState<string | null>(null)
|
|
@@ -2398,6 +2404,12 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
2398
2404
|
}
|
|
2399
2405
|
if (Array.isArray(body.patchDisabled)) setPatchDisabledNames(body.patchDisabled)
|
|
2400
2406
|
if (Array.isArray(body.unbundled)) setUnbundledNames(body.unbundled)
|
|
2407
|
+
setConfigNamespaceIssues(Array.isArray(body.configNamespaces)
|
|
2408
|
+
? body.configNamespaces.filter((item: unknown): item is { name: string; namespaces: string[]; ownEntryIds: string[] } =>
|
|
2409
|
+
typeof item === 'object' && item !== null
|
|
2410
|
+
&& typeof (item as { name?: unknown }).name === 'string'
|
|
2411
|
+
&& Array.isArray((item as { namespaces?: unknown }).namespaces))
|
|
2412
|
+
: [])
|
|
2401
2413
|
if (body.groups && typeof body.groups === 'object') setGroups(body.groups)
|
|
2402
2414
|
if (Array.isArray(body.groupOrder)) setGroupOrder(body.groupOrder)
|
|
2403
2415
|
if (Array.isArray(body.favorites)) setFavoriteUrls(body.favorites.filter((url: unknown): url is string => typeof url === 'string'))
|
|
@@ -5806,6 +5818,21 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
5806
5818
|
</span>
|
|
5807
5819
|
</div>
|
|
5808
5820
|
)}
|
|
5821
|
+
{tab === 'installed' && configNamespaceIssues.length > 0 && (
|
|
5822
|
+
<div className={css.banner}>
|
|
5823
|
+
<IconWarningOutline16 size={14} className={css.bannerIcon} />
|
|
5824
|
+
<span className={css.grow}>
|
|
5825
|
+
{configNamespaceIssues.map(finding => (
|
|
5826
|
+
<div key={finding.name}>
|
|
5827
|
+
{t('configNamespaceNotice')
|
|
5828
|
+
.replace('{0}', finding.name)
|
|
5829
|
+
.replace('{1}', finding.namespaces.join('、'))
|
|
5830
|
+
.replace('{2}', finding.ownEntryIds.join('、') || '—')}
|
|
5831
|
+
</div>
|
|
5832
|
+
))}
|
|
5833
|
+
</span>
|
|
5834
|
+
</div>
|
|
5835
|
+
)}
|
|
5809
5836
|
{tab === 'installed' && <HostDependencyDiagnostics findings={hostDependencyFindings} t={t} />}
|
|
5810
5837
|
{tab === 'installed' && brokenPluginNames.length > 0 && (
|
|
5811
5838
|
<div className={css.brokenPluginNotice}>
|
package/src/client/locales.ts
CHANGED
|
@@ -497,6 +497,7 @@ export const zh = {
|
|
|
497
497
|
// A plugin the market had to stop declaring (#663). Plain about what
|
|
498
498
|
// happened, because the user's plugin is simply gone from the list and
|
|
499
499
|
// nothing else in the UI can explain that.
|
|
500
|
+
configNamespaceNotice: '{0} 的设置面板读不到配置:它按「{1}」读取,而这个环境里没有这个名字的条目 id。它自己的条目是「{2}」,插件客户端应该改成读那个——这是插件自身的问题,市场无法代改。',
|
|
500
501
|
brokenPluginTitle: '{0} 更新失败后被移除了',
|
|
501
502
|
brokenPluginBody: 'DSH 正在运行时无法更新,而更新前的版本也已经损坏。为避免下次启动卡住,市场把它从 profile 里移除。插件目录还在原处,退出 DSH 后重新安装即可。',
|
|
502
503
|
brokenPluginTitleCancelled: '{0} 取消更新后被移除了',
|
|
@@ -603,6 +604,12 @@ export const zh = {
|
|
|
603
604
|
checkResidualsHint: '这些目录不被任何插件使用,也不影响启动,是更新中断后留下的。退出 DSH 后可以删除,或重新安装对应的插件。',
|
|
604
605
|
checkResidualTmp: 'pnpm 临时目录',
|
|
605
606
|
checkResidualIncomplete: '残缺的包目录(缺少 package.json)',
|
|
607
|
+
checkConfigNamespaces: '设置面板读不到配置的插件',
|
|
608
|
+
checkConfigNamespacesEmpty: '没有发现这类插件。',
|
|
609
|
+
checkConfigNamespacesHint: '插件面板按「条目 id」读自己的配置,写错名字不会报错,只会静默读不到值:面板空白、存不进去,插件会退回它写死的默认值(画布类插件因此白屏)。这是插件客户端里的名字写错了,市场改不了它,需要插件作者改一行代码。',
|
|
610
|
+
checkConfigNamespaceRow: '它按「{0}」读配置,但这不是任何条目 id',
|
|
611
|
+
checkConfigNamespaceOwn: '它自己的条目 id',
|
|
612
|
+
checkConfigNamespaceNone: '它自己没有声明任何条目 id',
|
|
606
613
|
checkOrphans: '无效的配置条目',
|
|
607
614
|
checkOrphansEmpty: '没有无效配置条目',
|
|
608
615
|
// Orphan patch reason → badge label (issue #98 supplement).
|
|
@@ -1217,6 +1224,7 @@ export const en: Record<MarketKey, string> = {
|
|
|
1217
1224
|
toggleFail: 'Toggle failed',
|
|
1218
1225
|
deprecatedBadge: 'Deprecated',
|
|
1219
1226
|
deprecatedWarn: 'This plugin is marked as deprecated by the catalog; new users are advised against installing it.',
|
|
1227
|
+
configNamespaceNotice: '{0}\'s settings panel cannot read its config: it asks for "{1}", and no entry id in this environment has that name. Its own entry is "{2}", which is what the plugin\'s client should ask for — the mistake is in the plugin, and the market cannot change it.',
|
|
1220
1228
|
brokenPluginTitle: '{0} was removed after a failed update',
|
|
1221
1229
|
brokenPluginBody: 'The running DSH blocked the update, and the version it was replacing was already damaged. The market removed it from the profile so the next start does not fail. Its directory is untouched — quit DSH and install it again.',
|
|
1222
1230
|
brokenPluginTitleCancelled: '{0} was removed after an update was cancelled',
|
|
@@ -1323,6 +1331,12 @@ export const en: Record<MarketKey, string> = {
|
|
|
1323
1331
|
checkResidualsHint: 'No plugin uses these directories and they do not stop DSH from starting: an interrupted update left them behind. Quit DSH, then delete them or reinstall the affected plugin.',
|
|
1324
1332
|
checkResidualTmp: 'pnpm temp directory',
|
|
1325
1333
|
checkResidualIncomplete: 'incomplete package (no package.json)',
|
|
1334
|
+
checkConfigNamespaces: 'Plugins whose settings read the wrong name',
|
|
1335
|
+
checkConfigNamespacesEmpty: 'No plugin does this.',
|
|
1336
|
+
checkConfigNamespacesHint: 'A plugin panel reads its own config by ENTRY ID. A wrong name there reports nothing: the panel is blank and cannot save, and the plugin falls back to whatever default it hardcoded (a canvas panel shows a white screen). The name is wrong inside the plugin\'s own client bundle — the market cannot fix it, its author has to change one line.',
|
|
1337
|
+
checkConfigNamespaceRow: 'it reads its config from "{0}", which is not an entry id',
|
|
1338
|
+
checkConfigNamespaceOwn: 'its own entry id',
|
|
1339
|
+
checkConfigNamespaceNone: 'it declares no entry id of its own',
|
|
1326
1340
|
checkOrphans: 'Invalid config entries',
|
|
1327
1341
|
checkOrphansEmpty: 'No invalid config entries',
|
|
1328
1342
|
// Orphan patch reason → badge label (issue #98 supplement).
|
package/src/profile.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
|
9
9
|
import { isDeepStrictEqual } from 'node:util'
|
|
10
10
|
import { JSON_SCHEMA, load } from 'js-yaml'
|
|
11
11
|
import { resolveDshHome } from './home-paths.ts'
|
|
12
|
-
import { githubRemoteIdentities, githubRepoIdentities, isGitHostedSpec, repoOfTarget } from './sources.ts'
|
|
12
|
+
import { githubRemoteIdentities, githubRepoIdentities, hostedRepoKey, isGitHostedSpec, repoOfTarget } from './sources.ts'
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Whether a profile name follows DSH's own directory-name contract.
|
|
@@ -727,13 +727,24 @@ export function readLockCommits(profile: string, explicitDir?: string): Map<stri
|
|
|
727
727
|
* by pnpm's `path:` selector, so the spec's subpath has to match too — and
|
|
728
728
|
* when the spec names no subpath while several entries of that remote do,
|
|
729
729
|
* there is no answer rather than the first sibling's commit (#632).
|
|
730
|
+
*
|
|
731
|
+
* The key on both sides of the comparison is `hostedRepoKey`, the identity
|
|
732
|
+
* the rest of the market already compares specs by (#851). This function
|
|
733
|
+
* used to derive its own, and that second spelling lost every install pnpm
|
|
734
|
+
* writes the shorthand back for: it demanded an `https?://` prefix or a
|
|
735
|
+
* `.git` suffix, so `github:owner/repo` keyed to nothing at all, and it kept
|
|
736
|
+
* the `.git` suffix, so the spec side `https://github.com/o/r` could never
|
|
737
|
+
* equal the lockfile side pnpm writes as `git@github.com:o/r.git`. A floating
|
|
738
|
+
* shorthand therefore had no commit, and with no commit the update route
|
|
739
|
+
* refuses to roll back — the market reports "cannot confirm the commit before
|
|
740
|
+
* the update" for a lockfile that states it plainly.
|
|
730
741
|
*/
|
|
731
742
|
export function readGitResolutionCommit(
|
|
732
743
|
profile: string,
|
|
733
744
|
spec: string,
|
|
734
745
|
explicitDir?: string,
|
|
735
746
|
): string | null {
|
|
736
|
-
const want =
|
|
747
|
+
const want = hostedRepoKey(spec)
|
|
737
748
|
if (want === null) return null
|
|
738
749
|
const wantPath = gitSubpathSelector(spec)
|
|
739
750
|
const matches: string[] = []
|
|
@@ -746,7 +757,7 @@ export function readGitResolutionCommit(
|
|
|
746
757
|
const commit = /\bcommit:\s*([0-9a-f]{40})\b/i.exec(body)
|
|
747
758
|
const repo = /\brepo:\s*([^\s,}]+)/.exec(body)
|
|
748
759
|
if (commit === null || repo === null) continue
|
|
749
|
-
if (
|
|
760
|
+
if (hostedRepoKey(repo[1]!) !== want) continue
|
|
750
761
|
const entryPath = /\bpath:\s*([^\s,}]+)/.exec(body)?.[1]?.replace(/^\/+|\/+$/g, '').toLowerCase() ?? null
|
|
751
762
|
if (wantPath !== null) {
|
|
752
763
|
if (entryPath === wantPath) return commit[1]!.toLowerCase()
|
|
@@ -770,20 +781,6 @@ function gitSubpathSelector(spec: string): string | null {
|
|
|
770
781
|
return trimmed === '' ? null : trimmed
|
|
771
782
|
}
|
|
772
783
|
|
|
773
|
-
/** Lowercased transport-agnostic key for comparing two git remote spellings. */
|
|
774
|
-
function normalizeGitRepoKey(spec: string): string | null {
|
|
775
|
-
let remote = spec.trim().replace(/^git\+/i, '')
|
|
776
|
-
const scp = /^git@([^:]+):(.+)$/.exec(remote)
|
|
777
|
-
if (scp !== null) remote = `https://${scp[1]}/${scp[2]!.replace(/^\/*/, '')}`
|
|
778
|
-
const hash = remote.indexOf('#')
|
|
779
|
-
if (hash !== -1) remote = remote.slice(0, hash)
|
|
780
|
-
const query = remote.indexOf('?')
|
|
781
|
-
if (query !== -1) remote = remote.slice(0, query)
|
|
782
|
-
remote = remote.replace(/\/+$/, '').toLowerCase()
|
|
783
|
-
if (!/^https?:\/\//.test(remote) && !remote.includes('.git')) return null
|
|
784
|
-
return remote
|
|
785
|
-
}
|
|
786
|
-
|
|
787
784
|
/** True when the installed package's manifest declares a dsh plugin surface. */
|
|
788
785
|
export function hasDshManifest(dir: string): boolean {
|
|
789
786
|
try {
|
|
@@ -904,30 +901,48 @@ export function parsePatchRows(text: string): { names: string[]; ids: string[];
|
|
|
904
901
|
// and the market uninstalled the newcomer over a clash the host does
|
|
905
902
|
// not have.
|
|
906
903
|
let payloadIndent: number | null = null
|
|
907
|
-
|
|
904
|
+
const isDash = (index: number): boolean => /^-(?:\s|$)/u.test(rows[index].trimmed)
|
|
905
|
+
/**
|
|
906
|
+
* Column where a line's key text starts: its indent plus any `- ` marker.
|
|
907
|
+
* A row's keys share this column whether written on the marker line or
|
|
908
|
+
* below it, however many spaces follow the `-`.
|
|
909
|
+
*/
|
|
910
|
+
const keyCol = (index: number): number => {
|
|
911
|
+
// A bare `-` puts the row's keys on the lines below it.
|
|
912
|
+
if (rows[index].trimmed === '-') {
|
|
913
|
+
const next = rows[index + 1]
|
|
914
|
+
return next !== undefined && next.indent > rows[index].indent ? next.indent : rows[index].indent + 2
|
|
915
|
+
}
|
|
916
|
+
const dash = /^-\s+/u.exec(rows[index].trimmed)
|
|
917
|
+
return rows[index].indent + (dash === null ? 0 : dash[0].length)
|
|
918
|
+
}
|
|
919
|
+
/** Index of the `- ` line that opens the row holding `from`, or -1. */
|
|
908
920
|
const rowStart = (from: number): number => {
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
if (
|
|
921
|
+
const col = keyCol(from)
|
|
922
|
+
for (let j = from; j >= 0; j--) {
|
|
923
|
+
if (isDash(j) && keyCol(j) === col) return j
|
|
924
|
+
// Shallower than the row's keys: left the row without finding its marker.
|
|
925
|
+
if (rows[j].indent < col) return -1
|
|
914
926
|
}
|
|
915
|
-
return
|
|
927
|
+
return -1
|
|
916
928
|
}
|
|
917
929
|
/**
|
|
918
930
|
* Whether the insert row holding the `config:` at `from` declares
|
|
919
931
|
* `group: true`. YAML gives a mapping's keys no order, so this reads the
|
|
920
|
-
* whole row — its own `- ` marker plus the key lines at the
|
|
921
|
-
*
|
|
932
|
+
* whole row — its own `- ` marker plus the key lines at the row's key
|
|
933
|
+
* column — rather than only what happened to be written above.
|
|
922
934
|
*/
|
|
923
935
|
const rowDeclaresGroup = (from: number): boolean => {
|
|
924
936
|
const start = rowStart(from)
|
|
937
|
+
if (start === -1) return false
|
|
925
938
|
const markerIndent = rows[start].indent
|
|
939
|
+
const col = keyCol(start)
|
|
926
940
|
for (let j = start; j < rows.length; j++) {
|
|
927
941
|
const row = rows[j]
|
|
928
942
|
if (j > start && row.indent <= markerIndent) break
|
|
929
|
-
//
|
|
930
|
-
|
|
943
|
+
// Only the row's own keys: a config body, or a list under another
|
|
944
|
+
// key, is not at the key column or starts with its own `- `.
|
|
945
|
+
if (j > start && (row.indent !== col || isDash(j))) continue
|
|
931
946
|
if (/^\s*-?\s*group:\s*['"]?true['"]?\s*$/u.test(row.line)) return true
|
|
932
947
|
}
|
|
933
948
|
return false
|
|
@@ -951,14 +966,21 @@ export function parsePatchRows(text: string): { names: string[]; ids: string[];
|
|
|
951
966
|
payloadIndent = null
|
|
952
967
|
continue
|
|
953
968
|
}
|
|
954
|
-
// Exactly one
|
|
955
|
-
// whose config is a LIST, because its children are
|
|
956
|
-
// the rule userPatchPackageReferences has always
|
|
957
|
-
// shape — a plain plugin may use a list of
|
|
958
|
-
// options, and those must stay payload.
|
|
959
|
-
//
|
|
960
|
-
|
|
961
|
-
|
|
969
|
+
// Exactly one block under a row carries loader rows: the `config:` of a
|
|
970
|
+
// `group: true` row whose config is a LIST, because its children are
|
|
971
|
+
// entries. That is the rule userPatchPackageReferences has always
|
|
972
|
+
// applied to this same shape — a plain plugin may use a list of
|
|
973
|
+
// `{ name: … }` as ordinary options, and those must stay payload. Any
|
|
974
|
+
// other block-valued key (`config:`, `tags:`, …) holds data too, so the
|
|
975
|
+
// block is skipped rather than counted.
|
|
976
|
+
// Inside a payload, a nested key is more payload: it must not move the
|
|
977
|
+
// payload's edge, or the payload ends early and its ids count.
|
|
978
|
+
const blockKey = /^\s*-?\s*([A-Za-z_][\w.-]*):\s*$/u.exec(line)
|
|
979
|
+
if (payloadIndent === null && insertIndent !== null && indent > insertIndent && blockKey !== null) {
|
|
980
|
+
// The key's own column, not the line's: when the key sits on a `- `
|
|
981
|
+
// line, the row's later keys share its column and are not payload.
|
|
982
|
+
const col = keyCol(i)
|
|
983
|
+
if (!(blockKey[1] === 'config' && rowDeclaresGroup(i) && valueIsList(i, col))) payloadIndent = col
|
|
962
984
|
continue
|
|
963
985
|
}
|
|
964
986
|
const name = /^\s*-?\s*name:\s*['"]?([^'"\s]+)/.exec(line)
|
package/src/recovery.ts
CHANGED
|
@@ -1088,6 +1088,16 @@ export async function runRecovery(
|
|
|
1088
1088
|
const servePort = options.port ?? config.port
|
|
1089
1089
|
/** Write errors from the previous apply; they travel into the next payload. */
|
|
1090
1090
|
let writeErrors: string[] = []
|
|
1091
|
+
// A port that ANSWERS is proof the host came up, and it needs no pid to say
|
|
1092
|
+
// so (#854). The helper's pid is the process IT spawned, which on Windows is
|
|
1093
|
+
// the `powershell.exe` wrapper rather than the node host that binds — so the
|
|
1094
|
+
// guard below cannot be trusted on its own, and a helper that gave up late
|
|
1095
|
+
// handed this surface a pid that was already gone while the host was still
|
|
1096
|
+
// starting. Asking the socket settles it either way.
|
|
1097
|
+
if (servePort !== null && servePort !== 0 && await portListening(servePort)) {
|
|
1098
|
+
note(config, `port ${String(servePort)} is already answering — the host came up after all; leaving it alone`)
|
|
1099
|
+
return 'booted'
|
|
1100
|
+
}
|
|
1091
1101
|
// The replacement is not necessarily dead just because it has not bound the
|
|
1092
1102
|
// port: on a slow host it is still starting. It gets the same grace as a
|
|
1093
1103
|
// released port before the surface takes the port out from under it (#719).
|
package/src/regions.ts
CHANGED
|
@@ -211,6 +211,29 @@ export function resetGithubRoutePreferences(): void {
|
|
|
211
211
|
preferredGithubRoutes.clear()
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Put a user's own prefix in front, and keep the region's other routes (#850).
|
|
216
|
+
*
|
|
217
|
+
* Heading rather than replacing, because a public proxy dying is the whole
|
|
218
|
+
* reason the setting exists — the reporter's was one of these mirrors, and it
|
|
219
|
+
* took the rest of the chain with it: `gh-proxy.com` stopped serving on
|
|
220
|
+
* 2026-10-13, and every GitHub-sourced update then failed on a timeout rather
|
|
221
|
+
* than trying the next candidate. Replacing the list buys one attempt per
|
|
222
|
+
* mirror outage; heading it buys one, and then the chain that was already
|
|
223
|
+
* measured to work.
|
|
224
|
+
*
|
|
225
|
+
* The proxy is also dropped from the tail if it is already there, so a user
|
|
226
|
+
* who accepted the value the settings UI pre-suggests gets exactly the
|
|
227
|
+
* region's own table back rather than the same prefix twice.
|
|
228
|
+
*
|
|
229
|
+
* The direct escape is never dropped: `githubRoutes` promises every list ends
|
|
230
|
+
* in one, and dropping it would turn a dead mirror into an outage instead of
|
|
231
|
+
* a slow request.
|
|
232
|
+
*/
|
|
233
|
+
function withCustomProxy(proxy: string, base: GithubRoute[]): GithubRoute[] {
|
|
234
|
+
return [proxy, ...base.filter(route => route !== proxy)]
|
|
235
|
+
}
|
|
236
|
+
|
|
214
237
|
/**
|
|
215
238
|
* The routes for a region, with environment overrides applied.
|
|
216
239
|
*
|
|
@@ -234,7 +257,11 @@ export function routesFor(region: Region, env: NodeJS.ProcessEnv = process.env):
|
|
|
234
257
|
raw: [...base.githubRoutes.raw],
|
|
235
258
|
avatar: [...base.githubRoutes.avatar],
|
|
236
259
|
}
|
|
237
|
-
: {
|
|
260
|
+
: {
|
|
261
|
+
git: withCustomProxy(githubProxy, base.githubRoutes.git),
|
|
262
|
+
raw: withCustomProxy(githubProxy, base.githubRoutes.raw),
|
|
263
|
+
avatar: withCustomProxy(githubProxy, base.githubRoutes.avatar),
|
|
264
|
+
}
|
|
238
265
|
return {
|
|
239
266
|
npmRegistry: registry,
|
|
240
267
|
githubProxy: githubProxy ?? base.githubProxy,
|
package/src/restart.ts
CHANGED
|
@@ -516,19 +516,39 @@ export function restartHelperSource(
|
|
|
516
516
|
// seen from the other side. A replacement that EXITS still ends this
|
|
517
517
|
// early (the `exited` break below), so a genuinely failed boot is not
|
|
518
518
|
// made slower by the wider backstop.
|
|
519
|
+
' const startedAt = Date.now()',
|
|
519
520
|
' const upBy = Date.now() + 45000 + SETTLE_MS',
|
|
521
|
+
// #854: the window is a budget for "has it come up", NOT a verdict on
|
|
522
|
+
// "is it dead". A cold start right after the market rewrote node_modules
|
|
523
|
+
// can outlast it (measured on Windows: bind at ~63s with a scanner on the
|
|
524
|
+
// fresh files), and handing the port over while the replacement is still
|
|
525
|
+
// RUNNING is what turns a slow boot into the deadlock #719 removed: the
|
|
526
|
+
// surface takes port 3080, the host reaches its own bind and dies on
|
|
527
|
+
// EADDRINUSE, and nothing can come back. So a replacement that has NOT
|
|
528
|
+
// exited keeps the port, up to a hard cap; one that has exited still ends
|
|
529
|
+
// the wait immediately, which is the failed-boot case.
|
|
530
|
+
' const hardBy = Date.now() + 240000',
|
|
520
531
|
' let steadySince = null',
|
|
521
|
-
'
|
|
532
|
+
' let announcedWait = false',
|
|
533
|
+
' while (true) {',
|
|
522
534
|
' if (await listening()) {',
|
|
523
535
|
' if (steadySince === null) steadySince = Date.now()',
|
|
524
536
|
' else if (Date.now() - steadySince >= SETTLE_MS) return',
|
|
525
537
|
' } else {',
|
|
526
538
|
' steadySince = null',
|
|
527
539
|
' if (exited !== null) break',
|
|
540
|
+
' const now = Date.now()',
|
|
541
|
+
' if (now >= hardBy) break',
|
|
542
|
+
' if (now >= upBy && !announcedWait) {',
|
|
543
|
+
' announcedWait = true',
|
|
544
|
+
// The numbers are computed, not typed twice: a log line that drifts from
|
|
545
|
+
// the constants it describes is worse than no line at all.
|
|
546
|
+
' note(`the replacement is still starting after ${String(Math.round((upBy - startedAt) / 1000))}s and has not exited — waiting up to ${String(Math.round((hardBy - startedAt) / 1000))}s for it rather than taking its port (it would die on EADDRINUSE)`)',
|
|
547
|
+
' }',
|
|
528
548
|
' }',
|
|
529
549
|
' await sleep(500)',
|
|
530
550
|
' }',
|
|
531
|
-
' note(`the replacement never came up on port ${port}${exited === null ? "" : ` (it exited with code ${exited})`} — see the output log beside this one`)',
|
|
551
|
+
' note(`the replacement never came up on port ${port}${exited === null ? " (it is still running, so the recovery surface waits for it to exit before binding)" : ` (it exited with code ${exited})`} — see the output log beside this one`)',
|
|
532
552
|
// The host is not coming back on its own. Everything the user can still
|
|
533
553
|
// do about it lives in the recovery surface, so start it while the
|
|
534
554
|
// browser tab that asked for the restart is still open.
|