zdashboard 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,13 +2,137 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import path10 from "path";
5
+ import fs11 from "fs";
5
6
  import { fileURLToPath as fileURLToPath2 } from "url";
6
7
  import { access, readdir } from "fs/promises";
7
8
 
8
9
  // src/server/detect.ts
10
+ import fs2 from "fs";
11
+ import path2 from "path";
12
+ import { execFile } from "child_process";
13
+
14
+ // src/server/bugs.ts
9
15
  import fs from "fs";
10
16
  import path from "path";
11
- import { execFile } from "child_process";
17
+
18
+ // src/server/api/fetch.ts
19
+ import ky from "ky";
20
+
21
+ // src/server/errors.ts
22
+ var HttpError = class extends Error {
23
+ constructor(status, message, body) {
24
+ super(message);
25
+ this.status = status;
26
+ this.body = body;
27
+ this.name = "HttpError";
28
+ }
29
+ status;
30
+ body;
31
+ };
32
+ var NetworkError = class extends Error {
33
+ constructor(message, cause) {
34
+ super(message);
35
+ this.cause = cause;
36
+ this.name = "NetworkError";
37
+ }
38
+ cause;
39
+ };
40
+
41
+ // src/server/api/fetch.ts
42
+ async function fetchJson(url, init) {
43
+ try {
44
+ const res = await ky(url, { ...init, timeout: 8e3, retry: 2 });
45
+ return await res.json();
46
+ } catch (e) {
47
+ if (e instanceof HttpError) throw e;
48
+ if (e instanceof Error && e.name === "TimeoutError") {
49
+ throw new NetworkError(`\u8BF7\u6C42\u8D85\u65F6: ${url}`);
50
+ }
51
+ if (e instanceof Error && e.name === "HTTPError") {
52
+ throw new HttpError(e.status ?? 500, e.message);
53
+ }
54
+ throw new NetworkError(`\u8BF7\u6C42\u5931\u8D25: ${url}`, e);
55
+ }
56
+ }
57
+
58
+ // src/server/bugs.ts
59
+ var BUGS_CONFIG_CANDIDATES = [".zdev/config.yaml", ".zgoal/config.yaml"];
60
+ function loadConfig(root) {
61
+ for (const rel of BUGS_CONFIG_CANDIDATES) {
62
+ const file = path.join(root, rel);
63
+ if (!fs.existsSync(file)) continue;
64
+ const kv = {};
65
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
66
+ const m = line.match(/^\s*([A-Za-z_]\w*)\s*:\s*(.+?)\s*$/);
67
+ if (m && !m[2].startsWith("#")) kv[m[1]] = m[2].replace(/^["']|["']$/g, "");
68
+ }
69
+ const product = Number(kv.product);
70
+ if (!kv.url || !product) return null;
71
+ return {
72
+ url: kv.url.replace(/\/+$/, ""),
73
+ account: kv.account ?? "",
74
+ password: kv.password,
75
+ token: kv.token,
76
+ product
77
+ };
78
+ }
79
+ return null;
80
+ }
81
+ var tokenCache = null;
82
+ async function getToken(cfg) {
83
+ if (cfg.token) return cfg.token;
84
+ const key = `${cfg.url}|${cfg.account}|${cfg.password ?? ""}`;
85
+ if (tokenCache && tokenCache.key === key && Date.now() - tokenCache.at < 10 * 6e4) return tokenCache.token;
86
+ const json = await fetchJson(`${cfg.url}/api.php/v1/tokens`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify({ account: cfg.account, password: cfg.password })
90
+ });
91
+ const token = typeof json.token === "string" ? json.token : "";
92
+ if (!token) throw new Error("token \u83B7\u53D6\u5931\u8D25:\u68C0\u67E5 account / password");
93
+ tokenCache = { key, token, at: Date.now() };
94
+ return token;
95
+ }
96
+ function normBug(b, account) {
97
+ const assigned = b.assignedTo;
98
+ const assignedTo = typeof assigned === "string" ? assigned : assigned && typeof assigned === "object" && "realname" in assigned ? String(assigned.realname ?? "") : "";
99
+ const assignedAccount = typeof assigned === "string" ? assigned : assigned && typeof assigned === "object" ? String(assigned.account ?? "") : "";
100
+ const mine = !!account && (assignedAccount === account || assignedTo === account);
101
+ return {
102
+ id: Number(b.id),
103
+ title: String(b.title ?? ""),
104
+ severity: b.severity ?? 4,
105
+ pri: b.pri ?? 3,
106
+ status: String(b.status ?? ""),
107
+ assignedTo,
108
+ openedBy: typeof b.openedBy === "string" ? b.openedBy : void 0,
109
+ mine
110
+ };
111
+ }
112
+ async function fetchBugs(root) {
113
+ const cfg = loadConfig(root);
114
+ if (!cfg) {
115
+ const anyExists = BUGS_CONFIG_CANDIDATES.some((rel) => fs.existsSync(path.join(root, rel)));
116
+ return {
117
+ ok: false,
118
+ 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)"
119
+ };
120
+ }
121
+ try {
122
+ const token = await getToken(cfg);
123
+ const json = await fetchJson(
124
+ `${cfg.url}/api.php/v1/products/${cfg.product}/bugs?page=1&limit=100`,
125
+ { headers: { Token: token } }
126
+ );
127
+ const raw = Array.isArray(json.bugs) ? json.bugs : [];
128
+ return { ok: true, url: cfg.url, total: Number(json.total ?? raw.length), bugs: raw.map((b) => normBug(b, cfg.account)) };
129
+ } catch (e) {
130
+ const msg = e instanceof Error ? e.message : String(e);
131
+ 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` };
132
+ }
133
+ }
134
+
135
+ // src/server/detect.ts
12
136
  function justAvailable(cwd) {
13
137
  return new Promise((resolve) => {
14
138
  const child = execFile("just", ["--list", "--unsorted"], { cwd, timeout: 5e3 }, (err) => {
@@ -18,10 +142,10 @@ function justAvailable(cwd) {
18
142
  });
19
143
  }
20
144
  async function detect(root) {
21
- const hasOpenspec = fs.existsSync(path.join(root, "openspec"));
22
- const hasDocs = fs.existsSync(path.join(root, "docs"));
145
+ const hasOpenspec = fs2.existsSync(path2.join(root, "openspec"));
146
+ const hasDocs = fs2.existsSync(path2.join(root, "docs"));
23
147
  const hasJust = await justAvailable(root);
24
- const hasBugs = fs.existsSync(path.join(root, ".zgoal", "config.yaml"));
148
+ const hasBugs = BUGS_CONFIG_CANDIDATES.some((rel) => fs2.existsSync(path2.join(root, rel)));
25
149
  return { hasOpenspec, hasDocs, hasJust, hasBugs };
26
150
  }
27
151
 
@@ -30,15 +154,15 @@ import { Context } from "cordis";
30
154
 
31
155
  // src/core/server.ts
32
156
  import http from "http";
33
- import fs3 from "fs";
34
- import path3 from "path";
157
+ import fs4 from "fs";
158
+ import path4 from "path";
35
159
  import crypto from "crypto";
36
160
  import { fileURLToPath } from "url";
37
161
 
38
162
  // package.json
39
163
  var package_default = {
40
164
  name: "zdashboard",
41
- version: "2.1.0",
165
+ version: "2.1.1",
42
166
  description: "ZCode skill dashboard platform \u2014 pluggable viewers for zdesign/zview/zreview/zgoal",
43
167
  type: "module",
44
168
  bin: {
@@ -124,20 +248,20 @@ var package_default = {
124
248
  import { Service } from "cordis";
125
249
 
126
250
  // src/core/instance.ts
127
- import fs2 from "fs";
128
- import path2 from "path";
251
+ import fs3 from "fs";
252
+ import path3 from "path";
129
253
  var RECORD_FILE = ".zdev/dashboard.json";
130
254
  var VERIFY_TIMEOUT_MS = 1500;
131
255
  var STOP_POLL_MS = 2e3;
132
256
  var STOP_POLL_INTERVAL_MS = 100;
133
257
  var STOP_FINAL_WAIT_MS = 100;
134
258
  function recordPath(root) {
135
- return path2.join(root, RECORD_FILE);
259
+ return path3.join(root, RECORD_FILE);
136
260
  }
137
261
  function readRecord(root) {
138
262
  try {
139
263
  const fp = recordPath(root);
140
- const raw = fs2.readFileSync(fp, "utf-8");
264
+ const raw = fs3.readFileSync(fp, "utf-8");
141
265
  const rec = JSON.parse(raw);
142
266
  if (typeof rec.pid === "number" && typeof rec.port === "number" && typeof rec.root === "string" && typeof rec.startedAt === "string") {
143
267
  return rec;
@@ -148,20 +272,20 @@ function readRecord(root) {
148
272
  }
149
273
  }
150
274
  function writeRecord(root, port) {
151
- fs2.mkdirSync(path2.dirname(recordPath(root)), { recursive: true });
275
+ fs3.mkdirSync(path3.dirname(recordPath(root)), { recursive: true });
152
276
  const rec = {
153
277
  pid: process.pid,
154
278
  port,
155
279
  root,
156
280
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
157
281
  };
158
- fs2.writeFileSync(recordPath(root), JSON.stringify(rec, null, 2) + "\n");
282
+ fs3.writeFileSync(recordPath(root), JSON.stringify(rec, null, 2) + "\n");
159
283
  }
160
284
  function clearRecord(root) {
161
285
  try {
162
286
  const rec = readRecord(root);
163
287
  if (rec && rec.pid !== process.pid) return;
164
- fs2.unlinkSync(recordPath(root));
288
+ fs3.unlinkSync(recordPath(root));
165
289
  } catch {
166
290
  }
167
291
  }
@@ -221,14 +345,14 @@ async function stopInstance(record) {
221
345
 
222
346
  // src/core/open-url.ts
223
347
  import { exec } from "child_process";
224
- function openUrl2(url) {
348
+ function openUrl(url) {
225
349
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
226
350
  exec(`${cmd} ${url}`);
227
351
  }
228
352
 
229
353
  // src/core/server.ts
230
354
  var VERSION = package_default.version;
231
- var __dirname = path3.dirname(fileURLToPath(import.meta.url));
355
+ var __dirname = path4.dirname(fileURLToPath(import.meta.url));
232
356
  var MIME = {
233
357
  ".html": "text/html; charset=utf-8",
234
358
  ".htm": "text/html; charset=utf-8",
@@ -286,6 +410,7 @@ var ServerService = class extends Service {
286
410
  open;
287
411
  page;
288
412
  det;
413
+ dataDir;
289
414
  routes = /* @__PURE__ */ new Map();
290
415
  sses = /* @__PURE__ */ new Map();
291
416
  prefixStatic = /* @__PURE__ */ new Map();
@@ -299,6 +424,7 @@ var ServerService = class extends Service {
299
424
  this.open = config.open;
300
425
  this.page = config.page;
301
426
  this.det = config.detect;
427
+ this.dataDir = config.dataDir;
302
428
  this.onListen = config.onListen;
303
429
  ctx.effect(() => () => this.dispose());
304
430
  this.route("/__config", (_req, res) => {
@@ -330,12 +456,12 @@ var ServerService = class extends Service {
330
456
  this.ctx.effect(() => () => this.prefixStatic.delete(prefix));
331
457
  }
332
458
  serveFile(filePath, res, injectHtml) {
333
- fs3.readFile(filePath, (err, data) => {
459
+ fs4.readFile(filePath, (err, data) => {
334
460
  if (err) {
335
461
  res.writeHead(404);
336
462
  return res.end("Not found");
337
463
  }
338
- const ext = path3.extname(filePath).toLowerCase();
464
+ const ext = path4.extname(filePath).toLowerCase();
339
465
  const ct = MIME[ext] ?? "application/octet-stream";
340
466
  let body = data;
341
467
  if (injectHtml && ext === ".html") {
@@ -350,9 +476,9 @@ var ServerService = class extends Service {
350
476
  for (const [prefix, dir] of this.prefixStatic) {
351
477
  if (url.indexOf(prefix) === 0) {
352
478
  let rel = this.safeDecode(url.slice(prefix.length));
353
- if (!rel || rel === "/" || !path3.extname(rel)) rel = path3.join(rel || "", "index.html");
354
- const fp = path3.join(dir, rel);
355
- if (fp.indexOf(dir + path3.sep) !== 0) {
479
+ if (!rel || rel === "/" || !path4.extname(rel)) rel = path4.join(rel || "", "index.html");
480
+ const fp = path4.join(dir, rel);
481
+ if (fp.indexOf(dir + path4.sep) !== 0) {
356
482
  res.writeHead(403);
357
483
  return res.end("Forbidden");
358
484
  }
@@ -391,17 +517,17 @@ var ServerService = class extends Service {
391
517
  if (this.servePrefix(url, res)) return;
392
518
  if (url === "/" || url.indexOf("/__app/") === 0 || url.indexOf("/assets/") === 0) {
393
519
  let fp2 = this.appDir;
394
- if (url !== "/") fp2 = path3.join(this.appDir, this.safeDecode(url));
395
- if (url.indexOf("/__app/") === 0) fp2 = path3.join(this.appDir, url.slice(7));
396
- if (fp2 !== this.appDir && fp2.indexOf(this.appDir + path3.sep) !== 0) {
520
+ if (url !== "/") fp2 = path4.join(this.appDir, this.safeDecode(url));
521
+ if (url.indexOf("/__app/") === 0) fp2 = path4.join(this.appDir, url.slice(7));
522
+ if (fp2 !== this.appDir && fp2.indexOf(this.appDir + path4.sep) !== 0) {
397
523
  res.writeHead(403);
398
524
  return res.end("Forbidden");
399
525
  }
400
- if (url === "/") fp2 = path3.join(this.appDir, "index.html");
526
+ if (url === "/") fp2 = path4.join(this.appDir, "index.html");
401
527
  return this.serveFile(fp2, res, false);
402
528
  }
403
- const fp = path3.join(this.root, this.safeDecode(url));
404
- if (fp !== this.root && fp.indexOf(this.root + path3.sep) !== 0) {
529
+ const fp = path4.join(this.root, this.safeDecode(url));
530
+ if (fp !== this.root && fp.indexOf(this.root + path4.sep) !== 0) {
405
531
  res.writeHead(403);
406
532
  return res.end("Forbidden");
407
533
  }
@@ -417,6 +543,8 @@ var ServerService = class extends Service {
417
543
  };
418
544
  start(port) {
419
545
  this.server = http.createServer(this.handler);
546
+ this.server.keepAliveTimeout = 65e3;
547
+ this.server.headersTimeout = 66e3;
420
548
  this.server.on("error", (err) => {
421
549
  if (err.code === "EADDRINUSE") {
422
550
  console.log(`[zdashboard] port ${port} busy, trying ${port + 1}`);
@@ -429,7 +557,8 @@ var ServerService = class extends Service {
429
557
  console.log(`[zdashboard] v${VERSION} dashboard -> ${u}`);
430
558
  console.log(`[zdashboard] project -> ${this.root}`);
431
559
  console.log(`[zdashboard] detect -> openspec:${this.det.hasOpenspec} docs:${this.det.hasDocs} just:${this.det.hasJust} bugs:${this.det.hasBugs}`);
432
- if (this.open) openUrl2(target);
560
+ if (this.dataDir) console.log(`[zdashboard] data -> ${this.dataDir}`);
561
+ if (this.open) openUrl(target);
433
562
  this.onListen?.(port);
434
563
  });
435
564
  }
@@ -467,7 +596,7 @@ var ServerService = class extends Service {
467
596
  };
468
597
 
469
598
  // src/core/reload.ts
470
- import fs4 from "fs";
599
+ import fs5 from "fs";
471
600
  import { Service as Service2 } from "cordis";
472
601
  var WATCH_DEBOUNCE_MS = 150;
473
602
  var ReloadService = class extends Service2 {
@@ -483,7 +612,7 @@ var ReloadService = class extends Service2 {
483
612
  return () => this.clients.delete(res);
484
613
  });
485
614
  try {
486
- this.watcher = fs4.watch(config.root, { recursive: true }, () => {
615
+ this.watcher = fs5.watch(config.root, { recursive: true }, () => {
487
616
  if (this.timer) clearTimeout(this.timer);
488
617
  this.timer = setTimeout(() => {
489
618
  this.broadcast("reload");
@@ -526,13 +655,13 @@ data: ${JSON.stringify(data == null ? "" : data)}
526
655
  };
527
656
 
528
657
  // src/server/spec-scan.ts
529
- import fs5 from "fs";
530
- import path4 from "path";
658
+ import fs6 from "fs";
659
+ import path5 from "path";
531
660
  function walkFiles(absDir, relDir, depth = 0) {
532
661
  if (depth > 4) return [];
533
662
  let ents;
534
663
  try {
535
- ents = fs5.readdirSync(absDir, { withFileTypes: true });
664
+ ents = fs6.readdirSync(absDir, { withFileTypes: true });
536
665
  } catch {
537
666
  return [];
538
667
  }
@@ -541,7 +670,7 @@ function walkFiles(absDir, relDir, depth = 0) {
541
670
  if (ent.name.startsWith(".") || ent.name === "node_modules") continue;
542
671
  const rel = relDir ? `${relDir}/${ent.name}` : ent.name;
543
672
  if (ent.isDirectory()) {
544
- nodes.push({ name: ent.name, kind: "dir", children: walkFiles(path4.join(absDir, ent.name), rel, depth + 1) });
673
+ nodes.push({ name: ent.name, kind: "dir", children: walkFiles(path5.join(absDir, ent.name), rel, depth + 1) });
545
674
  } else {
546
675
  nodes.push({ name: ent.name, kind: "file", path: rel });
547
676
  }
@@ -551,41 +680,41 @@ function walkFiles(absDir, relDir, depth = 0) {
551
680
  }
552
681
  function scanTree(root, hasOpenspec, hasDocs) {
553
682
  const tree = [];
554
- if (hasOpenspec && fs5.existsSync(path4.join(root, "openspec", "changes"))) {
555
- const changesDir = path4.join(root, "openspec", "changes");
683
+ if (hasOpenspec && fs6.existsSync(path5.join(root, "openspec", "changes"))) {
684
+ const changesDir = path5.join(root, "openspec", "changes");
556
685
  const active = [];
557
686
  const archived = [];
558
- for (const ent of fs5.readdirSync(changesDir, { withFileTypes: true })) {
687
+ for (const ent of fs6.readdirSync(changesDir, { withFileTypes: true })) {
559
688
  if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "archive") continue;
560
- active.push({ name: ent.name, kind: "dir", children: walkFiles(path4.join(changesDir, ent.name), `openspec/changes/${ent.name}`) });
689
+ active.push({ name: ent.name, kind: "dir", children: walkFiles(path5.join(changesDir, ent.name), `openspec/changes/${ent.name}`) });
561
690
  }
562
691
  active.sort((a, b) => a.name.localeCompare(b.name));
563
- const archiveDir = path4.join(changesDir, "archive");
564
- if (fs5.existsSync(archiveDir)) {
565
- for (const ent of fs5.readdirSync(archiveDir, { withFileTypes: true })) {
692
+ const archiveDir = path5.join(changesDir, "archive");
693
+ if (fs6.existsSync(archiveDir)) {
694
+ for (const ent of fs6.readdirSync(archiveDir, { withFileTypes: true })) {
566
695
  if (!ent.isDirectory() || ent.name.startsWith(".")) continue;
567
- archived.push({ name: ent.name, kind: "dir", children: walkFiles(path4.join(archiveDir, ent.name), `openspec/changes/archive/${ent.name}`) });
696
+ archived.push({ name: ent.name, kind: "dir", children: walkFiles(path5.join(archiveDir, ent.name), `openspec/changes/archive/${ent.name}`) });
568
697
  }
569
698
  archived.sort((a, b) => b.name.localeCompare(a.name));
570
699
  }
571
700
  if (active.length) tree.push({ name: `\u8FDB\u884C\u4E2D (${active.length})`, kind: "dir", children: active });
572
701
  if (archived.length) tree.push({ name: "archive", kind: "dir", defaultCollapsed: true, children: archived });
573
- const specsDir = path4.join(root, "openspec", "specs");
574
- if (fs5.existsSync(specsDir)) {
702
+ const specsDir = path5.join(root, "openspec", "specs");
703
+ if (fs6.existsSync(specsDir)) {
575
704
  const specs = walkFiles(specsDir, "openspec/specs");
576
705
  if (specs.length) tree.push({ name: "specs", kind: "dir", children: specs });
577
706
  }
578
707
  }
579
- if (hasDocs && fs5.existsSync(path4.join(root, "docs"))) {
580
- const docs = walkFiles(path4.join(root, "docs"), "docs");
708
+ if (hasDocs && fs6.existsSync(path5.join(root, "docs"))) {
709
+ const docs = walkFiles(path5.join(root, "docs"), "docs");
581
710
  if (docs.length) tree.push({ name: "docs", kind: "dir", children: docs });
582
711
  }
583
712
  const skip = /* @__PURE__ */ new Set(["openspec", "docs", "node_modules", ".git", "dist", "test-server"]);
584
713
  const etc = [];
585
714
  try {
586
- for (const ent of fs5.readdirSync(root, { withFileTypes: true })) {
715
+ for (const ent of fs6.readdirSync(root, { withFileTypes: true })) {
587
716
  if (ent.name.startsWith(".") || skip.has(ent.name)) continue;
588
- const ext = path4.extname(ent.name).toLowerCase();
717
+ const ext = path5.extname(ent.name).toLowerCase();
589
718
  if (ent.isFile() && (ext === ".md" || ext === ".markdown")) etc.push({ name: ent.name, kind: "file", path: ent.name });
590
719
  }
591
720
  } catch (e) {
@@ -655,13 +784,7 @@ import { spawn, execFile as execFile2 } from "child_process";
655
784
  var MAX_BUFFER = 1e3;
656
785
  var JustRunner = class {
657
786
  cwd;
658
- child = null;
659
- recipe = null;
660
- state = "idle";
661
- code = null;
662
- buffer = [];
663
- pending = "";
664
- // 行缓冲:块缓冲输出(如 maven)的 chunk 会在行中间断开,攒到 \n 才切行
787
+ tasks = /* @__PURE__ */ new Map();
665
788
  clients = /* @__PURE__ */ new Set();
666
789
  recipesCache = null;
667
790
  constructor(cwd) {
@@ -695,26 +818,25 @@ var JustRunner = class {
695
818
  }
696
819
  subscribe(fn) {
697
820
  this.clients.add(fn);
698
- for (const text of this.buffer) fn({ type: "log", text });
699
- fn({ type: "state", state: this.state, recipe: this.recipe, code: this.code });
821
+ for (const t of this.tasks.values()) {
822
+ for (const text of t.buffer) fn({ type: "log", recipe: t.recipe, text });
823
+ fn({ type: "state", recipe: t.recipe, state: t.state, code: t.code, startedAt: t.startedAt });
824
+ }
700
825
  return () => this.clients.delete(fn);
701
826
  }
702
827
  emit(ev) {
703
828
  for (const fn of this.clients) fn(ev);
704
829
  }
705
- info() {
706
- return { state: this.state, recipe: this.recipe, code: this.code };
830
+ list() {
831
+ return Array.from(this.tasks.values(), (t) => ({ recipe: t.recipe, state: t.state, code: t.code, startedAt: t.startedAt }));
707
832
  }
708
- /** 启动 recipe(调用方须先用 recipes() 校验名字);自动停旧进程 */
833
+ /** 启动 recipe:同名先停旧进程(重启语义),不影响其他任务;调用方须先用 recipes() 校验名字 */
709
834
  start(recipe) {
710
- this.killChild();
711
- this.recipe = recipe;
712
- this.code = null;
713
- this.state = "running";
714
- this.buffer = [];
715
- this.pending = "";
716
- this.emit({ type: "clear" });
717
- this.emit({ type: "state", state: "running", recipe, code: null });
835
+ this.killOne(recipe);
836
+ const task = { recipe, child: null, state: "running", code: null, startedAt: Date.now(), buffer: [], pending: "" };
837
+ this.tasks.set(recipe, task);
838
+ this.emit({ type: "clear", recipe });
839
+ this.emit({ type: "state", recipe, state: "running", code: null, startedAt: task.startedAt });
718
840
  const child = spawn("just", [recipe], {
719
841
  cwd: this.cwd,
720
842
  shell: true,
@@ -727,47 +849,62 @@ var JustRunner = class {
727
849
  CI: ""
728
850
  }
729
851
  });
730
- this.child = child;
852
+ task.child = child;
853
+ const isStale = () => this.tasks.get(recipe) !== task;
731
854
  const push = (d) => {
732
- this.pending += d.toString();
855
+ if (isStale()) return;
856
+ task.pending += d.toString();
733
857
  let idx;
734
- while ((idx = this.pending.indexOf("\n")) >= 0) {
735
- const line = this.pending.slice(0, idx + 1);
736
- this.pending = this.pending.slice(idx + 1);
737
- this.pushLine(line);
858
+ while ((idx = task.pending.indexOf("\n")) >= 0) {
859
+ const line = task.pending.slice(0, idx + 1);
860
+ task.pending = task.pending.slice(idx + 1);
861
+ this.pushLine(task, line);
738
862
  }
739
863
  };
740
864
  child.stdout?.on("data", push);
741
865
  child.stderr?.on("data", push);
742
866
  child.on("error", (err) => {
743
- this.pushLine(`[zdashboard] spawn error: ${err.message}
867
+ this.pushLine(task, `[zdashboard] spawn error: ${err.message}
744
868
  `);
745
869
  });
746
- child.on("exit", (code) => {
747
- if (this.pending) {
748
- this.pushLine(this.pending + "\n");
749
- this.pending = "";
870
+ child.on("exit", (code, signal) => {
871
+ if (task.pending && !isStale()) {
872
+ this.pushLine(task, task.pending + "\n");
750
873
  }
751
- this.child = null;
752
- this.state = "exited";
753
- this.code = code ?? 0;
754
- this.emit({ type: "state", state: "exited", recipe: this.recipe, code: this.code });
874
+ task.pending = "";
875
+ task.child = null;
876
+ task.state = "exited";
877
+ task.code = code ?? 0;
878
+ task.signal = signal ?? void 0;
879
+ if (isStale()) return;
880
+ this.emit({ type: "state", recipe, state: "exited", code: task.code, startedAt: task.startedAt, signal: task.signal });
755
881
  });
756
882
  }
757
- pushLine(line) {
758
- this.buffer.push(line);
759
- if (this.buffer.length > MAX_BUFFER) this.buffer.shift();
760
- this.emit({ type: "log", text: line });
883
+ pushLine(task, line) {
884
+ task.buffer.push(line);
885
+ if (task.buffer.length > MAX_BUFFER) task.buffer.shift();
886
+ this.emit({ type: "log", recipe: task.recipe, text: line });
761
887
  }
762
- stop() {
763
- this.killChild();
888
+ /** 停单个任务;不传 recipe 停全部 */
889
+ stop(recipe) {
890
+ if (recipe === void 0) {
891
+ for (const t of this.tasks.values()) this.killOne(t.recipe);
892
+ } else this.killOne(recipe);
764
893
  }
765
894
  restart(recipe) {
766
- const target = recipe ?? this.recipe;
767
- if (target) this.start(target);
768
- }
769
- killChild() {
770
- const child = this.child;
895
+ if (this.tasks.has(recipe)) this.start(recipe);
896
+ }
897
+ /** 清空某任务的日志缓冲并广播(真源清除,重连重放不会复活) */
898
+ clear(recipe) {
899
+ const task = this.tasks.get(recipe);
900
+ if (!task) return;
901
+ task.buffer = [];
902
+ task.pending = "";
903
+ this.emit({ type: "clear", recipe });
904
+ }
905
+ killOne(recipe) {
906
+ const task = this.tasks.get(recipe);
907
+ const child = task?.child;
771
908
  if (child?.pid) {
772
909
  try {
773
910
  if (process.platform === "win32") spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"]);
@@ -775,7 +912,9 @@ var JustRunner = class {
775
912
  } catch {
776
913
  }
777
914
  }
778
- this.child = null;
915
+ if (task) {
916
+ task.child = null;
917
+ }
779
918
  }
780
919
  };
781
920
 
@@ -788,6 +927,7 @@ var apply2 = {
788
927
  if (!ctx.server?.route) return;
789
928
  const runner = new JustRunner(root);
790
929
  ctx.effect(() => () => runner.stop());
930
+ ctx.dashboard.register({ mode: "just", label: "Just Runner", icon: "\u{1F4DC}", description: "Just \u591A\u4EFB\u52A1\u5E76\u53D1\u6267\u884C\u4E0E\u65E5\u5FD7" });
791
931
  ctx.server.route("/__just/recipes", async (_req, res) => {
792
932
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
793
933
  try {
@@ -805,7 +945,7 @@ var apply2 = {
805
945
  });
806
946
  return unsub;
807
947
  });
808
- ctx.server.route("/__just/start", async (req, res) => {
948
+ const handleAction = async (req, res, act) => {
809
949
  if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
810
950
  res.writeHead(403);
811
951
  res.end("forbidden");
@@ -817,61 +957,45 @@ var apply2 = {
817
957
  recipe = JSON.parse(body || "{}").recipe;
818
958
  } catch {
819
959
  }
820
- const target = recipe ?? runner.info().recipe;
821
- if (!target) {
822
- res.writeHead(400);
823
- res.end('{"error":"no recipe"}');
824
- return;
825
- }
826
- const recipes = await runner.recipes();
827
- if (!recipes.some((r) => r.name === target)) {
828
- res.writeHead(403);
829
- res.end('{"error":"unknown recipe"}');
830
- return;
831
- }
832
- runner.start(target);
833
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
834
- res.end(JSON.stringify(runner.info()));
835
- });
836
- ctx.server.route("/__just/stop", async (req, res) => {
837
- if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
838
- res.writeHead(403);
839
- res.end("forbidden");
840
- return;
841
- }
842
- runner.stop();
843
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
844
- res.end(JSON.stringify(runner.info()));
845
- });
846
- ctx.server.route("/__just/restart", async (req, res) => {
847
- if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
848
- res.writeHead(403);
849
- res.end("forbidden");
960
+ if (act === "clear") {
961
+ if (recipe) runner.clear(recipe);
962
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
963
+ res.end('{"ok":true}');
850
964
  return;
851
965
  }
852
- const body = await readBody(req);
853
- let recipe;
854
- try {
855
- recipe = JSON.parse(body || "{}").recipe;
856
- } catch {
857
- }
858
- const target = recipe ?? runner.info().recipe;
859
- if (!target) {
860
- res.writeHead(400);
861
- res.end('{"error":"no recipe"}');
862
- return;
863
- }
864
- const recipes = await runner.recipes();
865
- if (!recipes.some((r) => r.name === target)) {
866
- res.writeHead(403);
867
- res.end('{"error":"unknown recipe"}');
868
- return;
966
+ if (act !== "stop") {
967
+ if (!recipe) {
968
+ res.writeHead(400);
969
+ res.end('{"error":"no recipe"}');
970
+ return;
971
+ }
972
+ const recipes = await runner.recipes();
973
+ if (!recipes.some((r) => r.name === recipe)) {
974
+ res.writeHead(403);
975
+ res.end('{"error":"unknown recipe"}');
976
+ return;
977
+ }
978
+ runner.start(recipe);
979
+ } else {
980
+ runner.stop(recipe || void 0);
869
981
  }
870
- runner.restart(target);
871
982
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
872
- res.end(JSON.stringify(runner.info()));
873
- });
874
- ctx.dashboard.register({ mode: "just", label: "Just Runner", icon: "\u{1F4DC}", description: "Just \u4EFB\u52A1\u65E5\u5FD7\u4E0E\u6267\u884C" });
983
+ res.end(JSON.stringify(runner.list()));
984
+ };
985
+ const routeAction = (name) => {
986
+ ctx.server.route(`/__just/${name}`, (req, res) => {
987
+ handleAction(req, res, name).catch(() => {
988
+ if (!res.headersSent) {
989
+ res.writeHead(500);
990
+ res.end('{"error":"internal"}');
991
+ }
992
+ });
993
+ });
994
+ };
995
+ routeAction("start");
996
+ routeAction("stop");
997
+ routeAction("restart");
998
+ routeAction("clear");
875
999
  });
876
1000
  }
877
1001
  };
@@ -882,120 +1006,10 @@ async function readBody(req) {
882
1006
  data += c;
883
1007
  });
884
1008
  req.on("end", () => resolve(data));
1009
+ req.on("error", () => resolve(""));
885
1010
  });
886
1011
  }
887
1012
 
888
- // src/server/bugs.ts
889
- import fs6 from "fs";
890
- import path5 from "path";
891
-
892
- // src/server/api/fetch.ts
893
- import ky from "ky";
894
-
895
- // src/server/errors.ts
896
- var HttpError = class extends Error {
897
- constructor(status, message, body) {
898
- super(message);
899
- this.status = status;
900
- this.body = body;
901
- this.name = "HttpError";
902
- }
903
- status;
904
- body;
905
- };
906
- var NetworkError = class extends Error {
907
- constructor(message, cause) {
908
- super(message);
909
- this.cause = cause;
910
- this.name = "NetworkError";
911
- }
912
- cause;
913
- };
914
-
915
- // src/server/api/fetch.ts
916
- async function fetchJson(url, init) {
917
- try {
918
- const res = await ky(url, { ...init, timeout: 8e3, retry: 2 });
919
- return await res.json();
920
- } catch (e) {
921
- if (e instanceof HttpError) throw e;
922
- if (e instanceof Error && e.name === "TimeoutError") {
923
- throw new NetworkError(`\u8BF7\u6C42\u8D85\u65F6: ${url}`);
924
- }
925
- if (e instanceof Error && e.name === "HTTPError") {
926
- throw new HttpError(e.status ?? 500, e.message);
927
- }
928
- throw new NetworkError(`\u8BF7\u6C42\u5931\u8D25: ${url}`, e);
929
- }
930
- }
931
-
932
- // src/server/bugs.ts
933
- function loadZgoalConfig(root) {
934
- const file = path5.join(root, ".zgoal", "config.yaml");
935
- if (!fs6.existsSync(file)) return null;
936
- const kv = {};
937
- for (const line of fs6.readFileSync(file, "utf8").split("\n")) {
938
- const m = line.match(/^\s*([A-Za-z_]\w*)\s*:\s*(.+?)\s*$/);
939
- if (m && !m[2].startsWith("#")) kv[m[1]] = m[2].replace(/^["']|["']$/g, "");
940
- }
941
- const product = Number(kv.product);
942
- if (!kv.url || !product) return null;
943
- return {
944
- url: kv.url.replace(/\/+$/, ""),
945
- account: kv.account ?? "",
946
- password: kv.password,
947
- token: kv.token,
948
- product
949
- };
950
- }
951
- var tokenCache = null;
952
- async function getToken(cfg) {
953
- if (cfg.token) return cfg.token;
954
- const key = `${cfg.url}|${cfg.account}|${cfg.password ?? ""}`;
955
- if (tokenCache && tokenCache.key === key && Date.now() - tokenCache.at < 10 * 6e4) return tokenCache.token;
956
- const json = await fetchJson(`${cfg.url}/api.php/v1/tokens`, {
957
- method: "POST",
958
- headers: { "Content-Type": "application/json" },
959
- body: JSON.stringify({ account: cfg.account, password: cfg.password })
960
- });
961
- const token = typeof json.token === "string" ? json.token : "";
962
- if (!token) throw new Error("token \u83B7\u53D6\u5931\u8D25:\u68C0\u67E5 account / password");
963
- tokenCache = { key, token, at: Date.now() };
964
- return token;
965
- }
966
- function normBug(b, account) {
967
- const assigned = b.assignedTo;
968
- const assignedTo = typeof assigned === "string" ? assigned : assigned && typeof assigned === "object" && "realname" in assigned ? String(assigned.realname ?? "") : "";
969
- const assignedAccount = typeof assigned === "string" ? assigned : assigned && typeof assigned === "object" ? String(assigned.account ?? "") : "";
970
- const mine = !!account && (assignedAccount === account || assignedTo === account);
971
- return {
972
- id: Number(b.id),
973
- title: String(b.title ?? ""),
974
- severity: b.severity ?? 4,
975
- pri: b.pri ?? 3,
976
- status: String(b.status ?? ""),
977
- assignedTo,
978
- openedBy: typeof b.openedBy === "string" ? b.openedBy : void 0,
979
- mine
980
- };
981
- }
982
- async function fetchBugs(root) {
983
- const cfg = loadZgoalConfig(root);
984
- if (!cfg) return { ok: false, error: ".zgoal/config.yaml \u7F3A\u5931\u6216 url/product \u672A\u914D\u7F6E(\u7531 zgoal skill \u521B\u5EFA)" };
985
- try {
986
- const token = await getToken(cfg);
987
- const json = await fetchJson(
988
- `${cfg.url}/api.php/v1/products/${cfg.product}/bugs?page=1&limit=100`,
989
- { headers: { Token: token } }
990
- );
991
- const raw = Array.isArray(json.bugs) ? json.bugs : [];
992
- return { ok: true, url: cfg.url, total: Number(json.total ?? raw.length), bugs: raw.map((b) => normBug(b, cfg.account)) };
993
- } catch (e) {
994
- const msg = e instanceof Error ? e.message : String(e);
995
- 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` };
996
- }
997
- }
998
-
999
1013
  // src/plugins/bugs/index.ts
1000
1014
  var apply3 = {
1001
1015
  inject: ["server", "dashboard"],
@@ -1021,15 +1035,16 @@ var apply3 = {
1021
1035
  import fs7 from "fs";
1022
1036
  import path6 from "path";
1023
1037
  import YAML from "yaml";
1024
- var REVIEW_FILE = "review.yaml";
1038
+ var REVIEW_CANDIDATES = [".zdev/review.yaml", "review.yaml"];
1025
1039
  var ReviewStore = class {
1026
1040
  root;
1027
1041
  file;
1028
1042
  onChange;
1029
1043
  constructor(root, onChange) {
1030
1044
  this.root = root;
1031
- this.file = path6.join(root, REVIEW_FILE);
1032
1045
  this.onChange = onChange;
1046
+ const existing = REVIEW_CANDIDATES.find((rel) => fs7.existsSync(path6.join(root, rel)));
1047
+ this.file = path6.join(root, existing ?? REVIEW_CANDIDATES[0]);
1033
1048
  }
1034
1049
  exists() {
1035
1050
  return fs7.existsSync(this.file);
@@ -1044,6 +1059,7 @@ var ReviewStore = class {
1044
1059
  }
1045
1060
  }
1046
1061
  write(data) {
1062
+ fs7.mkdirSync(path6.dirname(this.file), { recursive: true });
1047
1063
  fs7.writeFileSync(this.file, YAML.stringify(data), "utf8");
1048
1064
  this.onChange?.();
1049
1065
  }
@@ -1068,7 +1084,9 @@ var ReviewStore = class {
1068
1084
  }
1069
1085
  docs() {
1070
1086
  try {
1071
- return fs7.readdirSync(this.root).filter((f) => /\.(md|markdown)$/i.test(f) && fs7.statSync(path6.join(this.root, f)).isFile()).sort();
1087
+ const zdevDir = path6.join(this.root, ".zdev");
1088
+ if (!fs7.existsSync(zdevDir) || !fs7.statSync(zdevDir).isDirectory()) return [];
1089
+ return fs7.readdirSync(zdevDir).filter((f) => /\.(md|markdown)$/i.test(f) && fs7.statSync(path6.join(zdevDir, f)).isFile()).sort();
1072
1090
  } catch {
1073
1091
  return [];
1074
1092
  }
@@ -1154,33 +1172,80 @@ function readText(p) {
1154
1172
  return "";
1155
1173
  }
1156
1174
  }
1175
+ function worktreeDir(root, name) {
1176
+ return path7.join(root, ".zworktree", name);
1177
+ }
1178
+ function changeDir(root, name) {
1179
+ return path7.join(root, "openspec", "changes", name);
1180
+ }
1181
+ function parseDependsOn(proposal) {
1182
+ const deps = [];
1183
+ const m = proposal.match(/^##\s+依赖\s*\n([\s\S]*?)(?:\n#{1,6}\s|\n---\s*\n|$)/i);
1184
+ if (!m) return deps;
1185
+ for (const line of m[1].split("\n")) {
1186
+ if (!/^-\s+/.test(line)) continue;
1187
+ const name = line.replace(/^-\s+/, "").trim();
1188
+ if (name) deps.push(name);
1189
+ }
1190
+ return deps;
1191
+ }
1157
1192
  function scanApplyChanges(root) {
1158
1193
  const changesDir = path7.join(root, "openspec", "changes");
1159
1194
  if (!fs8.existsSync(changesDir)) return [];
1160
1195
  const out = [];
1161
1196
  for (const ent of fs8.readdirSync(changesDir, { withFileTypes: true })) {
1162
1197
  if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "archive") continue;
1163
- const dir = path7.join(changesDir, ent.name);
1164
- const tasks = readText(path7.join(dir, "tasks.md"));
1165
- const { total, done } = countTasks(tasks);
1166
- out.push({
1167
- name: ent.name,
1168
- path: `openspec/changes/${ent.name}`,
1169
- total,
1170
- done,
1171
- hasProposal: fs8.existsSync(path7.join(dir, "proposal.md")),
1172
- hasDesign: fs8.existsSync(path7.join(dir, "design.md"))
1173
- });
1198
+ const wt = worktreeDir(root, ent.name);
1199
+ const tasks = readText(path7.join(wt, "openspec", "changes", ent.name, "tasks.md"));
1200
+ if (!tasks) {
1201
+ const fallback = readText(path7.join(changesDir, ent.name, "tasks.md"));
1202
+ if (fallback) {
1203
+ const { total, done } = countTasks(fallback);
1204
+ const wtChange = path7.join(wt, "openspec", "changes", ent.name);
1205
+ const mainChange = path7.join(changesDir, ent.name);
1206
+ out.push({
1207
+ name: ent.name,
1208
+ path: `openspec/changes/${ent.name}`,
1209
+ total,
1210
+ done,
1211
+ hasProposal: fs8.existsSync(path7.join(wtChange, "proposal.md")) || fs8.existsSync(path7.join(mainChange, "proposal.md")),
1212
+ hasDesign: fs8.existsSync(path7.join(wtChange, "design.md")) || fs8.existsSync(path7.join(mainChange, "design.md")),
1213
+ // worktree 目录存在即算执行中(tasks.md 可能尚未落盘)
1214
+ inWorktree: fs8.existsSync(wtChange)
1215
+ });
1216
+ }
1217
+ } else {
1218
+ const { total, done } = countTasks(tasks);
1219
+ const wtChange = path7.join(wt, "openspec", "changes", ent.name);
1220
+ const mainChange = path7.join(changesDir, ent.name);
1221
+ out.push({
1222
+ name: ent.name,
1223
+ path: `openspec/changes/${ent.name}`,
1224
+ total,
1225
+ done,
1226
+ // 任一侧存在即算(详情读取是 worktree 优先、主目录兜底,列表标记保持一致)
1227
+ hasProposal: fs8.existsSync(path7.join(wtChange, "proposal.md")) || fs8.existsSync(path7.join(mainChange, "proposal.md")),
1228
+ hasDesign: fs8.existsSync(path7.join(wtChange, "design.md")) || fs8.existsSync(path7.join(mainChange, "design.md")),
1229
+ inWorktree: true
1230
+ });
1231
+ }
1174
1232
  }
1175
1233
  out.sort((a, b) => a.name.localeCompare(b.name));
1176
1234
  return out;
1177
1235
  }
1178
1236
  function readApplyChange(root, name) {
1179
- const dir = path7.join(root, "openspec", "changes", name);
1180
- const proposal = readText(path7.join(dir, "proposal.md"));
1181
- const design = readText(path7.join(dir, "design.md"));
1182
- const tasks = readText(path7.join(dir, "tasks.md"));
1237
+ const wt = worktreeDir(root, name);
1238
+ const mainChangeDir = changeDir(root, name);
1239
+ const proposalFrom = fs8.existsSync(path7.join(wt, "openspec", "changes", name, "proposal.md")) ? path7.join(wt, "openspec", "changes", name, "proposal.md") : path7.join(mainChangeDir, "proposal.md");
1240
+ const designFrom = fs8.existsSync(path7.join(wt, "openspec", "changes", name, "design.md")) ? path7.join(wt, "openspec", "changes", name, "design.md") : path7.join(mainChangeDir, "design.md");
1241
+ const tasksFrom = fs8.existsSync(path7.join(wt, "openspec", "changes", name, "tasks.md")) ? path7.join(wt, "openspec", "changes", name, "tasks.md") : path7.join(mainChangeDir, "tasks.md");
1242
+ const proposal = readText(proposalFrom);
1243
+ const design = readText(designFrom);
1244
+ const tasks = readText(tasksFrom);
1183
1245
  const { total, done } = countTasks(tasks);
1246
+ const inWorktree = fs8.existsSync(path7.join(wt, "openspec", "changes", name));
1247
+ const dependsOn = parseDependsOn(proposal);
1248
+ const hasTestStrategy = /^##\s+测试策略\s*$/m.test(design);
1184
1249
  return {
1185
1250
  name,
1186
1251
  path: `openspec/changes/${name}`,
@@ -1188,13 +1253,63 @@ function readApplyChange(root, name) {
1188
1253
  done,
1189
1254
  hasProposal: !!proposal,
1190
1255
  hasDesign: !!design,
1256
+ inWorktree,
1191
1257
  proposal,
1192
1258
  design,
1193
- tasks
1259
+ tasks,
1260
+ dependsOn,
1261
+ hasTestStrategy
1194
1262
  };
1195
1263
  }
1196
1264
 
1197
1265
  // src/plugins/apply/index.ts
1266
+ import { execFile as execFile3 } from "child_process";
1267
+ var GIT_TIMEOUT_MS = 5e3;
1268
+ function gitWorktrees(root) {
1269
+ return new Promise((resolve) => {
1270
+ execFile3(
1271
+ "git",
1272
+ ["worktree", "list", "--porcelain"],
1273
+ { cwd: root, timeout: GIT_TIMEOUT_MS },
1274
+ (err, stdout) => {
1275
+ if (err) return resolve([]);
1276
+ const entries = [];
1277
+ const lines = stdout.split("\n");
1278
+ let current = {};
1279
+ for (const line of lines) {
1280
+ const m = line.match(/^(worktree|HEAD|branch|detached)\s+(.+)$/);
1281
+ if (m) {
1282
+ const [, key, val] = m;
1283
+ if (key === "worktree") {
1284
+ if (current.path && current.path.includes(".zworktree/")) {
1285
+ entries.push({
1286
+ path: current.path,
1287
+ name: current.path.split("/.zworktree/").pop() ?? "",
1288
+ branch: current.branch ?? "",
1289
+ head: current.head ?? ""
1290
+ });
1291
+ }
1292
+ current = { path: val, head: "", branch: "" };
1293
+ } else if (key === "HEAD") {
1294
+ current.head = val;
1295
+ } else if (key === "branch") {
1296
+ current.branch = val.replace(/^refs\/heads\//, "");
1297
+ }
1298
+ }
1299
+ }
1300
+ if (current.path && current.path.includes(".zworktree/")) {
1301
+ entries.push({
1302
+ path: current.path,
1303
+ name: current.path.split("/.zworktree/").pop() ?? "",
1304
+ branch: current.branch ?? "",
1305
+ head: current.head ?? ""
1306
+ });
1307
+ }
1308
+ resolve(entries);
1309
+ }
1310
+ );
1311
+ });
1312
+ }
1198
1313
  var apply5 = {
1199
1314
  inject: ["server", "dashboard"],
1200
1315
  apply(ctx, config) {
@@ -1228,6 +1343,16 @@ var apply5 = {
1228
1343
  res.end(JSON.stringify({ error: e instanceof Error ? e.message : "unknown error" }));
1229
1344
  }
1230
1345
  });
1346
+ ctx.server.route("/__worktrees", async (_req, res) => {
1347
+ try {
1348
+ const entries = await gitWorktrees(root);
1349
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1350
+ res.end(JSON.stringify(entries));
1351
+ } catch {
1352
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1353
+ res.end(JSON.stringify([]));
1354
+ }
1355
+ });
1231
1356
  });
1232
1357
  }
1233
1358
  };
@@ -1389,9 +1514,11 @@ var apply8 = {
1389
1514
 
1390
1515
  // src/cli.ts
1391
1516
  var __dirname2 = path10.dirname(fileURLToPath2(import.meta.url));
1517
+ var DEFAULT_PORT = 4190;
1392
1518
  function parseArgs() {
1393
1519
  const args = process.argv.slice(2);
1394
1520
  const opts = {};
1521
+ let portExplicit = false;
1395
1522
  for (let i = 0; i < args.length; i++) {
1396
1523
  const a = args[i];
1397
1524
  if (a.startsWith("--")) {
@@ -1403,11 +1530,13 @@ function parseArgs() {
1403
1530
  } else {
1404
1531
  opts[key] = true;
1405
1532
  }
1533
+ if (key === "port") portExplicit = true;
1406
1534
  }
1407
1535
  }
1408
1536
  return {
1409
1537
  dir: typeof opts.dir === "string" ? opts.dir : process.cwd(),
1410
- port: typeof opts.port === "string" ? Number(opts.port) : 4190,
1538
+ port: typeof opts.port === "string" ? Number(opts.port) : DEFAULT_PORT,
1539
+ portExplicit,
1411
1540
  open: !!opts.open,
1412
1541
  page: typeof opts.page === "string" ? opts.page : null,
1413
1542
  restart: !!opts.restart,
@@ -1481,20 +1610,25 @@ async function main() {
1481
1610
  await new Promise((resolve) => process.stdout.write("", () => resolve()));
1482
1611
  process.exit(0);
1483
1612
  }
1484
- if (existing && args.restart) {
1485
- console.log(`[zdashboard] --restart\uFF1A\u505C\u6B62\u65E7\u5B9E\u4F8B pid=${existing.pid}`);
1486
- await stopInstance(existing);
1613
+ const oldRecord = existing && args.restart ? existing : null;
1614
+ if (oldRecord) {
1615
+ console.log(`[zdashboard] --restart\uFF1A\u505C\u6B62\u65E7\u5B9E\u4F8B pid=${oldRecord.pid}`);
1616
+ await stopInstance(oldRecord);
1487
1617
  }
1488
1618
  const appDir = path10.resolve(__dirname2, "web");
1619
+ const startPort = oldRecord && !args.portExplicit ? oldRecord.port : args.port;
1620
+ const zdevDir = path10.join(root, ".zdev");
1621
+ const dataDir = fs11.existsSync(zdevDir) ? ".zdev/" : "";
1489
1622
  const det = await detect(root);
1490
1623
  const ctx = new Context();
1491
1624
  ctx.plugin(ServerService, {
1492
1625
  root,
1493
1626
  appDir,
1494
- port: args.port,
1627
+ port: startPort,
1495
1628
  open: args.open,
1496
1629
  detect: det,
1497
1630
  page: args.page,
1631
+ dataDir: dataDir || void 0,
1498
1632
  onListen: (port) => writeRecord(root, port)
1499
1633
  });
1500
1634
  ctx.plugin(ReloadService, { root });