dsh-tui-theme 0.5.1 → 0.6.1
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 +25 -6
- package/lib/types/autoTheme.d.ts +14 -1
- package/lib/types/autoTheme.d.ts.map +1 -1
- package/lib/types/autoTheme.js +10 -5
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +90 -8
- package/lib/types/runtimeThemes.d.ts +9 -0
- package/lib/types/runtimeThemes.d.ts.map +1 -0
- package/lib/types/runtimeThemes.js +39 -0
- package/lib/types/settingsSection.d.ts.map +1 -1
- package/lib/types/statusLine.d.ts +15 -2
- package/lib/types/statusLine.d.ts.map +1 -1
- package/lib/types/statusLine.js +46 -9
- package/lib/types/themeAssets.d.ts +23 -0
- package/lib/types/themeAssets.d.ts.map +1 -1
- package/lib/types/themeAssets.js +126 -12
- package/lib/types/toast.d.ts +40 -0
- package/lib/types/toast.d.ts.map +1 -0
- package/lib/types/toast.js +91 -0
- package/package.json +47 -5
- package/scripts/headless-order-test.mjs +23 -11
- package/scripts/runtime-themes-headless.mjs +180 -0
- package/scripts/validate-themes-against-host.mjs +22 -14
- package/scripts/verify-package.mjs +14 -1
- package/scripts/verify.mjs +396 -20
package/lib/types/themeAssets.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* contained per file: a theme garnish must never break the TUI's boot.
|
|
11
11
|
* @module dsh-tui-theme/themeAssets
|
|
12
12
|
*/
|
|
13
|
-
import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
@@ -27,6 +27,51 @@ export function bundledThemesDir() {
|
|
|
27
27
|
export function themesTargetDir() {
|
|
28
28
|
return join(homeDir(), '.dsh-tui', 'themes');
|
|
29
29
|
}
|
|
30
|
+
/** Read bundled descriptors for hosts that support runtime theme registration. */
|
|
31
|
+
export function readBundledThemes(sourceDir = bundledThemesDir()) {
|
|
32
|
+
let files;
|
|
33
|
+
try {
|
|
34
|
+
files = readdirSync(sourceDir).filter(entry => entry.toLowerCase().endsWith('.json'));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
console.warn(`dsh-tui-theme: could not read bundled themes from ${sourceDir}: ${String(error)}`);
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const themes = [];
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
try {
|
|
43
|
+
const value = JSON.parse(readFileSync(join(sourceDir, file), 'utf8'));
|
|
44
|
+
const name = value.name;
|
|
45
|
+
const base = value.base;
|
|
46
|
+
const colors = value.colors;
|
|
47
|
+
if (typeof name !== 'string' ||
|
|
48
|
+
!/^[a-z][a-z0-9_-]*(?::[a-z][a-z0-9_-]*)?$/u.test(name) ||
|
|
49
|
+
(base !== 'light' && base !== 'dark' && base !== 'dark-ansi') ||
|
|
50
|
+
colors === null ||
|
|
51
|
+
typeof colors !== 'object' ||
|
|
52
|
+
Array.isArray(colors)) {
|
|
53
|
+
throw new Error('invalid theme descriptor');
|
|
54
|
+
}
|
|
55
|
+
const normalizedColors = {};
|
|
56
|
+
for (const [key, color] of Object.entries(colors)) {
|
|
57
|
+
if (typeof color !== 'string')
|
|
58
|
+
throw new Error(`invalid color for ${key}`);
|
|
59
|
+
normalizedColors[key] = color;
|
|
60
|
+
}
|
|
61
|
+
themes.push({
|
|
62
|
+
file,
|
|
63
|
+
name,
|
|
64
|
+
...(typeof value.displayName === 'string' ? { displayName: value.displayName } : {}),
|
|
65
|
+
base,
|
|
66
|
+
colors: normalizedColors,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
console.warn(`dsh-tui-theme: skipping bundled theme ${file}: ${String(error)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return themes;
|
|
74
|
+
}
|
|
30
75
|
/**
|
|
31
76
|
* Install every bundled theme JSON that the target directory is missing.
|
|
32
77
|
* @param targetDir - Destination directory (defaults to ~/.dsh-tui/themes).
|
|
@@ -57,8 +102,11 @@ export function installBundledThemes(targetDir = themesTargetDir(), sourceDir =
|
|
|
57
102
|
}
|
|
58
103
|
catch (error) {
|
|
59
104
|
if (error.code === 'EEXIST') {
|
|
60
|
-
|
|
105
|
+
const healed = healCorruptTarget(target, text);
|
|
106
|
+
if (healed === 'repaired')
|
|
61
107
|
repaired.push(file);
|
|
108
|
+
else if (healed === 'failed')
|
|
109
|
+
failed.push(file);
|
|
62
110
|
else
|
|
63
111
|
skipped.push(file);
|
|
64
112
|
}
|
|
@@ -73,14 +121,73 @@ export function installBundledThemes(targetDir = themesTargetDir(), sourceDir =
|
|
|
73
121
|
}
|
|
74
122
|
return { installed, skipped, repaired, failed };
|
|
75
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Remove files that this activation installed when a runtime theme service
|
|
126
|
+
* becomes available. A byte-for-byte check preserves edits made in the small
|
|
127
|
+
* window between installation and runtime confirmation.
|
|
128
|
+
*/
|
|
129
|
+
export function removeBundledThemes(files, targetDir = themesTargetDir(), sourceDir = bundledThemesDir()) {
|
|
130
|
+
const removed = [];
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
try {
|
|
133
|
+
const bundled = readFileSync(join(sourceDir, file), 'utf8');
|
|
134
|
+
const target = join(targetDir, file);
|
|
135
|
+
if (readFileSync(target, 'utf8') !== bundled)
|
|
136
|
+
continue;
|
|
137
|
+
unlinkSync(target);
|
|
138
|
+
removed.push(file);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Runtime registration must remain best-effort if a file disappears or
|
|
142
|
+
// the target directory becomes unavailable during teardown.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (removed.length > 0) {
|
|
146
|
+
try {
|
|
147
|
+
if (readdirSync(targetDir).length === 0)
|
|
148
|
+
rmdirSync(targetDir);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// An empty directory is only a cosmetic artifact; leave it in place if
|
|
152
|
+
// it was removed concurrently or is no longer empty.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return removed;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Bundled theme files present in the target directory byte-for-byte
|
|
159
|
+
* identical to the bundled copy. On runtime-theme hosts such files shadow
|
|
160
|
+
* the registry while adding nothing, so the entry point can point them out
|
|
161
|
+
* once. User-edited or foreign same-named files are never reported — the
|
|
162
|
+
* never-overwrite rule keeps them, and no toast may nag about them.
|
|
163
|
+
*/
|
|
164
|
+
export function findShadowedBundledThemes(targetDir = themesTargetDir(), sourceDir = bundledThemesDir()) {
|
|
165
|
+
const shadowed = [];
|
|
166
|
+
let files;
|
|
167
|
+
try {
|
|
168
|
+
files = readdirSync(sourceDir).filter(entry => entry.toLowerCase().endsWith('.json'));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return shadowed;
|
|
172
|
+
}
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
try {
|
|
175
|
+
if (readFileSync(join(targetDir, file), 'utf8') === readFileSync(join(sourceDir, file), 'utf8')) {
|
|
176
|
+
shadowed.push(file);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Missing or unreadable target: not a shadow.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return shadowed;
|
|
184
|
+
}
|
|
76
185
|
/**
|
|
77
186
|
* Self-heal an existing target that fails to parse as JSON — the leftover of
|
|
78
187
|
* a torn write from an interrupted installation. The damaged file is kept as
|
|
79
|
-
* <target>.corrupt-<timestamp> and the bundled copy installed
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* corrupt), or when the backup/replace itself fails (degrade to the plain
|
|
83
|
-
* silent skip).
|
|
188
|
+
* <target>.corrupt-<timestamp>-<pid>-<random> and the bundled copy installed
|
|
189
|
+
* fresh. Returns `skipped` when the target is valid JSON or unreadable, and
|
|
190
|
+
* `failed` when the corrupt target was moved but replacement could not finish.
|
|
84
191
|
*/
|
|
85
192
|
function healCorruptTarget(target, text) {
|
|
86
193
|
let existing;
|
|
@@ -88,21 +195,28 @@ function healCorruptTarget(target, text) {
|
|
|
88
195
|
existing = readFileSync(target, 'utf8');
|
|
89
196
|
}
|
|
90
197
|
catch {
|
|
91
|
-
return
|
|
198
|
+
return 'skipped';
|
|
92
199
|
}
|
|
93
200
|
try {
|
|
94
201
|
JSON.parse(existing);
|
|
95
|
-
return
|
|
202
|
+
return 'skipped';
|
|
96
203
|
}
|
|
97
204
|
catch {
|
|
98
205
|
// Proven corrupt: fall through to backup and reinstall.
|
|
99
206
|
}
|
|
207
|
+
let backup;
|
|
208
|
+
try {
|
|
209
|
+
backup = `${target}.corrupt-${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`;
|
|
210
|
+
renameSync(target, backup);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return 'skipped';
|
|
214
|
+
}
|
|
100
215
|
try {
|
|
101
|
-
renameSync(target, `${target}.corrupt-${Date.now()}`);
|
|
102
216
|
writeFileSync(target, text, { flag: 'wx' });
|
|
103
|
-
return
|
|
217
|
+
return 'repaired';
|
|
104
218
|
}
|
|
105
219
|
catch {
|
|
106
|
-
return
|
|
220
|
+
return 'failed';
|
|
107
221
|
}
|
|
108
222
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toast relay for the plugin-facing transient-notification seam
|
|
3
|
+
* (dsh-TUI 0.10: `ctx.tuiToast`).
|
|
4
|
+
*
|
|
5
|
+
* The plugin's own logger output is invisible to a TUI user, so the few
|
|
6
|
+
* events worth surfacing — a follow-system pref write, a repaired theme
|
|
7
|
+
* file, a shadowing legacy static install — go through the host's toast
|
|
8
|
+
* surface instead. Sending is fire-and-forget: hosts without the seam
|
|
9
|
+
* (dsh-TUI < 0.10) never fire the inject and every send is a silent no-op,
|
|
10
|
+
* exactly like the other soft-probed seams.
|
|
11
|
+
*
|
|
12
|
+
* The host registers its toast sink late (the dsh-tui row applies after the
|
|
13
|
+
* extensions row, and both may trail this plugin), so a toast dropped for
|
|
14
|
+
* having no sink is retried on a short schedule before giving up. The
|
|
15
|
+
* tuiToast service itself arrives with the extensions row, so a send fired
|
|
16
|
+
* before the seam even exists waits on the same schedule. Retries are
|
|
17
|
+
* bounded, unref'd, and cleared with the activation; a toast lost to a host
|
|
18
|
+
* rate limit is not worth fighting — the next real event re-reports.
|
|
19
|
+
* @module dsh-tui-theme/toast
|
|
20
|
+
*/
|
|
21
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
22
|
+
/** Host toast color vocabulary (neutral when omitted). */
|
|
23
|
+
export type ToastColor = 'success' | 'warning' | 'error';
|
|
24
|
+
/** Send one transient notification; resolves to whether it was delivered. */
|
|
25
|
+
export type ToastSend = (text: string, color?: ToastColor) => boolean;
|
|
26
|
+
/**
|
|
27
|
+
* @internal Shrink the retry schedule for hermetic tests (verify.mjs only;
|
|
28
|
+
* not part of the plugin's behavioral contract).
|
|
29
|
+
*/
|
|
30
|
+
export declare function setToastRetryDelaysForTests(delays: readonly number[]): void;
|
|
31
|
+
/**
|
|
32
|
+
* Start the toast relay. Returns a sender that is callable immediately and
|
|
33
|
+
* from any later context (settings watches, timers): `show()` binds through
|
|
34
|
+
* the service's own activation, so unlike `tuiStatus.set` it takes no
|
|
35
|
+
* identity argument — but the service object itself must still be read
|
|
36
|
+
* inside the inject (same rule as every other seam).
|
|
37
|
+
* @param ctx - The plugin's own activation context.
|
|
38
|
+
*/
|
|
39
|
+
export declare function startToastRelay(ctx: Context): ToastSend;
|
|
40
|
+
//# sourceMappingURL=toast.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"toast.d.ts","sourceRoot":"","sources":["../../src/toast.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,0DAA0D;AAC1D,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAA;AAUxD,6EAA6E;AAC7E,MAAM,MAAM,SAAS,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,UAAU,KAAK,OAAO,CAAA;AAMrE;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAE3E;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,CAoDvD"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toast relay for the plugin-facing transient-notification seam
|
|
3
|
+
* (dsh-TUI 0.10: `ctx.tuiToast`).
|
|
4
|
+
*
|
|
5
|
+
* The plugin's own logger output is invisible to a TUI user, so the few
|
|
6
|
+
* events worth surfacing — a follow-system pref write, a repaired theme
|
|
7
|
+
* file, a shadowing legacy static install — go through the host's toast
|
|
8
|
+
* surface instead. Sending is fire-and-forget: hosts without the seam
|
|
9
|
+
* (dsh-TUI < 0.10) never fire the inject and every send is a silent no-op,
|
|
10
|
+
* exactly like the other soft-probed seams.
|
|
11
|
+
*
|
|
12
|
+
* The host registers its toast sink late (the dsh-tui row applies after the
|
|
13
|
+
* extensions row, and both may trail this plugin), so a toast dropped for
|
|
14
|
+
* having no sink is retried on a short schedule before giving up. The
|
|
15
|
+
* tuiToast service itself arrives with the extensions row, so a send fired
|
|
16
|
+
* before the seam even exists waits on the same schedule. Retries are
|
|
17
|
+
* bounded, unref'd, and cleared with the activation; a toast lost to a host
|
|
18
|
+
* rate limit is not worth fighting — the next real event re-reports.
|
|
19
|
+
* @module dsh-tui-theme/toast
|
|
20
|
+
*/
|
|
21
|
+
import { AsyncResource } from 'node:async_hooks';
|
|
22
|
+
/** Drop-retry schedule (ms). The first delivery attempt is always immediate. */
|
|
23
|
+
const RETRY_DELAYS_MS = [2_000, 4_000];
|
|
24
|
+
let retryDelays = RETRY_DELAYS_MS;
|
|
25
|
+
/**
|
|
26
|
+
* @internal Shrink the retry schedule for hermetic tests (verify.mjs only;
|
|
27
|
+
* not part of the plugin's behavioral contract).
|
|
28
|
+
*/
|
|
29
|
+
export function setToastRetryDelaysForTests(delays) {
|
|
30
|
+
retryDelays = delays;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Start the toast relay. Returns a sender that is callable immediately and
|
|
34
|
+
* from any later context (settings watches, timers): `show()` binds through
|
|
35
|
+
* the service's own activation, so unlike `tuiStatus.set` it takes no
|
|
36
|
+
* identity argument — but the service object itself must still be read
|
|
37
|
+
* inside the inject (same rule as every other seam).
|
|
38
|
+
* @param ctx - The plugin's own activation context.
|
|
39
|
+
*/
|
|
40
|
+
export function startToastRelay(ctx) {
|
|
41
|
+
const pending = new Set();
|
|
42
|
+
let show;
|
|
43
|
+
const clearPending = () => {
|
|
44
|
+
for (const timer of pending)
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
pending.clear();
|
|
47
|
+
};
|
|
48
|
+
ctx.inject(['tuiToast'], toastCtx => {
|
|
49
|
+
const toast = toastCtx.tuiToast;
|
|
50
|
+
// show() resolves its caller through the ambient Cordis activation, so a
|
|
51
|
+
// call from a foreign async scope (a settings watch, a host timer of
|
|
52
|
+
// another seam) is refused even though this plugin is alive. Capture the
|
|
53
|
+
// activation scope here — inside the inject, where it is ambient — and
|
|
54
|
+
// re-enter it for every send, exactly like the inject-created interval
|
|
55
|
+
// that keeps the status line rendering.
|
|
56
|
+
const scope = new AsyncResource('dsh-tui-theme-toast');
|
|
57
|
+
show = (text, options) => {
|
|
58
|
+
try {
|
|
59
|
+
return scope.runInAsyncScope(() => toast.show(text, options));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// A hostile or tearing-down host must never propagate into the caller.
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
toastCtx.effect(() => () => {
|
|
67
|
+
show = undefined;
|
|
68
|
+
clearPending();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
ctx.effect(() => () => {
|
|
72
|
+
show = undefined;
|
|
73
|
+
clearPending();
|
|
74
|
+
});
|
|
75
|
+
const attempt = (text, color, depth) => {
|
|
76
|
+
// A seam that has not arrived yet (the extensions row applies after this
|
|
77
|
+
// plugin) retries on the same bounded schedule as a dropped send.
|
|
78
|
+
if (show !== undefined && show(text, color === undefined ? {} : { color }))
|
|
79
|
+
return true;
|
|
80
|
+
if (depth >= retryDelays.length)
|
|
81
|
+
return false;
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
pending.delete(timer);
|
|
84
|
+
attempt(text, color, depth + 1);
|
|
85
|
+
}, retryDelays[depth]);
|
|
86
|
+
timer.unref?.();
|
|
87
|
+
pending.add(timer);
|
|
88
|
+
return false;
|
|
89
|
+
};
|
|
90
|
+
return (text, color) => attempt(text, color, 0);
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tui-theme",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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,7 +28,7 @@
|
|
|
28
28
|
"preverify": "npm run build",
|
|
29
29
|
"verify": "node scripts/verify.mjs",
|
|
30
30
|
"verify:package": "node scripts/verify-package.mjs",
|
|
31
|
-
"verify:host": "npm run build && node scripts/headless-order-test.mjs && node --import tsx/esm scripts/validate-themes-against-host.mjs",
|
|
31
|
+
"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
32
|
"release:check": "npm run build && npm run verify && npm run verify:package",
|
|
33
33
|
"prepack": "npm run release:check"
|
|
34
34
|
},
|
|
@@ -64,11 +64,53 @@
|
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
67
|
-
"@deepseek-ai/dsh-session": "
|
|
68
|
-
"@deepseek-ai/dsh-settings": "
|
|
67
|
+
"@deepseek-ai/dsh-session": "0.1.2-alpha.2",
|
|
68
|
+
"@deepseek-ai/dsh-settings": "0.1.2-alpha.2",
|
|
69
69
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
70
|
+
"@deepseek-harness-tui/dsh-tui": "0.10.0-beta.4",
|
|
70
71
|
"@types/node": "^22.0.0",
|
|
71
72
|
"tsx": "^4.23.12",
|
|
72
|
-
"typescript": "^6.0.3"
|
|
73
|
+
"typescript": "^6.0.3",
|
|
74
|
+
"@deepseek-ai/dsh-llm": "0.1.2-alpha.2",
|
|
75
|
+
"@deepseek-ai/dsh-agent": "0.1.2-alpha.2",
|
|
76
|
+
"@deepseek-ai/dsh-user-questions": "0.1.2-alpha.2",
|
|
77
|
+
"@deepseek-ai/dsh-user-approval": "0.1.2-alpha.2",
|
|
78
|
+
"@deepseek-ai/dsh-commands": "0.1.2-alpha.2",
|
|
79
|
+
"@deepseek-ai/dsh-atomic-write": "0.1.2-alpha.2",
|
|
80
|
+
"@deepseek-ai/dsh-tool-ask-user": "0.1.2-alpha.2",
|
|
81
|
+
"@deepseek-ai/dsh-skill": "0.1.2-alpha.2",
|
|
82
|
+
"@deepseek-ai/dsh-agent-instructions": "0.1.2-alpha.2",
|
|
83
|
+
"@deepseek-ai/dsh-tools": "0.1.2-alpha.2",
|
|
84
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.2",
|
|
85
|
+
"@deepseek-ai/dsh-invariants": "0.1.2-alpha.2"
|
|
86
|
+
},
|
|
87
|
+
"overrides": {
|
|
88
|
+
"@deepseek-ai/dsh-session": "0.1.2-alpha.2",
|
|
89
|
+
"@deepseek-ai/dsh-settings": "0.1.2-alpha.2",
|
|
90
|
+
"@deepseek-ai/dsh-llm": "0.1.2-alpha.2",
|
|
91
|
+
"@deepseek-ai/dsh-agent": "0.1.2-alpha.2",
|
|
92
|
+
"@deepseek-ai/dsh-user-questions": "0.1.2-alpha.2",
|
|
93
|
+
"@deepseek-ai/dsh-user-approval": "0.1.2-alpha.2",
|
|
94
|
+
"@deepseek-ai/dsh-commands": "0.1.2-alpha.2",
|
|
95
|
+
"@deepseek-ai/dsh-atomic-write": "0.1.2-alpha.2",
|
|
96
|
+
"@deepseek-ai/dsh-tool-ask-user": "0.1.2-alpha.2",
|
|
97
|
+
"@deepseek-ai/dsh-skill": "0.1.2-alpha.2",
|
|
98
|
+
"@deepseek-ai/dsh-agent-instructions": "0.1.2-alpha.2",
|
|
99
|
+
"@deepseek-ai/dsh-tools": "0.1.2-alpha.2",
|
|
100
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.2",
|
|
101
|
+
"@deepseek-ai/dsh-invariants": "0.1.2-alpha.2",
|
|
102
|
+
"@deepseek-ai/dsh-scope": "0.1.2-alpha.2",
|
|
103
|
+
"@deepseek-ai/dsh-attachment": "0.1.2-alpha.2",
|
|
104
|
+
"@deepseek-ai/dsh-brand": "0.1.2-alpha.2",
|
|
105
|
+
"@deepseek-ai/dsh-typert-protocol": "0.1.2-alpha.2",
|
|
106
|
+
"@deepseek-ai/dsh-timeout": "0.1.2-alpha.2",
|
|
107
|
+
"@deepseek-ai/dsh-session-projection": "0.1.2-alpha.2",
|
|
108
|
+
"@deepseek-ai/dsh-code-runtime": "0.1.2-alpha.2",
|
|
109
|
+
"@deepseek-ai/dsh-fs": "0.1.2-alpha.2",
|
|
110
|
+
"@deepseek-ai/dsh-home-paths": "0.1.2-alpha.2",
|
|
111
|
+
"@deepseek-ai/dsh-sandbox": "0.1.2-alpha.2"
|
|
112
|
+
},
|
|
113
|
+
"allowScripts": {
|
|
114
|
+
"esbuild@0.28.2": true
|
|
73
115
|
}
|
|
74
116
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* End-to-end order test with the installed dsh-TUI services. Composes this
|
|
3
3
|
* plugin before the extension services to exercise late service injection.
|
|
4
4
|
*
|
|
5
|
-
* DSH_TUI_ADAPTER_DIR
|
|
5
|
+
* DSH_TUI_ADAPTER_DIR may point at dsh-TUI's lib/types/dsh-adapter directory.
|
|
6
|
+
* When omitted, the installed devDependency is used.
|
|
6
7
|
* Script-side floor: the adapter must be a built dsh-TUI >= 0.9.0 — the
|
|
7
8
|
* settings-sections module and its getHostSettingsSections probe landed there.
|
|
8
9
|
* Set DSH_TUI_EXPECTED_VERSION only when an explicit release baseline needs
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
*/
|
|
11
12
|
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
12
13
|
import { tmpdir } from 'node:os'
|
|
13
|
-
import { join } from 'node:path'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
14
15
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
15
16
|
import { createRequire } from 'node:module'
|
|
16
17
|
import assert from 'node:assert/strict'
|
|
@@ -23,10 +24,10 @@ import { assertSettingsContract, SETTINGS_NAMESPACE } from './expected-settings-
|
|
|
23
24
|
const STATUS_CONTRIBUTION_KEY = SETTINGS_NAMESPACE
|
|
24
25
|
|
|
25
26
|
const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
const req = createRequire(import.meta.url)
|
|
28
|
+
const packageJson = req.resolve('@deepseek-harness-tui/dsh-tui/package.json')
|
|
29
|
+
const devHostRoot = dirname(packageJson)
|
|
30
|
+
const adapter = process.env.DSH_TUI_ADAPTER_DIR || join(devHostRoot, 'lib', 'types', 'dsh-adapter')
|
|
30
31
|
|
|
31
32
|
if (!existsSync(join(adapter, 'extensions.js'))) {
|
|
32
33
|
throw new Error(`dsh-TUI adapter not found at ${adapter}`)
|
|
@@ -63,17 +64,28 @@ if (!existsSync(settingsSectionsPath)) {
|
|
|
63
64
|
)
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
const
|
|
67
|
-
const { Context } = await import(pathToFileURL(
|
|
67
|
+
const hostRequire = createRequire(join(adapter, 'extensions.js'))
|
|
68
|
+
const { Context } = await import(pathToFileURL(hostRequire.resolve('@deepseek-ai/cordis')).href)
|
|
68
69
|
const extensions = await import(pathToFileURL(join(adapter, 'extensions.js')).href)
|
|
69
|
-
const ledgerModule = await import(pathToFileURL(join(adapter, 'effect-ledger.js')).href)
|
|
70
70
|
const statusModule = await import(pathToFileURL(join(adapter, 'status.js')).href)
|
|
71
71
|
const settingsSectionsModule = await import(pathToFileURL(settingsSectionsPath).href)
|
|
72
|
+
const pluginHostModule = await import(pathToFileURL(join(adapter, 'plugin-host.js')).href)
|
|
72
73
|
const pink = await import(pathToFileURL(join(pluginRoot, 'lib', 'types', 'index.js')).href)
|
|
73
74
|
|
|
74
75
|
const app = new Context()
|
|
75
|
-
await app.plugin(
|
|
76
|
-
|
|
76
|
+
await app.plugin(pluginHostModule.default ?? pluginHostModule)
|
|
77
|
+
const testUtilsPath = join(adapter, '..', 'test-utils.js')
|
|
78
|
+
if (existsSync(testUtilsPath)) {
|
|
79
|
+
const testUtils = await import(pathToFileURL(testUtilsPath).href)
|
|
80
|
+
const manifest = testUtils.testManifest({ id: SETTINGS_NAMESPACE })
|
|
81
|
+
const admitted = await testUtils.mountAdmitted(app, SETTINGS_NAMESPACE, manifest)
|
|
82
|
+
await admitted.context.plugin(pink)
|
|
83
|
+
} else {
|
|
84
|
+
// dsh-TUI < 0.10 has no public admission test helpers; keep the historical
|
|
85
|
+
// manual mount and report the structural limitation instead of hiding it.
|
|
86
|
+
console.log('* mountAdmitted unavailable on this host; using manual plugin mount')
|
|
87
|
+
await app.plugin(pink)
|
|
88
|
+
}
|
|
77
89
|
await app.plugin(extensions.default ?? extensions)
|
|
78
90
|
await app.plugin(settingsSectionsModule.default ?? settingsSectionsModule)
|
|
79
91
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only integration check for dsh-TUI 0.10 runtime theme registration.
|
|
3
|
+
* Uses the installed host adapter and the real admission path; no user HOME
|
|
4
|
+
* or static theme files are touched.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { createRequire } from 'node:module'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
|
|
13
|
+
const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
14
|
+
const req = createRequire(import.meta.url)
|
|
15
|
+
const hostPackagePath = req.resolve('@deepseek-harness-tui/dsh-tui/package.json')
|
|
16
|
+
const hostRoot = dirname(hostPackagePath)
|
|
17
|
+
const adapter = process.env.DSH_TUI_ADAPTER_DIR || join(hostRoot, 'lib', 'types', 'dsh-adapter')
|
|
18
|
+
if (!existsSync(join(adapter, 'extensions.js'))) {
|
|
19
|
+
throw new Error(`dsh-TUI runtime adapter not found at ${adapter}`)
|
|
20
|
+
}
|
|
21
|
+
if (!existsSync(join(adapter, 'themes.js'))) {
|
|
22
|
+
console.log('* runtime theme seam unavailable on this host; skipping 0.10 headless check')
|
|
23
|
+
process.exit(0)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sandbox = mkdtempSync(join(tmpdir(), 'pink-runtime-themes-'))
|
|
27
|
+
process.env.USERPROFILE = sandbox
|
|
28
|
+
process.env.HOME = sandbox
|
|
29
|
+
const dataDir = join(sandbox, '.dsh-tui')
|
|
30
|
+
const staticThemes = join(dataDir, 'themes')
|
|
31
|
+
mkdirSync(dataDir, { recursive: true })
|
|
32
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
33
|
+
|
|
34
|
+
const hostRequire = createRequire(join(adapter, 'extensions.js'))
|
|
35
|
+
const { Context } = await import(pathToFileURL(hostRequire.resolve('@deepseek-ai/cordis')).href)
|
|
36
|
+
const pluginHost = await import(pathToFileURL(join(adapter, 'plugin-host.js')).href)
|
|
37
|
+
const extensions = await import(pathToFileURL(join(adapter, 'extensions.js')).href)
|
|
38
|
+
const themesModule = await import(pathToFileURL(join(adapter, 'themes.js')).href)
|
|
39
|
+
const themeModule = await import(pathToFileURL(join(adapter, '..', 'theme.js')).href)
|
|
40
|
+
const testUtils = await import(pathToFileURL(join(adapter, '..', 'test-utils.js')).href)
|
|
41
|
+
const pink = await import(pathToFileURL(join(pluginRoot, 'lib', 'types', 'index.js')).href)
|
|
42
|
+
const { SETTINGS_NAMESPACE } = await import(pathToFileURL(join(pluginRoot, 'scripts', 'expected-settings-contract.mjs')).href)
|
|
43
|
+
|
|
44
|
+
const app = new Context()
|
|
45
|
+
await app.plugin(pluginHost.default ?? pluginHost)
|
|
46
|
+
const manifest = testUtils.testManifest({ id: SETTINGS_NAMESPACE })
|
|
47
|
+
const admitted = await testUtils.mountAdmitted(app, SETTINGS_NAMESPACE, manifest)
|
|
48
|
+
await admitted.context.plugin(pink)
|
|
49
|
+
await app.plugin(extensions.default ?? extensions)
|
|
50
|
+
|
|
51
|
+
const host = themesModule.getHostThemes(app.get('tuiThemes'))
|
|
52
|
+
assert.ok(host, 'runtime theme host must be mounted')
|
|
53
|
+
const deadline = Date.now() + 5_000
|
|
54
|
+
while (host.getSnapshot().length !== 3 && Date.now() < deadline) {
|
|
55
|
+
await testUtils.sleep(25)
|
|
56
|
+
}
|
|
57
|
+
const snapshot = host.getSnapshot()
|
|
58
|
+
assert.deepEqual(snapshot.map(entry => entry.name).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
|
|
59
|
+
|
|
60
|
+
for (const entry of snapshot) {
|
|
61
|
+
const expected = JSON.parse(readFileSync(join(pluginRoot, 'themes', `${entry.name}.json`), 'utf8'))
|
|
62
|
+
assert.equal(entry.displayName, expected.displayName)
|
|
63
|
+
assert.equal(entry.base, expected.base)
|
|
64
|
+
assert.deepEqual(entry.colors, expected.colors)
|
|
65
|
+
assert.deepEqual(host.resolve(entry.name), { ...themeModule.getTheme(entry.base), ...expected.colors })
|
|
66
|
+
assert.equal(themeModule.getTheme(entry.name).claude, expected.colors.claude)
|
|
67
|
+
}
|
|
68
|
+
assert.equal(existsSync(staticThemes), false, 'runtime registration must not create static theme files')
|
|
69
|
+
await testUtils.sleep(1_700)
|
|
70
|
+
assert.equal(existsSync(staticThemes), false, 'runtime confirmation must leave no static fallback files')
|
|
71
|
+
|
|
72
|
+
const ledgerPath = join(dataDir, 'effect-ledger.jsonl')
|
|
73
|
+
const readLedger = () => (existsSync(ledgerPath) ? readFileSync(ledgerPath, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) : [])
|
|
74
|
+
const themeCreates = readLedger().filter(entry => entry.resource?.kind === 'theme' && entry.operation === 'create')
|
|
75
|
+
assert.deepEqual(themeCreates.map(entry => entry.resource.id).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
|
|
76
|
+
|
|
77
|
+
await admitted.fiber.dispose()
|
|
78
|
+
await testUtils.sleep(50)
|
|
79
|
+
assert.deepEqual(host.getSnapshot(), [], 'disposing the admitted plugin must release runtime themes')
|
|
80
|
+
const themeReleases = readLedger().filter(entry => entry.resource?.kind === 'theme' && entry.operation === 'release')
|
|
81
|
+
assert.deepEqual(themeReleases.map(entry => entry.resource.id).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
|
|
82
|
+
assert.deepEqual(themeModule.getTheme('pink-night'), themeModule.getTheme('dark'), 'disposed runtime theme no longer resolves')
|
|
83
|
+
console.log('OK runtime themes: 3 registered, no static files, ledger create/release, disposal clean')
|
|
84
|
+
|
|
85
|
+
// ── Phase 2: real-host toast delivery (0.10 tuiToast seam) ──────────────────
|
|
86
|
+
// A pre-0.10 user's byte-identical static files shadow the runtime registry;
|
|
87
|
+
// the plugin must surface that through the real toast service. The sink is
|
|
88
|
+
// attached after the extensions row applies, so a toast fired earlier is
|
|
89
|
+
// dropped and must arrive through the relay's bounded retry instead — this
|
|
90
|
+
// exercises both the show() caller binding outside the inject callback and
|
|
91
|
+
// the drop-retry path against the real host.
|
|
92
|
+
const toastModule = await import(pathToFileURL(join(adapter, 'toast.js')).href)
|
|
93
|
+
const sandbox2 = mkdtempSync(join(tmpdir(), 'pink-toast-'))
|
|
94
|
+
process.env.USERPROFILE = sandbox2
|
|
95
|
+
process.env.HOME = sandbox2
|
|
96
|
+
const dataDir2 = join(sandbox2, '.dsh-tui')
|
|
97
|
+
mkdirSync(join(dataDir2, 'themes'), { recursive: true })
|
|
98
|
+
writeFileSync(join(dataDir2, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
99
|
+
for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
|
|
100
|
+
writeFileSync(
|
|
101
|
+
join(dataDir2, 'themes', `${theme}.json`),
|
|
102
|
+
readFileSync(join(pluginRoot, 'themes', `${theme}.json`), 'utf8'),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const deliveries = []
|
|
107
|
+
const app2 = new Context()
|
|
108
|
+
await app2.plugin(pluginHost.default ?? pluginHost)
|
|
109
|
+
const admitted2 = await testUtils.mountAdmitted(app2, SETTINGS_NAMESPACE, manifest)
|
|
110
|
+
await admitted2.context.plugin(pink)
|
|
111
|
+
await app2.plugin(extensions.default ?? extensions)
|
|
112
|
+
toastModule.getHostToastStore(app2.get('tuiToast'))?.setSink(delivery => deliveries.push(delivery))
|
|
113
|
+
|
|
114
|
+
const toastDeadline = Date.now() + 8_000
|
|
115
|
+
while (deliveries.length === 0 && Date.now() < toastDeadline) {
|
|
116
|
+
await testUtils.sleep(25)
|
|
117
|
+
}
|
|
118
|
+
assert.ok(deliveries.length >= 1, 'the shadow-hint toast must reach the host sink (retry included)')
|
|
119
|
+
assert.equal(deliveries[0].color, undefined, 'the shadow hint is neutral')
|
|
120
|
+
for (const file of ['pink-night.json', 'pink-day.json', 'pink-ansi.json']) {
|
|
121
|
+
assert.ok(deliveries[0].text.includes(file), `hint must name ${file}`)
|
|
122
|
+
}
|
|
123
|
+
await admitted2.fiber.dispose()
|
|
124
|
+
await testUtils.sleep(50)
|
|
125
|
+
console.log('OK toast: shadow hint delivered through the real tuiToast seam')
|
|
126
|
+
|
|
127
|
+
// ── Phase 3: apply-time toasts reach the sink through the seam-late retry ───
|
|
128
|
+
// On a real 0.10 host the tuiToast service arrives with the extensions row,
|
|
129
|
+
// after this plugin's apply, so toasts fired during apply (the self-heal
|
|
130
|
+
// warning, the boot-follow result) can only survive through the relay's
|
|
131
|
+
// bounded retry. A settings service present at apply time (the dsh CLI core
|
|
132
|
+
// service) drives the boot-follow path with a disagreeing cache.
|
|
133
|
+
const sandbox3 = mkdtempSync(join(tmpdir(), 'pink-apply-toast-'))
|
|
134
|
+
process.env.USERPROFILE = sandbox3
|
|
135
|
+
process.env.HOME = sandbox3
|
|
136
|
+
const dataDir3 = join(sandbox3, '.dsh-tui')
|
|
137
|
+
mkdirSync(join(dataDir3, 'themes'), { recursive: true })
|
|
138
|
+
// Corrupt self-heal target; boot-follow cache disagrees with the persisted pref.
|
|
139
|
+
writeFileSync(join(dataDir3, 'themes', 'pink-night.json'), '{ this is not json')
|
|
140
|
+
writeFileSync(join(dataDir3, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
141
|
+
writeFileSync(join(dataDir3, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }, null, 2))
|
|
142
|
+
|
|
143
|
+
// Minimal settings service standing in for the dsh CLI core service: present
|
|
144
|
+
// at plugin apply time with followSystem already enabled, mirrors scope.watch.
|
|
145
|
+
const fakeSettings = {
|
|
146
|
+
register() {
|
|
147
|
+
return {
|
|
148
|
+
get: () => ({ followSystem: true }),
|
|
149
|
+
watch(listener) {
|
|
150
|
+
listener({ followSystem: true })
|
|
151
|
+
return () => {}
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const deliveries3 = []
|
|
158
|
+
const app3 = new Context()
|
|
159
|
+
await app3.plugin(pluginHost.default ?? pluginHost)
|
|
160
|
+
const admitted3 = await testUtils.mountAdmitted(app3, SETTINGS_NAMESPACE, manifest)
|
|
161
|
+
app3.provide('settings', fakeSettings)
|
|
162
|
+
await admitted3.context.plugin(pink)
|
|
163
|
+
await app3.plugin(extensions.default ?? extensions)
|
|
164
|
+
toastModule.getHostToastStore(app3.get('tuiToast'))?.setSink(delivery => deliveries3.push(delivery))
|
|
165
|
+
|
|
166
|
+
const applyToastDeadline = Date.now() + 8_000
|
|
167
|
+
while (deliveries3.length < 2 && Date.now() < applyToastDeadline) {
|
|
168
|
+
await testUtils.sleep(25)
|
|
169
|
+
}
|
|
170
|
+
const healToast = deliveries3.find(delivery => delivery.text.includes('已修复'))
|
|
171
|
+
assert.ok(healToast, 'the self-heal warning fired during apply must reach the host sink')
|
|
172
|
+
assert.equal(healToast.color, 'warning', 'the self-heal toast is a warning')
|
|
173
|
+
assert.ok(healToast.text.includes('pink-night.json'), 'the heal toast names the repaired file')
|
|
174
|
+
const followToast = deliveries3.find(delivery => delivery.text.includes('已按保存的终端背景'))
|
|
175
|
+
assert.ok(followToast, 'the boot-follow result fired at settings time must reach the host sink')
|
|
176
|
+
assert.equal(followToast.color, 'success', 'the boot-follow toast is a success')
|
|
177
|
+
assert.ok(followToast.text.includes('pink-day') && followToast.text.includes('reload'), 'the follow toast names the new theme and the reload hint')
|
|
178
|
+
assert.equal(JSON.parse(readFileSync(join(dataDir3, 'theme.json'), 'utf8')).theme, 'pink-day', 'the follow pref write still happened')
|
|
179
|
+
await admitted3.fiber.dispose()
|
|
180
|
+
console.log('OK toast phase 3: apply-time self-heal and boot-follow toasts delivered on the real host')
|