dsh-plugin-deck-studio 0.2.10 → 0.2.13

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.
@@ -0,0 +1,288 @@
1
+ /**
2
+ * 数据目录(dataRoot / projectsRoot)解析与指针文件(P0)。
3
+ *
4
+ * 口径(规格 `.deck-assistant-scratch/deckline-data-dir-spec.md` §1 / §4):
5
+ * - 解析优先级:env `DSH_DECK_DATA_ROOT` > 指针文件 `$DSH_HOME/deckline/location.json`
6
+ * > profile patch `deck-studio.config.dataRoot` > 默认 `$DSH_HOME/deckline`;
7
+ * - 每次解析都把**命中来源**(env / pointer / profile / default)带出去,设置页与黄条直接显示;
8
+ * - 指针文件位置固定,不随 dataRoot 配置移动(否则自指);写入 0600、不含任何 key;
9
+ * - env 覆盖时指针只读:写了也不生效,界面必须说明「由环境变量 … 指定(只读)」。
10
+ *
11
+ * 本模块只做「解析 + 读写指针」,不碰 deck 内容;不匹配检测在 lib/data-dir-detect.js。
12
+ * @module dsh-plugin-deck-studio/data-dir
13
+ */
14
+
15
+ import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
16
+ import { dirname, isAbsolute, join } from "node:path";
17
+ import { defaultDataRoot, expandHome } from "./config.js";
18
+
19
+ /** 覆盖 dataRoot 的环境变量名(优先级最高)。 */
20
+ export const DATA_DIR_ENV = "DSH_DECK_DATA_ROOT";
21
+ /** 覆盖 projectsRoot 的环境变量名。 */
22
+ export const PROJECTS_ROOT_ENV = "DSH_DECK_PROJECTS_ROOT";
23
+ /** 指针文件名(固定住在 `$DSH_HOME/deckline/`,不随 dataRoot 移动)。 */
24
+ export const LOCATION_FILE_NAME = "location.json";
25
+ /** 指针文件格式版本。 */
26
+ export const LOCATION_VERSION = 1;
27
+
28
+ /** 解析命中来源(顺序 = 优先级)。 */
29
+ export const DATA_DIR_SOURCES = ["env", "pointer", "profile", "default"];
30
+
31
+ /** 命中来源的人话标签(设置页第一行 / 诊断信息共用)。 */
32
+ export const DATA_DIR_SOURCE_LABELS = {
33
+ env: `环境变量 ${DATA_DIR_ENV}`,
34
+ pointer: "指针文件 location.json",
35
+ profile: "profile patch(deck-studio.config.dataRoot)",
36
+ default: "默认 $DSH_HOME/deckline",
37
+ };
38
+
39
+ /** 每本 deck 的数据状态标签(检测模块与 UI 共用一张词表)。 */
40
+ export const DECK_DATA_STATUS_LABELS = {
41
+ ok: "正常",
42
+ mismatch: "不在这",
43
+ partial: "正文丢失",
44
+ absent: "尚未初始化",
45
+ };
46
+
47
+ /**
48
+ * 规范化用户输入的目录:展开 `~`、去首尾空白与尾部斜杠。
49
+ * **不**把相对路径悄悄 resolve 成绝对路径——写指针时要求绝对路径,相对路径直接报错。
50
+ * @param {unknown} value
51
+ * @param {NodeJS.ProcessEnv} [env]
52
+ */
53
+ export function normalizeDirPath(value, env = process.env) {
54
+ const expanded = expandHome(String(value ?? "").trim(), env).trim();
55
+ if (!expanded) return "";
56
+ return expanded.length > 1 ? expanded.replace(/[/\\]+$/, "") : expanded;
57
+ }
58
+
59
+ /** `$DSH_HOME`(未配置时 `~/.dsh`)。 */
60
+ export function dshHomeOf(env = process.env) {
61
+ return normalizeDirPath(env.DSH_HOME || "~/.dsh", env);
62
+ }
63
+
64
+ function isDirectory(path) {
65
+ try {
66
+ return statSync(path).isDirectory();
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * 目录可写?目录还不存在时看最近的已存在祖先(不替你建目录)。
74
+ * @param {string} path
75
+ */
76
+ export function dirWritable(path) {
77
+ const target = String(path ?? "").trim();
78
+ if (!target) return false;
79
+ let dir = target;
80
+ while (!existsSync(dir)) {
81
+ const parent = dirname(dir);
82
+ if (parent === dir) return false;
83
+ dir = parent;
84
+ }
85
+ try {
86
+ accessSync(dir, constants.W_OK);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 一个目录的三个事实(自检面板 / 首次引导校验共用)。
95
+ * @param {unknown} path
96
+ * @param {NodeJS.ProcessEnv} [env]
97
+ */
98
+ export function dirFacts(path, env = process.env) {
99
+ const value = normalizeDirPath(path, env);
100
+ return { value, exists: value ? isDirectory(value) : false, writable: dirWritable(value) };
101
+ }
102
+
103
+ /** 指针文件的固定位置:`<DSH_HOME>/deckline/location.json`。 */
104
+ export function locationFileOf(env = process.env) {
105
+ return join(defaultDataRoot(env), LOCATION_FILE_NAME);
106
+ }
107
+
108
+ /**
109
+ * 读指针文件。坏文件不抛:返回 `found:true, error:…`,让界面能说清「文件坏了」而不是「没有」。
110
+ * @param {NodeJS.ProcessEnv} [env]
111
+ */
112
+ export function readLocationPointer(env = process.env) {
113
+ const path = locationFileOf(env);
114
+ const base = { found: false, path, version: null, dataRoot: "", projectsRoot: "", setBy: "", updatedAt: "", error: null };
115
+ if (!existsSync(path)) return base;
116
+ try {
117
+ const raw = JSON.parse(readFileSync(path, "utf8"));
118
+ return {
119
+ found: true,
120
+ path,
121
+ version: Number.isFinite(Number(raw?.version)) ? Number(raw.version) : null,
122
+ dataRoot: normalizeDirPath(raw?.dataRoot ?? "", env),
123
+ projectsRoot: normalizeDirPath(raw?.projectsRoot ?? "", env),
124
+ setBy: typeof raw?.setBy === "string" ? raw.setBy.trim() : "",
125
+ updatedAt: typeof raw?.updatedAt === "string" ? raw.updatedAt.trim() : "",
126
+ error: null,
127
+ };
128
+ } catch (error) {
129
+ return { ...base, found: true, error: `location.json 解析失败:${error?.message ?? error}` };
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 指针文件所在目录能不能写(首次引导 / 设置页在写之前先问,避免「点了才发现不行」)。
135
+ * @param {NodeJS.ProcessEnv} [env]
136
+ */
137
+ export function pointerWritable(env = process.env) {
138
+ const file = locationFileOf(env);
139
+ let dir = dirname(file);
140
+ const missing = [];
141
+ while (!existsSync(dir)) {
142
+ const parent = dirname(dir);
143
+ if (parent === dir) break;
144
+ missing.unshift(dir);
145
+ dir = parent;
146
+ }
147
+ const writable = dirWritable(dir);
148
+ return {
149
+ path: file,
150
+ dir,
151
+ dirExists: existsSync(dirname(file)),
152
+ writable,
153
+ missing,
154
+ error: writable ? null : `目录不可写:${dir}`,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * 写指针文件(0600、不含任何 key)。
160
+ *
161
+ * 只改指针、不改 profile patch:这条路径不需要重启 DSH 就生效。
162
+ * env 覆盖时**不允许**写(写了也不生效,只会让人困惑);调用方先看 `resolveDataDir().envOverride.active`。
163
+ *
164
+ * @param {{dataRoot: unknown, projectsRoot?: unknown, setBy?: string}} input
165
+ * @param {NodeJS.ProcessEnv} [env]
166
+ * @param {Date} [now]
167
+ */
168
+ export function writeLocationPointer(input = {}, env = process.env, now = new Date()) {
169
+ const dataRoot = normalizeDirPath(input.dataRoot, env);
170
+ if (!dataRoot) throw new Error("dataRoot 不能为空");
171
+ if (!isAbsolute(dataRoot)) throw new Error(`数据目录必须是绝对路径:${dataRoot}`);
172
+ const projectsRoot = normalizeDirPath(input.projectsRoot, env);
173
+ if (projectsRoot && !isAbsolute(projectsRoot)) throw new Error(`projectsRoot 必须是绝对路径:${projectsRoot}`);
174
+ const setBy = String(input.setBy ?? "settings").trim() || "settings";
175
+ const pointer = { version: LOCATION_VERSION, dataRoot, projectsRoot, setBy, updatedAt: now.toISOString() };
176
+ const path = locationFileOf(env);
177
+ mkdirSync(dirname(path), { recursive: true });
178
+ writeFileSync(path, `${JSON.stringify(pointer, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
179
+ // mode 只对新建文件生效;已存在的文件显式收紧一次(失败不静默放宽,也不因此让写入失败)。
180
+ try {
181
+ chmodSync(path, 0o600);
182
+ } catch {
183
+ /* Windows / 特殊文件系统不支持 chmod */
184
+ }
185
+ return { path, pointer };
186
+ }
187
+
188
+ /** 删掉指针文件(切换回滚用;文件不存在不报错)。 */
189
+ export function deleteLocationPointer(env = process.env) {
190
+ const path = locationFileOf(env);
191
+ try {
192
+ rmSync(path, { force: true });
193
+ } catch {
194
+ /* 权限不足等:把结果交给上层提示,不在这里编造「已删除」 */
195
+ }
196
+ return { path, removed: !existsSync(path) };
197
+ }
198
+
199
+ /**
200
+ * 把指针文件回滚成 `snapshot`(= `readLocationPointer(env)` 的原样快照)。
201
+ * 切换失败时不能让指针停在新目录上,否则重启后直接进错目录。
202
+ */
203
+ export function restoreLocationPointer(snapshot, env = process.env) {
204
+ if (snapshot?.found) {
205
+ return writeLocationPointer({ dataRoot: snapshot.dataRoot, projectsRoot: snapshot.projectsRoot ?? "", setBy: snapshot.setBy ?? "rollback" }, env);
206
+ }
207
+ return deleteLocationPointer(env);
208
+ }
209
+
210
+ /** 统一的「根对象」形状:`{value, source, envOverridden, writable, exists}`。 */
211
+ function rootFact(value, source, env) {
212
+ const facts = dirFacts(value, env);
213
+ return { value: facts.value, source, envOverridden: source === "env", writable: facts.writable, exists: facts.exists };
214
+ }
215
+
216
+ /** `<dataRoot>/decks` 是否为空(不存在也算空)。 */
217
+ function decksDirEmpty(dataRoot) {
218
+ const dir = join(dataRoot, "decks");
219
+ if (!existsSync(dir)) return true;
220
+ try {
221
+ return readdirSync(dir).length === 0;
222
+ } catch {
223
+ return true;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * 解析 dataRoot / projectsRoot 与命中来源(唯一入口,纯读取)。
229
+ *
230
+ * @param {{env?: NodeJS.ProcessEnv, profileDataRoot?: string, profileProjectsRoot?: string, logger?: {warn?: Function}}} [input]
231
+ */
232
+ /** 冲突 warn 只打一次:5s 轮询与多次重建服务都不重复刷屏。 */
233
+ let warnedDataRootConflict = false;
234
+ let warnedProjectsRootConflict = false;
235
+
236
+ export function resolveDataDir({ env = process.env, profileDataRoot = "", profileProjectsRoot = "", logger = null } = {}) {
237
+ const envDataRoot = normalizeDirPath(env[DATA_DIR_ENV] ?? "", env);
238
+ const envProjectsRoot = normalizeDirPath(env[PROJECTS_ROOT_ENV] ?? "", env);
239
+ const profileRoot = normalizeDirPath(profileDataRoot, env);
240
+ const profileProjects = normalizeDirPath(profileProjectsRoot, env);
241
+ const warn = typeof logger?.warn === "function" ? logger.warn.bind(logger) : null;
242
+ // env 赢 profile 是规格要求,但会让「两处都配过」的老用户静默换目录:留一条 warn 痕迹,只打一次。
243
+ if (warn && envDataRoot && profileRoot && envDataRoot !== profileRoot && !warnedDataRootConflict) {
244
+ warnedDataRootConflict = true;
245
+ warn(`dataRoot 同时由环境变量与 profile 配置指定,已按 env 生效(env=${envDataRoot},profile=${profileRoot})`);
246
+ }
247
+ if (warn && envProjectsRoot && profileProjects && envProjectsRoot !== profileProjects && !warnedProjectsRootConflict) {
248
+ warnedProjectsRootConflict = true;
249
+ warn(`projectsRoot 同时由环境变量与 profile 配置指定,已按 env 生效(env=${envProjectsRoot},profile=${profileProjects})`);
250
+ }
251
+ const pointer = readLocationPointer(env);
252
+ // 坏指针 / 指向相对路径的指针不当依据:否则数据根会被悄悄换成空值。
253
+ const pointerDataRoot = pointer.error || !isAbsolute(pointer.dataRoot) ? "" : pointer.dataRoot;
254
+ const pointerProjectsRoot = pointer.error || !isAbsolute(pointer.projectsRoot) ? "" : pointer.projectsRoot;
255
+
256
+ const dataRoot = envDataRoot || pointerDataRoot || profileRoot || normalizeDirPath(defaultDataRoot(env), env);
257
+ const source = envDataRoot ? "env" : pointerDataRoot ? "pointer" : profileRoot ? "profile" : "default";
258
+ const projectsSource = envProjectsRoot ? "env" : pointerProjectsRoot ? "pointer" : profileProjects ? "profile" : "default";
259
+ const projectsRootValue = envProjectsRoot || pointerProjectsRoot || profileProjects;
260
+
261
+ const dataRootFact = rootFact(dataRoot, source, env);
262
+ const projectsRootFact = projectsRootValue
263
+ ? rootFact(projectsRootValue, projectsSource, env)
264
+ // 空串 = 沿用旧口径 `<dataRoot>/projects/<deckId>/source`:可写性跟 dataRoot 走,不伪造一个不存在的目录。
265
+ : { value: "", source: "default", envOverridden: false, writable: dataRootFact.writable, exists: dataRootFact.exists };
266
+
267
+ return {
268
+ dataRoot: dataRootFact,
269
+ projectsRoot: { ...projectsRootFact, effective: projectsRootValue || join(dataRootFact.value, "projects") },
270
+ dshHome: rootFact(dshHomeOf(env), env.DSH_HOME ? "env" : "default", env),
271
+ pointerFile: {
272
+ path: pointer.path,
273
+ exists: pointer.found,
274
+ error: pointer.error,
275
+ setBy: pointer.setBy,
276
+ updatedAt: pointer.updatedAt,
277
+ version: pointer.version,
278
+ },
279
+ envOverride: { active: source === "env", name: DATA_DIR_ENV },
280
+ // 真·首次:这个数据目录里还没有任何 deck、没存过出图设置、也没有指针文件(规格 §3 触发条件)。
281
+ firstRun: decksDirEmpty(dataRootFact.value) && !existsSync(join(dataRootFact.value, "render-settings.json")) && !pointer.found,
282
+ processCwd: process.cwd(),
283
+ sourceLabel: DATA_DIR_SOURCE_LABELS[source] ?? source,
284
+ profileDataRoot: profileRoot,
285
+ profileProjectsRoot: profileProjects,
286
+ defaults: { dataRoot: normalizeDirPath(defaultDataRoot(env), env) },
287
+ };
288
+ }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "dsh-plugin-deck-studio",
3
- "version": "0.2.10",
3
+ "version": "0.2.13",
4
4
  "description": "Deckline:DSH 的 image deck / PPT 工作台插件——原生 Chat + 整页图片 deck 工作区 + 设计风格库;用对话做 PPT、按页出图、版本可追踪、导出 PDF/PPTX。",
5
+ "icon": "assets/icon.svg",
5
6
  "homepage": "https://github.com/yuna78/dsh-deck-studio#readme",
6
7
  "repository": {
7
8
  "type": "git",
@@ -70,6 +71,7 @@
70
71
  "client.js",
71
72
  "lib/",
72
73
  "ui/",
74
+ "assets/",
73
75
  "seeds/",
74
76
  "cordis.patch.yml",
75
77
  "cordis.example.yml"
@@ -43,9 +43,13 @@ node -v && pnpm -v && dsh --version
43
43
  dsh --profile deckline --from-default-profile web
44
44
 
45
45
  # 2) install the plugin; dsh plugin add adds the bundle to the profile and enables it
46
- dsh plugin --profile deckline add dsh-plugin-deck-studio
46
+ dsh plugin --profile deckline add dsh-plugin-deck-studio@0.2.13
47
47
  ```
48
48
 
49
+ > Do not skip step 1: the `deckline` profile is **never created automatically**. Running
50
+ > `dsh --profile deckline --port 13090` without it fails with `profile "deckline" does not exist`
51
+ > — that is a missing profile, not a plugin problem.
52
+
49
53
  ### B. From the Git repository (public repo, no npm account needed)
50
54
 
51
55
  ```sh
@@ -146,17 +150,56 @@ creating a deck, check this table first.
146
150
  ```sh
147
151
  tar czf deckline-backup-$(date +%Y%m%d).tgz "$DSH_HOME/deckline" "<your projectsRoot>"
148
152
  ```
149
- - The workbench **Settings** panel shows the current data directory; keep `dataRoot` outside the source tree
150
- in normal installs so runtime data is portable and plugin upgrades cannot overwrite it.
153
+ - **Where to look:** if a top banner says the body/images are not in the current data directory, use its
154
+ "switch to this directory" (one click, no restart). With no banner (this deck was never initialized here) go
155
+ **⚙ → right-hand Settings → "Other" → "current data directory" → "open self-check" → "change data directory"**.
156
+ Both show `dataRoot` and what decided it (`env var` / `pointer file` / `profile config` / `default`); with
157
+ `DSH_DECK_DATA_ROOT` they add "set by environment variable (read-only)" — edit that env var and restart DSH.
158
+ - **Two instances on one machine read their own `dataRoot`.** 3080 (the default `web` profile) and 13090 (the
159
+ `deckline` profile) do not share a process and do **not** share data by default — each resolves its own
160
+ directory, both defaulting to `~/.dsh/deckline`; once either side sets `dataRoot` / `DSH_DECK_DATA_ROOT`
161
+ they diverge. So "the other port shows an empty deck / titles but blank pages" is not a port problem: check
162
+ the ⚙ panel on that instance. **Changing the port never changes data; only `dataRoot` does.**
163
+ - Do not assemble paths by hand when reporting a problem: **Copy diagnostics** at the bottom of the self-check
164
+ panel copies a sanitized report generated by the plugin (never keys) — cwd / `dataRoot` with its source /
165
+ `projectsRoot` / whether each deck has `script/current.md`.
166
+ - In normal installs keep `dataRoot` outside the source tree, and never write runtime data into the repo
167
+ (see the "development data directory" note in section 7).
168
+
169
+ ## 3. Start: the profile comes first, the port is cosmetic
170
+
171
+ `--profile deckline` only **selects** a profile, it never creates one. Starting without one stops at:
172
+
173
+ ```text
174
+ profile "deckline" does not exist
175
+ ```
151
176
 
152
- ## 3. Start
177
+ From zero to 13090, run these three commands in order (options for steps 1–2 are in section 1):
153
178
 
154
179
  ```sh
180
+ # 1) create the deckline profile (from the web template shipped with DSH)
181
+ dsh --profile deckline --from-default-profile web
182
+
183
+ # 2) install the plugin (adds it to that profile and enables it)
184
+ dsh plugin --profile deckline add dsh-plugin-deck-studio@0.2.13
185
+
186
+ # 3) start
155
187
  dsh --profile deckline --port 13090
156
188
  ```
157
189
 
158
190
  Open the `http://127.0.0.1:13090/?token=…` URL printed in the terminal.
159
- `--port` is optional; the default is 13090 to avoid colliding with DSH's web profile on 3080.
191
+ The explicit form `dsh --profile deckline web --port 13090` is the same thing with the `web` subcommand;
192
+ `profile "deckline" does not exist` has nothing to do with that spelling — it means the profile was never created.
193
+
194
+ ### How 3080 and 13090 relate
195
+
196
+ - **Ports pick the browser address, not the data.** Each instance resolves its own `dataRoot`; both default to
197
+ `~/.dsh/deckline`, so with defaults you see the same data. Set `dataRoot` / `DSH_DECK_DATA_ROOT` on one side and
198
+ they diverge.
199
+ - 3080 is the default `web` profile (`dsh web`); 13090 is the `deckline` profile. If the `web` profile also has
200
+ Deckline installed, it is simply a second, independent instance with its own data directory.
201
+ - **Changing the port never empties your data.** Titles showing with blank pages means the two instances point at
202
+ different `dataRoot`s — compare "current data directory" under ⚙ on both (section 2.5).
160
203
 
161
204
  ## 4. Configure the image key (required for real renders)
162
205
 
@@ -249,11 +292,20 @@ Swiss minimal / aurora gradient / deep-sea amber), each with samples. See [style
249
292
  |---|---|
250
293
  | Port already in use | `dsh --profile deckline --port 13100`; or kill the old process with `lsof -tiTCP:13090 -sTCP:LISTEN` |
251
294
  | Page 404 / blank | Make sure the plugin bundle is enabled: `dsh --profile deckline --dump-config \| grep deck-studio` |
295
+ | `profile "deckline" does not exist` on start | The profile was never created: run `dsh --profile deckline --from-default-profile web`, then `dsh plugin --profile deckline add …`, then start (section 3) |
296
+ | 3080 and 13090 show different content (titles but blank pages) | Not a port issue: the two instances have different `dataRoot`s. Compare "current data directory" under ⚙ on both (section 2.5); point them at the same path, or use only one |
297
+ | Top banner says body/images are not in the current data directory | Click "switch to this directory" in the banner — no restart needed. With no candidate listed, type the absolute path into the banner and confirm |
298
+ | No banner, but this deck has no body here | Normal only for a **locally created deck** on first open (its source is under `projectsRoot` and nothing has been rendered yet). Anything else — especially an external source deck — that has no body in the current data directory **always** gets the banner; with no candidate, the banner offers an absolute-path input |
299
+ | Settings says "set by environment variable DSH_DECK_DATA_ROOT (read-only)" | The UI cannot change it: edit that env var and restart DSH (unrelated to `--port`) |
300
+ | Inspector says "当前数据目录没有这本 deck 的 script" | The current data directory has no working copy of this deck. Check the `dataRoot` printed in that message (section 2.5) |
252
301
  | No "render" button / render fails | Check the key state in Settings; confirm `render.mode: real`; `curl` the node endpoint |
253
302
  | New decks land in the wrong place | Set `projectsRoot`; otherwise they go to `<dataRoot>/projects/` (by default `<DSH_HOME>/deckline/projects/`) |
254
303
  | Where are data / samples | See section 2.5: source decks in `projectsRoot`, working copies / renders in `dataRoot` (default `~/.dsh/deckline`) |
255
304
  | Upgrading the plugin | `dsh plugin --profile deckline install <spec>`, or `add` the new version again |
256
305
 
306
+ > Early profiles were named `deck-studio`: swap `deckline` in the commands above for that name — the two differ only
307
+ > in the profile name, which must match the directory under `~/.dsh/profiles/`.
308
+
257
309
  ## 7. Developers: local verification
258
310
 
259
311
  ```sh
@@ -264,3 +316,9 @@ dsh --profile deck-studio --no-open # development profile (port conf
264
316
 
265
317
  The plugin has no third-party runtime dependencies (Node built-ins only). Changes to
266
318
  `plugin/deck-studio/index.js` require a profile restart; changes to `ui/` html/css hot-reload.
319
+
320
+ > **Development data directory (debug only, not a recommendation):** the repo dev profile (`deck-studio`), or
321
+ > pointing `dataRoot` at the repo, writes runtime data to `.deck/` at the repo root (gitignored, never committed);
322
+ > the UI labels it as the **development data directory**. It exists so both profiles share one repo-local data
323
+ > root and traces stay continuous (ADR-0006 D6) — never present it as a default. Normal installs use
324
+ > `~/.dsh/deckline` or another directory outside the repo.
@@ -45,12 +45,16 @@ node -v && pnpm -v && dsh --version
45
45
  dsh --profile deckline --from-default-profile web
46
46
 
47
47
  # 2) 装插件;dsh plugin add 会自动把组合包加入 profile 并默认启用
48
- dsh plugin --profile deckline add dsh-plugin-deck-studio@0.2.4
48
+ dsh plugin --profile deckline add dsh-plugin-deck-studio@0.2.13
49
49
  ```
50
50
 
51
+ > 第 1 步不能省:`deckline` profile **不会**被自动创建。跳过第 1 步直接 `dsh --profile deckline --port 13090`,
52
+ > 报的是 `profile "deckline" does not exist` —— 这不是插件问题,是 profile 不存在。
53
+ > 装过之后再升级只走第 2 步,不用再建一次 profile。
54
+
51
55
  > 国内网络直连 `registry.npmjs.org` 可能很慢(20 MB 包实测约 19 KB/s,要十几分钟)。换镜像会快很多:
52
56
  > ```sh
53
- > npm_config_registry=https://registry.npmmirror.com dsh plugin --profile deckline add dsh-plugin-deck-studio@0.2.4
57
+ > npm_config_registry=https://registry.npmmirror.com dsh plugin --profile deckline add dsh-plugin-deck-studio@0.2.13
54
58
  > ```
55
59
  > 或一次性 `npm config set registry https://registry.npmmirror.com`。
56
60
 
@@ -71,7 +75,7 @@ DSH 目前没有官方插件市场,市场是「索引源」模式:添加下
71
75
  https://raw.githubusercontent.com/yuna78/dsh-deck-studio/main/market-index.json
72
76
  ```
73
77
 
74
- 3. 搜索 `Deckline` 或 `PPT` → **安装**,会安装 `dsh-plugin-deck-studio@0.2.4`(索引里声明了国内镜像 registry)。
78
+ 3. 搜索 `Deckline` 或 `PPT` → **安装**,会安装 `dsh-plugin-deck-studio@0.2.13`(索引里声明了国内镜像 registry)。
75
79
  4. 之后同 A 的第 3 步启动。
76
80
 
77
81
  索引文件就是仓库根目录的 [`market-index.json`](../market-index.json)(格式 1),新版本发布后更新版本号即可。
@@ -175,19 +179,61 @@ Deckline 的数据分两处:**你的 deck 源**(`projectsRoot`,纯 md +
175
179
  ```sh
176
180
  tar czf deckline-backup-$(date +%Y%m%d).tgz "$DSH_HOME/deckline" "<你的 projectsRoot>"
177
181
  ```
178
- - 工作台「设置」面板会显示当前数据目录;开发环境也建议使用独立的 `dataRoot`,不要把运行数据写进仓库。
182
+ - **「当前数据目录」在哪看**:指错目录时先看顶栏有没有黄条——有就点黄条里的「切到这个目录」,一步到位;没有黄条
183
+ (这本 deck 从没初始化过)才走 **⚙ → 右栏设置 →「其它」→「当前数据目录」→「打开自检面板」→「改数据目录」** 手填。
184
+ 两处都写着 `dataRoot` 和它是被谁定的(`环境变量` / `指针文件` / `profile 配置` / `默认值`);env 覆盖时追加
185
+ 「由环境变量 DSH_DECK_DATA_ROOT 指定(只读)」,界面改不了,只能改环境变量后重启 DSH。
186
+ - **同一台机器上的两个实例,看的是各自的 `dataRoot`**:3080(默认 `web` profile)和 13090(`deckline` profile)
187
+ 不共享进程,也**不天然共享数据**——各自解析自己的数据目录,默认都是 `~/.dsh/deckline`;任意一边配了
188
+ `dataRoot` / `DSH_DECK_DATA_ROOT`,两边就各看各的。所以「换到另一个端口,内容变空 / 标题在但页面空白」先别怀疑端口:
189
+ 去那个实例的 ⚙ 看「当前数据目录」,再和这边显示的路径比一比。
190
+ **换端口不改数据,换 dataRoot 才改数据。**
191
+ - 报问题不用自己拼路径:⚙ →「当前数据目录与自检」底部 **「复制诊断信息」** 一键复制(内容由插件生成、已脱敏,
192
+ 不含 key),贴出来就包含 cwd / dataRoot(含来源)/ projectsRoot / 每本 deck 有没有 `script/current.md`。
193
+ - 开发环境用独立 `dataRoot`,不要把运行数据写进仓库(见 §7「开发数据目录」)。
179
194
  - **preset 工具实例要和根挂载用同一个数据根**(F-98):DSH 面板路由与 Agent 工具是两个实例,根挂载里覆盖了
180
195
  `dataRoot` / `projectsRoot` 时,preset 的 `deck-studio-tools` 也要写同一组值(推荐 YAML 锚点),
181
196
  否则新建 deck 只进一边的注册表,Agent 会报「未知 deck:…」。
182
197
 
183
- ## 3. 启动
198
+ ## 3. 启动:先有 profile,再谈端口
199
+
200
+ `--profile deckline` 只**选** profile,不会创建它。profile 没建就启动,会停在这一行:
201
+
202
+ ```text
203
+ profile "deckline" does not exist
204
+ ```
205
+
206
+ 从零跑到 13090,三条命令按顺序执行(第 1、2 步的具体选项见 §1;少任何一条都会停在上面的报错或白屏):
184
207
 
185
208
  ```sh
209
+ # 1) 建 deckline profile(基于 DSH 自带的 web 模板)
210
+ dsh --profile deckline --from-default-profile web
211
+
212
+ # 2) 装插件(自动加进该 profile 并启用)
213
+ dsh plugin --profile deckline add dsh-plugin-deck-studio@0.2.13
214
+
215
+ # 3) 启动
186
216
  dsh --profile deckline --port 13090
187
217
  ```
188
218
 
189
219
  浏览器打开终端打印的 `http://127.0.0.1:13090/?token=…`。
190
- `--port` 可换;不换默认 13090,避免和 DSH web profile 的 3080 冲突。
220
+
221
+ 带 `web` 子命令的写法也常见:`dsh --profile deckline web --port 13090`。它和上面第 3 条只差一个子命令;
222
+ `profile "deckline" does not exist` 与写法无关,只说明 profile 没建。
223
+
224
+ ### 3080 和 13090 是什么关系
225
+
226
+ | | 3080(默认 `web` profile) | 13090(`deckline` profile) |
227
+ |---|---|---|
228
+ | 怎么起 | `dsh web`(不带 `--profile`) | `dsh --profile deckline --port 13090` |
229
+ | 装了什么 | 你日常的插件;**它若也装了 Deckline,就是另一个独立实例** | 单独一份 Deckline 挂载(同一个 npm 包) |
230
+ | 看哪份数据 | 这个 profile 自己的 `dataRoot` | 这个 profile 自己的 `dataRoot` |
231
+
232
+ - **端口只决定浏览器地址,不决定数据。** 两边各自解析 `dataRoot`:默认都是 `~/.dsh/deckline`,
233
+ 所以都保持默认时看到的是同一份数据;哪一边配了 `dataRoot` / `DSH_DECK_DATA_ROOT`,那边就换成另一个目录。
234
+ - 想只给 Deckline 一个入口,就用 13090;想让两边看到同一份数据,就**别只给一边配 `dataRoot`**(都不配,或配成同一路径)。
235
+ - 反过来:**换端口不会让内容变空**。两个实例看到不同内容,几乎都是 `dataRoot` 指向了不同目录
236
+ (界面表现:标题在、页面空白、Inspector 报「读取正文失败」)——按 §2.5 在两边 ⚙ 里比对「当前数据目录」。
191
237
 
192
238
  ## 4. 配置生图 key(正式出图必看)
193
239
 
@@ -303,11 +349,20 @@ Designs 页 **+ 导入预设风格** 会一次导入 9 个自带风格(商
303
349
  |---|---|
304
350
  | 端口被占 | `dsh --profile deckline --port 13100`;或 `lsof -tiTCP:13090 -sTCP:LISTEN` 杀掉旧进程 |
305
351
  | 页面 404 / 白屏 | 确认插件组合包已启用:`dsh --profile deckline --dump-config \| grep deck-studio` |
352
+ | 启动报 `profile "deckline" does not exist` | profile 没建,不是插件问题:先 `dsh --profile deckline --from-default-profile web`,再 `dsh plugin --profile deckline add …`,然后启动(§3) |
353
+ | 3080 和 13090 内容不一样(标题在、页面空白) | 不是端口问题:两个实例的 `dataRoot` 不同。按 §2.5 在两边 ⚙ 里比对「当前数据目录」;让两边指向同一路径,或只用其中一个 |
354
+ | 顶栏黄条:正文 / 图片不在当前数据目录(有 deck 但没 script,或图片在正文丢了) | 点黄条里的「切到这个目录」,不重启即恢复;候选为空时在黄条里填绝对路径 →「用这个目录」 |
355
+ | 没有黄条,但这本 deck 里没有正文 | 正常:只有**本机新建 deck**(源就在 `projectsRoot`,还没出过图)首次打开时静默。其它情况——尤其外部源 deck——当前数据目录里找不到正文**一定**出黄条,零候选时黄条里给手填绝对路径输入框 |
356
+ | 设置里显示「由环境变量 DSH_DECK_DATA_ROOT 指定(只读)」 | 界面不能改:改这个环境变量后重启 DSH(与 `--port` 无关) |
357
+ | Inspector 报「读取正文失败:当前数据目录没有这本 deck 的 script」 | 当前数据目录里没有这本 deck 的工作副本。按文案里的 `dataRoot` 核对(§2.5) |
306
358
  | 没有「生图」按钮 / 出图失败 | 设置面板看 key 是否配置;确认 `render.mode: real`;节点 endpoint 可 `curl` 通 |
307
359
  | 新 deck 目录落错位置 | 配 `projectsRoot`;否则落在 `<dataRoot>/projects/`(默认 `<DSH_HOME>/deckline/projects/`) |
308
360
  | 数据 / 样张在哪 | 见 §2.5:源 deck 在 `projectsRoot`,工作副本 / 出图在 `dataRoot`(默认 `~/.dsh/deckline`) |
309
361
  | 升级插件 | `dsh plugin --profile deckline install <spec>`,或重新 `add` 新版本 |
310
362
 
363
+ > 早期按 `deck-studio` 建过 profile 的机器:把上面命令里的 `deckline` 换成那个名字即可——两者只是 profile 名不同,
364
+ > 命令里的名字要和 `~/.dsh/profiles/` 下的目录名一致。
365
+
311
366
  ## 7. 开发者:本地验证
312
367
 
313
368
  ```sh
@@ -319,6 +374,11 @@ dsh --profile deck-studio --no-open # 本仓开发 profile(端口
319
374
  插件无第三方运行时依赖(只用 Node 内置模块);改 `plugin/deck-studio/index.js` 需要重启 profile,
320
375
  只改 `ui/` 里的 html/css 会热更新。
321
376
 
377
+ > **开发数据目录(仅调试用,不是推荐值)**:本仓开发 profile(`deck-studio`),或手动把 `dataRoot` 指到仓库时,
378
+ > 运行数据会落在仓库根的 `.deck/`(已在 `.gitignore`,不会进 git),界面上标成**「开发数据目录」**。
379
+ > 这么做只为「两个 profile 同仓同数据、trace 连续」(ADR-0006 D6),**不要当成默认值写给用户**:
380
+ > 正常安装用默认的 `~/.dsh/deckline`,或自己指定一个仓库外的目录。
381
+
322
382
  ## 2.5 首次启动会自动准备好什么(bootstrap)
323
383
 
324
384
  装完第一次启动(DSH 0.2.x 会先弹一次 Preview Notice,点 **Continue** 即可),Deckline 默认做四件事:
package/ui/workbench.css CHANGED
@@ -238,6 +238,44 @@ body.resizing{user-select:none;cursor:col-resize}
238
238
  .hn-label{flex:1}
239
239
  .home-nav-item em{font-style:normal;font-size:10px;color:#93a0a3;background:#fffdf8;border:1px solid var(--line);border-radius:9px;padding:0 5px}
240
240
  .home-nav-item.active em{color:var(--teal);border-color:#bfe0dc}
241
+ /* F-193/F-194:左栏「关于」单入口(版本 chip + 有新版小圆点) */
242
+ .home-nav-sep{height:1px;background:var(--line);margin:10px 4px 0}
243
+ .hn-dot{width:6px;height:6px;border-radius:50%;background:var(--gold);box-shadow:0 0 0 3px color-mix(in srgb,var(--gold) 18%,transparent)}
244
+ .hn-dot[hidden]{display:none}
245
+ /* F-194:「关于」单页——从上到下:作者 → 公众号 → 版本 → 开源 → 其他项目 */
246
+ .about-stack{display:flex;flex-direction:column;gap:12px;max-width:880px}
247
+ .about-card{border:1px solid var(--line);background:#fffdf8;border-radius:10px;padding:16px 18px;min-width:0}
248
+ .about-card h3{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin:0 0 8px;font-family:"Noto Serif SC",Georgia,serif;font-size:16px;color:var(--ink)}
249
+ .about-role{font-family:Inter,-apple-system,sans-serif;font-size:10.5px;font-weight:400;color:#93a0a3;letter-spacing:.02em}
250
+ .about-hero{display:flex;flex-direction:column;gap:14px;background:linear-gradient(180deg,#fffdf8 0%,#faf5e9 100%)}
251
+ .about-profile{display:flex;gap:14px;align-items:center;min-width:0}
252
+ .about-wechat{display:flex;align-items:center;gap:14px;border-top:1px solid var(--line);padding-top:13px}
253
+ .about-wechat-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
254
+ .about-wechat b{font-size:13px;color:var(--teal);line-height:1.5}
255
+ .about-wechat .eyebrow{color:#93a0a3}
256
+ .about-wechat .about-hint{margin:2px 0 0}
257
+ .about-wechat .about-qr{flex:0 0 auto;width:104px;max-width:104px;border-radius:7px}
258
+ .about-avatar{flex:0 0 auto;width:58px;height:58px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-family:"Noto Serif SC",Georgia,serif;font-size:24px;color:#fffdf8;background:linear-gradient(145deg,#0e5563,#08343d);box-shadow:inset 0 0 0 1px #ffffff22}
259
+ .about-profile-body{min-width:0;max-width:560px}
260
+ .about-desc{margin:0 0 8px;color:#5d7276;font-size:12px;line-height:1.65}
261
+ .about-line{margin:0 0 4px;color:#728086;font-size:11.5px}
262
+ .about-line b{color:var(--teal)}
263
+ .about-line code{font-size:11px;color:#5d7276;background:#f3efe4;border:1px solid var(--line);border-radius:5px;padding:1px 5px}
264
+ .about-hint{margin:8px 0 0;color:#93a0a3;font-size:10.5px;line-height:1.6;word-break:break-word}
265
+ .about-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
266
+ .about-actions .btn{font-size:11px}
267
+ .about-state{margin:8px 0 0;color:var(--gold)}
268
+ .about-qr{display:block;width:140px;max-width:140px;aspect-ratio:1;object-fit:contain;border:1px solid var(--line);border-radius:8px;background:#fff}
269
+ .about-qr-empty{display:flex;align-items:center;justify-content:center;width:140px;aspect-ratio:1;color:#93a0a3;font-size:11px;border:1px dashed var(--line);border-radius:8px;background:#f8f5ed;text-align:center;padding:8px}
270
+ .about-project-list{list-style:none;margin:2px 0 0;padding:0}
271
+ .about-project-row{display:flex;gap:14px;align-items:flex-start;justify-content:space-between;padding:12px 0;border-top:1px solid var(--line)}
272
+ .about-project-row:first-child{border-top:0;padding-top:2px}
273
+ .about-project-main{flex:1;min-width:0}
274
+ .about-project-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
275
+ .about-project-head b{font-size:13px;color:var(--ink)}
276
+ .about-badge{flex:0 0 auto;font-size:10px;color:var(--teal);background:#e2f1ef;border:1px solid #bfe0dc;border-radius:9px;padding:1px 7px}
277
+ .about-project-row .about-desc{margin:4px 0 0}
278
+ .about-project-row .btn{flex:0 0 auto}
241
279
  .home-main{padding:26px 32px;overflow-y:auto;min-width:0}
242
280
  .home-greet{padding-bottom:16px;border-bottom:1px solid var(--line);margin-bottom:18px}
243
281
  .home-greet h1{margin:5px 0 4px;font-family:"Noto Serif SC",Georgia,serif;font-size:30px;color:var(--ink);letter-spacing:.01em}