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.
- package/README.md +6 -0
- package/docs/decisions/2026-09-26-settings-generation-adaptation.md +61 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +67 -39
- package/lib/types/liveConfig.d.ts +88 -0
- package/lib/types/liveConfig.d.ts.map +1 -0
- package/lib/types/liveConfig.js +151 -0
- package/lib/types/settingsSection.d.ts +73 -15
- package/lib/types/settingsSection.d.ts.map +1 -1
- package/lib/types/settingsSection.js +214 -52
- package/package.json +3 -2
- package/scripts/verify-package.mjs +3 -0
- package/scripts/verify-settings-generation.mjs +306 -0
- package/scripts/verify.mjs +1573 -1289
|
@@ -2,12 +2,34 @@
|
|
|
2
2
|
* /settings integration (seam six: tuiSettingsSections over the dsh settings
|
|
3
3
|
* service).
|
|
4
4
|
*
|
|
5
|
-
* Registers the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* @param
|
|
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,
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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.
|
|
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')
|