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/src/routes.ts CHANGED
@@ -56,7 +56,7 @@ import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.ts'
56
56
  import { resolveDshHome } from './home-paths.ts'
57
57
  import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, type RecoveryHandoffConfig, restartReachableFrom } from './restart.ts'
58
58
  import type { RecoveryPlugin } from './recovery.ts'
59
- import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
59
+ import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, findUnservedConfigNamespaces, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
60
60
  import {
61
61
  carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags,
62
62
  readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences,
@@ -701,6 +701,16 @@ export function mountMarketRoutes(
701
701
  * newer decision than the market's list (the market removes a name from
702
702
  * its list BEFORE writing its own enable row), so the list follows it.
703
703
  *
704
+ * Scoped to THIS profile's layer, deliberately (#519). DSH also composes a
705
+ * home-level layer, and a third-party manager that wrote its switches there
706
+ * asked us to follow that too. We do not: the market writes the profile
707
+ * layer and nothing else, so following a layer it cannot write would give
708
+ * the user a switch that appears to work and silently reverts at the next
709
+ * boot — worse than a boundary they can see. What a home-level decision
710
+ * DOES get is a name in the log when the market overrides it (see
711
+ * `noteHomeLayerConflict`), so nobody has to guess why a plugin came back
712
+ * off.
713
+ *
704
714
  * @returns true when the package was re-enabled elsewhere and has been
705
715
  * dropped from the market's disable list.
706
716
  */
@@ -714,6 +724,27 @@ export function mountMarketRoutes(
714
724
  return true
715
725
  }
716
726
 
727
+ /**
728
+ * Say so when the market overrides an enable recorded in the HOME patch
729
+ * layer (#519).
730
+ *
731
+ * The market keeps a plugin off from its own record; a decision in the
732
+ * profile layer is followed instead (`followOutsideEnable`), but the home
733
+ * layer is not read for it — see there for why. The cost of that boundary
734
+ * is a plugin that turns off again on every boot while another manager's
735
+ * layer says it should be on: a perfectly reasonable thing for a user to be
736
+ * confused by, and its only explanation today was silence.
737
+ */
738
+ function noteHomeLayerConflict(name: string): void {
739
+ const homePatchPath = join(resolveDshHome(), 'cordis.patch.yml')
740
+ const home = readUserPatchState(homePatchPath)
741
+ if (home.forced.length === 0) return
742
+ const flags = packagePatchFlags(host, activeProfileDir, [name], home)
743
+ if (!flags.forced.includes(name) || flags.disabled.includes(name)) return
744
+ logEvent('warn', 'toggle',
745
+ `${name}: kept off by this market's own record, while ${homePatchPath} enables it — that layer is not the one the market writes, so the market will not follow it. Move the row into this profile's cordis.patch.yml to make it stick (#519)`)
746
+ }
747
+
717
748
  // Client-only packages (dsh.client without dsh.bundle) are invisible to the
718
749
  // bundle layer in every boot; the market shim-mounts them so their client
719
750
  // bundles are actually served.
@@ -725,6 +756,7 @@ export function mountMarketRoutes(
725
756
  // shims for disabled plugins were already skipped by mountClientOnlyDeps.
726
757
  for (const name of [...disabled]) {
727
758
  if (followOutsideEnable(name)) continue
759
+ noteHomeLayerConflict(name)
728
760
  if (await themes.setEntryDisabled(name, true)) logEvent('info', 'boot', `plugin kept off: ${name}`)
729
761
  }
730
762
  })
@@ -2656,6 +2688,37 @@ export function mountMarketRoutes(
2656
2688
  packageName,
2657
2689
  manifest,
2658
2690
  })))
2691
+ // #810: a client that reads its config from a name no loader entry
2692
+ // answers to reads NOTHING — blank or unwritable settings, and in the
2693
+ // reported case a panel stuck on a hardcoded fallback port — with no
2694
+ // error anywhere. Judged against everything this profile can be seen
2695
+ // to serve: each installed package's own patch rows (read once, for
2696
+ // the set AND for the "write this instead" value), the user patch's
2697
+ // rows — its inserts, plus the disables and force-enables, which both
2698
+ // name existing entry ids — and whatever the loader is running now.
2699
+ //
2700
+ // The gap: host-layer ids the market never reads a patch for. A plugin
2701
+ // reading a HOST entry's config by literal would be flagged wrongly
2702
+ // here, where the diagnostics page — which composes the host layers
2703
+ // too — can settle that difference.
2704
+ const ownEntryIds = new Map(Object.keys(installed).map(
2705
+ packageName => [packageName, bundlePatchInsertedIds(join(activeProfileDir, 'node_modules', packageName))] as const,
2706
+ ))
2707
+ const servedEntryIds = new Set<string>([...patch.inserts, ...patch.disables, ...patch.forced])
2708
+ for (const ids of ownEntryIds.values()) for (const id of ids) servedEntryIds.add(id)
2709
+ for (const entry of host.loader.entries()) {
2710
+ const id = entry.options?.id
2711
+ if (id === undefined || id === '') continue
2712
+ servedEntryIds.add(id)
2713
+ const bare = id.split(':').pop()
2714
+ if (bare !== undefined) servedEntryIds.add(bare)
2715
+ }
2716
+ const configNamespaces = findUnservedConfigNamespaces(
2717
+ activeProfileDir,
2718
+ servedEntryIds,
2719
+ name => ownEntryIds.get(name) ?? [],
2720
+ activeProfileDir,
2721
+ )
2659
2722
  sendJson(response, 200, {
2660
2723
  profile: config.profile,
2661
2724
  installed,
@@ -2683,6 +2746,9 @@ export function mountMarketRoutes(
2683
2746
  brokenPlugins: marketState.brokenPlugins ?? {},
2684
2747
  patchForced: patchFlags.forced,
2685
2748
  bundles: readProfileBundles(activeProfileDir).filter(name => !INBOX_BUNDLES.has(name)),
2749
+ // Only packages with a finding appear, so a healthy profile sends
2750
+ // nothing and the row renders nothing.
2751
+ configNamespaces: configNamespaces.length > 0 ? configNamespaces : undefined,
2686
2752
  })
2687
2753
  },
2688
2754
  }),
@@ -2708,7 +2774,25 @@ export function mountMarketRoutes(
2708
2774
  ? classifyPeer(row.plugin, row.name, row.range, row.resolved, row.optional === true)
2709
2775
  : { kind: 'none' }
2710
2776
  }
2711
- sendJson(response, 200, report)
2777
+ // #810: a client that reads its config from a name no loader entry
2778
+ // answers to. Attached here rather than computed inside
2779
+ // `analyzeProfile` because that function is documented as safe on
2780
+ // every market open and this reads one bundle per installed plugin —
2781
+ // the diagnostics page is a page the user opened on purpose.
2782
+ //
2783
+ // Judged against the COMPOSITION's ids, not the package's own: a
2784
+ // package may mount another one (carrier form), and judging by own
2785
+ // ids would flag it.
2786
+ const served = new Set(report.rows.map(row => row.id))
2787
+ sendJson(response, 200, {
2788
+ ...report,
2789
+ configNamespaces: findUnservedConfigNamespaces(
2790
+ activeProfileDir,
2791
+ served,
2792
+ name => report.rows.filter(row => row.layer === name).map(row => row.id),
2793
+ activeProfileDir,
2794
+ ),
2795
+ })
2712
2796
  } catch (error) {
2713
2797
  sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
2714
2798
  }
package/src/themes.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * persisted across restarts.
6
6
  */
7
7
 
8
+ import { readFileSync } from 'node:fs'
8
9
  import { join } from 'node:path'
9
10
  import { loadRegistry, pluginCategories } from './registry.ts'
10
11
  import { hotMount, hotUnmount, listHotMounts, writeDisabled } from './hot.ts'
@@ -12,7 +13,7 @@ import { logEvent } from './log.ts'
12
13
  import { LifecyclePendingError, LifecycleWaitError, waitForLifecycle } from './lifecycle.ts'
13
14
  import { nameMatchesPackage } from './entry-identity.ts'
14
15
  import { bundlePatchInsertedIds, profileDir, readInstalled } from './profile.ts'
15
- import { repoOf } from './sources.ts'
16
+ import { parseSourceUrl, repoOfTarget } from './sources.ts'
16
17
 
17
18
  /** The slice of a cordis loader entry the market needs for live enable/disable. */
18
19
  export interface LoaderEntry {
@@ -34,6 +35,29 @@ export interface ThemeManager {
34
35
  activateTheme(name: string): Promise<boolean>
35
36
  }
36
37
 
38
+ /**
39
+ * Whether an installed package declares itself a skin.
40
+ *
41
+ * The market's own criterion for "this is a theme" is the catalog, and the
42
+ * catalog does not list every skin a monorepo ships: the reporter's second
43
+ * skin is absent from it entirely, while the MANAGER that ships with them is
44
+ * a package from the same repo. Judging by repo alone made the manager a
45
+ * theme, so activating any other theme switched it off — and with it the
46
+ * user's own skin-management UI (#519). Both skins ship `skin.json` at their
47
+ * root (it names the package and its wiring); the manager ships none, which
48
+ * is the distinction the reporter pointed at.
49
+ */
50
+ function hasSkinManifest(profileDirectory: string, name: string): boolean {
51
+ try {
52
+ const manifest = JSON.parse(
53
+ readFileSync(join(profileDirectory, 'node_modules', name, 'skin.json'), 'utf8'),
54
+ ) as { id?: unknown }
55
+ return typeof manifest.id === 'string' && manifest.id !== ''
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
37
61
  /**
38
62
  * Whether a loader entry belongs to `packageName`.
39
63
  *
@@ -83,16 +107,41 @@ export function createThemeManager(
83
107
  const registry = await loadRegistry()
84
108
  const themeEntries = registry.plugins.filter(p => pluginCategories(p).includes('theme'))
85
109
  const themeNames = new Set(themeEntries.map(p => p.name))
86
- const themeRepos = new Set(
87
- themeEntries.map(p => repoOf(p.url)).filter((r): r is string => r !== null).map(r => r.toLowerCase()),
88
- )
110
+ // Repo → the subdirectories its catalog entries point at, plus whether
111
+ // any entry is the repo itself. One repository can ship several PACKAGES
112
+ // — a skin manager and its skins — and matching by repo alone made every
113
+ // one of them a theme, so activating another theme switched the manager
114
+ // off too, taking the user's own management UI with it (#519).
115
+ const themeRepos = new Map<string, { root: boolean; subpaths: Set<string> }>()
116
+ for (const entry of themeEntries) {
117
+ const parsed = parseSourceUrl(entry.url)
118
+ if (parsed === null) continue
119
+ const key = parsed.repo.toLowerCase()
120
+ const record = themeRepos.get(key) ?? { root: false, subpaths: new Set<string>() }
121
+ if (parsed.subpath === null) record.root = true
122
+ else record.subpaths.add(parsed.subpath.toLowerCase())
123
+ themeRepos.set(key, record)
124
+ }
89
125
  for (const [name, spec] of Object.entries(readInstalled(profile, activeProfileDir))) {
90
126
  if (themeNames.has(name)) {
91
127
  names.add(name)
92
128
  continue
93
129
  }
94
- const match = /github:([^#\s]+)/.exec(String(spec).toLowerCase())
95
- if (match !== null && themeRepos.has(match[1])) names.add(name)
130
+ const identity = repoOfTarget(String(spec).toLowerCase())
131
+ if (identity === null) continue
132
+ const at = identity.indexOf('#path:/')
133
+ const repo = at === -1 ? identity : identity.slice(0, at)
134
+ const subpath = at === -1 ? null : identity.slice(at + '#path:/'.length)
135
+ const record = themeRepos.get(repo)
136
+ if (record === undefined) continue
137
+ // Only a repo whose themes ALL live in subdirectories can be told
138
+ // apart this way. A catalog entry at the repo root says nothing about
139
+ // subdirectories, so that shape keeps the permissive match it had —
140
+ // which is also what keeps a monorepo skin working when the entry
141
+ // points at the repo.
142
+ if (subpath !== null && !record.root && record.subpaths.size > 0
143
+ && !record.subpaths.has(subpath) && !hasSkinManifest(activeProfileDir, name)) continue
144
+ names.add(name)
96
145
  }
97
146
  } catch { /* registry unavailable — nothing classifies as a theme */ }
98
147
  return names
package/src/verify.ts CHANGED
@@ -615,6 +615,129 @@ export interface BundleCheck {
615
615
  reason: string | null
616
616
  }
617
617
 
618
+ /**
619
+ * One assignment of a string literal to a name, and the `configForms.get`
620
+ * call sites (#810).
621
+ *
622
+ * The namespace a client asks its config from is a PROFILE ENTRY ID, not a
623
+ * package name. A client that writes the package name reads nothing: the
624
+ * settings document has no entry under it, the call returns empty, and the
625
+ * plugin degrades silently — blank or unwritable settings forms, and in the
626
+ * reported case a panel stuck on a hardcoded fallback port. Nothing logs it,
627
+ * and nothing on the market side noticed.
628
+ *
629
+ * `configForms.get` is matched by name, so an aliased receiver
630
+ * (`const forms = ctx.configForms; forms.get("x")`) is not seen — the same
631
+ * honesty as the syntax check above: this reports what it can prove, and
632
+ * says nothing otherwise.
633
+ */
634
+ const STRING_ASSIGNMENT = /(?:^|[^\w$.])(?:var|let|const)?\s*([A-Za-z_$][\w$]*)\s*=\s*(['"])([^'"\\\n]*)\2/gu
635
+ const CONFIG_FORMS_GET = /configForms\s*\.\s*get\s*\(\s*(?:([A-Za-z_$][\w$]*)|(['"])([^'"\\\n]*)\2)\s*\)/gu
636
+
637
+ /** A value that is a plain loader entry id: a backslash means it is not one. */
638
+ function plainNamespace(value: string): string | null {
639
+ return value !== '' && value.length <= 200 && !value.includes('\\') && /^[\w@/.:-]+$/u.test(value) ? value : null
640
+ }
641
+
642
+ /**
643
+ * Every namespace a client bundle asks its config from (#810).
644
+ *
645
+ * One level of constant propagation, as asked for in the report: the plugin
646
+ * that started this writes `var NAMESPACE = "dsh-comfyui-canvas"` and calls
647
+ * `configForms.get(NAMESPACE)`, so a literal-only scan would miss the very
648
+ * case the check exists for. A name assigned twice is dropped rather than
649
+ * guessed at — which value it holds at the call site is not decidable from
650
+ * text alone, and a wrong guess here would be reported as a plugin defect.
651
+ */
652
+ export function configFormNamespaces(source: string): string[] {
653
+ // Measured on a 750KB bundle: these scans cost ~9ms, because both walk
654
+ // every character, and this runs on a request the market polls. Almost no
655
+ // client mentions configForms at all, and — an order of magnitude rarer —
656
+ // almost none of those calls a method on it: for both, this pair of plain
657
+ // substring searches IS the whole cost (0.23ms measured), and only a client
658
+ // that actually asks for a config pays for a scan.
659
+ if (!source.includes('configForms') || !source.includes('configForms.get')) return []
660
+ const constants = new Map<string, string | null>()
661
+ for (const match of source.matchAll(STRING_ASSIGNMENT)) {
662
+ const name = match[1]!
663
+ const value = plainNamespace(match[3]!)
664
+ constants.set(name, constants.has(name) || value === null ? null : value)
665
+ }
666
+ const namespaces = new Set<string>()
667
+ for (const match of source.matchAll(CONFIG_FORMS_GET)) {
668
+ // A namespace is a loader entry id: no escapes, no whitespace runs, no
669
+ // arbitrary length. Anything else is not this pattern and stays out.
670
+ const value = plainNamespace(match[3] ?? '') ?? constants.get(match[1]!) ?? null
671
+ if (value !== null) namespaces.add(value)
672
+ }
673
+ return [...namespaces]
674
+ }
675
+
676
+ /** A package whose client asks for a namespace nothing in the composition serves (#810). */
677
+ export interface UnservedConfigNamespace {
678
+ /** The installed package whose client bundle asks. */
679
+ name: string
680
+ /** The namespaces it asks for that no loader entry id answers to. */
681
+ namespaces: string[]
682
+ /** Entry ids this package's own patch declares — the value it meant. */
683
+ ownEntryIds: string[]
684
+ }
685
+
686
+ /** The client bundle of an installed package, read; null when it has none to read. */
687
+ function readClientBundle(profile: string, name: string, explicitDir?: string): string | null {
688
+ const root = join(profileDir(profile, explicitDir), 'node_modules', name)
689
+ let manifest: { dsh?: { client?: unknown }; exports?: unknown }
690
+ try {
691
+ manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as typeof manifest
692
+ } catch {
693
+ return null
694
+ }
695
+ if (manifest.dsh?.client === undefined) return null
696
+ const exportsField = manifest.exports
697
+ const relative = exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)
698
+ ? clientBundlePath((exportsField as Record<string, unknown>)['./client'])
699
+ : null
700
+ if (relative === null) return null
701
+ try {
702
+ return readFileSync(join(root, relative), 'utf8')
703
+ } catch {
704
+ return null
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Installed plugins whose client reads its config from a name nothing serves
710
+ * (#810).
711
+ *
712
+ * `served` is the entry id set of the WHOLE composition — deliberately not
713
+ * this package's own ids: a package may legitimately mount another one
714
+ * (carrier form), and judging by own ids alone would flag those. `ownIdsOf`
715
+ * answers what the package itself declares, which is the value to write
716
+ * instead — the report asked for the correct spelling to be shown, not just
717
+ * the mistake.
718
+ *
719
+ * The scan reads one bundle per installed plugin. Measured against the same
720
+ * cost the syntax sweep above already pays (0.40ms per plugin on a 385KB
721
+ * bundle), so a 30-plugin profile is ~12ms — this runs on a request a user
722
+ * made, not in a loop.
723
+ */
724
+ export function findUnservedConfigNamespaces(
725
+ profile: string,
726
+ served: ReadonlySet<string>,
727
+ ownIdsOf: (name: string) => string[],
728
+ explicitDir?: string,
729
+ ): UnservedConfigNamespace[] {
730
+ const found: UnservedConfigNamespace[] = []
731
+ for (const name of Object.keys(readInstalled(profile, explicitDir))) {
732
+ const source = readClientBundle(profile, name, explicitDir)
733
+ if (source === null) continue
734
+ const unserved = configFormNamespaces(source).filter(namespace => !served.has(namespace))
735
+ if (unserved.length === 0) continue
736
+ found.push({ name, namespaces: unserved, ownEntryIds: ownIdsOf(name) })
737
+ }
738
+ return found
739
+ }
740
+
618
741
  /**
619
742
  * Whether a package's client bundle still parses as JavaScript (#222).
620
743
  *
@@ -686,30 +809,14 @@ export function newlyBrokenBundles(
686
809
  }
687
810
 
688
811
  export function checkClientBundle(profile: string, name: string, explicitDir?: string): BundleCheck {
689
- const root = join(profileDir(profile, explicitDir), 'node_modules', name)
690
- let manifest: { dsh?: { client?: unknown }; exports?: unknown }
691
- try {
692
- manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as typeof manifest
693
- } catch {
694
- return { ok: true, reason: null }
695
- }
696
- if (manifest.dsh?.client === undefined) return { ok: true, reason: null }
697
- const exportsField = manifest.exports
698
- const relative = exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)
699
- ? clientBundlePath((exportsField as Record<string, unknown>)['./client'])
700
- : null
701
- if (relative === null) return { ok: true, reason: null }
702
- let source: string
703
- try {
704
- source = readFileSync(join(root, relative), 'utf8')
705
- } catch {
706
- // Declared but absent is verifyActivation's territory (a missing entry
707
- // artifact is already `broken` there); duplicating it here would report
708
- // one problem twice in two different vocabularies.
709
- return { ok: true, reason: null }
710
- }
812
+ // Declared but absent is verifyActivation's territory (a missing entry
813
+ // artifact is already `broken` there); duplicating it here would report one
814
+ // problem twice in two different vocabularies — so the reader's null covers
815
+ // both "no client bundle" and "declared but unreadable" with the same ok.
816
+ const source = readClientBundle(profile, name, explicitDir)
817
+ if (source === null) return { ok: true, reason: null }
711
818
  try {
712
- new Script(source, { filename: relative })
819
+ new Script(source, { filename: `${name}/client` })
713
820
  return { ok: true, reason: null }
714
821
  } catch (error) {
715
822
  const message = error instanceof Error ? error.message : String(error)