zdashboard 2.6.2 → 2.8.0

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.
Files changed (42) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +695 -708
  3. package/dist/cli.js.map +1 -1
  4. package/dist/web/assets/BatchView-C7Y83zJ8.js +2 -0
  5. package/dist/web/assets/CheckpointViewer-Dnakl7qw.js +1 -0
  6. package/dist/web/assets/{FileIcon-Bc03q6Gz.js → CodeViewer-DKQhzhtt.js} +31 -31
  7. package/dist/web/assets/DependencyGraph-M-usNrkC.js +1 -0
  8. package/dist/web/assets/FileIcon-AydLkQKG.js +1 -0
  9. package/dist/web/assets/{FilterPills-CSZQenbh.js → FilterPills-X52teHq7.js} +1 -1
  10. package/dist/web/assets/{ProgressBar-ycyJBwG-.js → ProgressBar-C8W_FpI2.js} +1 -1
  11. package/dist/web/assets/Sidebar-BUEb7NX4.js +1 -0
  12. package/dist/web/assets/Sidebar-R9gmd6Ss.js +1 -0
  13. package/dist/web/assets/SingleChangeView-Cfr_Wkzz.js +1 -0
  14. package/dist/web/assets/Workspace-C2bvxp8p.js +1 -0
  15. package/dist/web/assets/Workspace-CkU9yWvw.js +5 -0
  16. package/dist/web/assets/Workspace-CqTemGJp.js +2 -0
  17. package/dist/web/assets/Workspace-Do07KofT.js +1 -0
  18. package/dist/web/assets/Workspace-qzGIvIU-.js +1 -0
  19. package/dist/web/assets/client-Cw4K1Nx3.js +1 -0
  20. package/dist/web/assets/{index-Co_rMXPa.js → index-B1epVp2c.js} +1522 -1522
  21. package/dist/web/assets/index-BJDneNeU.css +1 -0
  22. package/dist/web/assets/{index-BrvOKJRF.js → index-DOZp2EHd.js} +2 -2
  23. package/dist/web/assets/usePluginData-jGCocXxu.js +1 -0
  24. package/dist/web/assets/web-BROwAvbQ.js +2 -0
  25. package/dist/web/assets/web-C-_Hr8JN.js +2 -0
  26. package/dist/web/assets/web-C4mUiGNR.js +2 -0
  27. package/dist/web/assets/web-He9MXVk-.js +2 -0
  28. package/dist/web/assets/web-VATakLhV.js +2 -0
  29. package/dist/web/favicon.svg +13 -5
  30. package/dist/web/index.html +3 -3
  31. package/package.json +5 -1
  32. package/dist/web/assets/Workspace-jCfxaTOM.js +0 -1
  33. package/dist/web/assets/badge-DUO7nDks.js +0 -1
  34. package/dist/web/assets/index-4aiRmNbP.css +0 -1
  35. package/dist/web/assets/web-BKh2HY35.js +0 -2
  36. package/dist/web/assets/web-BNjs9lCy.js +0 -139
  37. package/dist/web/assets/web-BrsVNrGk.js +0 -1
  38. package/dist/web/assets/web-Bt0s1qFE.js +0 -5
  39. package/dist/web/assets/web-CCoznDNk.js +0 -2
  40. package/dist/web/assets/web-DxK2pSVl.js +0 -1
  41. package/dist/web/assets/web-ShIDOvFD.js +0 -1
  42. /package/dist/web/assets/{FileIcon-BTcE7LSd.css → CodeViewer-BTcE7LSd.css} +0 -0
package/dist/cli.js CHANGED
@@ -8,150 +8,26 @@ import { access, readdir } from "fs/promises";
8
8
  import { parseArgs as utilParseArgs } from "util";
9
9
 
10
10
  // src/server/detect.ts
11
- import fs2 from "fs";
12
- import path2 from "path";
13
- import { execFile } from "child_process";
14
-
15
- // src/server/bugs.ts
16
11
  import fs from "fs";
17
12
  import path from "path";
18
- import YAML from "yaml";
19
-
20
- // src/server/api/fetch.ts
21
- import ky from "ky";
22
-
23
- // src/server/errors.ts
24
- var HttpError = class extends Error {
25
- constructor(status, message, body) {
26
- super(message);
27
- this.status = status;
28
- this.body = body;
29
- this.name = "HttpError";
30
- }
31
- status;
32
- body;
33
- };
34
- var NetworkError = class extends Error {
35
- constructor(message, cause) {
36
- super(message);
37
- this.cause = cause;
38
- this.name = "NetworkError";
39
- }
40
- cause;
41
- };
42
-
43
- // src/server/api/fetch.ts
44
- async function fetchJson(url, init) {
45
- try {
46
- const res = await ky(url, { ...init, timeout: 8e3, retry: 2 });
47
- return await res.json();
48
- } catch (e) {
49
- if (e instanceof HttpError) throw e;
50
- if (e instanceof Error && e.name === "TimeoutError") {
51
- throw new NetworkError(`\u8BF7\u6C42\u8D85\u65F6: ${url}`);
52
- }
53
- if (e instanceof Error && e.name === "HTTPError") {
54
- throw new HttpError(e.status ?? 500, e.message);
55
- }
56
- throw new NetworkError(`\u8BF7\u6C42\u5931\u8D25: ${url}`, e);
57
- }
58
- }
59
-
60
- // src/server/bugs.ts
61
- var BUGS_CONFIG_CANDIDATES = [".zdev/config.yaml", ".zgoal/config.yaml"];
62
- function loadConfig(root) {
63
- for (const rel of BUGS_CONFIG_CANDIDATES) {
64
- const file = path.join(root, rel);
65
- if (!fs.existsSync(file)) continue;
66
- try {
67
- const kv = YAML.parse(fs.readFileSync(file, "utf8"));
68
- if (!kv || typeof kv !== "object") return null;
69
- const record = kv;
70
- const product = Number(record.product);
71
- if (!record.url || !product) return null;
72
- const str = (v) => typeof v === "string" ? v : v == null ? void 0 : String(v);
73
- return {
74
- url: String(record.url).replace(/\/+$/, ""),
75
- account: str(record.account) ?? "",
76
- password: str(record.password),
77
- token: str(record.token),
78
- product
79
- };
80
- } catch {
81
- return null;
82
- }
83
- }
84
- return null;
85
- }
86
- var tokenCache = null;
87
- async function getToken(cfg) {
88
- if (cfg.token) return cfg.token;
89
- const key = `${cfg.url}|${cfg.account}|${cfg.password ?? ""}`;
90
- if (tokenCache && tokenCache.key === key && Date.now() - tokenCache.at < 10 * 6e4) return tokenCache.token;
91
- const json = await fetchJson(`${cfg.url}/api.php/v1/tokens`, {
92
- method: "POST",
93
- headers: { "Content-Type": "application/json" },
94
- body: JSON.stringify({ account: cfg.account, password: cfg.password })
95
- });
96
- const token = typeof json.token === "string" ? json.token : "";
97
- if (!token) throw new Error("token \u83B7\u53D6\u5931\u8D25:\u68C0\u67E5 account / password");
98
- tokenCache = { key, token, at: Date.now() };
99
- return token;
100
- }
101
- function normBug(b, account) {
102
- const assigned = b.assignedTo;
103
- const assignedTo = typeof assigned === "string" ? assigned : assigned && typeof assigned === "object" && "realname" in assigned ? String(assigned.realname ?? "") : "";
104
- const assignedAccount = typeof assigned === "string" ? assigned : assigned && typeof assigned === "object" ? String(assigned.account ?? "") : "";
105
- const mine = !!account && (assignedAccount === account || assignedTo === account);
106
- return {
107
- id: Number(b.id),
108
- title: String(b.title ?? ""),
109
- severity: b.severity ?? 4,
110
- pri: b.pri ?? 3,
111
- status: String(b.status ?? ""),
112
- assignedTo,
113
- openedBy: typeof b.openedBy === "string" ? b.openedBy : void 0,
114
- mine
115
- };
116
- }
117
- async function fetchBugs(root) {
118
- const cfg = loadConfig(root);
119
- if (!cfg) {
120
- const anyExists = BUGS_CONFIG_CANDIDATES.some((rel) => fs.existsSync(path.join(root, rel)));
121
- return {
122
- ok: false,
123
- error: anyExists ? "\u914D\u7F6E\u5B58\u5728\u4F46\u65E0\u6548(\u7F3A url \u6216 product \u5B57\u6BB5)\u2014\u2014\u68C0\u67E5 .zdev/config.yaml" : ".zdev/config.yaml \u7F3A\u5931(\u7531 zgoal skill \u521B\u5EFA;\u5B58\u91CF .zgoal/config.yaml \u4EA6\u53EF)"
124
- };
125
- }
126
- try {
127
- const token = await getToken(cfg);
128
- const json = await fetchJson(
129
- `${cfg.url}/api.php/v1/products/${cfg.product}/bugs?page=1&limit=100`,
130
- { headers: { Token: token } }
131
- );
132
- const raw = Array.isArray(json.bugs) ? json.bugs : [];
133
- return { ok: true, url: cfg.url, total: Number(json.total ?? raw.length), bugs: raw.map((b) => normBug(b, cfg.account)) };
134
- } catch (e) {
135
- const msg = e instanceof Error ? e.message : String(e);
136
- return { ok: false, error: `\u7985\u9053\u8BF7\u6C42\u5931\u8D25(${msg})\u2014\u2014\u68C0\u67E5 url / \u51ED\u636E / \u662F\u5426\u5F00\u542F RESTful API v1` };
137
- }
138
- }
139
-
140
- // src/server/detect.ts
13
+ import { execFile } from "child_process";
14
+ var JUST_PROBE_TIMEOUT_MS = 5e3;
15
+ var JUST_FALLBACK_TIMEOUT_MS = 3e3;
141
16
  function justAvailable(cwd) {
142
- return new Promise((resolve) => {
143
- const child = execFile("just", ["--list", "--unsorted"], { cwd, timeout: 5e3 }, (err) => {
144
- resolve(!err);
145
- });
17
+ const probe = (args, timeout) => new Promise((resolve) => {
18
+ const child = execFile("just", args, { cwd, timeout }, (err) => resolve(!err));
146
19
  if (child.killed) resolve(false);
147
20
  });
21
+ return probe(["--list", "--unsupported"], JUST_PROBE_TIMEOUT_MS).then((ok) => ok ? true : probe(["--version"], JUST_FALLBACK_TIMEOUT_MS));
148
22
  }
149
23
  async function detect(root) {
150
- const hasOpenspec = fs2.existsSync(path2.join(root, "openspec"));
151
- const hasDocs = fs2.existsSync(path2.join(root, "docs"));
24
+ const hasOpenspec = fs.existsSync(path.join(root, "openspec"));
25
+ const hasDocs = fs.existsSync(path.join(root, "docs"));
152
26
  const hasJust = await justAvailable(root);
153
- const hasBugs = BUGS_CONFIG_CANDIDATES.some((rel) => fs2.existsSync(path2.join(root, rel)));
154
- return { hasOpenspec, hasDocs, hasJust, hasBugs };
27
+ return { hasOpenspec, hasDocs, hasJust };
28
+ }
29
+ async function detectLiveShape(root) {
30
+ return detect(root);
155
31
  }
156
32
 
157
33
  // src/cli.ts
@@ -159,15 +35,15 @@ import { Context } from "cordis";
159
35
 
160
36
  // src/core/server.ts
161
37
  import http from "http";
162
- import fs4 from "fs";
163
- import path4 from "path";
38
+ import fs3 from "fs";
39
+ import path3 from "path";
164
40
  import crypto from "crypto";
165
41
  import { fileURLToPath } from "url";
166
42
 
167
43
  // package.json
168
44
  var package_default = {
169
45
  name: "zdashboard",
170
- version: "2.6.2",
46
+ version: "2.8.0",
171
47
  description: "ZCode skill dashboard platform \u2014 pluggable viewers for zdesign/zview/zreview/zgoal",
172
48
  type: "module",
173
49
  bin: {
@@ -185,6 +61,9 @@ var package_default = {
185
61
  "build:web": "vite build",
186
62
  "build:node": "tsup",
187
63
  start: "node dist/cli.js",
64
+ test: "vitest run",
65
+ typecheck: "tsc --noEmit",
66
+ "test:coverage": "vitest run --coverage",
188
67
  preview: "vite preview"
189
68
  },
190
69
  dependencies: {
@@ -236,6 +115,7 @@ var package_default = {
236
115
  "@types/react": "^18.3.12",
237
116
  "@types/react-dom": "^18.3.1",
238
117
  "@vitejs/plugin-react": "^4.7.0",
118
+ "@vitest/coverage-v8": "1.6.0",
239
119
  autoprefixer: "^10.4.20",
240
120
  jsdom: "^30.0.1",
241
121
  postcss: "^8.4.49",
@@ -257,20 +137,20 @@ var package_default = {
257
137
  import { Service } from "cordis";
258
138
 
259
139
  // src/core/instance.ts
260
- import fs3 from "fs";
261
- import path3 from "path";
140
+ import fs2 from "fs";
141
+ import path2 from "path";
262
142
  var RECORD_FILE = ".zdev/dashboard.json";
263
143
  var VERIFY_TIMEOUT_MS = 1500;
264
144
  var STOP_POLL_MS = 2e3;
265
145
  var STOP_POLL_INTERVAL_MS = 100;
266
146
  var STOP_FINAL_WAIT_MS = 100;
267
147
  function recordPath(root) {
268
- return path3.join(root, RECORD_FILE);
148
+ return path2.join(root, RECORD_FILE);
269
149
  }
270
150
  function readRecord(root) {
271
151
  try {
272
152
  const fp = recordPath(root);
273
- const raw = fs3.readFileSync(fp, "utf-8");
153
+ const raw = fs2.readFileSync(fp, "utf-8");
274
154
  const rec = JSON.parse(raw);
275
155
  if (typeof rec.pid === "number" && typeof rec.port === "number" && typeof rec.root === "string" && typeof rec.startedAt === "string") {
276
156
  return rec;
@@ -281,20 +161,89 @@ function readRecord(root) {
281
161
  }
282
162
  }
283
163
  function writeRecord(root, port) {
284
- fs3.mkdirSync(path3.dirname(recordPath(root)), { recursive: true });
164
+ const fp = recordPath(root);
165
+ fs2.mkdirSync(path2.dirname(fp), { recursive: true });
166
+ const old = readRecord(root);
285
167
  const rec = {
286
168
  pid: process.pid,
287
169
  port,
288
170
  root,
289
171
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
290
172
  };
291
- fs3.writeFileSync(recordPath(root), JSON.stringify(rec, null, 2) + "\n");
173
+ if (old?.plugins && typeof old.plugins === "object") {
174
+ rec.plugins = old.plugins;
175
+ }
176
+ const tmp = fp + ".tmp";
177
+ fs2.writeFileSync(tmp, JSON.stringify(rec, null, 2) + "\n");
178
+ fs2.renameSync(tmp, fp);
179
+ }
180
+ function readPluginsConfig(root) {
181
+ try {
182
+ const rec = readRecord(root);
183
+ if (rec?.plugins && typeof rec.plugins === "object") {
184
+ return rec.plugins;
185
+ }
186
+ } catch {
187
+ }
188
+ return {};
189
+ }
190
+ function writePluginsConfig(root, plugins) {
191
+ const fp = recordPath(root);
192
+ let rec;
193
+ try {
194
+ const raw = fs2.readFileSync(fp, "utf-8");
195
+ rec = JSON.parse(raw);
196
+ } catch {
197
+ rec = { pid: process.pid, port: 0, root, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
198
+ }
199
+ rec.plugins = plugins;
200
+ const tmp = fp + ".tmp";
201
+ fs2.writeFileSync(tmp, JSON.stringify(rec, null, 2) + "\n");
202
+ fs2.renameSync(tmp, fp);
203
+ }
204
+ function stripDeadPluginConfig(root, manifests) {
205
+ try {
206
+ const rec = readRecord(root);
207
+ if (!rec?.plugins || typeof rec.plugins !== "object") return false;
208
+ const byMode = new Map(manifests.map((m) => [m.mode, m]));
209
+ const stored = rec.plugins;
210
+ const plugins = { ...stored };
211
+ let changed = false;
212
+ for (const mode of Object.keys(plugins)) {
213
+ const m = byMode.get(mode);
214
+ if (!m || m.external) continue;
215
+ const section = plugins[mode];
216
+ if (section === null || typeof section !== "object") continue;
217
+ const keys = Object.keys(section);
218
+ if (!m.config || Object.keys(m.config).length === 0) {
219
+ if (keys.length > 0) {
220
+ delete plugins[mode];
221
+ changed = true;
222
+ }
223
+ continue;
224
+ }
225
+ const kept = {};
226
+ for (const key of Object.keys(m.config)) {
227
+ if (key in section) kept[key] = section[key];
228
+ }
229
+ if (Object.keys(kept).length !== keys.length) {
230
+ if (Object.keys(kept).length === 0) delete plugins[mode];
231
+ else plugins[mode] = kept;
232
+ changed = true;
233
+ }
234
+ }
235
+ if (!changed) return false;
236
+ writePluginsConfig(root, plugins);
237
+ return true;
238
+ } catch {
239
+ return false;
240
+ }
292
241
  }
293
242
  function clearRecord(root) {
294
243
  try {
295
244
  const rec = readRecord(root);
296
245
  if (rec && rec.pid !== process.pid) return;
297
- fs3.unlinkSync(recordPath(root));
246
+ fs2.unlinkSync(recordPath(root));
298
247
  } catch {
299
248
  }
300
249
  }
@@ -366,7 +315,7 @@ function openUrl(url) {
366
315
  // src/core/server.ts
367
316
  import { execFile as execFile3 } from "child_process";
368
317
  var VERSION = package_default.version;
369
- var __dirname = path4.dirname(fileURLToPath(import.meta.url));
318
+ var __dirname = path3.dirname(fileURLToPath(import.meta.url));
370
319
  var MIME = {
371
320
  ".html": "text/html; charset=utf-8",
372
321
  ".htm": "text/html; charset=utf-8",
@@ -421,8 +370,20 @@ var MIME = {
421
370
  ".ttf": "font/woff",
422
371
  ".map": "application/json; charset=utf-8"
423
372
  };
424
- var INJECT = `<script>(function(){try{var es=new EventSource('/__reload');es.addEventListener('reload',function(){location.reload();});es.onerror=function(){es.close();};}catch(e){}document.addEventListener('click',function(e){var t=e.target;if(t&&t.closest){var a=t.closest('a[target]');if(a&&a.target!=='_self'){a.target='_self';}}},true);})();</script>`;
373
+ var INJECT = `<script>(function(){document.addEventListener('click',function(e){var t=e.target;if(t&&t.closest){var a=t.closest('a[target]');if(a&&a.target!=='_self'){a.target='_self';}}},true);})();</script>`;
425
374
  var PLUGIN_STATIC_PREFIX = "/__plugin/";
375
+ function json(res, data, status = 200) {
376
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
377
+ res.end(JSON.stringify(data));
378
+ }
379
+ function guarded(req, res, token) {
380
+ if (req.headers["x-stop-token"] !== token) {
381
+ res.writeHead(403);
382
+ res.end("forbidden");
383
+ return false;
384
+ }
385
+ return true;
386
+ }
426
387
  var ServerService = class extends Service {
427
388
  stopToken;
428
389
  root;
@@ -449,17 +410,43 @@ var ServerService = class extends Service {
449
410
  ctx.effect(() => () => this.dispose());
450
411
  this.refreshGitInfo();
451
412
  this.route("/__config", (_req, res) => {
452
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
453
- res.end(JSON.stringify({ stopToken: this.stopToken, version: VERSION, root: this.root, ...this.gitInfo }));
413
+ json(res, { stopToken: this.stopToken, version: VERSION, root: this.root, ...this.gitInfo });
454
414
  });
455
- this.route("/__stop", async (req, res) => {
456
- if (req.headers["x-stop-token"] !== this.stopToken) {
415
+ this.route("/__detect", async (_req, res) => {
416
+ const shape = await detectLiveShape(this.root);
417
+ json(res, shape);
418
+ });
419
+ this.route("/favicon.svg", (req, res) => {
420
+ this.serveFile(path3.join(this.appDir, "favicon.svg"), req, res, false);
421
+ });
422
+ this.route("/__file-content", async (req, res) => {
423
+ const url = new URL(req.url || "/", "http://localhost");
424
+ const rawPath = url.pathname.replace(/^\/__file-content\/?/, "");
425
+ if (!rawPath || rawPath === "/") {
426
+ res.writeHead(400);
427
+ res.end("missing path");
428
+ return;
429
+ }
430
+ const filePath = path3.join(this.root, decodeURIComponent(rawPath));
431
+ if (!filePath.startsWith(this.root + path3.sep) && filePath !== this.root) {
457
432
  res.writeHead(403);
458
- res.end("forbidden");
433
+ res.end("Forbidden");
459
434
  return;
460
435
  }
461
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
462
- res.end('{"ok":true}');
436
+ fs3.stat(filePath, (err) => {
437
+ if (err) {
438
+ res.writeHead(404);
439
+ return res.end("Not found");
440
+ }
441
+ const ext = path3.extname(filePath).toLowerCase();
442
+ const ct = MIME[ext] ?? "application/octet-stream";
443
+ res.writeHead(200, { "Content-Type": ct, "Cache-Control": "no-cache" });
444
+ fs3.createReadStream(filePath).pipe(res);
445
+ });
446
+ });
447
+ this.route("/__stop", async (req, res) => {
448
+ if (!guarded(req, res, this.stopToken)) return;
449
+ json(res, { ok: true });
463
450
  this.stop();
464
451
  });
465
452
  this.start(config.port);
@@ -476,13 +463,24 @@ var ServerService = class extends Service {
476
463
  this.prefixStatic.set(prefix, dir);
477
464
  this.ctx.effect(() => () => this.prefixStatic.delete(prefix));
478
465
  }
466
+ pluginsConfig = {};
467
+ getPluginConfig(mode) {
468
+ return this.pluginsConfig[mode] ?? {};
469
+ }
470
+ refreshPluginsConfig() {
471
+ this.pluginsConfig = readPluginsConfig(this.root);
472
+ }
473
+ savePluginConfig(merged) {
474
+ this.pluginsConfig = merged;
475
+ writePluginsConfig(this.root, merged);
476
+ }
479
477
  serveFile(filePath, req, res, injectHtml) {
480
- fs4.stat(filePath, (err, stat) => {
478
+ fs3.stat(filePath, (err, stat) => {
481
479
  if (err) {
482
480
  res.writeHead(404);
483
481
  return res.end("Not found");
484
482
  }
485
- const ext = path4.extname(filePath).toLowerCase();
483
+ const ext = path3.extname(filePath).toLowerCase();
486
484
  const ct = MIME[ext] ?? "application/octet-stream";
487
485
  const rangeHeader = req.headers["range"];
488
486
  let range = null;
@@ -506,7 +504,7 @@ var ServerService = class extends Service {
506
504
  "Accept-Ranges": "bytes",
507
505
  "Cache-Control": "no-cache"
508
506
  });
509
- const stream = fs4.createReadStream(filePath, { start, end });
507
+ const stream = fs3.createReadStream(filePath, { start, end });
510
508
  stream.on("error", () => {
511
509
  try {
512
510
  res.destroy();
@@ -516,7 +514,7 @@ var ServerService = class extends Service {
516
514
  stream.pipe(res);
517
515
  return;
518
516
  }
519
- fs4.readFile(filePath, (err2, data) => {
517
+ fs3.readFile(filePath, (err2, data) => {
520
518
  if (err2) {
521
519
  res.writeHead(404);
522
520
  return res.end("Not found");
@@ -535,9 +533,9 @@ var ServerService = class extends Service {
535
533
  for (const [prefix, dir] of this.prefixStatic) {
536
534
  if (url.indexOf(prefix) === 0) {
537
535
  let rel = this.safeDecode(url.slice(prefix.length));
538
- if (!rel || rel === "/" || !path4.extname(rel)) rel = path4.join(rel || "", "index.html");
539
- const fp = path4.join(dir, rel);
540
- if (fp.indexOf(dir + path4.sep) !== 0) {
536
+ if (!rel || rel === "/" || !path3.extname(rel)) rel = path3.join(rel || "", "index.html");
537
+ const fp = path3.join(dir, rel);
538
+ if (fp.indexOf(dir + path3.sep) !== 0) {
541
539
  res.writeHead(403);
542
540
  res.end("Forbidden");
543
541
  return false;
@@ -564,6 +562,12 @@ var ServerService = class extends Service {
564
562
  rh(req, res);
565
563
  return;
566
564
  }
565
+ for (const [rp, rh2] of this.routes) {
566
+ if (url.startsWith(rp + "/")) {
567
+ rh2(req, res);
568
+ return;
569
+ }
570
+ }
567
571
  const sh = this.sses.get(url);
568
572
  if (sh) {
569
573
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
@@ -577,17 +581,17 @@ var ServerService = class extends Service {
577
581
  if (this.servePrefix(url, req, res)) return;
578
582
  if (url === "/" || url.indexOf("/__app/") === 0 || url.indexOf("/assets/") === 0) {
579
583
  let fp2 = this.appDir;
580
- if (url !== "/") fp2 = path4.join(this.appDir, this.safeDecode(url));
581
- if (url.indexOf("/__app/") === 0) fp2 = path4.join(this.appDir, url.slice(7));
582
- if (fp2 !== this.appDir && fp2.indexOf(this.appDir + path4.sep) !== 0) {
584
+ if (url !== "/") fp2 = path3.join(this.appDir, this.safeDecode(url));
585
+ if (url.indexOf("/__app/") === 0) fp2 = path3.join(this.appDir, url.slice(7));
586
+ if (fp2 !== this.appDir && fp2.indexOf(this.appDir + path3.sep) !== 0) {
583
587
  res.writeHead(403);
584
588
  return res.end("Forbidden");
585
589
  }
586
- if (url === "/") fp2 = path4.join(this.appDir, "index.html");
590
+ if (url === "/") fp2 = path3.join(this.appDir, "index.html");
587
591
  return this.serveFile(fp2, req, res, false);
588
592
  }
589
- const fp = path4.join(this.root, this.safeDecode(url));
590
- if (fp !== this.root && fp.indexOf(this.root + path4.sep) !== 0) {
593
+ const fp = path3.join(this.root, this.safeDecode(url));
594
+ if (fp !== this.root && fp.indexOf(this.root + path3.sep) !== 0) {
591
595
  res.writeHead(403);
592
596
  return res.end("Forbidden");
593
597
  }
@@ -612,14 +616,16 @@ var ServerService = class extends Service {
612
616
  } else throw err;
613
617
  });
614
618
  this.server.listen(port, "127.0.0.1", () => {
615
- const u = `http://localhost:${port}`;
619
+ const addr = this.server?.address();
620
+ const bound = typeof addr === "object" && addr ? addr.port : port;
621
+ const u = `http://localhost:${bound}`;
616
622
  const target = this.page ? `${u}#${this.page}` : u;
617
623
  console.log(`[zdashboard] v${VERSION} dashboard -> ${u}`);
618
624
  console.log(`[zdashboard] project -> ${this.root}`);
619
- console.log(`[zdashboard] detect -> openspec:${this.det.hasOpenspec} docs:${this.det.hasDocs} just:${this.det.hasJust} bugs:${this.det.hasBugs}`);
625
+ console.log(`[zdashboard] detect -> openspec:${this.det.hasOpenspec} docs:${this.det.hasDocs} just:${this.det.hasJust}`);
620
626
  if (this.dataDir) console.log(`[zdashboard] data -> ${this.dataDir}`);
621
627
  if (this.open) openUrl(target);
622
- this.onListen?.(port);
628
+ this.onListen?.(bound);
623
629
  });
624
630
  }
625
631
  /** 项目 git 信息(分支/脏文件数),启动探测一次,文件变更时刷新 */
@@ -671,9 +677,15 @@ var ServerService = class extends Service {
671
677
  };
672
678
 
673
679
  // src/core/reload.ts
674
- import fs5 from "fs";
680
+ import fs4 from "fs";
675
681
  import { Service as Service2 } from "cordis";
676
- var WATCH_DEBOUNCE_MS = 150;
682
+ var WATCH_DEBOUNCE_MS = 300;
683
+ var IGNORED_DIR_RE = /^(?:\.git|node_modules|dist|\.pnpm)(?:[/\\]|$)/;
684
+ var IGNORED_FILE_RE = /\.(?:swp|tmp|log)$|~$|^\.DS_Store$|^Thumbs\.db$/i;
685
+ function isIgnoredPath(filename) {
686
+ if (!filename) return false;
687
+ return IGNORED_DIR_RE.test(filename) || IGNORED_FILE_RE.test(filename);
688
+ }
677
689
  var ReloadService = class extends Service2 {
678
690
  static inject = ["server"];
679
691
  clients = /* @__PURE__ */ new Set();
@@ -687,13 +699,14 @@ var ReloadService = class extends Service2 {
687
699
  return () => this.clients.delete(res);
688
700
  });
689
701
  try {
690
- this.watcher = fs5.watch(config.root, { recursive: true }, (eventType, filename) => {
691
- if (filename && /^(?:\.git|node_modules|dist|\.pnpm)(?:[/\\]|$)/.test(filename)) return;
702
+ this.watcher = fs4.watch(config.root, { recursive: true }, (eventType, filename) => {
703
+ if (isIgnoredPath(filename)) return;
692
704
  if (this.timer) clearTimeout(this.timer);
693
705
  this.timer = setTimeout(() => {
694
706
  this.ctx.server.refreshGitInfo();
695
707
  this.broadcast("reload");
696
708
  this.broadcast("files");
709
+ this.timer = void 0;
697
710
  }, WATCH_DEBOUNCE_MS);
698
711
  });
699
712
  } catch {
@@ -711,6 +724,10 @@ data: ${JSON.stringify(data == null ? "" : data)}
711
724
  }
712
725
  }
713
726
  }
727
+ /** 插件专用频道:plugin:<mode>:<event>,客户端 usePluginData subscribe 消费 */
728
+ broadcastPlugin(mode, event, data = "") {
729
+ this.broadcast(`plugin:${mode}:${event}`, data);
730
+ }
714
731
  dispose() {
715
732
  if (this.watcher) {
716
733
  try {
@@ -732,25 +749,26 @@ data: ${JSON.stringify(data == null ? "" : data)}
732
749
  };
733
750
 
734
751
  // src/server/spec-scan.ts
735
- import fs7 from "fs";
736
- import path6 from "path";
737
-
738
- // src/server/walk.ts
739
752
  import fs6 from "fs";
740
753
  import path5 from "path";
754
+
755
+ // src/server/walk.ts
756
+ import fs5 from "fs";
757
+ import path4 from "path";
741
758
  function walkDir(root, opts = {}) {
742
759
  const { skip = /* @__PURE__ */ new Set(), maxDepth, onFile, onDir } = opts;
743
760
  function walk(dir, rel, depth) {
744
761
  if (typeof maxDepth === "number" && depth > maxDepth) return;
745
762
  let ents;
746
763
  try {
747
- ents = fs6.readdirSync(dir, { withFileTypes: true });
764
+ ents = fs5.readdirSync(dir, { withFileTypes: true });
748
765
  } catch {
749
766
  return;
750
767
  }
751
768
  for (const ent of ents) {
752
- if (ent.name.startsWith(".") || skip.has(ent.name)) continue;
753
- const abs = path5.join(dir, ent.name);
769
+ if (ent.name.startsWith(".")) continue;
770
+ if (skip.has(ent.name)) continue;
771
+ const abs = path4.join(dir, ent.name);
754
772
  const r = rel ? `${rel}/${ent.name}` : ent.name;
755
773
  if (ent.isDirectory()) {
756
774
  onDir?.(abs, r);
@@ -764,7 +782,7 @@ function walkDir(root, opts = {}) {
764
782
  }
765
783
 
766
784
  // src/server/spec-scan.ts
767
- function buildTree(paths) {
785
+ function buildTree(paths, defaultExpandDepth = 2) {
768
786
  const root = [];
769
787
  const map = /* @__PURE__ */ new Map();
770
788
  for (const p of paths) {
@@ -775,10 +793,14 @@ function buildTree(paths) {
775
793
  const part = parts[i];
776
794
  current = current ? `${current}/${part}` : part;
777
795
  const isLast = i === parts.length - 1;
796
+ const depth = i;
778
797
  if (!map.has(current)) {
798
+ const isDir = !isLast;
799
+ const collapsed = isDir && typeof defaultExpandDepth === "number" && depth > defaultExpandDepth;
779
800
  const node = {
780
801
  name: part,
781
802
  kind: isLast ? "file" : "dir",
803
+ ...collapsed ? { defaultCollapsed: true } : {},
782
804
  ...isLast ? { path: p } : { children: [] }
783
805
  };
784
806
  map.set(current, node);
@@ -798,88 +820,53 @@ function buildTree(paths) {
798
820
  sortNodes(root);
799
821
  return root;
800
822
  }
801
- function scanTree(root, hasOpenspec, hasDocs) {
802
- const tree = [];
803
- if (hasOpenspec && fs7.existsSync(path6.join(root, "openspec", "changes"))) {
804
- const changesDir = path6.join(root, "openspec", "changes");
805
- const active = [];
806
- const archived = [];
807
- for (const ent of fs7.readdirSync(changesDir, { withFileTypes: true })) {
808
- if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "archive") continue;
809
- const relBase = `openspec/changes/${ent.name}`;
810
- const paths = [];
811
- walkDir(path6.join(changesDir, ent.name), { maxDepth: 4, onFile: (_, rel) => paths.push(rel ? `${relBase}/${rel}` : relBase) });
812
- const children = buildTree(paths).map((n) => ({ ...n, path: n.path ? `${relBase}/${n.path}` : void 0 }));
813
- active.push({ name: ent.name, kind: "dir", children });
823
+ function prefixPaths(nodes, prefix) {
824
+ return nodes.map((n) => {
825
+ if (n.kind === "file" && n.path) {
826
+ return { ...n, path: `${prefix}/${n.path}` };
814
827
  }
815
- active.sort((a, b) => a.name.localeCompare(b.name));
816
- const archiveDir = path6.join(changesDir, "archive");
817
- if (fs7.existsSync(archiveDir)) {
818
- for (const ent of fs7.readdirSync(archiveDir, { withFileTypes: true })) {
819
- if (!ent.isDirectory() || ent.name.startsWith(".")) continue;
820
- const relBase = `openspec/changes/archive/${ent.name}`;
821
- const paths = [];
822
- walkDir(path6.join(archiveDir, ent.name), { maxDepth: 4, onFile: (_, rel) => paths.push(rel ? `${relBase}/${rel}` : relBase) });
823
- const children = buildTree(paths).map((n) => ({ ...n, path: n.path ? `${relBase}/${n.path}` : void 0 }));
824
- archived.push({ name: ent.name, kind: "dir", children });
825
- }
826
- archived.sort((a, b) => b.name.localeCompare(a.name));
827
- }
828
- if (active.length) tree.push({ name: `changes (${active.length})`, kind: "dir", children: active });
829
- if (archived.length) tree.push({ name: `archive (${archived.length})`, kind: "dir", defaultCollapsed: true, children: archived });
830
- const specsDir = path6.join(root, "openspec", "specs");
831
- if (fs7.existsSync(specsDir)) {
832
- const paths = [];
833
- walkDir(specsDir, { maxDepth: 4, onFile: (_, rel) => paths.push(`openspec/specs/${rel}`) });
834
- const specs = buildTree(paths).map((n) => ({ ...n, path: n.path ? `openspec/specs/${n.path}` : void 0 }));
835
- if (specs.length) tree.push({ name: "specs", kind: "dir", children: specs });
828
+ if (n.children) {
829
+ return { ...n, children: prefixPaths(n.children, prefix) };
836
830
  }
837
- }
838
- if (hasDocs && fs7.existsSync(path6.join(root, "docs"))) {
831
+ return n;
832
+ });
833
+ }
834
+ function scanTree(root, scanDirs, opts) {
835
+ const tree = [];
836
+ const defaultExpandDepth = typeof opts?.defaultExpandDepth === "number" ? opts.defaultExpandDepth : 2;
837
+ const dirs = Array.isArray(scanDirs) ? scanDirs.filter((d) => typeof d === "string" && d) : [];
838
+ for (const dir of dirs) {
839
+ const dirPath = path5.join(root, dir);
840
+ if (!fs6.existsSync(dirPath)) continue;
839
841
  const paths = [];
840
- walkDir(path6.join(root, "docs"), { maxDepth: 4, onFile: (_, rel) => paths.push(`docs/${rel}`) });
841
- const docs = buildTree(paths).map((n) => ({ ...n, path: n.path ? `docs/${n.path}` : void 0 }));
842
- if (docs.length) tree.push({ name: "docs", kind: "dir", children: docs });
843
- }
844
- const skip = /* @__PURE__ */ new Set(["openspec", "docs", "node_modules", ".git", "dist", "playground", ".zreview"]);
845
- const etc = [];
846
- try {
847
- for (const ent of fs7.readdirSync(root, { withFileTypes: true })) {
848
- if (ent.name.startsWith(".") || skip.has(ent.name)) continue;
849
- const ext = path6.extname(ent.name).toLowerCase();
850
- if (ent.isFile() && (ext === ".md" || ext === ".markdown")) etc.push({ name: ent.name, kind: "file", path: ent.name });
842
+ walkDir(dirPath, { maxDepth: 4, onFile: (_, rel) => paths.push(rel) });
843
+ const prefix = dir;
844
+ const children = prefixPaths(buildTree(paths, defaultExpandDepth), prefix);
845
+ if (children.length) {
846
+ tree.push({ name: `${dir} (${children.length})`, kind: "dir", children });
851
847
  }
852
- } catch (e) {
853
- console.error("[zdashboard] scan root etc failed:", e);
854
848
  }
855
- etc.sort((a, b) => a.name.localeCompare(b.name));
856
- if (etc.length) tree.push({ name: `other (${etc.length})`, kind: "dir", children: etc });
857
849
  return tree;
858
850
  }
859
851
 
860
852
  // src/core/tree.ts
853
+ var CONVENTION_SCAN_DIRS = ["openspec", "docs"];
861
854
  var apply = {
862
855
  inject: ["server"],
863
856
  apply(ctx, config) {
864
- let cached = null;
865
- const getDet = async () => {
866
- if (cached?.v) return cached.v;
867
- if (!cached) cached = { p: detect(config.root).catch(() => ({ hasOpenspec: false, hasDocs: false, hasJust: false, hasBugs: false })) };
868
- const d = await cached.p;
869
- cached.v = d;
870
- return d;
871
- };
872
857
  const server = ctx.server;
873
858
  if (server?.route) {
874
- server.route("/__files", async (_req, res) => {
859
+ server.route("/__files", async (req, res) => {
875
860
  try {
876
- const d = await getDet();
877
- const tree = scanTree(config.root, d.hasOpenspec, d.hasDocs);
861
+ const url = new URL(req.url || "/", "http://localhost");
862
+ const wt = url.searchParams.get("wt");
863
+ const scanRoot = wt ? decodeURIComponent(wt) : config.root;
864
+ const tree = scanTree(scanRoot, [...CONVENTION_SCAN_DIRS]);
878
865
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
879
- res.end(JSON.stringify({ tree, ...d }));
866
+ res.end(JSON.stringify({ tree }));
880
867
  } catch {
881
868
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
882
- res.end(JSON.stringify({ tree: [], hasOpenspec: false, hasDocs: false, hasJust: false, hasBugs: false }));
869
+ res.end(JSON.stringify({ tree: [] }));
883
870
  }
884
871
  });
885
872
  }
@@ -966,20 +953,83 @@ var apply2 = {
966
953
 
967
954
  // src/core/manifest.ts
968
955
  import { Service as Service3 } from "cordis";
956
+
957
+ // src/core/read-body.ts
958
+ async function readBody(req) {
959
+ return new Promise((resolve) => {
960
+ let data = "";
961
+ req.on("data", (chunk) => {
962
+ data += chunk;
963
+ });
964
+ req.on("end", () => resolve(data));
965
+ req.on("error", () => resolve(""));
966
+ });
967
+ }
968
+
969
+ // src/core/manifest.ts
969
970
  var DashboardService = class extends Service3 {
970
- static inject = ["server"];
971
+ static inject = ["server", "reload"];
971
972
  plugins = /* @__PURE__ */ new Map();
973
+ reloadRef;
972
974
  constructor(ctx) {
973
975
  super(ctx, "dashboard");
974
976
  this.ctx.effect(() => () => this.plugins.clear());
977
+ this.reloadRef = ctx.reload;
975
978
  this.ctx.server.route("/__plugins", (_req, res) => {
976
979
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
977
980
  res.end(JSON.stringify({ plugins: Array.from(this.plugins.values()) }));
978
981
  });
982
+ this.ctx.server.route("/__plugins/config", async (req, res) => {
983
+ const out = {};
984
+ if (req.method === "GET") {
985
+ for (const m of this.plugins.values()) {
986
+ const stored = this.ctx.server.getPluginConfig(m.mode);
987
+ const schema = m.config ?? {};
988
+ const merged = {};
989
+ for (const [key, field] of Object.entries(schema)) {
990
+ merged[key] = key in stored ? stored[key] : field.default ?? "";
991
+ }
992
+ if (Object.keys(merged).length) out[m.mode] = merged;
993
+ }
994
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
995
+ res.end(JSON.stringify(out));
996
+ return;
997
+ }
998
+ if (req.method === "POST") {
999
+ if (req.headers["x-stop-token"] !== this.ctx.server.stopToken) {
1000
+ res.writeHead(403);
1001
+ res.end("forbidden");
1002
+ return;
1003
+ }
1004
+ try {
1005
+ const body = JSON.parse(await readBody(req));
1006
+ const merged = {};
1007
+ for (const mode of Object.keys(body)) {
1008
+ merged[mode] = body[mode] ?? {};
1009
+ }
1010
+ this.ctx.server.savePluginConfig(merged);
1011
+ for (const mode of Object.keys(body)) {
1012
+ try {
1013
+ this.reloadRef.broadcast("config", { plugin: mode });
1014
+ } catch {
1015
+ }
1016
+ }
1017
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
1018
+ res.end('{"ok":true}');
1019
+ } catch (e) {
1020
+ console.error("[config] POST error", e);
1021
+ res.writeHead(400);
1022
+ res.end('{"error":"bad request"}');
1023
+ }
1024
+ return;
1025
+ }
1026
+ res.writeHead(405);
1027
+ res.end('{"error":"method not allowed"}');
1028
+ });
979
1029
  }
980
- register(manifest) {
981
- this.plugins.set(manifest.mode, manifest);
982
- this.ctx.effect(() => () => this.plugins.delete(manifest.mode));
1030
+ register(manifest6) {
1031
+ this.plugins.set(manifest6.mode, manifest6);
1032
+ this.ctx.effect(() => () => this.plugins.delete(manifest6.mode));
983
1033
  }
984
1034
  list() {
985
1035
  return Array.from(this.plugins.values());
@@ -987,6 +1037,9 @@ var DashboardService = class extends Service3 {
987
1037
  get(mode) {
988
1038
  return this.plugins.get(mode);
989
1039
  }
1040
+ getConfig(mode) {
1041
+ return this.ctx.server.getPluginConfig(mode);
1042
+ }
990
1043
  };
991
1044
 
992
1045
  // src/server/just-runner.ts
@@ -1128,403 +1181,202 @@ var JustRunner = class {
1128
1181
  }
1129
1182
  };
1130
1183
 
1131
- // src/core/read-body.ts
1132
- async function readBody(req) {
1133
- return new Promise((resolve) => {
1134
- let data = "";
1135
- req.on("data", (chunk) => {
1136
- data += chunk;
1137
- });
1138
- req.on("end", () => resolve(data));
1139
- req.on("error", () => resolve(""));
1140
- });
1141
- }
1142
-
1143
- // src/plugins/just/index.ts
1144
- var apply3 = {
1145
- inject: ["server", "dashboard"],
1146
- apply(ctx, config) {
1147
- const root = config.root;
1148
- ctx.inject(["server"], () => {
1149
- if (!ctx.server?.route) return;
1150
- const runner = new JustRunner(root);
1151
- ctx.effect(() => () => runner.stop());
1152
- ctx.dashboard.register({ mode: "just", label: "Just Runner", icon: "\u{1F4DC}", description: "Just \u591A\u4EFB\u52A1\u5E76\u53D1\u6267\u884C\u4E0E\u65E5\u5FD7" });
1153
- ctx.server.route("/__just/recipes", async (_req, res) => {
1154
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1155
- try {
1156
- const recipes = await runner.recipes();
1157
- res.end(JSON.stringify(recipes));
1158
- } catch {
1159
- res.end(JSON.stringify([]));
1184
+ // src/sdk/server.ts
1185
+ function definePlugin(def) {
1186
+ const { manifest: manifest6, setup } = def;
1187
+ return {
1188
+ inject: ["server", "dashboard", "reload"],
1189
+ apply(ctx) {
1190
+ const disposers = [];
1191
+ ctx.effect(() => () => {
1192
+ for (const fn of disposers.splice(0)) {
1193
+ try {
1194
+ fn();
1195
+ } catch (e) {
1196
+ console.error(`[zdashboard] plugin ${manifest6.mode} dispose error:`, e);
1197
+ }
1160
1198
  }
1161
1199
  });
1162
- ctx.server.sse("/__just/logs", (res) => {
1163
- const unsub = runner.subscribe((ev) => {
1164
- res.write(`data: ${JSON.stringify(ev)}
1165
-
1166
- `);
1167
- });
1168
- return unsub;
1169
- });
1170
- const handleAction = async (req, res, act) => {
1171
- if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
1172
- res.writeHead(403);
1173
- res.end("forbidden");
1174
- return;
1175
- }
1176
- const body = await readBody(req);
1177
- let recipe;
1200
+ ctx.dashboard.register(manifest6);
1201
+ const respondWith = (handler) => async (req, res) => {
1202
+ if (res.headersSent) return;
1178
1203
  try {
1179
- recipe = JSON.parse(body || "{}").recipe;
1180
- } catch {
1181
- }
1182
- if (act === "clear") {
1183
- if (recipe) runner.clear(recipe);
1184
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
1185
- res.end('{"ok":true}');
1186
- return;
1187
- }
1188
- if (act !== "stop") {
1189
- if (!recipe) {
1190
- res.writeHead(400);
1191
- res.end('{"error":"no recipe"}');
1192
- return;
1193
- }
1194
- const recipes = await runner.recipes();
1195
- if (!recipes.some((r) => r.name === recipe)) {
1196
- res.writeHead(403);
1197
- res.end('{"error":"unknown recipe"}');
1198
- return;
1199
- }
1200
- runner.start(recipe);
1201
- } else {
1202
- runner.stop(recipe || void 0);
1204
+ const data = await handler(req, res);
1205
+ if (data !== void 0 && !res.writableEnded) json(res, data);
1206
+ } catch (e) {
1207
+ console.error(`[zdashboard] plugin ${manifest6.mode} route error:`, e);
1208
+ if (!res.headersSent) json(res, { error: "internal" }, 500);
1203
1209
  }
1204
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
1205
- res.end(JSON.stringify(runner.list()));
1206
1210
  };
1207
- const routeAction = (name) => {
1208
- ctx.server.route(`/__just/${name}`, (req, res) => {
1209
- handleAction(req, res, name).catch(() => {
1210
- if (!res.headersSent) {
1211
- res.writeHead(500);
1212
- res.end('{"error":"internal"}');
1213
- }
1211
+ const pctx = {
1212
+ mode: manifest6.mode,
1213
+ config() {
1214
+ const schema = manifest6.config ?? {};
1215
+ const stored = ctx.server.getPluginConfig(manifest6.mode) ?? {};
1216
+ const defaults = Object.fromEntries(
1217
+ Object.entries(schema).map(([key, field]) => [key, field.default])
1218
+ );
1219
+ return { ...defaults, ...stored };
1220
+ },
1221
+ route(path12, handler) {
1222
+ ctx.server.route(path12, respondWith(handler));
1223
+ },
1224
+ guardedRoute(path12, handler) {
1225
+ const token = () => ctx.server.stopToken;
1226
+ ctx.server.route(path12, (req, res) => {
1227
+ if (!guarded(req, res, token())) return;
1228
+ void respondWith(handler)(req, res);
1214
1229
  });
1215
- });
1216
- };
1217
- routeAction("start");
1218
- routeAction("stop");
1219
- routeAction("restart");
1220
- routeAction("clear");
1221
- });
1222
- }
1223
- };
1224
-
1225
- // src/plugins/bugs/index.ts
1226
- var apply4 = {
1227
- inject: ["server", "dashboard"],
1228
- apply(ctx, config) {
1229
- const root = config.root;
1230
- ctx.inject(["server"], () => {
1231
- if (!ctx.server?.route) return;
1232
- ctx.dashboard.register({ mode: "bugs", label: "\u7985\u9053 Bugs", icon: "\u{1F3AF}", description: "\u53EA\u8BFB bug \u5217\u8868" });
1233
- ctx.server.route("/__bugs", async (_req, res) => {
1234
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1235
- try {
1236
- const result = await fetchBugs(root);
1237
- res.end(JSON.stringify(result));
1238
- } catch (e) {
1239
- res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "unknown error" }));
1230
+ },
1231
+ sse(path12, handler) {
1232
+ ctx.server.sse(path12, handler);
1233
+ },
1234
+ static(prefix, dir) {
1235
+ ctx.server.static(prefix, dir);
1236
+ },
1237
+ broadcast(event, data) {
1238
+ ctx.reload?.broadcast(`plugin:${manifest6.mode}:${event}`, data ?? "");
1239
+ },
1240
+ onDispose(fn) {
1241
+ disposers.push(fn);
1240
1242
  }
1241
- });
1242
- });
1243
- }
1244
- };
1245
-
1246
- // src/server/review-store.ts
1247
- import fs8 from "fs";
1248
- import path7 from "path";
1249
- import YAML2 from "yaml";
1250
- var REVIEW_DIR = ".zreview";
1251
- var REVIEW_FILE = path7.join(REVIEW_DIR, "review.yaml");
1252
- var DOCS_DIR = path7.join(REVIEW_DIR, "docs");
1253
- var DIAGRAMS_DIR = path7.join(REVIEW_DIR, "diagrams");
1254
- var ReviewStore = class {
1255
- root;
1256
- onChange;
1257
- constructor(root, onChange) {
1258
- this.root = root;
1259
- this.onChange = onChange;
1260
- }
1261
- exists() {
1262
- return fs8.existsSync(path7.join(this.root, REVIEW_FILE));
1263
- }
1264
- read() {
1265
- const fullPath = path7.join(this.root, REVIEW_FILE);
1266
- if (!fs8.existsSync(fullPath)) {
1267
- return { status: "draft", summary: "", documents: [], codebases: [], diagrams: [], items: [] };
1268
- }
1269
- try {
1270
- const parsed = YAML2.parse(fs8.readFileSync(fullPath, "utf8"));
1271
- if (!parsed || !Array.isArray(parsed.items)) {
1272
- return { status: "draft", summary: parsed?.summary ?? "", documents: parsed?.documents ?? [], codebases: parsed?.codebases ?? [], diagrams: parsed?.diagrams ?? [], items: [] };
1273
- }
1274
- return {
1275
- status: parsed.status ?? "draft",
1276
- summary: parsed.summary ?? "",
1277
- documents: parsed.documents ?? [],
1278
- codebases: parsed.codebases ?? [],
1279
- diagrams: parsed.diagrams ?? [],
1280
- items: parsed.items
1281
1243
  };
1282
- } catch {
1283
- return { status: "draft", summary: "", documents: [], codebases: [], diagrams: [], items: [] };
1284
- }
1285
- }
1286
- write(data) {
1287
- fs8.mkdirSync(path7.join(this.root, REVIEW_DIR), { recursive: true });
1288
- fs8.writeFileSync(path7.join(this.root, REVIEW_FILE), YAML2.stringify(data), "utf8");
1289
- this.onChange?.();
1290
- }
1291
- updateItem(id, patch) {
1292
- const data = this.read();
1293
- const item = this.findItem(data.items, id);
1294
- if (!item) throw new Error(`item ${id} not found`);
1295
- if (patch.answer !== void 0) item.answer = patch.answer;
1296
- if (patch.state !== void 0) item.state = patch.state;
1297
- if (patch.priority !== void 0) item.priority = patch.priority;
1298
- if (patch.title !== void 0) item.title = patch.title;
1299
- if (patch.state === "answered" && !patch.answer && !item.answer) item.answer = "";
1300
- this.write(data);
1301
- return data;
1302
- }
1303
- addChild(parentId, title, priority = "medium") {
1304
- const data = this.read();
1305
- const parent = this.findItem(data.items, parentId);
1306
- if (!parent) throw new Error(`parent ${parentId} not found`);
1307
- if (!parent.children) parent.children = [];
1308
- const newId = `${parentId}-${parent.children.length + 1}`;
1309
- parent.children.push({
1310
- id: newId,
1311
- type: "decomposition",
1312
- state: "open",
1313
- title,
1314
- priority
1315
- });
1316
- this.write(data);
1317
- return data;
1318
- }
1319
- removeItem(id) {
1320
- const data = this.read();
1321
- const removed = this.removeItemFromList(data.items, id);
1322
- if (!removed) throw new Error(`item ${id} not found`);
1323
- this.write(data);
1324
- return data;
1325
- }
1326
- setStatus(status) {
1327
- const data = this.read();
1328
- if (status === "passed" && this.hasOpenItems(data.items)) {
1329
- throw new Error("\u5B58\u5728\u672A\u5904\u7406\u7684\u8BC4\u5BA1\u9879(open),\u4E0D\u80FD\u901A\u8FC7");
1244
+ setup(pctx);
1330
1245
  }
1331
- data.status = status;
1332
- this.write(data);
1333
- return data;
1334
- }
1335
- listDocs() {
1336
- const docsDir = path7.join(this.root, DOCS_DIR);
1337
- if (!fs8.existsSync(docsDir)) return [];
1338
- try {
1339
- return fs8.readdirSync(docsDir).filter((f) => /\.(md|markdown|txt)$/i.test(f)).sort();
1340
- } catch {
1341
- return [];
1342
- }
1343
- }
1344
- readDoc(name) {
1345
- const fullPath = path7.join(this.root, DOCS_DIR, name);
1346
- if (!fs8.existsSync(fullPath)) throw new Error(`doc ${name} not found`);
1347
- return fs8.readFileSync(fullPath, "utf8");
1348
- }
1349
- listDiagrams() {
1350
- const dir = path7.join(this.root, DIAGRAMS_DIR);
1351
- if (!fs8.existsSync(dir)) return [];
1352
- try {
1353
- return fs8.readdirSync(dir).filter((f) => /\.html$/i.test(f)).sort();
1354
- } catch {
1355
- return [];
1356
- }
1357
- }
1358
- readDiagram(name) {
1359
- const fullPath = path7.join(this.root, DIAGRAMS_DIR, name);
1360
- if (!fs8.existsSync(fullPath)) throw new Error(`diagram ${name} not found`);
1361
- return fs8.readFileSync(fullPath, "utf8");
1362
- }
1363
- findItem(items, id) {
1364
- for (const item of items) {
1365
- if (item.id === id) return item;
1366
- if (item.children) {
1367
- const found = this.findItem(item.children, id);
1368
- if (found) return found;
1369
- }
1370
- }
1371
- return null;
1372
- }
1373
- removeItemFromList(items, id) {
1374
- for (let i = 0; i < items.length; i++) {
1375
- if (items[i].id === id) {
1376
- items.splice(i, 1);
1377
- return true;
1378
- }
1379
- if (items[i].children && this.removeItemFromList(items[i].children, id)) {
1380
- return true;
1381
- }
1246
+ };
1247
+ }
1248
+
1249
+ // src/plugins/builtin.ts
1250
+ function defineBuiltin(def) {
1251
+ let currentRoot = "";
1252
+ const inner = definePlugin({
1253
+ manifest: def.manifest,
1254
+ setup(pctx) {
1255
+ def.setup(pctx, currentRoot);
1382
1256
  }
1383
- return false;
1384
- }
1385
- hasOpenItems(items) {
1386
- for (const item of items) {
1387
- if (item.state === "open") return true;
1388
- if (item.children && this.hasOpenItems(item.children)) return true;
1257
+ });
1258
+ return {
1259
+ inject: inner.inject,
1260
+ manifest: def.manifest,
1261
+ // cli 启动期死键剥离按 manifest 判定(config 声明/external)
1262
+ apply(ctx, config) {
1263
+ currentRoot = config?.root ?? "";
1264
+ inner.apply(ctx, config);
1389
1265
  }
1390
- return false;
1391
- }
1266
+ };
1267
+ }
1268
+
1269
+ // src/plugins/just/manifest.ts
1270
+ var manifest = {
1271
+ mode: "just",
1272
+ label: "Just Runner",
1273
+ icon: "\u{1F4DC}",
1274
+ description: "Just \u4EFB\u52A1\u65E5\u5FD7\u4E0E\u6267\u884C",
1275
+ order: 50
1392
1276
  };
1393
1277
 
1394
- // src/plugins/review/index.ts
1395
- var apply5 = {
1396
- inject: ["server", "dashboard", "reload"],
1397
- apply(ctx, config) {
1398
- const root = config.root;
1399
- ctx.inject(["server"], () => {
1400
- if (!ctx.server?.route) return;
1401
- ctx.dashboard.register({ mode: "review", label: "\u6587\u6863\u8BC4\u5BA1", icon: "\u2705", description: "\u591A\u6587\u6863\u9700\u6C42\u8BC4\u5BA1\u4E0E\u62C6\u89E3 \xB7 zreview" });
1402
- const reviewStore = new ReviewStore(root, () => {
1403
- ctx.reload.broadcast("files");
1404
- });
1405
- ctx.server.route("/__review", async (_req, res) => {
1406
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1407
- res.end(JSON.stringify(reviewStore.read()));
1408
- });
1409
- ctx.server.route("/__review/item", async (req, res) => {
1410
- if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
1411
- res.writeHead(403);
1412
- res.end("forbidden");
1413
- return;
1414
- }
1415
- try {
1416
- const body = JSON.parse(await readBody(req) || "{}");
1417
- const data = reviewStore.updateItem(body.id, {
1418
- answer: body.answer,
1419
- state: body.state,
1420
- priority: body.priority,
1421
- title: body.title
1422
- });
1423
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1424
- res.end(JSON.stringify(data));
1425
- } catch (e) {
1426
- res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
1427
- res.end(JSON.stringify({ error: e instanceof Error ? e.message : "unknown error" }));
1428
- }
1429
- });
1430
- ctx.server.route("/__review/item/add-child", async (req, res) => {
1431
- if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
1432
- res.writeHead(403);
1433
- res.end("forbidden");
1434
- return;
1435
- }
1436
- try {
1437
- const body = JSON.parse(await readBody(req) || "{}");
1438
- const data = reviewStore.addChild(body.parentId, body.title, body.priority);
1439
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1440
- res.end(JSON.stringify(data));
1441
- } catch (e) {
1442
- res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
1443
- res.end(JSON.stringify({ error: e instanceof Error ? e.message : "unknown error" }));
1444
- }
1445
- });
1446
- ctx.server.route("/__review/item/remove", async (req, res) => {
1447
- if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
1448
- res.writeHead(403);
1449
- res.end("forbidden");
1450
- return;
1451
- }
1278
+ // src/plugins/just/index.ts
1279
+ var apply3 = defineBuiltin({
1280
+ manifest,
1281
+ setup(ctx, root) {
1282
+ const runner = new JustRunner(root);
1283
+ ctx.onDispose(() => runner.stop());
1284
+ ctx.route("/__just/recipes", async () => {
1285
+ try {
1286
+ return await runner.recipes();
1287
+ } catch {
1288
+ return [];
1289
+ }
1290
+ });
1291
+ ctx.route("/__just/tasks", async () => runner.list());
1292
+ ctx.sse("/__just/logs", (res) => {
1293
+ const unsub = runner.subscribe((ev) => {
1452
1294
  try {
1453
- const body = JSON.parse(await readBody(req) || "{}");
1454
- const data = reviewStore.removeItem(body.id);
1455
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1456
- res.end(JSON.stringify(data));
1457
- } catch (e) {
1458
- res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
1459
- res.end(JSON.stringify({ error: e instanceof Error ? e.message : "unknown error" }));
1295
+ ctx.broadcast(ev.type, ev);
1296
+ } catch {
1460
1297
  }
1298
+ res.write(`data: ${JSON.stringify(ev)}
1299
+
1300
+ `);
1461
1301
  });
1462
- ctx.server.route("/__review/status", async (req, res) => {
1463
- if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
1464
- res.writeHead(403);
1465
- res.end("forbidden");
1466
- return;
1467
- }
1468
- try {
1469
- const body = JSON.parse(await readBody(req) || "{}");
1470
- const data = reviewStore.setStatus(body.status);
1471
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1472
- res.end(JSON.stringify(data));
1473
- } catch (e) {
1302
+ return unsub;
1303
+ });
1304
+ const handleAction = async (req, res, act) => {
1305
+ const body = await readBody(req);
1306
+ let recipe;
1307
+ try {
1308
+ recipe = JSON.parse(body || "{}").recipe;
1309
+ } catch {
1310
+ }
1311
+ if (act === "clear") {
1312
+ if (recipe) runner.clear(recipe);
1313
+ return { ok: true };
1314
+ }
1315
+ if (act !== "stop") {
1316
+ if (!recipe) {
1474
1317
  res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
1475
- res.end(JSON.stringify({ error: e instanceof Error ? e.message : "unknown error" }));
1318
+ res.end('{"error":"no recipe"}');
1319
+ return void 0;
1476
1320
  }
1477
- });
1478
- ctx.server.route("/__docs", async (_req, res) => {
1479
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1480
- res.end(JSON.stringify(reviewStore.listDocs()));
1481
- });
1482
- ctx.server.route("/__docs/:name", async (req, res) => {
1483
- try {
1484
- const name = req.params?.name;
1485
- if (!name) throw new Error("missing doc name");
1486
- const content = reviewStore.readDoc(name);
1487
- res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Cache-Control": "no-cache" });
1488
- res.end(content);
1489
- } catch (e) {
1490
- res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
1491
- res.end(JSON.stringify({ error: e instanceof Error ? e.message : "not found" }));
1321
+ const recipes = await runner.recipes();
1322
+ if (!recipes.some((r) => r.name === recipe)) {
1323
+ res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
1324
+ res.end('{"error":"unknown recipe"}');
1325
+ return void 0;
1492
1326
  }
1493
- });
1494
- });
1327
+ runner.start(recipe);
1328
+ } else {
1329
+ runner.stop(recipe || void 0);
1330
+ }
1331
+ return runner.list();
1332
+ };
1333
+ const routeAction = (name) => {
1334
+ ctx.guardedRoute(`/__just/${name}`, ((req, res) => handleAction(req, res, name)));
1335
+ };
1336
+ routeAction("start");
1337
+ routeAction("stop");
1338
+ routeAction("restart");
1339
+ routeAction("clear");
1495
1340
  }
1496
- };
1341
+ });
1497
1342
 
1498
1343
  // src/plugins/apply/scan.ts
1499
- import fs9 from "fs";
1500
- import path8 from "path";
1344
+ import fs7 from "fs";
1345
+ import path6 from "path";
1501
1346
 
1502
1347
  // src/plugins/apply/parse-tasks.ts
1503
1348
  var TASK_RE = /^\s*-\s*\[([ xX])\]\s*(.*)$/;
1349
+ var MANUAL_PREFIX = "\u{1F527}[\u4EBA\u5DE5]";
1504
1350
  function parseTasks(md) {
1505
- return md.split("\n").filter((l) => TASK_RE.test(l)).map((l) => {
1351
+ return md.split(/\r?\n/).filter((l) => TASK_RE.test(l)).map((l) => {
1506
1352
  const m = l.match(TASK_RE);
1507
- return { text: (m?.[2] ?? l).trim(), checked: m?.[1] !== " " };
1353
+ const text = (m?.[2] ?? l).trim();
1354
+ return { text, checked: m?.[1] !== " ", manual: text.startsWith(MANUAL_PREFIX) };
1508
1355
  });
1509
1356
  }
1510
1357
  function countTasks(md) {
1511
1358
  const tasks = parseTasks(md);
1512
- return { total: tasks.length, done: tasks.filter((t) => t.checked).length };
1359
+ const normal = tasks.filter((t) => !t.manual);
1360
+ return {
1361
+ total: normal.length,
1362
+ done: normal.filter((t) => t.checked).length,
1363
+ manual: tasks.length - normal.length
1364
+ };
1513
1365
  }
1514
1366
 
1515
1367
  // src/plugins/apply/scan.ts
1516
1368
  function readText(p) {
1517
1369
  try {
1518
- return fs9.readFileSync(p, "utf8");
1370
+ return fs7.readFileSync(p, "utf8");
1519
1371
  } catch {
1520
1372
  return "";
1521
1373
  }
1522
1374
  }
1523
1375
  function worktreeDir(root, name) {
1524
- return path8.join(root, ".zworktree", name);
1376
+ return path6.join(root, ".zworktree", name);
1525
1377
  }
1526
1378
  function changeDir(root, name) {
1527
- return path8.join(root, "openspec", "changes", name);
1379
+ return path6.join(root, "openspec", "changes", name);
1528
1380
  }
1529
1381
  function parseDependsOn(proposal) {
1530
1382
  const deps = [];
@@ -1538,42 +1390,42 @@ function parseDependsOn(proposal) {
1538
1390
  return deps;
1539
1391
  }
1540
1392
  function scanApplyChanges(root) {
1541
- const changesDir = path8.join(root, "openspec", "changes");
1542
- if (!fs9.existsSync(changesDir)) return [];
1393
+ const changesDir = path6.join(root, "openspec", "changes");
1394
+ if (!fs7.existsSync(changesDir)) return [];
1543
1395
  const out = [];
1544
- for (const ent of fs9.readdirSync(changesDir, { withFileTypes: true })) {
1396
+ for (const ent of fs7.readdirSync(changesDir, { withFileTypes: true })) {
1545
1397
  if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "archive") continue;
1546
1398
  const wt = worktreeDir(root, ent.name);
1547
- const tasks = readText(path8.join(wt, "openspec", "changes", ent.name, "tasks.md"));
1399
+ const tasks = readText(path6.join(wt, "openspec", "changes", ent.name, "tasks.md"));
1548
1400
  if (!tasks) {
1549
- const fallback = readText(path8.join(changesDir, ent.name, "tasks.md"));
1401
+ const fallback = readText(path6.join(changesDir, ent.name, "tasks.md"));
1550
1402
  if (fallback) {
1551
1403
  const { total, done } = countTasks(fallback);
1552
- const wtChange = path8.join(wt, "openspec", "changes", ent.name);
1553
- const mainChange = path8.join(changesDir, ent.name);
1404
+ const wtChange = path6.join(wt, "openspec", "changes", ent.name);
1405
+ const mainChange = path6.join(changesDir, ent.name);
1554
1406
  out.push({
1555
1407
  name: ent.name,
1556
1408
  path: `openspec/changes/${ent.name}`,
1557
1409
  total,
1558
1410
  done,
1559
- hasProposal: fs9.existsSync(path8.join(wtChange, "proposal.md")) || fs9.existsSync(path8.join(mainChange, "proposal.md")),
1560
- hasDesign: fs9.existsSync(path8.join(wtChange, "design.md")) || fs9.existsSync(path8.join(mainChange, "design.md")),
1411
+ hasProposal: fs7.existsSync(path6.join(wtChange, "proposal.md")) || fs7.existsSync(path6.join(mainChange, "proposal.md")),
1412
+ hasDesign: fs7.existsSync(path6.join(wtChange, "design.md")) || fs7.existsSync(path6.join(mainChange, "design.md")),
1561
1413
  // worktree 目录存在即算执行中(tasks.md 可能尚未落盘)
1562
- inWorktree: fs9.existsSync(wtChange)
1414
+ inWorktree: fs7.existsSync(wtChange)
1563
1415
  });
1564
1416
  }
1565
1417
  } else {
1566
1418
  const { total, done } = countTasks(tasks);
1567
- const wtChange = path8.join(wt, "openspec", "changes", ent.name);
1568
- const mainChange = path8.join(changesDir, ent.name);
1419
+ const wtChange = path6.join(wt, "openspec", "changes", ent.name);
1420
+ const mainChange = path6.join(changesDir, ent.name);
1569
1421
  out.push({
1570
1422
  name: ent.name,
1571
1423
  path: `openspec/changes/${ent.name}`,
1572
1424
  total,
1573
1425
  done,
1574
1426
  // 任一侧存在即算(详情读取是 worktree 优先、主目录兜底,列表标记保持一致)
1575
- hasProposal: fs9.existsSync(path8.join(wtChange, "proposal.md")) || fs9.existsSync(path8.join(mainChange, "proposal.md")),
1576
- hasDesign: fs9.existsSync(path8.join(wtChange, "design.md")) || fs9.existsSync(path8.join(mainChange, "design.md")),
1427
+ hasProposal: fs7.existsSync(path6.join(wtChange, "proposal.md")) || fs7.existsSync(path6.join(mainChange, "proposal.md")),
1428
+ hasDesign: fs7.existsSync(path6.join(wtChange, "design.md")) || fs7.existsSync(path6.join(mainChange, "design.md")),
1577
1429
  inWorktree: true
1578
1430
  });
1579
1431
  }
@@ -1584,14 +1436,14 @@ function scanApplyChanges(root) {
1584
1436
  function readApplyChange(root, name) {
1585
1437
  const wt = worktreeDir(root, name);
1586
1438
  const mainChangeDir = changeDir(root, name);
1587
- const proposalFrom = fs9.existsSync(path8.join(wt, "openspec", "changes", name, "proposal.md")) ? path8.join(wt, "openspec", "changes", name, "proposal.md") : path8.join(mainChangeDir, "proposal.md");
1588
- const designFrom = fs9.existsSync(path8.join(wt, "openspec", "changes", name, "design.md")) ? path8.join(wt, "openspec", "changes", name, "design.md") : path8.join(mainChangeDir, "design.md");
1589
- const tasksFrom = fs9.existsSync(path8.join(wt, "openspec", "changes", name, "tasks.md")) ? path8.join(wt, "openspec", "changes", name, "tasks.md") : path8.join(mainChangeDir, "tasks.md");
1439
+ const proposalFrom = fs7.existsSync(path6.join(wt, "openspec", "changes", name, "proposal.md")) ? path6.join(wt, "openspec", "changes", name, "proposal.md") : path6.join(mainChangeDir, "proposal.md");
1440
+ const designFrom = fs7.existsSync(path6.join(wt, "openspec", "changes", name, "design.md")) ? path6.join(wt, "openspec", "changes", name, "design.md") : path6.join(mainChangeDir, "design.md");
1441
+ const tasksFrom = fs7.existsSync(path6.join(wt, "openspec", "changes", name, "tasks.md")) ? path6.join(wt, "openspec", "changes", name, "tasks.md") : path6.join(mainChangeDir, "tasks.md");
1590
1442
  const proposal = readText(proposalFrom);
1591
1443
  const design = readText(designFrom);
1592
1444
  const tasks = readText(tasksFrom);
1593
1445
  const { total, done } = countTasks(tasks);
1594
- const inWorktree = fs9.existsSync(path8.join(wt, "openspec", "changes", name));
1446
+ const inWorktree = fs7.existsSync(path6.join(wt, "openspec", "changes", name));
1595
1447
  const dependsOn = parseDependsOn(proposal);
1596
1448
  const hasTestStrategy = /^##\s+测试策略\s*$/m.test(design);
1597
1449
  return {
@@ -1610,46 +1462,113 @@ function readApplyChange(root, name) {
1610
1462
  };
1611
1463
  }
1612
1464
 
1465
+ // src/plugins/apply/batch.ts
1466
+ import fs8 from "fs";
1467
+ import path7 from "path";
1468
+ var RUN_ID_PATTERN = /^[A-Za-z0-9-]+$/;
1469
+ function isPlainObject(v) {
1470
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1471
+ }
1472
+ function readText2(p) {
1473
+ try {
1474
+ return fs8.readFileSync(p, "utf8");
1475
+ } catch {
1476
+ return null;
1477
+ }
1478
+ }
1479
+ function readBatchState(root) {
1480
+ const applyDir = path7.join(root, ".zdev", "apply");
1481
+ const raw = readText2(path7.join(applyDir, "CURRENT"));
1482
+ const runId = raw?.trim() ?? "";
1483
+ if (!runId || !RUN_ID_PATTERN.test(runId)) {
1484
+ return { run: null, state: null };
1485
+ }
1486
+ const parsed = (() => {
1487
+ const text = readText2(path7.join(applyDir, "runs", runId, "state.json"));
1488
+ if (text == null) return null;
1489
+ try {
1490
+ return JSON.parse(text);
1491
+ } catch {
1492
+ return null;
1493
+ }
1494
+ })();
1495
+ if (!isPlainObject(parsed)) {
1496
+ return { run: { id: runId }, state: null };
1497
+ }
1498
+ return { run: { id: runId }, state: parsed };
1499
+ }
1500
+ function projectGraph(state) {
1501
+ if (!state) {
1502
+ return { changes: [], batches: [], conflicts: [] };
1503
+ }
1504
+ return {
1505
+ changes: state.changes.map((c) => ({
1506
+ name: c.name,
1507
+ status: c.status,
1508
+ dependencies: c.dependencies,
1509
+ batchIndex: c.batchIndex
1510
+ })),
1511
+ batches: state.batches,
1512
+ conflicts: state.conflicts
1513
+ };
1514
+ }
1515
+ var BATCH_LOGS_TAIL = 100;
1516
+ function tailLogs(state) {
1517
+ return (state?.logs ?? []).slice(-BATCH_LOGS_TAIL);
1518
+ }
1519
+ function readBatchPlan(root, runId) {
1520
+ return readText2(path7.join(root, ".zdev", "apply", "runs", runId, "plan.md"));
1521
+ }
1522
+
1523
+ // src/plugins/apply/manifest.ts
1524
+ var manifest2 = {
1525
+ mode: "apply",
1526
+ label: "\u6267\u884C\u8FDB\u5EA6",
1527
+ icon: "\u2699\uFE0F",
1528
+ description: "OpenSpec \u6267\u884C\u8FDB\u5EA6 \xB7 zapply \u5355 change \u4E0E\u6279\u91CF",
1529
+ order: 40
1530
+ };
1531
+
1613
1532
  // src/plugins/apply/index.ts
1614
- var apply6 = {
1615
- inject: ["server", "dashboard"],
1616
- apply(ctx, config) {
1617
- const root = config.root;
1618
- ctx.inject(["server"], () => {
1619
- if (!ctx.server?.route) return;
1620
- ctx.dashboard.register({ mode: "apply", label: "\u6267\u884C\u8FDB\u5EA6", icon: "\u2699\uFE0F", description: "OpenSpec change \u4EFB\u52A1\u8FDB\u5EA6" });
1621
- ctx.server.route("/__apply", async (_req, res) => {
1622
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1623
- res.end(JSON.stringify(scanApplyChanges(root)));
1624
- });
1625
- ctx.server.route("/__apply/change", async (req, res) => {
1626
- const url = new URL(req.url || "", "http://x");
1627
- const name = url.searchParams.get("name");
1628
- if (!name) {
1629
- res.writeHead(400);
1630
- res.end(JSON.stringify({ error: "missing name" }));
1631
- return;
1632
- }
1633
- if (name.includes("..") || name.includes("/") || name.includes("\\")) {
1634
- res.writeHead(400);
1635
- res.end(JSON.stringify({ error: "invalid name" }));
1636
- return;
1637
- }
1638
- try {
1639
- const data = readApplyChange(root, name);
1640
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1641
- res.end(JSON.stringify(data));
1642
- } catch (e) {
1643
- res.writeHead(400);
1644
- res.end(JSON.stringify({ error: e instanceof Error ? e.message : "unknown error" }));
1645
- }
1646
- });
1533
+ function errorResponse(res, status, msg) {
1534
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
1535
+ res.end(JSON.stringify({ error: msg }));
1536
+ return void 0;
1537
+ }
1538
+ var apply4 = defineBuiltin({
1539
+ manifest: manifest2,
1540
+ setup(ctx, root) {
1541
+ ctx.route("/__apply", async () => scanApplyChanges(root));
1542
+ ctx.route("/__apply/change", async (req, res) => {
1543
+ const url = new URL(req.url || "", "http://x");
1544
+ const name = url.searchParams.get("name");
1545
+ const bad = (msg) => errorResponse(res, 400, msg);
1546
+ if (!name) return bad("missing name");
1547
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) return bad("invalid name");
1548
+ try {
1549
+ return readApplyChange(root, name);
1550
+ } catch (e) {
1551
+ return bad(e instanceof Error ? e.message : "unknown error");
1552
+ }
1553
+ });
1554
+ ctx.route("/__apply/batch", async () => readBatchState(root));
1555
+ ctx.route("/__apply/batch/graph", async () => projectGraph(readBatchState(root).state));
1556
+ ctx.route("/__apply/batch/logs", async () => tailLogs(readBatchState(root).state));
1557
+ ctx.route("/__apply/batch/plan", async (_req, res) => {
1558
+ const snap = readBatchState(root);
1559
+ const plan = snap.run ? readBatchPlan(root, snap.run.id) : null;
1560
+ if (plan == null) return errorResponse(res, 404, "plan not found");
1561
+ return { plan };
1647
1562
  });
1648
1563
  }
1649
- };
1564
+ });
1650
1565
 
1651
- // src/server/design-assets.ts
1566
+ // src/plugins/design/index.ts
1652
1567
  import path9 from "path";
1568
+ import fs9 from "fs";
1569
+
1570
+ // src/server/design-assets.ts
1571
+ import path8 from "path";
1653
1572
  var PAGE_EXTS = [".html", ".htm"];
1654
1573
  var ICON_EXTS = [".svg", ".png", ".ico", ".jpg", ".jpeg", ".gif", ".webp"];
1655
1574
  var VIDEO_EXTS = [".mp4", ".webm", ".mov", ".ogg", ".ogv"];
@@ -1672,47 +1591,114 @@ function categorize(rel, ext) {
1672
1591
  }
1673
1592
  return null;
1674
1593
  }
1675
- function scanAssets(root) {
1594
+ function scanAssets(root, subDir = "") {
1676
1595
  const out = {};
1677
1596
  const keys = ["page", "component", "icon", "token", "md", "video", "audio", "pdf", "font"];
1678
1597
  for (const k of keys) out[k] = [];
1679
1598
  const SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "coverage", ".next", ".cache"]);
1680
- walkDir(root, { skip: SKIP, onFile: (abs, rel) => {
1681
- const ext = path9.extname(abs).toLowerCase();
1599
+ const scanRoot = subDir ? path8.join(root, subDir) : root;
1600
+ walkDir(scanRoot, { skip: SKIP, onFile: (abs, rel) => {
1601
+ const ext = path8.extname(abs).toLowerCase();
1682
1602
  const t = categorize(rel, ext);
1683
- if (t) out[t].push({ path: rel, name: path9.basename(abs), ext, type: t });
1603
+ if (t) out[t].push({ path: rel, name: path8.basename(abs), ext, type: t });
1684
1604
  } });
1685
1605
  return out;
1686
1606
  }
1687
1607
 
1608
+ // src/plugins/design/manifest.ts
1609
+ var manifest3 = {
1610
+ mode: "design",
1611
+ label: "\u8BBE\u8BA1\u8D44\u4EA7",
1612
+ icon: "\u{1F3A8}",
1613
+ description: "\u8BBE\u8BA1\u8D44\u4EA7\u5206\u7C7B\u6D4F\u89C8 \xB7 zdesign",
1614
+ order: 30
1615
+ };
1616
+
1688
1617
  // src/plugins/design/index.ts
1689
- var apply7 = {
1690
- inject: ["server", "dashboard"],
1691
- apply(ctx, config) {
1692
- const root = config.root;
1693
- ctx.inject(["server"], () => {
1694
- if (!ctx.server?.route) return;
1695
- ctx.dashboard.register({ mode: "design", label: "\u8BBE\u8BA1\u8D44\u4EA7", icon: "\u{1F3A8}", description: "\u9875\u9762/\u7EC4\u4EF6/\u56FE\u6807/Token \u5206\u7C7B\u9884\u89C8" });
1696
- ctx.server.route("/__design/assets", async (_req, res) => {
1697
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1698
- res.end(JSON.stringify(scanAssets(root)));
1618
+ var ASSET_KEYS = ["page", "component", "icon", "token", "md", "video", "audio", "pdf", "font"];
1619
+ function emptyScan() {
1620
+ const out = {};
1621
+ for (const k of ASSET_KEYS) out[k] = [];
1622
+ return out;
1623
+ }
1624
+ var apply5 = defineBuiltin({
1625
+ manifest: manifest3,
1626
+ setup(ctx, root) {
1627
+ ctx.route("/__design/assets", async () => {
1628
+ const designRoot = path9.join(root, ".zdev", "design");
1629
+ return fs9.existsSync(designRoot) ? scanAssets(designRoot) : emptyScan();
1630
+ });
1631
+ ctx.route("/__design/asset", async (req, res) => {
1632
+ const rel = new URL(req.url || "/", "http://localhost").searchParams.get("path") ?? "";
1633
+ if (!rel || rel.includes("..") || rel.includes("\\") || path9.isAbsolute(rel)) {
1634
+ res.writeHead(400);
1635
+ res.end("bad path");
1636
+ return;
1637
+ }
1638
+ const designRoot = path9.join(root, ".zdev", "design");
1639
+ const filePath = path9.join(designRoot, rel);
1640
+ if (!filePath.startsWith(designRoot + path9.sep)) {
1641
+ res.writeHead(400);
1642
+ res.end("bad path");
1643
+ return;
1644
+ }
1645
+ fs9.stat(filePath, (err) => {
1646
+ if (err) {
1647
+ res.writeHead(404);
1648
+ return res.end("Not found");
1649
+ }
1650
+ const ct = MIME[path9.extname(filePath).toLowerCase()] ?? "application/octet-stream";
1651
+ res.writeHead(200, { "Content-Type": ct, "Cache-Control": "no-cache" });
1652
+ const stream = fs9.createReadStream(filePath);
1653
+ stream.on("error", () => {
1654
+ try {
1655
+ res.destroy();
1656
+ } catch {
1657
+ }
1658
+ });
1659
+ stream.pipe(res);
1699
1660
  });
1700
1661
  });
1701
1662
  }
1663
+ });
1664
+
1665
+ // src/plugins/view/manifest.ts
1666
+ var manifest4 = {
1667
+ mode: "view",
1668
+ label: "\u9879\u76EE\u6D4F\u89C8",
1669
+ icon: "\u{1F441}\uFE0F",
1670
+ description: "openspec / docs / \u6587\u6863\u9884\u89C8",
1671
+ order: 20
1702
1672
  };
1703
1673
 
1704
1674
  // src/plugins/view/index.ts
1705
- var apply8 = {
1706
- inject: ["dashboard"],
1707
- apply(ctx) {
1708
- ctx.dashboard.register({ mode: "view", label: "\u9879\u76EE\u6D4F\u89C8", icon: "\u{1F441}\uFE0F", description: "openspec / docs / \u6587\u6863\u9884\u89C8" });
1675
+ var apply6 = defineBuiltin({
1676
+ manifest: manifest4,
1677
+ setup() {
1709
1678
  }
1710
- };
1679
+ });
1711
1680
 
1712
1681
  // src/plugins/stats/index.ts
1713
1682
  import fs10 from "fs";
1714
1683
  import path10 from "path";
1684
+ import { execFile as execFile6 } from "child_process";
1685
+
1686
+ // src/plugins/stats/manifest.ts
1687
+ var manifest5 = {
1688
+ mode: "stats",
1689
+ label: "\u9879\u76EE\u7EDF\u8BA1",
1690
+ icon: "\u{1F4CA}",
1691
+ description: "\u9879\u76EE\u6587\u4EF6\u7EDF\u8BA1 \xB7 \u626B\u63CF\u751F\u6210",
1692
+ order: 10
1693
+ };
1694
+
1695
+ // src/plugins/stats/index.ts
1715
1696
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", ".cache"]);
1697
+ function execFilePromise(file, args, opts) {
1698
+ return new Promise((resolve) => {
1699
+ execFile6(file, args, opts, (err, stdout) => resolve(err ? "" : stdout));
1700
+ });
1701
+ }
1716
1702
  function scan(root) {
1717
1703
  const stats = {
1718
1704
  root: path10.basename(root),
@@ -1722,7 +1708,8 @@ function scan(root) {
1722
1708
  byExt: [],
1723
1709
  markdown: 0,
1724
1710
  openspec: { active: 0, archived: 0 },
1725
- hasJust: fs10.existsSync(path10.join(root, "justfile"))
1711
+ hasJust: fs10.existsSync(path10.join(root, "justfile")),
1712
+ worktrees: 0
1726
1713
  };
1727
1714
  const extMap = /* @__PURE__ */ new Map();
1728
1715
  const walk = (dir) => {
@@ -1755,26 +1742,31 @@ function scan(root) {
1755
1742
  stats.byExt = Array.from(extMap.entries()).map(([ext, count]) => ({ ext, count })).sort((a, b) => b.count - a.count).slice(0, 10);
1756
1743
  return stats;
1757
1744
  }
1758
- var apply9 = {
1759
- inject: ["server", "dashboard"],
1760
- apply(ctx, config) {
1761
- ctx.inject(["server", "dashboard"], () => {
1762
- const server = ctx.server;
1763
- const dashboard = ctx.dashboard;
1764
- if (!server?.route || !dashboard?.register) return;
1765
- dashboard.register({
1766
- mode: "stats",
1767
- label: "\u9879\u76EE\u7EDF\u8BA1",
1768
- icon: "\u{1F4CA}",
1769
- description: "\u9879\u76EE\u6587\u4EF6\u7EDF\u8BA1 \xB7 \u626B\u63CF\u751F\u6210"
1770
- });
1771
- server.route("/__stats/data", (_req, res) => {
1772
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1773
- res.end(JSON.stringify(scan(config.root)));
1774
- });
1745
+ var apply7 = defineBuiltin({
1746
+ manifest: manifest5,
1747
+ setup(ctx, root) {
1748
+ ctx.route("/__stats/data", async () => {
1749
+ const stats = scan(root);
1750
+ try {
1751
+ const wts = await listWorktrees(root);
1752
+ stats.worktrees = wts.length;
1753
+ } catch {
1754
+ stats.worktrees = 0;
1755
+ }
1756
+ try {
1757
+ const branchOut = await execFilePromise("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root });
1758
+ stats.branch = branchOut.trim() || void 0;
1759
+ } catch {
1760
+ }
1761
+ try {
1762
+ const statusOut = await execFilePromise("git", ["status", "--porcelain"], { cwd: root });
1763
+ stats.dirty = statusOut.trim() ? statusOut.trim().split("\n").length : 0;
1764
+ } catch {
1765
+ }
1766
+ return stats;
1775
1767
  });
1776
1768
  }
1777
- };
1769
+ });
1778
1770
 
1779
1771
  // src/cli.ts
1780
1772
  var __dirname2 = path11.dirname(fileURLToPath2(import.meta.url));
@@ -1877,6 +1869,10 @@ async function main() {
1877
1869
  await stopInstance(oldRecord);
1878
1870
  }
1879
1871
  const appDir = path11.resolve(__dirname2, "web");
1872
+ const BUILTIN_PLUGINS = [apply7, apply3, apply4, apply5, apply6];
1873
+ if (stripDeadPluginConfig(root, BUILTIN_PLUGINS.map((p) => p.manifest))) {
1874
+ console.log("[zdashboard] \u5DF2\u6E05\u7406 dashboard.json \u4E2D\u5185\u7F6E\u63D2\u4EF6\u7684\u8FC7\u65F6\u914D\u7F6E\u952E");
1875
+ }
1880
1876
  const startPort = oldRecord && !args.portExplicit ? oldRecord.port : args.port;
1881
1877
  const zdevDir = path11.join(root, ".zdev");
1882
1878
  const dataDir = fs11.existsSync(zdevDir) ? ".zdev/" : "";
@@ -1896,20 +1892,11 @@ async function main() {
1896
1892
  ctx.plugin(apply, { root });
1897
1893
  ctx.plugin(apply2, { root });
1898
1894
  ctx.plugin(DashboardService);
1899
- const plugins = [
1900
- { name: "stats", apply: apply9 },
1901
- { name: "just", apply: apply3 },
1902
- { name: "bugs", apply: apply4 },
1903
- { name: "review", apply: apply5 },
1904
- { name: "apply", apply: apply6 },
1905
- { name: "design", apply: apply7 },
1906
- { name: "view", apply: apply8 }
1907
- ];
1908
- for (const p of plugins) {
1895
+ for (const apply8 of BUILTIN_PLUGINS) {
1909
1896
  try {
1910
- ctx.plugin(p.apply, { root });
1897
+ ctx.plugin(apply8, { root });
1911
1898
  } catch (e) {
1912
- console.error(`[zdashboard] plugin ${p.name} failed:`, e);
1899
+ console.error("[zdashboard] builtin plugin failed:", e);
1913
1900
  }
1914
1901
  }
1915
1902
  if (args.plugins) {