dsh-tui-theme 0.7.1 → 0.7.2

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.
@@ -2,12 +2,34 @@
2
2
  * /settings integration (seam six: tuiSettingsSections over the dsh settings
3
3
  * service).
4
4
  *
5
- * Registers the `pink-theme` settings namespace and a declarative editing
6
- * section for it. Storage, schema validation, and layered resolution stay on
7
- * the dsh settings service; the TUI only renders. Fields carry no schema
8
- * defaults on purpose — an unset user layer must fall through to the cordis
9
- * config layer (mirrors the host's own lang/fullscreen fields), and format()
10
- * displays the effective value instead of a misleading blank.
5
+ * Registers a declarative editing section for the plugin's settings and, on
6
+ * hosts that still have the registration API, the settings namespace itself.
7
+ * Storage, schema validation, and layered resolution stay on the dsh settings
8
+ * service; the TUI only renders. Fields carry no schema defaults on purpose —
9
+ * an unset user layer must fall through to the cordis config layer (mirrors
10
+ * the host's own lang/fullscreen fields), and format() displays the effective
11
+ * value instead of a misleading blank.
12
+ *
13
+ * Two generations, branched by capability (never by version),
14
+ * `dsh-settings`'s 0.1.7 break being the reason
15
+ * (docs/decisions/2026-09-24-settings-generation-adaptation.md):
16
+ *
17
+ * - **≤0.1.6** (dsh-TUI 0.9.x/0.10.x) — `settings.register(ns, schema)` owns
18
+ * the namespace. The card's paths resolve against the scope schema declared
19
+ * here, and `scope.watch` pushes edits into the plugin.
20
+ * - **≥0.1.7** (dsh-TUI 0.11+) — there is no registration API at all
21
+ * (`SettingsForms` projects each profile entry's *Config* instead). The
22
+ * namespace is this plugin's profile entry id (see
23
+ * {@link resolveSettingsNamespace}) and the form schema is `Config` filtered
24
+ * to its volatile fields (liveConfig.ts). Edits land in the profile patch,
25
+ * the loader rewrites those same refs in place, and
26
+ * `loader/volatile-update` announces it; this module re-reads the live config
27
+ * on that event. The card stays ours, so the auto-generated page is turned
28
+ * off with `configure({ auto: false })`.
29
+ *
30
+ * A miss in either direction is silent and user-visible only as a card that
31
+ * renders but never saves (≤0.1.6) or a `命名空间未注册` badge (≥0.1.7) — hence
32
+ * the branch below warns instead of swallowing the failure.
11
33
  *
12
34
  * The section splits into two navigation groups (背景跟随 / 状态行). The two
13
35
  * text fields (glyph, separator) validate their drafts with parse(): an
@@ -23,64 +45,74 @@ import { readFollowCache } from './autoTheme.js';
23
45
  import { parseOrnamentDraft } from './ornament.js';
24
46
  import { PLUGIN_ID } from './pluginId.js';
25
47
  import { homeDir } from './themeAssets.js';
48
+ /** The settings namespace this plugin owns by default, and the fallback when
49
+ * the Loader entry id is unusable. On ≥0.1.7 hosts the *entry id* is the
50
+ * namespace the host keys by, so the effective value is
51
+ * {@link resolveSettingsNamespace}: a default install lands on exactly this
52
+ * string (`cordis.patch.yml` pins `id: dsh-tui-theme`), and it stays the name
53
+ * the README documents for settings storage. */
54
+ export const SETTINGS_NS = PLUGIN_ID;
55
+ /** The grammar plugin-owned sections must satisfy
56
+ * (`tuiSettingsSections.register`; the host only relaxes it for its own
57
+ * sections, which may carry opaque Loader ids). */
58
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9_-]*$/;
59
+ /** The plugin's Loader entry id, when the host gives us one. */
60
+ function loaderEntryId(ctx) {
61
+ const fiber = ctx.fiber;
62
+ const id = fiber?.entry?.options?.id;
63
+ return typeof id === 'string' ? id : undefined;
64
+ }
65
+ /**
66
+ * The namespace this plugin's settings live under: the Loader entry id when it
67
+ * satisfies the section grammar, {@link SETTINGS_NS} otherwise.
68
+ *
69
+ * A `dsh-settings` ≥0.1.7 host keys namespaces by the Loader entry id
70
+ * (`SettingsForms.describe()` → `entry.options.id`), so keying the card off
71
+ * anything else breaks the moment the row is renamed — the fragility dsh-TUI
72
+ * #990 records, where even the host's own section had to stop hard-coding its
73
+ * name. Both generations resolve to the same string here, so the card, the
74
+ * legacy registration and the host's projection cannot disagree.
75
+ */
76
+ export function resolveSettingsNamespace(ctx) {
77
+ const id = loaderEntryId(ctx);
78
+ return id !== undefined && NAMESPACE_PATTERN.test(id) ? id : SETTINGS_NS;
79
+ }
26
80
  /**
27
- * Register the settings namespace (mirror the resolved document to the
28
- * caller) and, separately, the /settings section for it. Each part waits for
29
- * its own service; neither is required for the other.
81
+ * Register the /settings section and, on hosts that still have the
82
+ * registration API, the settings namespace behind it. Each part waits for its
83
+ * own service; neither is required for the other.
30
84
  *
31
- * @param ctx - The plugin's own activation context.
32
- * @param cordis - The cordis-config layer (shown as the effective value for
33
- * still-unset fields).
34
- * @param onDoc - Called with the defined-valued subset of the settings doc,
35
- * initially and on every committed edit.
85
+ * @param ctx - The plugin's own activation context: the Config owner the
86
+ * ≥0.1.7 page policy and the volatile-update listener must attach to (their
87
+ * disposers ride the inject child, which is what a service reload recycles).
88
+ * @param wiring - The value source and sink for the card.
36
89
  * @param dataDir - The host data directory (~/.dsh-tui), read by the
37
90
  * followSystem field's format() to surface the cached follow state.
38
91
  */
39
- export function registerPinkSettings(ctx, cordis, onDoc, dataDir = joinHomeDataDir()) {
92
+ export function registerPinkSettings(ctx, wiring, dataDir = joinHomeDataDir()) {
93
+ const ns = resolveSettingsNamespace(ctx);
40
94
  ctx.inject(['settings'], settingsCtx => {
41
95
  const settings = settingsCtx.settings;
42
- try {
43
- // dsh-settings validates the raw namespace at registration time. Keep
44
- // this as a plain string so alpha.2 hosts, which removed the runtime
45
- // settingsNamespace() helper, can load the plugin without a missing
46
- // named export while older hosts retain the same behavior.
47
- const scope = settings.register(PLUGIN_ID, z.object({
48
- followSystem: z.boolean(),
49
- showGlyph: z.boolean(),
50
- showClock: z.boolean(),
51
- showTurns: z.boolean(),
52
- statusScope: z.union(['pink-only', 'all-themes']),
53
- statusGlyph: z.string(),
54
- statusSeparator: z.string(),
55
- }));
56
- const emit = (doc) => {
57
- if (doc === null || typeof doc !== 'object')
58
- return;
59
- const clean = {};
60
- for (const [key, value] of Object.entries(doc)) {
61
- if (value !== undefined)
62
- clean[key] = value;
63
- }
64
- onDoc(clean);
65
- };
66
- // Own the watcher on the inject-scoped ledger so it survives exactly as
67
- // long as this activation (scope.watch's disposer is otherwise leaked).
68
- settingsCtx.effect(() => {
69
- emit(scope.get());
70
- return scope.watch(emit);
71
- });
96
+ if (settings === undefined)
97
+ return;
98
+ if (typeof settings.register === 'function') {
99
+ registerNamespaceScope(settingsCtx, settings, wiring, ns);
100
+ return;
72
101
  }
73
- catch (error) {
74
- // A duplicate registration (hot reload race) or a stricter host must
75
- // not take the plugin — or the TUI — down.
76
- settingsCtx.logger.warn(`dsh-tui-theme: settings namespace registration failed: ${String(error)}`);
102
+ if (typeof settings.configure !== 'function') {
103
+ settingsCtx.logger.info('dsh-tui-theme: the settings service exposes neither the namespace registration nor the Config-derived surface; the settings card stays unavailable this session');
104
+ return;
77
105
  }
106
+ diagnoseConfigGeneration(ctx, ns, wiring.hasLiveFields);
107
+ configureOwnPage(ctx, settingsCtx, settings);
108
+ wiring.onDoc(definedOnly(wiring.readLive()));
109
+ watchLiveConfig(ctx, settingsCtx, wiring);
78
110
  });
79
111
  ctx.inject(['tuiSettingsSections'], sectionsCtx => {
80
112
  const sections = sectionsCtx
81
113
  .tuiSettingsSections;
82
114
  try {
83
- const unregister = sections.register(sectionDefinition(cordis, dataDir));
115
+ const unregister = sections.register(sectionDefinition(ns, wiring.cordis, dataDir));
84
116
  sectionsCtx.effect(() => () => unregister());
85
117
  }
86
118
  catch (error) {
@@ -90,6 +122,136 @@ export function registerPinkSettings(ctx, cordis, onDoc, dataDir = joinHomeDataD
90
122
  }
91
123
  });
92
124
  }
125
+ /** The defined-valued subset of a settings document: the ≤0.1.6 user layer and
126
+ * the ≥0.1.7 live config both arrive with unset keys present but undefined. */
127
+ function definedOnly(doc) {
128
+ if (doc === null || typeof doc !== 'object')
129
+ return {};
130
+ const clean = {};
131
+ for (const [key, value] of Object.entries(doc)) {
132
+ if (value !== undefined)
133
+ clean[key] = value;
134
+ }
135
+ return clean;
136
+ }
137
+ /**
138
+ * ≤0.1.6 generation: own the namespace and follow its scope. The schema spells
139
+ * out the same field set the card exposes and deliberately carries no defaults
140
+ * — an unset user layer must fall through to the cordis layer, which format()
141
+ * already displays as the effective value.
142
+ */
143
+ function registerNamespaceScope(settingsCtx, settings, wiring, ns) {
144
+ try {
145
+ // Call it as a method (optional-call on the property): the provider's
146
+ // register() reads its own state, so a detached reference would lose
147
+ // `this` and throw.
148
+ //
149
+ // The namespace is passed as a plain string: dsh-settings validates the
150
+ // raw value at registration time, and alpha.2 hosts removed the runtime
151
+ // settingsNamespace() helper, so a branded value would be a missing named
152
+ // export there while older hosts keep the same behavior.
153
+ const scope = settings.register?.(ns, z.object({
154
+ followSystem: z.boolean(),
155
+ showGlyph: z.boolean(),
156
+ showClock: z.boolean(),
157
+ showTurns: z.boolean(),
158
+ statusScope: z.union(['pink-only', 'all-themes']),
159
+ statusGlyph: z.string(),
160
+ statusSeparator: z.string(),
161
+ }));
162
+ if (scope === undefined)
163
+ throw new Error('the settings service exposes no register()');
164
+ // Own the watcher on the inject-scoped ledger so it survives exactly as
165
+ // long as this activation (scope.watch's disposer is otherwise leaked).
166
+ const emit = (doc) => {
167
+ wiring.onDoc(definedOnly(doc));
168
+ };
169
+ settingsCtx.effect(() => {
170
+ emit(scope.get());
171
+ return scope.watch(emit);
172
+ });
173
+ }
174
+ catch (error) {
175
+ // Contained, but never silent: swallowing the provider's error is what
176
+ // made the ≥0.1.7 transition surface as a bare `命名空间未注册` badge with
177
+ // no line in the log to explain it.
178
+ settingsCtx.logger.warn(`dsh-tui-theme: settings namespace registration failed: ${String(error)}`);
179
+ }
180
+ }
181
+ /**
182
+ * Explain the two ways a ≥0.1.7 host can leave this card unserved, instead of
183
+ * letting `命名空间未注册` be the only clue (the diagnosis cost of dsh-TUI #990).
184
+ * Both are warnings, not failures: the row config and every other seam keep
185
+ * working.
186
+ */
187
+ function diagnoseConfigGeneration(ctx, ns, hasLiveFields) {
188
+ const entryId = loaderEntryId(ctx);
189
+ if (entryId !== undefined && entryId !== ns) {
190
+ ctx.logger.warn(`dsh-tui-theme: the settings service keys namespaces by Loader entry id "${entryId}", which is not a valid section namespace — the settings card cannot be served; rename the plugin row to a lowercase kebab-case id`);
191
+ }
192
+ if (!hasLiveFields) {
193
+ ctx.logger.warn('dsh-tui-theme: no row-config field carries the live marker — this host cannot serve the settings card (needs a schemastery that accepts volatile fields); the row config keeps working');
194
+ }
195
+ }
196
+ /**
197
+ * ≥0.1.7 generation: the namespace is this profile entry and its form schema is
198
+ * the marked slice of `Config`, so there is nothing to register. The card is
199
+ * the plugin's own page, so opt out of the auto-generated one; the policy must
200
+ * be attached to the plugin's own fiber (the entry that owns the Config), not
201
+ * to the injected child.
202
+ */
203
+ function configureOwnPage(ctx, settingsCtx, settings) {
204
+ try {
205
+ const dispose = settings.configure?.({ auto: false }, ctx.fiber);
206
+ if (typeof dispose === 'function') {
207
+ const stop = dispose;
208
+ settingsCtx.effect(() => stop);
209
+ }
210
+ }
211
+ catch (error) {
212
+ // Decorative: the card renders and saves either way; a future
213
+ // auto-generated page would merely duplicate it. `configure` throws when
214
+ // this fiber already registered a policy (a second inject pass).
215
+ settingsCtx.logger.info(`dsh-tui-theme: settings page policy not applied (${String(error)})`);
216
+ }
217
+ }
218
+ /**
219
+ * Follow the loader's live-config announcements.
220
+ *
221
+ * The *listener* registers on the plugin's own context: the loader emits
222
+ * `loader/volatile-update` on the Config-owning fiber and `Context.filter`
223
+ * delivers it to that fiber alone, which is the same reason the host's compat
224
+ * shim passes the Config owner rather than the injected child.
225
+ *
226
+ * Its *disposal*, however, belongs to the inject child (`owner`). This body
227
+ * runs once per settings-service arrival, in a fiber cordis recycles, while the
228
+ * plugin fiber outlives every pass: an effect owned by the plugin would let a
229
+ * second pass stack a second listener on top of the first (each event then
230
+ * re-reading the config twice). Without the event the initial read stands for
231
+ * the session — logged, because that means `/settings` edits would not reach
232
+ * the running plugin until it reloads.
233
+ */
234
+ function watchLiveConfig(ctx, owner, wiring) {
235
+ const refresh = () => {
236
+ wiring.onDoc(definedOnly(wiring.readLive()));
237
+ };
238
+ const events = ctx;
239
+ let dispose;
240
+ try {
241
+ dispose = events.on?.('loader/volatile-update', refresh);
242
+ }
243
+ catch {
244
+ dispose = undefined;
245
+ }
246
+ if (typeof dispose !== 'function') {
247
+ ctx.logger.info('dsh-tui-theme: loader/volatile-update is unavailable; /settings edits apply at the next plugin reload');
248
+ return;
249
+ }
250
+ // `ctx.on` already ties the listener to the plugin fiber (cordis's own
251
+ // effect inside `Context.on`); this one exists for the recycled inject child,
252
+ // so it must run on that child's ledger rather than the plugin's.
253
+ owner.effect(() => dispose);
254
+ }
93
255
  function joinHomeDataDir() {
94
256
  return join(homeDir(), '.dsh-tui');
95
257
  }
@@ -111,9 +273,9 @@ function followCacheDate(at) {
111
273
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
112
274
  }
113
275
  /** The declarative /settings block (labels bilingual, zh via descriptions). */
114
- function sectionDefinition(cordis, dataDir) {
276
+ function sectionDefinition(ns, cordis, dataDir) {
115
277
  return {
116
- ns: PLUGIN_ID,
278
+ ns,
117
279
  title: 'pink-theme',
118
280
  descriptions: { zh: 'pink-theme' },
119
281
  groups: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tui-theme",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Sakura-pink themes for dsh-TUI with optional cached background follow (pink-day/pink-night), a blossom status line, and a /settings section. No shortcuts, no commands.",
5
5
  "type": "module",
6
6
  "main": "lib/types/index.js",
@@ -28,8 +28,9 @@
28
28
  "preverify": "npm run build",
29
29
  "verify": "node scripts/verify.mjs",
30
30
  "verify:package": "node scripts/verify-package.mjs",
31
+ "verify:settings": "node scripts/verify-settings-generation.mjs",
31
32
  "verify:host": "npm run build && node scripts/headless-order-test.mjs && node scripts/runtime-themes-headless.mjs && node --import tsx/esm scripts/validate-themes-against-host.mjs",
32
- "release:check": "npm run build && npm run verify && npm run verify:package",
33
+ "release:check": "npm run build && npm run verify && npm run verify:package && npm run verify:settings",
33
34
  "prepack": "npm run release:check"
34
35
  },
35
36
  "license": "MIT",
@@ -41,6 +41,8 @@ for (const required of [
41
41
  'lib/types/pluginId.d.ts',
42
42
  'lib/types/settingsSection.js',
43
43
  'lib/types/settingsSection.d.ts',
44
+ 'lib/types/liveConfig.js',
45
+ 'lib/types/liveConfig.d.ts',
44
46
  'lib/types/statusLine.js',
45
47
  'lib/types/statusLine.d.ts',
46
48
  'lib/types/themeAssets.js',
@@ -63,6 +65,7 @@ for (const required of [
63
65
  'docs/screenshots/settings.png',
64
66
  'scripts/verify.mjs',
65
67
  'scripts/verify-package.mjs',
68
+ 'scripts/verify-settings-generation.mjs',
66
69
  'scripts/headless-order-test.mjs',
67
70
  'scripts/runtime-themes-headless.mjs',
68
71
  'scripts/validate-themes-against-host.mjs',
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Verify the plugin against a real `@deepseek-ai/dsh-settings` install of the
3
+ * ≥0.1.7 generation — the Config-derived forms the `/settings` screen reads.
4
+ *
5
+ * Why this exists: 0.1.7 removed the namespace-registration API entirely and
6
+ * projects each profile entry's Cordis Config instead. A plugin that misses the
7
+ * transition still boots, still registers its card, and simply renders
8
+ * `命名空间未注册`; a card field whose Config key is not marked volatile renders
9
+ * `(未设置)` forever. Both are silent, so they get a gate — the reference
10
+ * record is docs/decisions/2026-09-24-settings-generation-adaptation.md (in the
11
+ * dsh-tui-find repo), whose migration checklist this script implements for
12
+ * dsh-tui-theme.
13
+ *
14
+ * What it checks (against the built `lib/types/`, not the sources):
15
+ * 1. `volatileForm(Config)` is non-empty and holds exactly the live keys
16
+ * (the entry is listed by `describe()` only when it is);
17
+ * 2. every live key is writable through the `isVolatilePath` gate;
18
+ * 3. the card's field paths equal the live keys — the parity that keeps a
19
+ * field from rendering editable while nothing serves it;
20
+ * 4. `projectForm` over a resolved row config yields a value for every live
21
+ * knob (all of them carry defaults, so nothing may read `(未设置)`);
22
+ * 5. the wiring against a real-shaped service (no `register`, has
23
+ * `configure`): page policy opts out of the auto page on the plugin's own
24
+ * fiber, the initial value comes from the live config, and a
25
+ * `loader/volatile-update` re-read sees edited values.
26
+ *
27
+ * Usage:
28
+ * npm run build && node scripts/verify-settings-generation.mjs
29
+ * node scripts/verify-settings-generation.mjs --settings <dir>
30
+ * DSH_SETTINGS_DIR=<dir> node scripts/verify-settings-generation.mjs
31
+ *
32
+ * `<dir>` is a `@deepseek-ai/dsh-settings` package directory, e.g. the one a
33
+ * real profile resolves:
34
+ * %USERPROFILE%\.dsh\profiles\node_modules\@deepseek-ai\dsh-settings
35
+ *
36
+ * Generation detection is behavioural, never a version parse: a legacy
37
+ * install (≤0.1.6) is reported as "nothing to verify here" and exits 0 — its
38
+ * path is covered by scripts/verify.mjs. So a green run with the repo's own
39
+ * (legacy) dependency means "the new-generation path was not exercised", not
40
+ * "verified"; point DSH_SETTINGS_DIR at a real 0.1.7+ install to gate it.
41
+ * Exit code 1 on any failed check.
42
+ *
43
+ * The probe runs from a scratch directory inside the settings tree's own
44
+ * `node_modules`, so the plugin's bare import (`@deepseek-ai/schemastery`)
45
+ * resolves exactly as it does at runtime there — the marking only happens on a
46
+ * schemastery that knows `.volatile()` (3.18.3+), and that is the host's copy,
47
+ * not this repo's. The directory is removed on the way out.
48
+ */
49
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
50
+ import { createRequire } from 'node:module'
51
+ import { dirname, join } from 'node:path'
52
+ import { fileURLToPath, pathToFileURL } from 'node:url'
53
+
54
+ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)))
55
+
56
+ function parseArgs(argv) {
57
+ let settings
58
+ for (let i = 0; i < argv.length; i++) {
59
+ if (argv[i] === '--settings') {
60
+ if (argv[i + 1] === undefined) throw new Error('--settings needs a directory argument')
61
+ settings = argv[++i]
62
+ } else {
63
+ throw new Error(`unknown option: ${argv[i]}`)
64
+ }
65
+ }
66
+ return { settings }
67
+ }
68
+
69
+ /** Where the settings package lives: --settings, then DSH_SETTINGS_DIR, then
70
+ * this repo's own tree. */
71
+ function resolveSettingsDir(explicit) {
72
+ const candidate =
73
+ explicit ?? process.env['DSH_SETTINGS_DIR'] ?? join(repoRoot, 'node_modules', '@deepseek-ai', 'dsh-settings')
74
+ if (!existsSync(join(candidate, 'package.json'))) {
75
+ throw new Error(
76
+ `no @deepseek-ai/dsh-settings at ${candidate}\n` +
77
+ 'pass --settings <dir> or set DSH_SETTINGS_DIR (a real profile resolves it under ' +
78
+ '<DSH_HOME>/profiles/node_modules/@deepseek-ai/dsh-settings)',
79
+ )
80
+ }
81
+ return candidate
82
+ }
83
+
84
+ const failures = []
85
+ function check(label, ok, detail = '') {
86
+ console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail === '' ? '' : ` — ${detail}`}`)
87
+ if (!ok) failures.push(label)
88
+ }
89
+ function note(text) {
90
+ console.log(` · ${text}`)
91
+ }
92
+
93
+ const { settings: explicitSettings } = parseArgs(process.argv.slice(2))
94
+ const settingsDir = resolveSettingsDir(explicitSettings)
95
+ const version = JSON.parse(readFileSync(join(settingsDir, 'package.json'), 'utf8')).version
96
+ console.log(`* @deepseek-ai/dsh-settings ${version} (${settingsDir})`)
97
+
98
+ // Generation detection: the Config-derived surface needs `lib/types/schema.js`
99
+ // (volatileForm/projectForm/isVolatilePath) and there must be no
100
+ // namespace-registration class to fall back on.
101
+ const schemaModule = join(settingsDir, 'lib', 'types', 'schema.js')
102
+ const lib = await import(pathToFileURL(join(settingsDir, 'lib', 'index.js')).href)
103
+ const provider =
104
+ lib.SettingsForms ??
105
+ lib.default ??
106
+ Object.values(lib).find(value => typeof value === 'function' && value.prototype !== undefined)
107
+ const legacyRegister = typeof provider?.prototype?.register === 'function'
108
+ if (!existsSync(schemaModule) || legacyRegister) {
109
+ console.log('* ≤0.1.6 generation (namespace registration): nothing to verify here.')
110
+ console.log(' The plugin\'s legacy path is covered by scripts/verify.mjs.')
111
+ console.log(' Point DSH_SETTINGS_DIR at a real ≥0.1.7 install to exercise the Config-derived path.')
112
+ process.exit(0)
113
+ }
114
+ console.log('* ≥0.1.7 generation (Config-derived forms): verifying the plugin card against it.')
115
+
116
+ const buildDir = join(repoRoot, 'lib', 'types')
117
+ for (const file of ['index.js', 'liveConfig.js', 'settingsSection.js']) {
118
+ if (!existsSync(join(buildDir, file))) {
119
+ throw new Error(`lib/types/${file} is missing — run \`npm run build\` first`)
120
+ }
121
+ }
122
+
123
+ // Scratch dir inside the settings tree's node_modules: bare specifiers resolve
124
+ // from there exactly as the plugin's own copy would at runtime.
125
+ const probeRoot = join(dirname(dirname(settingsDir)), `.dsh-tui-theme-settings-probe-${process.pid}`)
126
+ try {
127
+ rmSync(probeRoot, { recursive: true, force: true })
128
+ mkdirSync(probeRoot, { recursive: true })
129
+ cpSync(buildDir, join(probeRoot, 'types'), { recursive: true })
130
+
131
+ const plugin = name => import(pathToFileURL(join(probeRoot, 'types', name)).href)
132
+ const helper = name => import(pathToFileURL(join(settingsDir, 'lib', 'types', name)).href)
133
+
134
+ const { Config } = await plugin('index.js')
135
+ const { LIVE_CONFIG_KEYS, hasLiveConfigFields, readConfigValues } = await plugin('liveConfig.js')
136
+ const { registerPinkSettings, resolveSettingsNamespace, SETTINGS_NS } = await plugin('settingsSection.js')
137
+ const { volatileForm, projectForm, plainConfig, isVolatilePath } = await helper('schema.js')
138
+
139
+ // The host's own schemastery: 3.18.3+ parses marked fields into live refs.
140
+ const hostRequire = createRequire(join(settingsDir, 'lib', 'index.js'))
141
+ const z = (await import(pathToFileURL(hostRequire.resolve('@deepseek-ai/schemastery')).href)).default
142
+ const volatileCapable = typeof z?.string?.().volatile === 'function'
143
+
144
+ const liveKeys = [...LIVE_CONFIG_KEYS].sort()
145
+ const form = volatileForm(Config)
146
+ check('a live marker exists on the shipped Config', hasLiveConfigFields(Config))
147
+ check('volatileForm(Config) is non-empty (describe() lists the entry)', form !== undefined)
148
+ const formKeys = Object.keys(form?.dict ?? {}).sort()
149
+ check(
150
+ 'live keys == form-projected keys',
151
+ JSON.stringify(formKeys) === JSON.stringify(liveKeys),
152
+ formKeys.join(', '),
153
+ )
154
+ check('every live key passes the write gate', LIVE_CONFIG_KEYS.every(key => isVolatilePath(Config, [key])))
155
+
156
+ // The namespace follows the Loader entry id on this generation (dsh-TUI
157
+ // #990's fragility: the host keys by entry id, so a renamed row must move
158
+ // the card with it) and falls back to the constant for an unusable id.
159
+ const withEntry = id => ({ fiber: { entry: { options: { id } } } })
160
+ check(
161
+ 'namespace follows the Loader entry id',
162
+ resolveSettingsNamespace(withEntry('custom-theme')) === 'custom-theme',
163
+ resolveSettingsNamespace(withEntry('custom-theme')),
164
+ )
165
+ check(
166
+ 'namespace falls back for an unusable entry id',
167
+ resolveSettingsNamespace(withEntry('Custom.TUI')) === SETTINGS_NS,
168
+ )
169
+ check('namespace defaults to the constant without a Loader entry', resolveSettingsNamespace({}) === SETTINGS_NS)
170
+
171
+ // Every live knob carries a schema default here, so the projection must
172
+ // serve a value for each one (the `(未设置)` symptom is a defaulted field
173
+ // missing from the form).
174
+ const unset = projectForm(form, plainConfig(Config({})))
175
+ const unsetMissing = LIVE_CONFIG_KEYS.filter(key => unset[key] === undefined)
176
+ check('every live knob is served (no (未设置))', unsetMissing.length === 0, unsetMissing.join(', '))
177
+
178
+ const rowConfig = Config({
179
+ followSystem: true,
180
+ statusGlyph: '❀',
181
+ statusSeparator: '✦',
182
+ showGlyph: true,
183
+ showClock: false,
184
+ showTurns: true,
185
+ statusScope: 'all-themes',
186
+ })
187
+ const view = projectForm(form, plainConfig(rowConfig))
188
+ check(
189
+ 'user values survive the projection',
190
+ view.statusGlyph === '❀' &&
191
+ view.statusSeparator === '✦' &&
192
+ view.showClock === false &&
193
+ view.statusScope === 'all-themes' &&
194
+ view.followSystem === true,
195
+ JSON.stringify(view),
196
+ )
197
+
198
+ if (volatileCapable) {
199
+ check(
200
+ 'the host sends marked fields as live refs',
201
+ typeof rowConfig.statusGlyph === 'object' && rowConfig.statusGlyph !== null,
202
+ )
203
+ check('apply-time config resolves through live refs', readConfigValues(rowConfig).statusGlyph === '❀')
204
+ } else {
205
+ note('this host schemastery has no .volatile(): values stay plain; meta marking still projects')
206
+ }
207
+
208
+ // The wiring, against a service shaped like the real one: no `register`, a
209
+ // per-instance page policy, and the values coming from the live Config.
210
+ const warns = []
211
+ const cards = []
212
+ const configured = []
213
+ const applied = []
214
+ let refresh
215
+ const service = {
216
+ configure: (presentation, owner) => {
217
+ configured.push({ presentation, owner })
218
+ return () => {}
219
+ },
220
+ describe: () => [],
221
+ update: async () => {},
222
+ mutate: async () => {},
223
+ }
224
+ const sections = {
225
+ register: section => {
226
+ cards.push(section)
227
+ return () => {}
228
+ },
229
+ }
230
+ const logger = { warn: message => warns.push(String(message)), info: () => {}, error: () => {} }
231
+ const child = dep => ({
232
+ [dep]: dep === 'settings' ? service : sections,
233
+ effect: factory => factory(),
234
+ logger,
235
+ })
236
+ const ctx = {
237
+ get: key => (key === 'tuiSettingsSections' ? sections : undefined),
238
+ effect: factory => factory(),
239
+ inject: (deps, callback) => {
240
+ for (const dep of deps) callback(child(dep))
241
+ },
242
+ on: (event, listener) => {
243
+ check('listens on loader/volatile-update', event === 'loader/volatile-update', event)
244
+ refresh = listener
245
+ return () => {}
246
+ },
247
+ // The plugin's own fiber, as the Loader reports it: the namespace source
248
+ // and the owner the page policy must attach to.
249
+ fiber: { entry: { options: { id: SETTINGS_NS } } },
250
+ logger,
251
+ }
252
+
253
+ const live = { current: rowConfig }
254
+ registerPinkSettings(
255
+ ctx,
256
+ {
257
+ cordis: { statusGlyph: '✿', statusSeparator: '·', showGlyph: true, showClock: true, showTurns: true, statusScope: 'pink-only', statusEnabled: true, followSystem: false },
258
+ readLive: () => readConfigValues(live.current),
259
+ hasLiveFields: hasLiveConfigFields(Config),
260
+ onDoc: doc => applied.push(doc),
261
+ },
262
+ join(probeRoot, 'data'),
263
+ )
264
+
265
+ check('the card takes the Loader entry id as its namespace', cards[0]?.ns === SETTINGS_NS, String(cards[0]?.ns))
266
+ check('no namespace registration is attempted', typeof service.register === 'undefined')
267
+ check(
268
+ 'page policy opts out of the auto page on the plugin fiber',
269
+ configured.length === 1 &&
270
+ configured[0].presentation.auto === false &&
271
+ configured[0].owner === ctx.fiber,
272
+ )
273
+ const cardPaths = (cards[0]?.fields ?? []).map(field => field.path.join('.')).sort()
274
+ check('card fields == live keys', JSON.stringify(cardPaths) === JSON.stringify(liveKeys), cardPaths.join(', '))
275
+ check('initial value comes from the live config', applied.at(-1)?.statusGlyph === '❀')
276
+ check('a healthy ≥0.1.7 host logs no warning', warns.length === 0, warns.join(' | '))
277
+
278
+ const edited = Config({
279
+ followSystem: false,
280
+ statusGlyph: '🌸',
281
+ statusSeparator: '·',
282
+ showGlyph: false,
283
+ showClock: true,
284
+ showTurns: false,
285
+ statusScope: 'pink-only',
286
+ })
287
+ // Model the loader's in-place rewrite: the plugin re-reads the same object.
288
+ for (const key of Object.keys(edited)) live.current[key] = edited[key]
289
+ refresh?.()
290
+ check(
291
+ 'a volatile update re-reads the edited config',
292
+ applied.at(-1)?.statusGlyph === '🌸' &&
293
+ applied.at(-1)?.showGlyph === false &&
294
+ applied.at(-1)?.showClock === true &&
295
+ applied.at(-1)?.followSystem === false,
296
+ JSON.stringify(applied.at(-1)),
297
+ )
298
+ } finally {
299
+ rmSync(probeRoot, { recursive: true, force: true })
300
+ }
301
+
302
+ if (failures.length > 0) {
303
+ console.error(`\n${failures.length} check(s) failed`)
304
+ process.exit(1)
305
+ }
306
+ console.log('\nall checks passed')