dsh-tui-theme 0.7.0 → 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.
@@ -240,3 +240,78 @@ assert.ok(followToast.text.includes('pink-day') && followToast.text.includes('re
240
240
  assert.equal(JSON.parse(readFileSync(join(dataDir3, 'theme.json'), 'utf8')).theme, 'pink-day', 'the follow pref write still happened')
241
241
  await mount3.fiber.dispose()
242
242
  console.log('OK toast phase 3: apply-time self-heal and boot-follow toasts delivered on the real host')
243
+
244
+ // ── Phase 4: settings namespace registers against the REAL service, late ────
245
+ // The stub suites cover the namespace registration with fakes, and phase 3
246
+ // stands in a minimal fake at apply time; this phase mounts a real
247
+ // @deepseek-ai/dsh-settings SettingsProvider AFTER the plugin (and after the
248
+ // extensions row), so the plugin's parked ['settings'] inject fires against
249
+ // the genuine register()/scope/watch machinery. The end-to-end signal: a
250
+ // user-layer followSystem write through the real service flips theme.json.
251
+ const sandbox4 = mkdtempSync(join(tmpdir(), 'pink-settings-late-'))
252
+ process.env.USERPROFILE = sandbox4
253
+ process.env.HOME = sandbox4
254
+ const dataDir4 = join(sandbox4, '.dsh-tui')
255
+ mkdirSync(dataDir4, { recursive: true })
256
+ writeFileSync(join(dataDir4, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }, null, 2))
257
+
258
+ const { SettingsProvider } = await import(pathToFileURL(hostRequire.resolve('@deepseek-ai/dsh-settings')).href)
259
+
260
+ /** Minimal concrete provider: in-memory storage, no file, no background IO. */
261
+ class MemorySettingsProvider extends SettingsProvider {
262
+ static provide = 'settings'
263
+ writable = true
264
+ stored = {}
265
+ async load() {
266
+ return { ...this.stored }
267
+ }
268
+
269
+ async persist(ns, section) {
270
+ this.stored[ns] = { ...section }
271
+ }
272
+ }
273
+
274
+ const app4 = new Context()
275
+ await app4.plugin(pluginHost.default ?? pluginHost)
276
+ const mount4 = await mountPlugin(app4)
277
+ await mount4.context.plugin(pink)
278
+ await app4.plugin(extensions.default ?? extensions)
279
+ // Deliberately last: the namespace registration can only be "late" when the
280
+ // service arrives after everything the plugin injects at apply time.
281
+ await app4.plugin(MemorySettingsProvider)
282
+
283
+ const provider = app4.get('settings')
284
+ assert.ok(provider, 'the real dsh-settings provider must be mounted')
285
+ const settingsDeadline = Date.now() + 5_000
286
+ while (provider.get(SETTINGS_NAMESPACE) === undefined && Date.now() < settingsDeadline) {
287
+ await sleep(25)
288
+ }
289
+ assert.ok(
290
+ provider.get(SETTINGS_NAMESPACE),
291
+ `the plugin namespace must register with the real service within ${5_000}ms of its late arrival`,
292
+ )
293
+ await provider.update(SETTINGS_NAMESPACE, { followSystem: true })
294
+ assert.equal(
295
+ provider.get(SETTINGS_NAMESPACE)?.followSystem,
296
+ true,
297
+ 'the real service must resolve the committed user layer for the namespace',
298
+ )
299
+
300
+ const prefPath4 = join(dataDir4, 'theme.json')
301
+ const followDeadline = Date.now() + 5_000
302
+ let followed = false
303
+ while (!followed && Date.now() < followDeadline) {
304
+ try {
305
+ followed = JSON.parse(readFileSync(prefPath4, 'utf8')).theme === 'pink-day'
306
+ } catch {
307
+ // Pref not written yet.
308
+ }
309
+ if (!followed) await sleep(25)
310
+ }
311
+ assert.equal(
312
+ followed,
313
+ true,
314
+ 'the user-layer follow toggle must apply the cached light background through the real service',
315
+ )
316
+ await mount4.fiber.dispose()
317
+ console.log('OK settings: namespace registers late on the real dsh-settings service and drives the follow pref')
@@ -105,6 +105,13 @@ assert.deepEqual(
105
105
  // guarantee, so the count is reported instead of pinned to a hardcoded floor.
106
106
  console.log(`* host Theme key count: ${allKeys.length} (coverage asserted per key)`)
107
107
 
108
+ // The color keys the host's /settings screen paints with (screens/Settings.tsx
109
+ // at the pinned baseline: promptBorder card frame, permission card title,
110
+ // selectionBg focused row, suggestion/error/success/inactive/subtle row text
111
+ // and checkbox chips, warning badges). Maintained by hand because the screen
112
+ // has no exported key manifest; revisit this list whenever a dsh-TUI release
113
+ // adds a Theme key to that screen — the per-key coverage assertion below then
114
+ // fails loudly instead of shipping an unreadable settings card.
108
115
  const settingsKeys = [
109
116
  'promptBorder',
110
117
  'selectionBg',
@@ -7,9 +7,14 @@ import { existsSync, readFileSync } from 'node:fs'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import assert from 'node:assert/strict'
9
9
 
10
+ // This repo deliberately ships no package-lock.json: the pinned host tarball
11
+ // bundles @dsh-std/* packages whose manifests still declare workspace:*, and
12
+ // npm replays a committed lockfile faithfully — every install (npm ci too)
13
+ // dies with EUNSUPPORTEDPROTOCOL. Removing the lockfile makes npm skip the
14
+ // bundled directories instead. The peer/dev consistency checks below therefore
15
+ // read package.json only; CI's contract job guards the deletion (REVIEW.md G1).
10
16
  const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
11
17
  const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
12
- const lockfile = JSON.parse(readFileSync(new URL('../package-lock.json', import.meta.url), 'utf8'))
13
18
  const packCommand = process.platform === 'win32'
14
19
  ? { file: 'cmd.exe', args: ['/d', '/s', '/c', 'npm pack --dry-run --json --ignore-scripts'] }
15
20
  : { file: 'npm', args: ['pack', '--dry-run', '--json', '--ignore-scripts'] }
@@ -36,6 +41,8 @@ for (const required of [
36
41
  'lib/types/pluginId.d.ts',
37
42
  'lib/types/settingsSection.js',
38
43
  'lib/types/settingsSection.d.ts',
44
+ 'lib/types/liveConfig.js',
45
+ 'lib/types/liveConfig.d.ts',
39
46
  'lib/types/statusLine.js',
40
47
  'lib/types/statusLine.d.ts',
41
48
  'lib/types/themeAssets.js',
@@ -58,6 +65,7 @@ for (const required of [
58
65
  'docs/screenshots/settings.png',
59
66
  'scripts/verify.mjs',
60
67
  'scripts/verify-package.mjs',
68
+ 'scripts/verify-settings-generation.mjs',
61
69
  'scripts/headless-order-test.mjs',
62
70
  'scripts/runtime-themes-headless.mjs',
63
71
  'scripts/validate-themes-against-host.mjs',
@@ -90,12 +98,10 @@ for (const name of Object.keys(packageJson.peerDependencies)) {
90
98
  assert.equal(devAccepted, true, `${name} dev pin must be accepted by its peer range`)
91
99
  }
92
100
  assert.equal(packageJson.dependencies?.['@deepseek-ai/schemastery'], undefined)
93
- const rootLock = lockfile.packages?.['']
94
- assert.ok(rootLock, 'lockfile must have root metadata')
95
- assert.equal(lockfile.version, packageJson.version)
96
- assert.deepEqual(rootLock.devDependencies, packageJson.devDependencies)
97
- assert.deepEqual(rootLock.peerDependencies, packageJson.peerDependencies)
98
- assert.equal(rootLock.dependencies, undefined)
101
+ // The lockfile's root metadata is deliberately not compared here: this script
102
+ // also ships inside the published tarball (package.json `files`), and npm never
103
+ // packs a lockfile, so the check could not hold for a consumer. The repo-level
104
+ // lockfile invariant lives in the CI contract job instead (REVIEW.md G1).
99
105
  assert.equal(existsSync(new URL('../lib/types/index.js', import.meta.url)), true)
100
106
 
101
107
  console.log(`✓ package manifest: ${packed.name}@${packed.version}, ${files.size} files`)
@@ -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')