dsh-client-ui-skin-tianlight 2.0.2 → 2.0.3

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 CHANGED
@@ -6,6 +6,14 @@
6
6
  >
7
7
  > npm 包名 `dsh-client-ui-skin-tianlight`(v2.0.2 起;旧链接安装包名 `@dsh-external/dsh-client-ui-skin-tianlight`)· 布线行 `ui-skin-tianlight` · body 属性 `data-dsh-tianlight`
8
8
 
9
+ ## v2.0.3 · 一条命令安装
10
+
11
+ ```bash
12
+ npx dsh-client-ui-skin-tianlight@latest
13
+ ```
14
+
15
+ 先完全退出 DeepSeek Harness,再运行上面这一条命令;装完打开 DSH、页面按 Ctrl+F5 即可。它会自动找到 DSH 数据目录和 `desktop` profile,备份 profile 清单,按插件管理器的格式登记依赖与 bundle,自动注销旧包名,复制并逐字节校验插件文件。不改任何 `cordis.patch.yml`,不碰 `<profile>/tianlight/` 里的用户数据。另有 `npx dsh-client-ui-skin-tianlight doctor`(只读体检)和 `uninstall`(卸载,保留用户数据)。插件本体与 2.0.2 相同。
16
+
9
17
  ## v2.0.2 · 正式版:npm 发布 + 包名迁移
10
18
 
11
19
  `2.0.2` 把插件**发布到公开 npm registry**,安装从「下载 Release / 目录链接」变成一条命令(见下方「从 npm 安装」):
@@ -423,8 +431,19 @@ v0.5.0 的 P0–P4 功能全部保留;这一版是增量补丁,同一份构
423
431
 
424
432
  ### 从 npm 安装(推荐,一条命令)
425
433
 
426
- npm 包:**`dsh-client-ui-skin-tianlight`**(公开 registry,预构建、免 `allowBuilds` 构建授权)。三种装法任选:
434
+ npm 包:**`dsh-client-ui-skin-tianlight`**(公开 registry,预构建、免 `allowBuilds` 构建授权)。装法任选:
435
+
436
+ - **一条命令(v2.0.3 起,推荐)**:先**完全退出 DSH**(含托盘图标),然后运行
437
+
438
+ ```bash
439
+ npx dsh-client-ui-skin-tianlight@latest
440
+ ```
427
441
 
442
+ 装完打开 DSH,页面按 Ctrl+F5。需要本机有 Node.js 18+。常用参数:`--profile <名称>`(默认 `desktop`,只有一个 profile 时用它)、`--dsh-home <目录>`(默认 `$DSH_HOME` 或 `~/.dsh`)、`--dry-run`(只显示计划)、`--lang zh|en`。npm 官方源慢时可加 `--registry=https://registry.npmmirror.com`。
443
+ - 它做的事:备份 profile 的 `package.json` / `pnpm-lock.yaml` / `pnpm-workspace.yaml` 到 `<profile>/.tianlight-installer/backups/`(保留最近 3 份,旧版插件文件也放在这里便于回滚);把依赖写成精确版本、在 `dsh.profile.bundles` 登记一次;注销旧包名 `@dsh-external/dsh-client-ui-skin-tianlight`、`@local/dsh-enable-tianlight`;把插件文件复制进 `node_modules/dsh-client-ui-skin-tianlight` 并逐字节校验。原来是 `link:` 链接安装的,只移除链接本身,不动链接指向的开发目录。
444
+ - 它不做的事:不改任何 `cordis.patch.yml`(发现 `ui-skin-tianlight` 被 `disabled: true` 或被手写插入时只提示);不碰 `<profile>/tianlight/` 用户数据;DSH 运行时默认拒绝安装(Windows 会锁住正在使用的插件文件,强行替换会报 EPERM),确需继续可加 `--force`。
445
+ - 体检:`npx dsh-client-ui-skin-tianlight doctor`,只读,检查登记、旧包名残留、文件是否与发布包逐字节一致、被禁用的行、用户数据。卸载:`npx dsh-client-ui-skin-tianlight uninstall`(保留用户数据)。
446
+ - profile 的 `pnpm-lock.yaml` 由插件管理器在下一次安装/更新时自动补齐。
428
447
  - **DSH Desktop → 插件管理器**:安装/添加插件时输入包名 `dsh-client-ui-skin-tianlight`(或从市场页安装),插件管理器会把依赖与 `dsh.profile.bundles` 一起登记。
429
448
  - **命令行**:`dsh plugin --profile desktop add dsh-client-ui-skin-tianlight` 安装依赖,然后在插件管理器里启用 `ui-skin-tianlight` 行。
430
449
  - 升级:插件管理器的「更新」按 registry 新版本更新;或 `dsh plugin --profile desktop add dsh-client-ui-skin-tianlight@latest` 后重启。**升级后重启 DSH 一次 + Ctrl+F5。**
package/bin/cli.mjs ADDED
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+ // npx dsh-client-ui-skin-tianlight [install|uninstall|doctor] [options]
3
+ //
4
+ // install (default) put this version into a DSH profile and register it
5
+ // uninstall unregister + remove the package files (user data is kept)
6
+ // doctor read-only health check of the DSH home / profile / install
7
+ //
8
+ // options: --profile <name> --dsh-home <dir> --dry-run --force --lang zh|en
9
+ import { existsSync, readFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import {
12
+ LEGACY_PACKAGES,
13
+ PKG_NAME,
14
+ ROW_ID,
15
+ SELF_ROOT,
16
+ compareTrees,
17
+ countFiles,
18
+ inspectInstalled,
19
+ inspectUserData,
20
+ installFiles,
21
+ isDshRunning,
22
+ listProfiles,
23
+ makeBackupDir,
24
+ packageEntries,
25
+ pickProfile,
26
+ planInstall,
27
+ planUninstall,
28
+ pruneBackups,
29
+ readJson,
30
+ removeInstalledFiles,
31
+ resolveDshHome,
32
+ scanPatches,
33
+ writeJsonAtomic,
34
+ } from './installer.mjs'
35
+
36
+ const SELF = readJson(join(SELF_ROOT, 'package.json'))
37
+ const VERSION = SELF.version
38
+
39
+ // ---- args -------------------------------------------------------------------
40
+ const argv = process.argv.slice(2)
41
+ const flags = new Set(argv.filter((a) => a.startsWith('--') || a === '-h' || a === '-v'))
42
+ const value = (name) => {
43
+ const i = argv.indexOf(name)
44
+ return i >= 0 && argv[i + 1] !== undefined && argv[i + 1].startsWith('--') === false ? argv[i + 1] : null
45
+ }
46
+ const valueFlags = new Set(['--profile', '--dsh-home', '--lang'])
47
+ const positional = argv.filter((a, i) => a.startsWith('-') === false && valueFlags.has(argv[i - 1]) === false)
48
+ const command = positional[0] ?? 'install'
49
+ const dryRun = flags.has('--dry-run')
50
+ const force = flags.has('--force')
51
+
52
+ const envLocale = String(process.env.LC_ALL || process.env.LANG || Intl.DateTimeFormat().resolvedOptions().locale || '')
53
+ const lang = value('--lang') === 'en' ? 'en' : value('--lang') === 'zh' ? 'zh' : /^zh/i.test(envLocale) ? 'zh' : 'en'
54
+
55
+ // ---- messages ---------------------------------------------------------------
56
+ const M = {
57
+ zh: {
58
+ title: `天光 TIANLIGHT 皮肤安装器 v${VERSION}`,
59
+ home: (p) => `DSH 数据目录:${p}`,
60
+ noHome: (p) => `找不到 DSH 数据目录 ${p}\n 请先安装并启动一次 DeepSeek Harness;数据目录不在默认位置时用 --dsh-home <目录> 指定。`,
61
+ noProfiles: '这个 DSH 数据目录里还没有 profile。请先启动一次 DSH 再运行本命令。',
62
+ profileMissing: (p, list) => `没有名为 "${p}" 的 profile。现有:${list.join('、') || '(无)'}`,
63
+ profileAmbiguous: (list) => `有多个 profile:${list.join('、')}\n 请用 --profile <名称> 指定要装到哪一个。`,
64
+ profile: (p) => `目标 profile:${p}`,
65
+ notProfile: 'profile 的 package.json 里没有 dsh.profile.bundles,看起来不是 DSH profile,已停止。',
66
+ running: 'DeepSeek Harness 正在运行。请先完全退出(包括托盘图标)再运行本命令;\n 运行中替换插件文件会被 Windows 拒绝(EPERM)。确实要继续可加 --force。',
67
+ runningForced: '注意:DSH 正在运行,已按 --force 继续。',
68
+ runningUnknown: '无法确认 DSH 是否在运行;如果开着,请先退出。',
69
+ current: (k, v) => (k === 'missing' ? '当前:未安装' : k === 'link' ? `当前:链接安装(指向 ${v ?? '未知'}),将改为 npm 版本,链接目标目录不会被改动` : `当前:已安装 ${v ?? '未知版本'}`),
70
+ plan: '将要执行:',
71
+ change: {
72
+ 'legacy-dependency-removed': (c) => `移除旧包依赖 ${c.name}`,
73
+ 'legacy-bundle-removed': (c) => `移除旧包 bundle 登记 ${c.name}`,
74
+ 'dependency-set': (c) => `依赖 ${PKG_NAME}:${c.from ?? '(无)'} → ${c.to}`,
75
+ 'bundle-added': () => `在 dsh.profile.bundles 登记 ${PKG_NAME}`,
76
+ 'dependency-removed': (c) => `移除依赖 ${c.name}`,
77
+ 'bundle-removed': (c) => `移除 bundle 登记 ${c.name}`,
78
+ },
79
+ copy: (n) => `复制插件文件到 node_modules/${PKG_NAME}(${n} 个文件)`,
80
+ sameFiles: '插件文件已是这个版本且逐字节一致,跳过复制',
81
+ manifestSame: 'profile 登记已经正确,无需修改',
82
+ dry: '(--dry-run:只显示计划,没有改动任何文件)',
83
+ backup: (p) => `已备份到:${p}`,
84
+ verified: (n) => `校验通过:${n} 个文件与发布包一致`,
85
+ verifyFailed: (n) => `校验失败:${n} 个文件不一致,安装可能不完整(备份在上面的目录)`,
86
+ patchDisabled: (f) => `警告:${f} 里有一段把 ${ROW_ID} 设为 disabled: true,皮肤会被禁用。\n 请在插件管理器里启用,或删掉那一段后重启 DSH(本工具不会改 cordis.patch.yml)。`,
87
+ patchInsert: (f) => `警告:${f} 里手写插入了 ${ROW_ID} 行,会与插件自带的行重复挂载,请删掉那一段。`,
88
+ data: (d) => (d.exists ? `你的天光数据保留在:${d.root}(设置版本 ${d.revision ?? '未知'},上传 ${d.uploads} 个)` : '还没有天光用户数据(首次安装会自动套用作者默认外观)'),
89
+ installed: (v) => `完成:天光 ${v} 已安装。`,
90
+ next: [
91
+ '下一步:',
92
+ ' 1. 打开 DeepSeek Harness(如果开着,请完全退出后重开);',
93
+ ' 2. 页面按 Ctrl+F5;',
94
+ ' 3. 插件管理器里能看到 dsh-client-ui-skin-tianlight。',
95
+ ' profile 的 pnpm-lock.yaml 会在插件管理器下一次安装/更新时自动补齐。',
96
+ ],
97
+ uninstalled: '完成:已从 profile 移除天光(用户数据未删除;插件文件已移到备份目录)。',
98
+ nothing: '这个 profile 里没有安装天光,无需卸载。',
99
+ doctorTitle: '体检结果:',
100
+ ok: '正常',
101
+ warn: '注意',
102
+ err: '问题',
103
+ dRegistered: (s) => `已登记依赖 ${PKG_NAME}@${s}`,
104
+ dNotRegistered: `没有登记依赖 ${PKG_NAME}`,
105
+ dBundle: (y) => (y ? '已登记到 dsh.profile.bundles' : '没有登记到 dsh.profile.bundles(不会被加载)'),
106
+ dLegacy: (n) => `仍登记了旧包 ${n}(会重复挂载),运行 install 会自动移除`,
107
+ dFiles: (i) => (i.kind === 'missing' ? 'node_modules 里没有插件文件' : i.kind === 'link' ? `插件是链接安装,指向 ${i.linkTarget ?? '未知'}` : `已安装文件版本 ${i.version ?? '未知'}${i.buildInfo?.commit ? `(commit ${String(i.buildInfo.commit).slice(0, 8)})` : ''}`),
108
+ dMismatch: (a, b) => `登记的版本 ${a} 与已安装文件版本 ${b} 不一致`,
109
+ dSameAsSelf: (n) => `已安装文件与本安装器自带的 ${VERSION} 发布包逐字节一致(${n} 个文件)`,
110
+ dDiffSelf: (n) => `已安装文件与 ${VERSION} 发布包有 ${n} 处不同(可能被修改或损坏),可运行 install 修复`,
111
+ dNewer: (v) => `有新版本可装:${v}(运行 npx ${PKG_NAME}@latest)`,
112
+ dRunning: (r) => (r === true ? 'DSH 正在运行' : r === false ? 'DSH 未运行' : '无法确认 DSH 是否在运行'),
113
+ summary: (e, w) => `结论:${e} 个问题,${w} 个注意事项`,
114
+ usage: `用法:npx ${PKG_NAME} [install|uninstall|doctor] [--profile <名称>] [--dsh-home <目录>] [--dry-run] [--force] [--lang zh|en]`,
115
+ unknownCommand: (c) => `未知命令:${c}`,
116
+ failed: (m) => `安装失败:${m}`,
117
+ },
118
+ en: {
119
+ title: `TIANLIGHT skin installer v${VERSION}`,
120
+ home: (p) => `DSH home: ${p}`,
121
+ noHome: (p) => `DSH home not found: ${p}\n Install and start DeepSeek Harness once first, or pass --dsh-home <dir>.`,
122
+ noProfiles: 'This DSH home has no profile yet. Start DSH once, then run this command again.',
123
+ profileMissing: (p, list) => `No profile named "${p}". Existing: ${list.join(', ') || '(none)'}`,
124
+ profileAmbiguous: (list) => `Several profiles exist: ${list.join(', ')}\n Pick one with --profile <name>.`,
125
+ profile: (p) => `Target profile: ${p}`,
126
+ notProfile: 'The profile package.json has no dsh.profile.bundles; it does not look like a DSH profile. Stopped.',
127
+ running: 'DeepSeek Harness is running. Quit it completely (tray icon too) and run this again;\n Windows refuses to replace plugin files while DSH has them open (EPERM). Use --force to continue anyway.',
128
+ runningForced: 'Note: DSH is running; continuing because of --force.',
129
+ runningUnknown: 'Could not tell whether DSH is running; quit it first if it is.',
130
+ current: (k, v) => (k === 'missing' ? 'Current: not installed' : k === 'link' ? `Current: linked install (-> ${v ?? 'unknown'}); it becomes the npm version and the linked folder is left untouched` : `Current: installed ${v ?? 'unknown version'}`),
131
+ plan: 'Plan:',
132
+ change: {
133
+ 'legacy-dependency-removed': (c) => `remove legacy dependency ${c.name}`,
134
+ 'legacy-bundle-removed': (c) => `remove legacy bundle entry ${c.name}`,
135
+ 'dependency-set': (c) => `dependency ${PKG_NAME}: ${c.from ?? '(none)'} -> ${c.to}`,
136
+ 'bundle-added': () => `register ${PKG_NAME} in dsh.profile.bundles`,
137
+ 'dependency-removed': (c) => `remove dependency ${c.name}`,
138
+ 'bundle-removed': (c) => `remove bundle entry ${c.name}`,
139
+ },
140
+ copy: (n) => `copy the package files to node_modules/${PKG_NAME} (${n} files)`,
141
+ sameFiles: 'Package files are already this version and byte-identical; copy skipped',
142
+ manifestSame: 'Profile registration is already correct',
143
+ dry: '(--dry-run: plan only, nothing was changed)',
144
+ backup: (p) => `Backup: ${p}`,
145
+ verified: (n) => `Verified: ${n} files match the published package`,
146
+ verifyFailed: (n) => `Verification failed: ${n} files differ; the install may be incomplete (see the backup above)`,
147
+ patchDisabled: (f) => `Warning: ${f} sets ${ROW_ID} to disabled: true, which keeps the skin off.\n Enable it in the plugin manager or delete that block and restart DSH (this tool never edits cordis.patch.yml).`,
148
+ patchInsert: (f) => `Warning: ${f} inserts a ${ROW_ID} row by hand; it double-mounts the skin. Delete that block.`,
149
+ data: (d) => (d.exists ? `Your Tianlight data stays in: ${d.root} (settings revision ${d.revision ?? 'unknown'}, ${d.uploads} uploads)` : 'No Tianlight user data yet (a fresh install applies the author default look)'),
150
+ installed: (v) => `Done: Tianlight ${v} is installed.`,
151
+ next: [
152
+ 'Next:',
153
+ ' 1. Open DeepSeek Harness (quit and reopen it if it was running);',
154
+ ' 2. press Ctrl+F5 in the page;',
155
+ ' 3. the plugin manager lists dsh-client-ui-skin-tianlight.',
156
+ ' The profile pnpm-lock.yaml is completed by the plugin manager on its next install/update.',
157
+ ],
158
+ uninstalled: 'Done: Tianlight was removed from the profile (user data kept; package files moved to the backup folder).',
159
+ nothing: 'Tianlight is not installed in this profile; nothing to remove.',
160
+ doctorTitle: 'Health check:',
161
+ ok: 'ok',
162
+ warn: 'warn',
163
+ err: 'problem',
164
+ dRegistered: (s) => `dependency ${PKG_NAME}@${s} is registered`,
165
+ dNotRegistered: `dependency ${PKG_NAME} is not registered`,
166
+ dBundle: (y) => (y ? 'registered in dsh.profile.bundles' : 'not in dsh.profile.bundles (it will not load)'),
167
+ dLegacy: (n) => `legacy package ${n} is still registered (double mount); install removes it`,
168
+ dFiles: (i) => (i.kind === 'missing' ? 'no package files in node_modules' : i.kind === 'link' ? `linked install -> ${i.linkTarget ?? 'unknown'}` : `installed files: version ${i.version ?? 'unknown'}${i.buildInfo?.commit ? ` (commit ${String(i.buildInfo.commit).slice(0, 8)})` : ''}`),
169
+ dMismatch: (a, b) => `registered version ${a} differs from installed files ${b}`,
170
+ dSameAsSelf: (n) => `installed files are byte-identical to the ${VERSION} package shipped with this installer (${n} files)`,
171
+ dDiffSelf: (n) => `installed files differ from the ${VERSION} package in ${n} places (modified or damaged); run install to repair`,
172
+ dNewer: (v) => `a newer version is available: ${v} (run npx ${PKG_NAME}@latest)`,
173
+ dRunning: (r) => (r === true ? 'DSH is running' : r === false ? 'DSH is not running' : 'could not tell whether DSH is running'),
174
+ summary: (e, w) => `Result: ${e} problems, ${w} warnings`,
175
+ usage: `usage: npx ${PKG_NAME} [install|uninstall|doctor] [--profile <name>] [--dsh-home <dir>] [--dry-run] [--force] [--lang zh|en]`,
176
+ unknownCommand: (c) => `unknown command: ${c}`,
177
+ failed: (m) => `install failed: ${m}`,
178
+ },
179
+ }[lang]
180
+
181
+ const say = (line = '') => console.log(line)
182
+ const die = (line, code = 1) => {
183
+ console.error(line)
184
+ process.exit(code)
185
+ }
186
+
187
+ // ---- shared steps -----------------------------------------------------------
188
+ function locate() {
189
+ const dshHome = resolveDshHome({ dshHome: value('--dsh-home') })
190
+ say(M.home(dshHome))
191
+ if (existsSync(dshHome) === false) die(M.noHome(dshHome))
192
+ const profiles = listProfiles(dshHome)
193
+ const picked = pickProfile(profiles, value('--profile'))
194
+ if (picked.ok === false) {
195
+ if (picked.reason === 'no-profiles') die(M.noProfiles)
196
+ if (picked.reason === 'profile-missing') die(M.profileMissing(value('--profile'), profiles))
197
+ die(M.profileAmbiguous(profiles))
198
+ }
199
+ say(M.profile(picked.profile))
200
+ return { dshHome, profile: picked.profile, profileDir: join(dshHome, 'profiles', picked.profile) }
201
+ }
202
+
203
+ function guardRunning() {
204
+ const running = isDshRunning()
205
+ if (running === true && force === false && dryRun === false) die(M.running, 3)
206
+ if (running === true && force) say(M.runningForced)
207
+ if (running === null) say(M.runningUnknown)
208
+ }
209
+
210
+ function reportPatches(dshHome, profileDir) {
211
+ let count = 0
212
+ for (const item of scanPatches(dshHome, profileDir)) {
213
+ say(item.finding === 'disabled' ? M.patchDisabled(item.file) : M.patchInsert(item.file))
214
+ count += 1
215
+ }
216
+ return count
217
+ }
218
+
219
+ // ---- commands ---------------------------------------------------------------
220
+ function install() {
221
+ const { dshHome, profileDir } = locate()
222
+ guardRunning()
223
+ const manifestPath = join(profileDir, 'package.json')
224
+ const manifest = readJson(manifestPath)
225
+ const plan = planInstall(manifest, VERSION)
226
+ if (plan.ok === false) die(M.notProfile)
227
+ const before = inspectInstalled(profileDir)
228
+ say(M.current(before.kind, before.kind === 'link' ? before.linkTarget : before.version))
229
+
230
+ const entries = packageEntries(SELF_ROOT)
231
+ const fileCount = countFiles(SELF_ROOT, entries)
232
+ const sameFiles = before.kind === 'dir' && before.version === VERSION && compareTrees(SELF_ROOT, before.path, entries).length === 0
233
+ say(M.plan)
234
+ if (plan.changes.length === 0) say(' - ' + M.manifestSame)
235
+ for (const change of plan.changes) say(' - ' + M.change[change.kind](change))
236
+ say(' - ' + (sameFiles ? M.sameFiles : M.copy(fileCount)))
237
+ if (dryRun) {
238
+ reportPatches(dshHome, profileDir)
239
+ say(M.dry)
240
+ return
241
+ }
242
+
243
+ const backupDir = makeBackupDir(profileDir)
244
+ say(M.backup(backupDir))
245
+ if (sameFiles === false) installFiles({ srcRoot: SELF_ROOT, profileDir, backupDir, entries })
246
+ if (plan.changes.length > 0) writeJsonAtomic(manifestPath, plan.manifest)
247
+ pruneBackups(profileDir)
248
+
249
+ const problems = compareTrees(SELF_ROOT, join(profileDir, 'node_modules', PKG_NAME), entries)
250
+ if (problems.length > 0) {
251
+ for (const p of problems.slice(0, 10)) say(` ${p.problem}: ${p.path}`)
252
+ die(M.verifyFailed(problems.length))
253
+ }
254
+ say(M.verified(fileCount))
255
+ reportPatches(dshHome, profileDir)
256
+ say(M.data(inspectUserData(profileDir)))
257
+ say()
258
+ say(M.installed(VERSION))
259
+ for (const line of M.next) say(line)
260
+ }
261
+
262
+ function uninstall() {
263
+ const { profileDir } = locate()
264
+ guardRunning()
265
+ const manifestPath = join(profileDir, 'package.json')
266
+ const plan = planUninstall(readJson(manifestPath))
267
+ const before = inspectInstalled(profileDir)
268
+ if (plan.changes.length === 0 && before.kind === 'missing') {
269
+ say(M.nothing)
270
+ return
271
+ }
272
+ say(M.plan)
273
+ for (const change of plan.changes) say(' - ' + M.change[change.kind](change))
274
+ if (dryRun) {
275
+ say(M.dry)
276
+ return
277
+ }
278
+ const backupDir = makeBackupDir(profileDir)
279
+ say(M.backup(backupDir))
280
+ removeInstalledFiles({ profileDir, backupDir })
281
+ if (plan.changes.length > 0) writeJsonAtomic(manifestPath, plan.manifest)
282
+ pruneBackups(profileDir)
283
+ say(M.data(inspectUserData(profileDir)))
284
+ say(M.uninstalled)
285
+ }
286
+
287
+ function doctor() {
288
+ const { dshHome, profileDir } = locate()
289
+ const rows = []
290
+ const add = (level, text) => rows.push({ level, text })
291
+ const running = isDshRunning()
292
+ add(running === null ? 'warn' : 'ok', M.dRunning(running))
293
+
294
+ const manifest = readJson(join(profileDir, 'package.json'))
295
+ const spec = manifest.dependencies?.[PKG_NAME]
296
+ const bundles = Array.isArray(manifest.dsh?.profile?.bundles) ? manifest.dsh.profile.bundles : []
297
+ add(spec ? 'ok' : 'err', spec ? M.dRegistered(spec) : M.dNotRegistered)
298
+ add(bundles.includes(PKG_NAME) ? 'ok' : 'err', M.dBundle(bundles.includes(PKG_NAME)))
299
+ for (const legacy of LEGACY_PACKAGES) {
300
+ if (manifest.dependencies?.[legacy] !== undefined || bundles.includes(legacy)) add('err', M.dLegacy(legacy))
301
+ }
302
+
303
+ const installed = inspectInstalled(profileDir)
304
+ add(installed.kind === 'missing' ? 'err' : installed.kind === 'link' ? 'warn' : 'ok', M.dFiles(installed))
305
+ if (installed.kind === 'dir' && spec && /^\d/.test(spec) && installed.version !== spec) add('err', M.dMismatch(spec, installed.version))
306
+ if (installed.kind === 'dir' && installed.version === VERSION) {
307
+ const entries = packageEntries(SELF_ROOT)
308
+ const diff = compareTrees(SELF_ROOT, installed.path, entries)
309
+ add(diff.length === 0 ? 'ok' : 'err', diff.length === 0 ? M.dSameAsSelf(countFiles(SELF_ROOT, entries)) : M.dDiffSelf(diff.length))
310
+ }
311
+ for (const item of scanPatches(dshHome, profileDir)) {
312
+ add('err', item.finding === 'disabled' ? M.patchDisabled(item.file) : M.patchInsert(item.file))
313
+ }
314
+ const data = inspectUserData(profileDir)
315
+ add('ok', M.data(data))
316
+ if (installed.kind === 'dir' && installed.version && compareVersions(VERSION, installed.version) > 0) add('warn', M.dNewer(VERSION))
317
+
318
+ say()
319
+ say(M.doctorTitle)
320
+ const label = { ok: M.ok, warn: M.warn, err: M.err }
321
+ for (const row of rows) say(` [${label[row.level]}] ${row.text}`)
322
+ const errors = rows.filter((r) => r.level === 'err').length
323
+ const warns = rows.filter((r) => r.level === 'warn').length
324
+ say(M.summary(errors, warns))
325
+ if (errors > 0) process.exitCode = 1
326
+ }
327
+
328
+ function compareVersions(a, b) {
329
+ const pa = String(a).split(/[.-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : x))
330
+ const pb = String(b).split(/[.-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : x))
331
+ for (let i = 0; i < 3; i += 1) {
332
+ if (pa[i] !== pb[i]) return Number(pa[i]) > Number(pb[i]) ? 1 : -1
333
+ }
334
+ return 0
335
+ }
336
+
337
+ // ---- main -------------------------------------------------------------------
338
+ if (flags.has('--help') || flags.has('-h') || command === 'help') {
339
+ say(M.usage)
340
+ process.exit(0)
341
+ }
342
+ if (flags.has('--version') || flags.has('-v')) {
343
+ say(VERSION)
344
+ process.exit(0)
345
+ }
346
+ say(M.title)
347
+ try {
348
+ if (command === 'install') install()
349
+ else if (command === 'uninstall') uninstall()
350
+ else if (command === 'doctor') doctor()
351
+ else die(M.unknownCommand(command) + '\n' + M.usage, 2)
352
+ } catch (error) {
353
+ const message = error instanceof Error ? `${error.code ? error.code + ' ' : ''}${error.message}` : String(error)
354
+ die(M.failed(message))
355
+ }
@@ -0,0 +1,462 @@
1
+ // One-command installer core for `npx dsh-client-ui-skin-tianlight`.
2
+ //
3
+ // npx has already downloaded (and integrity-checked) the published package into the
4
+ // npm cache before this code runs, so installing is purely local:
5
+ // 1. find $DSH_HOME and the target profile (desktop by default);
6
+ // 2. back up the profile manifest (+ pnpm lock/workspace files);
7
+ // 3. register the package the same way the DSH plugin manager does: an exact
8
+ // registry version in `dependencies` and one entry in `dsh.profile.bundles`
9
+ // (legacy package names are unregistered, never deleted);
10
+ // 4. copy this package's published files into <profile>/node_modules/<name>,
11
+ // staged in a temp dir and swapped in, then verified byte-for-byte.
12
+ //
13
+ // The bundle's own cordis.patch.yml inserts the `ui-skin-tianlight` row, so nothing is
14
+ // ever written to any cordis.patch.yml. User data (<profile>/tianlight/) is never
15
+ // touched by install or uninstall. Everything here is pure Node (no dependencies) so
16
+ // tools/test.mjs can drive it against a temporary fake DSH home.
17
+ import { createHash } from 'node:crypto'
18
+ import { spawnSync } from 'node:child_process'
19
+ import {
20
+ cpSync,
21
+ existsSync,
22
+ lstatSync,
23
+ mkdirSync,
24
+ readdirSync,
25
+ readFileSync,
26
+ readlinkSync,
27
+ realpathSync,
28
+ renameSync,
29
+ rmdirSync,
30
+ rmSync,
31
+ statSync,
32
+ unlinkSync,
33
+ writeFileSync,
34
+ } from 'node:fs'
35
+ import { homedir } from 'node:os'
36
+ import { basename, dirname, join, resolve } from 'node:path'
37
+ import { fileURLToPath } from 'node:url'
38
+
39
+ export const PKG_NAME = 'dsh-client-ui-skin-tianlight'
40
+ export const ROW_ID = 'ui-skin-tianlight'
41
+ /** Old names that must not stay registered next to the current one (double mount). */
42
+ export const LEGACY_PACKAGES = ['@dsh-external/dsh-client-ui-skin-tianlight', '@local/dsh-enable-tianlight']
43
+ export const INSTALLER_DIR = '.tianlight-installer'
44
+ const KEEP_BACKUPS = 3
45
+
46
+ /** Root of the package this file ships in (bin/.. ). */
47
+ export const SELF_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
48
+
49
+ export function readJson(file) {
50
+ return JSON.parse(readFileSync(file, 'utf8'))
51
+ }
52
+
53
+ /** Write via temp file + rename so a crash never leaves half a manifest behind. */
54
+ export function writeJsonAtomic(file, value) {
55
+ const tmp = `${file}.tl-tmp-${process.pid}`
56
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', 'utf8')
57
+ renameSync(tmp, file)
58
+ }
59
+
60
+ // ---- locating DSH -----------------------------------------------------------
61
+
62
+ export function resolveDshHome({ dshHome = null, env = process.env, home = homedir() } = {}) {
63
+ if (typeof dshHome === 'string' && dshHome.trim() !== '') return resolve(dshHome.trim())
64
+ if (typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() !== '') return resolve(env.DSH_HOME.trim())
65
+ return join(home, '.dsh')
66
+ }
67
+
68
+ /** Profiles = directories under <home>/profiles that carry a package.json. */
69
+ export function listProfiles(dshHome) {
70
+ const root = join(dshHome, 'profiles')
71
+ if (existsSync(root) === false) return []
72
+ return readdirSync(root, { withFileTypes: true })
73
+ .filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, 'package.json')))
74
+ .map((entry) => entry.name)
75
+ .sort()
76
+ }
77
+
78
+ /**
79
+ * Explicit --profile wins; otherwise `desktop` (DSH Desktop's profile); otherwise the
80
+ * only profile. Returns {ok, profile} or {ok:false, reason, profiles}.
81
+ */
82
+ export function pickProfile(profiles, requested = null) {
83
+ if (typeof requested === 'string' && requested !== '') {
84
+ return profiles.includes(requested) ? { ok: true, profile: requested } : { ok: false, reason: 'profile-missing', profiles }
85
+ }
86
+ if (profiles.includes('desktop')) return { ok: true, profile: 'desktop' }
87
+ if (profiles.length === 1) return { ok: true, profile: profiles[0] }
88
+ if (profiles.length === 0) return { ok: false, reason: 'no-profiles', profiles }
89
+ return { ok: false, reason: 'profile-ambiguous', profiles }
90
+ }
91
+
92
+ /** `tasklist /FO CSV /NH` output -> is the DSH Desktop image running? */
93
+ export function tasklistHasDsh(stdout) {
94
+ return String(stdout)
95
+ .split(/\r?\n/)
96
+ .some((line) => line.toLowerCase().startsWith('"deepseek harness.exe"'))
97
+ }
98
+
99
+ /**
100
+ * `ps -A -o pid=,comm=` output -> is a DSH process running? Matches the process NAME
101
+ * only (never full command lines, which would match this installer when a path
102
+ * contains "DeepSeek Harness") and ignores the given own pids.
103
+ */
104
+ export function psHasDsh(stdout, ownPids = []) {
105
+ return String(stdout)
106
+ .split(/\r?\n/)
107
+ .some((line) => {
108
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line)
109
+ if (match === null || ownPids.includes(Number(match[1]))) return false
110
+ const name = match[2].trim().split('/').pop()
111
+ return /^deepseek[ -]harness/i.test(name)
112
+ })
113
+ }
114
+
115
+ /** true / false / null (unknown). DSH must be closed: open files make Windows refuse the swap (EPERM). */
116
+ export function isDshRunning(platform = process.platform) {
117
+ try {
118
+ if (platform === 'win32') {
119
+ const result = spawnSync('tasklist', ['/FO', 'CSV', '/NH'], { encoding: 'utf8', windowsHide: true })
120
+ if (result.status !== 0 || typeof result.stdout !== 'string') return null
121
+ return tasklistHasDsh(result.stdout)
122
+ }
123
+ const result = spawnSync('ps', ['-A', '-o', 'pid=,comm='], { encoding: 'utf8' })
124
+ if (result.status !== 0 || typeof result.stdout !== 'string') return null
125
+ return psHasDsh(result.stdout, [process.pid, process.ppid])
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+
131
+ // ---- manifest planning (pure) -------------------------------------------------
132
+
133
+ function bundlesOf(manifest) {
134
+ const bundles = manifest?.dsh?.profile?.bundles
135
+ return Array.isArray(bundles) ? bundles : null
136
+ }
137
+
138
+ /**
139
+ * Returns {ok, manifest, changes} with a NEW manifest object (input untouched).
140
+ * Refuses a manifest without dsh.profile.bundles: that is not a DSH profile and the
141
+ * base bundles cannot be guessed.
142
+ */
143
+ export function planInstall(manifest, version) {
144
+ const next = JSON.parse(JSON.stringify(manifest))
145
+ const bundles = bundlesOf(next)
146
+ if (bundles === null) return { ok: false, reason: 'not-a-profile', changes: [] }
147
+ const changes = []
148
+ if (next.dependencies === undefined || next.dependencies === null || typeof next.dependencies !== 'object') {
149
+ next.dependencies = {}
150
+ }
151
+ for (const legacy of LEGACY_PACKAGES) {
152
+ if (Object.prototype.hasOwnProperty.call(next.dependencies, legacy)) {
153
+ delete next.dependencies[legacy]
154
+ changes.push({ kind: 'legacy-dependency-removed', name: legacy })
155
+ }
156
+ const at = bundles.indexOf(legacy)
157
+ if (at >= 0) {
158
+ bundles.splice(at, 1)
159
+ changes.push({ kind: 'legacy-bundle-removed', name: legacy })
160
+ }
161
+ }
162
+ const previous = next.dependencies[PKG_NAME]
163
+ if (previous !== version) {
164
+ next.dependencies[PKG_NAME] = version
165
+ changes.push({ kind: 'dependency-set', from: previous ?? null, to: version })
166
+ }
167
+ if (bundles.includes(PKG_NAME) === false) {
168
+ bundles.push(PKG_NAME)
169
+ changes.push({ kind: 'bundle-added' })
170
+ }
171
+ return { ok: true, manifest: next, changes }
172
+ }
173
+
174
+ export function planUninstall(manifest) {
175
+ const next = JSON.parse(JSON.stringify(manifest))
176
+ const bundles = bundlesOf(next)
177
+ const changes = []
178
+ for (const name of [PKG_NAME, ...LEGACY_PACKAGES]) {
179
+ if (next.dependencies && Object.prototype.hasOwnProperty.call(next.dependencies, name)) {
180
+ delete next.dependencies[name]
181
+ changes.push({ kind: 'dependency-removed', name })
182
+ }
183
+ if (bundles !== null) {
184
+ const at = bundles.indexOf(name)
185
+ if (at >= 0) {
186
+ bundles.splice(at, 1)
187
+ changes.push({ kind: 'bundle-removed', name })
188
+ }
189
+ }
190
+ }
191
+ return { ok: true, manifest: next, changes }
192
+ }
193
+
194
+ // ---- cordis patch scan (read-only) ------------------------------------------
195
+
196
+ /**
197
+ * Finds `ui-skin-tianlight` rows in a cordis.patch.yml text. Each top-level list
198
+ * item is one block; a block that targets the row and sets `disabled: true` keeps the
199
+ * skin off, and a block that inserts the row by hand double-mounts it.
200
+ */
201
+ export function scanPatchText(text) {
202
+ const findings = []
203
+ const blocks = String(text).replace(/\r\n/g, '\n').split(/\n(?=- )/)
204
+ const rowPattern = new RegExp(`id:\\s*['"]?${ROW_ID}['"]?\\s*$`, 'm')
205
+ for (const block of blocks) {
206
+ if (rowPattern.test(block) === false) continue
207
+ if (/^\s*disabled:\s*true\s*$/m.test(block)) findings.push('disabled')
208
+ if (/^-\s*insert:/m.test(block)) findings.push('manual-insert')
209
+ }
210
+ return findings
211
+ }
212
+
213
+ export function scanPatches(dshHome, profileDir) {
214
+ const result = []
215
+ for (const file of [join(dshHome, 'cordis.patch.yml'), join(profileDir, 'cordis.patch.yml')]) {
216
+ if (existsSync(file) === false) continue
217
+ for (const finding of scanPatchText(readFileSync(file, 'utf8'))) result.push({ file, finding })
218
+ }
219
+ return result
220
+ }
221
+
222
+ // ---- package files ----------------------------------------------------------
223
+
224
+ /** The published file list: package.json + `files` (only entries that exist). */
225
+ export function packageEntries(srcRoot = SELF_ROOT) {
226
+ const manifest = readJson(join(srcRoot, 'package.json'))
227
+ const listed = Array.isArray(manifest.files) ? manifest.files : []
228
+ return ['package.json', ...listed.filter((entry) => entry !== 'package.json')].filter((entry) =>
229
+ existsSync(join(srcRoot, entry)),
230
+ )
231
+ }
232
+
233
+ function walkFiles(root, rel = '') {
234
+ const out = []
235
+ const full = join(root, rel)
236
+ const info = statSync(full)
237
+ if (info.isDirectory() === false) return [rel]
238
+ for (const name of readdirSync(full).sort()) out.push(...walkFiles(root, rel === '' ? name : `${rel}/${name}`))
239
+ return out
240
+ }
241
+
242
+ /** Number of files under the published entries (what install copies and verifies). */
243
+ export function countFiles(srcRoot, entries) {
244
+ let total = 0
245
+ for (const entry of entries) total += walkFiles(srcRoot, entry).length
246
+ return total
247
+ }
248
+
249
+ function sha256File(file) {
250
+ return createHash('sha256').update(readFileSync(file)).digest('hex')
251
+ }
252
+
253
+ /** Byte comparison of every published file; returns the list of differing/missing paths. */
254
+ export function compareTrees(srcRoot, targetDir, entries) {
255
+ const problems = []
256
+ for (const entry of entries) {
257
+ for (const rel of walkFiles(srcRoot, entry)) {
258
+ const target = join(targetDir, rel)
259
+ if (existsSync(target) === false) {
260
+ problems.push({ path: rel, problem: 'missing' })
261
+ continue
262
+ }
263
+ if (rel === 'package.json') {
264
+ // the dev-tree manifest carries `private`; installed copies drop it (like tools/pack.mjs)
265
+ const a = readJson(join(srcRoot, rel))
266
+ const b = readJson(target)
267
+ delete a.private
268
+ delete b.private
269
+ if (JSON.stringify(a) !== JSON.stringify(b)) problems.push({ path: rel, problem: 'differs' })
270
+ continue
271
+ }
272
+ if (sha256File(join(srcRoot, rel)) !== sha256File(target)) problems.push({ path: rel, problem: 'differs' })
273
+ }
274
+ }
275
+ return problems
276
+ }
277
+
278
+ const samePath = (a, b, platform) => (platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b)
279
+
280
+ /**
281
+ * If `path` is a link of any kind, returns where it points; otherwise null.
282
+ * Windows junctions (what `link:` installs create) are NOT always reported by
283
+ * lstat().isSymbolicLink(): on Node 24 they show up as plain directories and
284
+ * readlink() fails with EINVAL. So a directory whose real path is not
285
+ * <real parent>/<name> is treated as a link too. Anything treated as a link is only
286
+ * ever unlinked, never renamed into backups or removed recursively.
287
+ */
288
+ export function linkTargetOf(path, { lstat = lstatSync, realpath = realpathSync.native, platform = process.platform } = {}) {
289
+ let info
290
+ try {
291
+ info = lstat(path)
292
+ } catch {
293
+ return null
294
+ }
295
+ if (info.isSymbolicLink()) {
296
+ try {
297
+ return realpath(path)
298
+ } catch {
299
+ try {
300
+ return readlinkSync(path)
301
+ } catch {
302
+ return '(unresolved link)'
303
+ }
304
+ }
305
+ }
306
+ if (info.isDirectory() === false) return null
307
+ try {
308
+ const real = realpath(path)
309
+ const expected = join(realpath(dirname(path)), basename(path))
310
+ return samePath(real, expected, platform) ? null : real
311
+ } catch {
312
+ return null
313
+ }
314
+ }
315
+
316
+ /** Recursive delete that unlinks links instead of descending into them. */
317
+ export function safeRemoveTree(path) {
318
+ let info
319
+ try {
320
+ info = lstatSync(path)
321
+ } catch {
322
+ return
323
+ }
324
+ if (linkTargetOf(path) !== null) {
325
+ removeLink(path)
326
+ return
327
+ }
328
+ if (info.isDirectory()) {
329
+ for (const name of readdirSync(path)) safeRemoveTree(join(path, name))
330
+ rmdirSync(path)
331
+ return
332
+ }
333
+ unlinkSync(path)
334
+ }
335
+
336
+ /** missing | link (symlink/junction, e.g. a `link:` dev install) | dir. */
337
+ export function inspectInstalled(profileDir) {
338
+ const target = join(profileDir, 'node_modules', PKG_NAME)
339
+ try {
340
+ lstatSync(target)
341
+ } catch {
342
+ return { kind: 'missing', path: target }
343
+ }
344
+ const linkTarget = linkTargetOf(target)
345
+ if (linkTarget !== null) return { kind: 'link', path: target, linkTarget }
346
+ let version = null
347
+ let buildInfo = null
348
+ try {
349
+ version = readJson(join(target, 'package.json')).version ?? null
350
+ } catch {
351
+ version = null
352
+ }
353
+ try {
354
+ buildInfo = readJson(join(target, 'build-info.json'))
355
+ } catch {
356
+ buildInfo = null
357
+ }
358
+ return { kind: 'dir', path: target, version, buildInfo }
359
+ }
360
+
361
+ /**
362
+ * Removes only the link itself — never recurses into a linked development tree.
363
+ * A non-recursive rmdir on a junction/directory symlink deletes the reparse point,
364
+ * not the target's contents; unlink covers file symlinks and POSIX links.
365
+ */
366
+ export function removeLink(path) {
367
+ try {
368
+ unlinkSync(path)
369
+ } catch {
370
+ rmdirSync(path)
371
+ }
372
+ if (existsSync(path)) throw new Error('could not remove link ' + path)
373
+ }
374
+
375
+ function stampNow() {
376
+ return new Date().toISOString().replace(/[:.]/g, '-')
377
+ }
378
+
379
+ export function makeBackupDir(profileDir) {
380
+ const dir = join(profileDir, INSTALLER_DIR, 'backups', stampNow())
381
+ mkdirSync(dir, { recursive: true })
382
+ for (const name of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) {
383
+ const file = join(profileDir, name)
384
+ if (existsSync(file)) cpSync(file, join(dir, name))
385
+ }
386
+ return dir
387
+ }
388
+
389
+ /** Keep the newest KEEP_BACKUPS backup folders (each may hold a previous package copy). */
390
+ export function pruneBackups(profileDir, keep = KEEP_BACKUPS) {
391
+ const root = join(profileDir, INSTALLER_DIR, 'backups')
392
+ if (existsSync(root) === false) return []
393
+ const names = readdirSync(root).sort()
394
+ const removed = names.slice(0, Math.max(0, names.length - keep))
395
+ for (const name of removed) safeRemoveTree(join(root, name))
396
+ return removed
397
+ }
398
+
399
+ /**
400
+ * Stage the published files next to the target, move the previous copy into the
401
+ * backup folder (a link is just unlinked), then swap the staged copy in. If the swap
402
+ * fails the previous copy is put back.
403
+ */
404
+ export function installFiles({ srcRoot = SELF_ROOT, profileDir, backupDir, entries = packageEntries(srcRoot) }) {
405
+ const modules = join(profileDir, 'node_modules')
406
+ mkdirSync(modules, { recursive: true })
407
+ const target = join(modules, PKG_NAME)
408
+ const staging = join(modules, `.${PKG_NAME}.tl-staging-${process.pid}`)
409
+ rmSync(staging, { recursive: true, force: true })
410
+ mkdirSync(staging, { recursive: true })
411
+ for (const entry of entries) cpSync(join(srcRoot, entry), join(staging, entry), { recursive: true })
412
+ const stagedManifestPath = join(staging, 'package.json')
413
+ const stagedManifest = readJson(stagedManifestPath)
414
+ if (stagedManifest.private !== undefined) {
415
+ delete stagedManifest.private
416
+ writeFileSync(stagedManifestPath, JSON.stringify(stagedManifest, null, 2) + '\n', 'utf8')
417
+ }
418
+
419
+ const before = inspectInstalled(profileDir)
420
+ let parked = null
421
+ if (before.kind === 'link') {
422
+ removeLink(target)
423
+ } else if (before.kind === 'dir') {
424
+ parked = join(backupDir, 'previous-package')
425
+ renameSync(target, parked)
426
+ }
427
+ try {
428
+ renameSync(staging, target)
429
+ } catch (error) {
430
+ if (parked !== null && existsSync(target) === false) renameSync(parked, target)
431
+ rmSync(staging, { recursive: true, force: true })
432
+ throw error
433
+ }
434
+ return { previous: before, parked }
435
+ }
436
+
437
+ export function removeInstalledFiles({ profileDir, backupDir }) {
438
+ const before = inspectInstalled(profileDir)
439
+ if (before.kind === 'link') removeLink(before.path)
440
+ else if (before.kind === 'dir') renameSync(before.path, join(backupDir, 'previous-package'))
441
+ return before
442
+ }
443
+
444
+ // ---- user data (read-only) ----------------------------------------------------
445
+
446
+ export function inspectUserData(profileDir) {
447
+ const root = join(profileDir, 'tianlight')
448
+ if (existsSync(root) === false) return { exists: false, root }
449
+ let revision = null
450
+ try {
451
+ revision = readJson(join(root, 'state', 'settings.json')).revision ?? null
452
+ } catch {
453
+ revision = null
454
+ }
455
+ let uploads = 0
456
+ try {
457
+ uploads = readdirSync(join(root, 'uploads')).length
458
+ } catch {
459
+ uploads = 0
460
+ }
461
+ return { exists: true, root, revision, uploads }
462
+ }
package/build-info.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "product": "dsh-client-ui-skin-tianlight",
4
- "version": "2.0.2",
5
- "commit": "4f2496eb29db818e8fd591d64d7cc047b807bed6",
6
- "builtAt": "2026-11-12T14:20:01+08:00",
4
+ "version": "2.0.3",
5
+ "commit": "e257c5800dbf9dafeba84093f8c57a292ce0eb9c",
6
+ "builtAt": "2026-11-12T23:55:42+08:00",
7
7
  "source": "pack"
8
8
  }
package/lib/index.js CHANGED
@@ -73,7 +73,7 @@ const HOST_API = '2'
73
73
  * manifest is stamped with it. The handshake no longer compares it, so a
74
74
  * browser-half-only update can run against an unchanged host process.
75
75
  */
76
- const BUILD = '2.0.2'
76
+ const BUILD = '2.0.3'
77
77
 
78
78
  function readRoots() {
79
79
  try {
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "dsh-client-ui-skin-tianlight",
3
3
  "description": "Tianlight skin for the dsh web GUI: Makoto Shinkai daylight skies, a looping video wallpaper, a wallpaper library with upload/rotation, optional WebWallGL scene support, and a draggable Mint chibi-cat desktop companion",
4
- "version": "2.0.2",
4
+ "version": "2.0.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
+ "bin": {
8
+ "dsh-client-ui-skin-tianlight": "bin/cli.mjs"
9
+ },
7
10
  "exports": {
8
11
  ".": "./lib/index.js",
9
12
  "./client": "./lib/client.js",
@@ -59,6 +62,7 @@
59
62
  "npm:publish:dry": "node tools/npm-publish.mjs --dry-run"
60
63
  },
61
64
  "files": [
65
+ "bin",
62
66
  "lib",
63
67
  "vendor",
64
68
  "assets",
package/skin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "id": "tianlight",
3
3
  "name": "天光",
4
4
  "nameEn": "TIANLIGHT",
5
- "version": "2.0.2",
5
+ "version": "2.0.3",
6
6
  "author": "local",
7
7
  "tagline": "新海诚式天光 ・ 动态壁纸与可上传壁纸库 ・ 可选 WebWallGL 场景 ・ 薄荷小猫桌宠 ・ 职责通道(role pack → 桌宠气泡/选择) ・ 生活节奏与主动开口预算 ・ 可替换图片/≤32MiB 视频形象 ・ 液态玻璃三档 ・ AI 桌宠(严格权限) ・ 接入 DSH Agent(受控) ・ 桌宠即入口 ・ 兼容 DSH 0.2.0–0.2.2",
8
8
  "description": "以新海诚天光为基调的沉浸式皮肤:全屏循环视频壁纸置于内容之下,半透明天光玻璃面板;壁纸库支持上传、隐藏、轮播、画面滤镜与标签检索;可选的 WebWallGL 通道实时渲染 Wallpaper Engine 场景;可拖拽的薄荷 Q 版小猫桌宠会靠近回应、陪你互动。v0.5.0 加入了宿主配置同步与 schema 迁移、模块开关、渲染预算、特效与氛围层、时段/专注联动、系统音频与 Now Playing、网页壁纸、动态分辨率与预裁剪缓存、可选转码、导入导出包与配方分享链接、排版增强、可读性护栏、键盘无障碍与 zh/en 双语;v0.6.0 接入 Wallpaper Engine 壁纸库;v0.7.0 新增职责通道:外部角色包可在桌宠旁显示带按钮的职责气泡(审批/完成/喝水等),并把用户选择(等会儿/静音/休息时再说)回传给职责引擎。本版只改浏览器半,无需重启 DSH(Ctrl+F5 即可)。同一份构建适配 DSH 0.2.0 / 0.2.1 / 0.2.2(选择器桥按运行时钩子分层降级)。 v1.0.0 正式版:桌宠升级为有生活节奏的角色智能体——虚拟时钟/每日作息/主动开口预算/跨会话记忆,职责提醒可被静默与预算闸门延迟并在窗口期自动回放;形象可替换为本地图片或 ≤32 MiB 视频;新增可选液态玻璃(真实/柔和/磨砂,低电量自动降级);新增「角色 / 生活」设置页与专注计时;无宿主路由改动,Ctrl+F5 即可。 v1.1.0:角色中枢(侧栏入口 + 全屏面板,一屏管生活/形象/玻璃/记忆)、AI 桌宠(本地大脑 + 可选 LLM 适配器,严格能力白名单与硬上限、审计与暂停开关)、形象支持本地上传图片/视频(≤32MiB)。 v1.2.0:桌宠右键直达角色中枢;新增受控 DSH Agent 接入(agentChat 权限默认关闭、显式授权、无自动触发、每日≤10 次/≤500 字/60s,可连接现有会话或新建桌宠会话)。 v1.3.0:角色入口收进桌宠——去掉设置页「角色 / 生活」与侧栏「角色」按钮,右键桌宠(或桌宠菜单「角色中枢」)打开全部功能;生活节奏设置已并入角色中枢。 v1.4.0:权限模型调整——「驱动 DSH」仍强烈永久禁止;直接联网/读写文件/执行命令/代审批/代发消息/安装插件改为用户可显式授予的高风险权限(默认关闭、可撤销、全程审计;皮肤不内置实现,需你注册能力适配器才会被调用)。 v1.5.0:恢复默认桌宠形象、液态玻璃默认开启并加预览(实时可见)、角色中枢新增天气卡(启用/城市/定位/刷新,显示当前天气)、一键功能自检、应用内反馈面板(复制/预填 GitHub Issue/下载 JSON)+ GitHub issue 模板。 v1.6.0:安全上限整体上调(主动开口 12/50、本地 chat 20/分 1200/天、输入 1000 字、LLM 日 60/上限 120、分钟 6、输出 1200 字、20s 超时、Agent 日 30/上限 100、输入 1000 字/120s);液态玻璃在 depth!=off 时统一应用到全部界面(侧栏/设置/壁纸库/中枢/对话框/菜单/列表框/输入区/气泡/胶囊/提示等),无壁纸场景时自动补一层玻璃兜底,关闭档零变化、低电量自动降级。 v1.6.1:液态玻璃升级 v2(OriginOS 风更厚磨砂、多层 specular 高光、1px 亮暗边、外柔光、细噪点与柔和回弹);修复侧栏「新会话」按钮深色块并玻璃化;DSH 主界面右上新增常驻天气胶囊(点击打开角色中枢天气卡)。 v1.6.2:移除壁纸左上角固定的「晨光/太阳」装饰(旧版每张壁纸重建场景都会绘制一个 top-left dawn burst,看起来像常驻小太阳);场景层只保留 veil 天空光渐变,切换壁纸不再出现任何固定角标(仅视觉变化)。 v1.6.3:显示名精简为「天光」(skin id / 包名 / data 属性 / 设置键均不变,旧用户无迁移成本);完成 DSH 0.2.0-rc.2 / 0.2.1-rc.5 / 0.2.2-rc.1 三线兼容性实证(compat-matrix 三行全 COMPATIBLE、hook-scan 全桥接钩子在位、upgrade-check 升级假设全部成立)。 v1.6.4(公开 v1.0.3 正式版):桌宠新增「AI 对话」菜单(输入条 + 气泡回复,识别当前替换的图片/视频形象,视频上方空间不足自动翻转);每个自定义形象可编辑一套动作名与回复(按 mediaId 生效,未改动跟随默认);角色中枢形象文件名常显列表(文件名/类型/大小,点击切换);完成全代码审查修复(默认文案冻结、WE 宿主 stream error、低电量异步泄漏、bridge 节点清理、转码超时兜底、宿主同源栅栏收紧、聊天视口翻转)。 v1.6.5(公开 v1.0.4 正式版):修复桌宠「AI 对话」关不掉的问题——新增面板「×」关闭按钮、点空白关闭、全局 Esc 关闭、[hidden] 真正隐藏,并支持桌宠靠近屏幕底部时点桌宠直接退出(菜单被裁到视口外也能退出)。仅浏览器半,无宿主路由改动,Ctrl+F5 即可。 v1.6.6(公开 v1.0.5 正式版):修复 AI 对话面板在桌宠默认靠右位置时「×」关闭按钮被视口裁掉的问题(面板水平夹取进视口,拖拽/窗口缩放后重算),并把顶部翻转阈值改为按面板实际高度计算(新增按钮行后不再裁到顶部),略增大关闭按钮点击区域。仅浏览器半;版本常量变更,Ctrl+F5 + 重启 DSH 一次可消除宿主半 stale 提示。 v1.6.7(公开 v1.0.6 正式版):壁纸面板默认视图新增一等「URL 地址」「ShaderToy 代码」快捷来源(点击直达编辑器,并修复 URL/Shader 应用假成功与卡死);桌宠「AI 对话」收到回复即自动收起输入面板、只留气泡回复;新增「☰」对话历史(与 AI 对话相邻):可查看历史、开启新对话、编辑已发消息、删除对话,数据存本机 localStorage。仅浏览器半;版本常量变更,Ctrl+F5 + 重启 DSH 一次可消除宿主半 stale 提示。 v1.6.8(公开 v1.0.7 正式版):桌宠新增「自主思考」智能体——按间隔做内心独白、可自主开口(受既有主动开口预算约束),聊天回复融入最近对话/时段/记忆/当前想法更像人;角色中枢新增「自主思考」卡片(开关/间隔/自主开口/现在想一下/最近想法/清空)。不新增权限与网络通道,不驱动 DSH Agent;仅浏览器半,Ctrl+F5 即可;版本常量变更,重启 DSH 一次可消除宿主半 stale 提示。 v2.0.0:桌宠 AI 对话接入 DSH 自带 LLM(宿主路由 _pet-chat;默认跟随 DSH 默认模型,仍须在角色中枢两步授权 llm;不建会话、不调工具、60/日、6/分、1200 字、20s 超时;失败按稳定错误码提示);版本号统一为 2.0.0(仓库 / DSH 安装 / GitHub 发布同名同号);并入 v1.6.8 之后的体检修复批次(设置同步合并与冲突保护、宿主/桥接安全收口、统一浮层层序、壁纸切换与性能、可读性/低电量、导出导入一致性);默认演示素材精简为 bg-4k.mp4(H.264 4K)与 cat.png,bg-main.mp4 改为可选自备首选槽。本版改了宿主半:升级后必须重启 DSH 一次 + Ctrl+F5,之后纯浏览器半更新仍只需 Ctrl+F5。",