zdashboard 2.0.0 → 2.1.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.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import path8 from "path";
4
+ import path10 from "path";
5
5
  import { fileURLToPath as fileURLToPath2 } from "url";
6
6
  import { access, readdir } from "fs/promises";
7
7
 
@@ -30,16 +30,15 @@ import { Context } from "cordis";
30
30
 
31
31
  // src/core/server.ts
32
32
  import http from "http";
33
- import fs2 from "fs";
34
- import path2 from "path";
33
+ import fs3 from "fs";
34
+ import path3 from "path";
35
35
  import crypto from "crypto";
36
- import { exec } from "child_process";
37
36
  import { fileURLToPath } from "url";
38
37
 
39
38
  // package.json
40
39
  var package_default = {
41
40
  name: "zdashboard",
42
- version: "2.0.0",
41
+ version: "2.1.0",
43
42
  description: "ZCode skill dashboard platform \u2014 pluggable viewers for zdesign/zview/zreview/zgoal",
44
43
  type: "module",
45
44
  bin: {
@@ -123,8 +122,113 @@ var package_default = {
123
122
 
124
123
  // src/core/server.ts
125
124
  import { Service } from "cordis";
125
+
126
+ // src/core/instance.ts
127
+ import fs2 from "fs";
128
+ import path2 from "path";
129
+ var RECORD_FILE = ".zdev/dashboard.json";
130
+ var VERIFY_TIMEOUT_MS = 1500;
131
+ var STOP_POLL_MS = 2e3;
132
+ var STOP_POLL_INTERVAL_MS = 100;
133
+ var STOP_FINAL_WAIT_MS = 100;
134
+ function recordPath(root) {
135
+ return path2.join(root, RECORD_FILE);
136
+ }
137
+ function readRecord(root) {
138
+ try {
139
+ const fp = recordPath(root);
140
+ const raw = fs2.readFileSync(fp, "utf-8");
141
+ const rec = JSON.parse(raw);
142
+ if (typeof rec.pid === "number" && typeof rec.port === "number" && typeof rec.root === "string" && typeof rec.startedAt === "string") {
143
+ return rec;
144
+ }
145
+ return null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+ function writeRecord(root, port) {
151
+ fs2.mkdirSync(path2.dirname(recordPath(root)), { recursive: true });
152
+ const rec = {
153
+ pid: process.pid,
154
+ port,
155
+ root,
156
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
157
+ };
158
+ fs2.writeFileSync(recordPath(root), JSON.stringify(rec, null, 2) + "\n");
159
+ }
160
+ function clearRecord(root) {
161
+ try {
162
+ const rec = readRecord(root);
163
+ if (rec && rec.pid !== process.pid) return;
164
+ fs2.unlinkSync(recordPath(root));
165
+ } catch {
166
+ }
167
+ }
168
+ function isAlive(pid) {
169
+ try {
170
+ process.kill(pid, 0);
171
+ return true;
172
+ } catch (e) {
173
+ if (e.code === "ESRCH") return false;
174
+ return true;
175
+ }
176
+ }
177
+ async function fetchWithTimeout(url) {
178
+ const controller = new AbortController();
179
+ const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
180
+ try {
181
+ return await fetch(url, { signal: controller.signal });
182
+ } finally {
183
+ clearTimeout(timer);
184
+ }
185
+ }
186
+ async function verifyRoot(port, root) {
187
+ try {
188
+ const res = await fetchWithTimeout(`http://127.0.0.1:${port}/__config`);
189
+ if (res.status !== 200) return false;
190
+ const body = await res.json();
191
+ if (typeof body.root !== "string") return false;
192
+ return body.root === root;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+ async function findReusable(root) {
198
+ const rec = readRecord(root);
199
+ if (!rec) return null;
200
+ if (!isAlive(rec.pid)) return null;
201
+ if (!await verifyRoot(rec.port, root)) return null;
202
+ return rec;
203
+ }
204
+ async function stopInstance(record) {
205
+ try {
206
+ process.kill(record.pid, "SIGTERM");
207
+ } catch {
208
+ return;
209
+ }
210
+ const deadline = Date.now() + STOP_POLL_MS;
211
+ while (Date.now() < deadline) {
212
+ if (!isAlive(record.pid)) return;
213
+ await new Promise((r) => setTimeout(r, STOP_POLL_INTERVAL_MS));
214
+ }
215
+ try {
216
+ process.kill(record.pid, "SIGKILL");
217
+ } catch {
218
+ }
219
+ await new Promise((r) => setTimeout(r, STOP_FINAL_WAIT_MS));
220
+ }
221
+
222
+ // src/core/open-url.ts
223
+ import { exec } from "child_process";
224
+ function openUrl2(url) {
225
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
226
+ exec(`${cmd} ${url}`);
227
+ }
228
+
229
+ // src/core/server.ts
126
230
  var VERSION = package_default.version;
127
- var __dirname = path2.dirname(fileURLToPath(import.meta.url));
231
+ var __dirname = path3.dirname(fileURLToPath(import.meta.url));
128
232
  var MIME = {
129
233
  ".html": "text/html; charset=utf-8",
130
234
  ".htm": "text/html; charset=utf-8",
@@ -186,6 +290,7 @@ var ServerService = class extends Service {
186
290
  sses = /* @__PURE__ */ new Map();
187
291
  prefixStatic = /* @__PURE__ */ new Map();
188
292
  server;
293
+ onListen;
189
294
  constructor(ctx, config) {
190
295
  super(ctx, "server");
191
296
  this.stopToken = crypto.randomBytes(12).toString("hex");
@@ -194,6 +299,7 @@ var ServerService = class extends Service {
194
299
  this.open = config.open;
195
300
  this.page = config.page;
196
301
  this.det = config.detect;
302
+ this.onListen = config.onListen;
197
303
  ctx.effect(() => () => this.dispose());
198
304
  this.route("/__config", (_req, res) => {
199
305
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
@@ -211,25 +317,25 @@ var ServerService = class extends Service {
211
317
  });
212
318
  this.start(config.port);
213
319
  }
214
- route(path9, handler) {
215
- this.routes.set(path9, handler);
216
- this.ctx.effect(() => () => this.routes.delete(path9));
320
+ route(path11, handler) {
321
+ this.routes.set(path11, handler);
322
+ this.ctx.effect(() => () => this.routes.delete(path11));
217
323
  }
218
- sse(path9, onConnect) {
219
- this.sses.set(path9, onConnect);
220
- this.ctx.effect(() => () => this.sses.delete(path9));
324
+ sse(path11, onConnect) {
325
+ this.sses.set(path11, onConnect);
326
+ this.ctx.effect(() => () => this.sses.delete(path11));
221
327
  }
222
328
  static(prefix, dir) {
223
329
  this.prefixStatic.set(prefix, dir);
224
330
  this.ctx.effect(() => () => this.prefixStatic.delete(prefix));
225
331
  }
226
332
  serveFile(filePath, res, injectHtml) {
227
- fs2.readFile(filePath, (err, data) => {
333
+ fs3.readFile(filePath, (err, data) => {
228
334
  if (err) {
229
335
  res.writeHead(404);
230
336
  return res.end("Not found");
231
337
  }
232
- const ext = path2.extname(filePath).toLowerCase();
338
+ const ext = path3.extname(filePath).toLowerCase();
233
339
  const ct = MIME[ext] ?? "application/octet-stream";
234
340
  let body = data;
235
341
  if (injectHtml && ext === ".html") {
@@ -244,9 +350,9 @@ var ServerService = class extends Service {
244
350
  for (const [prefix, dir] of this.prefixStatic) {
245
351
  if (url.indexOf(prefix) === 0) {
246
352
  let rel = this.safeDecode(url.slice(prefix.length));
247
- if (!rel || rel === "/" || !path2.extname(rel)) rel = path2.join(rel || "", "index.html");
248
- const fp = path2.join(dir, rel);
249
- if (fp.indexOf(dir + path2.sep) !== 0) {
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) {
250
356
  res.writeHead(403);
251
357
  return res.end("Forbidden");
252
358
  }
@@ -285,17 +391,17 @@ var ServerService = class extends Service {
285
391
  if (this.servePrefix(url, res)) return;
286
392
  if (url === "/" || url.indexOf("/__app/") === 0 || url.indexOf("/assets/") === 0) {
287
393
  let fp2 = this.appDir;
288
- if (url !== "/") fp2 = path2.join(this.appDir, this.safeDecode(url));
289
- if (url.indexOf("/__app/") === 0) fp2 = path2.join(this.appDir, url.slice(7));
290
- if (fp2 !== this.appDir && fp2.indexOf(this.appDir + path2.sep) !== 0) {
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) {
291
397
  res.writeHead(403);
292
398
  return res.end("Forbidden");
293
399
  }
294
- if (url === "/") fp2 = path2.join(this.appDir, "index.html");
400
+ if (url === "/") fp2 = path3.join(this.appDir, "index.html");
295
401
  return this.serveFile(fp2, res, false);
296
402
  }
297
- const fp = path2.join(this.root, this.safeDecode(url));
298
- if (fp !== this.root && fp.indexOf(this.root + path2.sep) !== 0) {
403
+ const fp = path3.join(this.root, this.safeDecode(url));
404
+ if (fp !== this.root && fp.indexOf(this.root + path3.sep) !== 0) {
299
405
  res.writeHead(403);
300
406
  return res.end("Forbidden");
301
407
  }
@@ -323,7 +429,8 @@ var ServerService = class extends Service {
323
429
  console.log(`[zdashboard] v${VERSION} dashboard -> ${u}`);
324
430
  console.log(`[zdashboard] project -> ${this.root}`);
325
431
  console.log(`[zdashboard] detect -> openspec:${this.det.hasOpenspec} docs:${this.det.hasDocs} just:${this.det.hasJust} bugs:${this.det.hasBugs}`);
326
- if (this.open) exec(process.platform === "darwin" ? `open ${target}` : `start ${target}`);
432
+ if (this.open) openUrl2(target);
433
+ this.onListen?.(port);
327
434
  });
328
435
  }
329
436
  stop() {
@@ -333,6 +440,10 @@ var ServerService = class extends Service {
333
440
  } catch {
334
441
  }
335
442
  }
443
+ try {
444
+ clearRecord(this.root);
445
+ } catch {
446
+ }
336
447
  setTimeout(() => {
337
448
  try {
338
449
  this.ctx.root.fiber.dispose();
@@ -348,11 +459,15 @@ var ServerService = class extends Service {
348
459
  } catch {
349
460
  }
350
461
  }
462
+ try {
463
+ clearRecord(this.root);
464
+ } catch {
465
+ }
351
466
  }
352
467
  };
353
468
 
354
469
  // src/core/reload.ts
355
- import fs3 from "fs";
470
+ import fs4 from "fs";
356
471
  import { Service as Service2 } from "cordis";
357
472
  var WATCH_DEBOUNCE_MS = 150;
358
473
  var ReloadService = class extends Service2 {
@@ -368,7 +483,7 @@ var ReloadService = class extends Service2 {
368
483
  return () => this.clients.delete(res);
369
484
  });
370
485
  try {
371
- this.watcher = fs3.watch(config.root, { recursive: true }, () => {
486
+ this.watcher = fs4.watch(config.root, { recursive: true }, () => {
372
487
  if (this.timer) clearTimeout(this.timer);
373
488
  this.timer = setTimeout(() => {
374
489
  this.broadcast("reload");
@@ -411,13 +526,13 @@ data: ${JSON.stringify(data == null ? "" : data)}
411
526
  };
412
527
 
413
528
  // src/server/spec-scan.ts
414
- import fs4 from "fs";
415
- import path3 from "path";
529
+ import fs5 from "fs";
530
+ import path4 from "path";
416
531
  function walkFiles(absDir, relDir, depth = 0) {
417
532
  if (depth > 4) return [];
418
533
  let ents;
419
534
  try {
420
- ents = fs4.readdirSync(absDir, { withFileTypes: true });
535
+ ents = fs5.readdirSync(absDir, { withFileTypes: true });
421
536
  } catch {
422
537
  return [];
423
538
  }
@@ -426,7 +541,7 @@ function walkFiles(absDir, relDir, depth = 0) {
426
541
  if (ent.name.startsWith(".") || ent.name === "node_modules") continue;
427
542
  const rel = relDir ? `${relDir}/${ent.name}` : ent.name;
428
543
  if (ent.isDirectory()) {
429
- nodes.push({ name: ent.name, kind: "dir", children: walkFiles(path3.join(absDir, ent.name), rel, depth + 1) });
544
+ nodes.push({ name: ent.name, kind: "dir", children: walkFiles(path4.join(absDir, ent.name), rel, depth + 1) });
430
545
  } else {
431
546
  nodes.push({ name: ent.name, kind: "file", path: rel });
432
547
  }
@@ -436,41 +551,41 @@ function walkFiles(absDir, relDir, depth = 0) {
436
551
  }
437
552
  function scanTree(root, hasOpenspec, hasDocs) {
438
553
  const tree = [];
439
- if (hasOpenspec && fs4.existsSync(path3.join(root, "openspec", "changes"))) {
440
- const changesDir = path3.join(root, "openspec", "changes");
554
+ if (hasOpenspec && fs5.existsSync(path4.join(root, "openspec", "changes"))) {
555
+ const changesDir = path4.join(root, "openspec", "changes");
441
556
  const active = [];
442
557
  const archived = [];
443
- for (const ent of fs4.readdirSync(changesDir, { withFileTypes: true })) {
558
+ for (const ent of fs5.readdirSync(changesDir, { withFileTypes: true })) {
444
559
  if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "archive") continue;
445
- active.push({ name: ent.name, kind: "dir", children: walkFiles(path3.join(changesDir, ent.name), `openspec/changes/${ent.name}`) });
560
+ active.push({ name: ent.name, kind: "dir", children: walkFiles(path4.join(changesDir, ent.name), `openspec/changes/${ent.name}`) });
446
561
  }
447
562
  active.sort((a, b) => a.name.localeCompare(b.name));
448
- const archiveDir = path3.join(changesDir, "archive");
449
- if (fs4.existsSync(archiveDir)) {
450
- for (const ent of fs4.readdirSync(archiveDir, { withFileTypes: true })) {
563
+ const archiveDir = path4.join(changesDir, "archive");
564
+ if (fs5.existsSync(archiveDir)) {
565
+ for (const ent of fs5.readdirSync(archiveDir, { withFileTypes: true })) {
451
566
  if (!ent.isDirectory() || ent.name.startsWith(".")) continue;
452
- archived.push({ name: ent.name, kind: "dir", children: walkFiles(path3.join(archiveDir, ent.name), `openspec/changes/archive/${ent.name}`) });
567
+ archived.push({ name: ent.name, kind: "dir", children: walkFiles(path4.join(archiveDir, ent.name), `openspec/changes/archive/${ent.name}`) });
453
568
  }
454
569
  archived.sort((a, b) => b.name.localeCompare(a.name));
455
570
  }
456
571
  if (active.length) tree.push({ name: `\u8FDB\u884C\u4E2D (${active.length})`, kind: "dir", children: active });
457
572
  if (archived.length) tree.push({ name: "archive", kind: "dir", defaultCollapsed: true, children: archived });
458
- const specsDir = path3.join(root, "openspec", "specs");
459
- if (fs4.existsSync(specsDir)) {
573
+ const specsDir = path4.join(root, "openspec", "specs");
574
+ if (fs5.existsSync(specsDir)) {
460
575
  const specs = walkFiles(specsDir, "openspec/specs");
461
576
  if (specs.length) tree.push({ name: "specs", kind: "dir", children: specs });
462
577
  }
463
578
  }
464
- if (hasDocs && fs4.existsSync(path3.join(root, "docs"))) {
465
- const docs = walkFiles(path3.join(root, "docs"), "docs");
579
+ if (hasDocs && fs5.existsSync(path4.join(root, "docs"))) {
580
+ const docs = walkFiles(path4.join(root, "docs"), "docs");
466
581
  if (docs.length) tree.push({ name: "docs", kind: "dir", children: docs });
467
582
  }
468
583
  const skip = /* @__PURE__ */ new Set(["openspec", "docs", "node_modules", ".git", "dist", "test-server"]);
469
584
  const etc = [];
470
585
  try {
471
- for (const ent of fs4.readdirSync(root, { withFileTypes: true })) {
586
+ for (const ent of fs5.readdirSync(root, { withFileTypes: true })) {
472
587
  if (ent.name.startsWith(".") || skip.has(ent.name)) continue;
473
- const ext = path3.extname(ent.name).toLowerCase();
588
+ const ext = path4.extname(ent.name).toLowerCase();
474
589
  if (ent.isFile() && (ext === ".md" || ext === ".markdown")) etc.push({ name: ent.name, kind: "file", path: ent.name });
475
590
  }
476
591
  } catch (e) {
@@ -771,8 +886,8 @@ async function readBody(req) {
771
886
  }
772
887
 
773
888
  // src/server/bugs.ts
774
- import fs5 from "fs";
775
- import path4 from "path";
889
+ import fs6 from "fs";
890
+ import path5 from "path";
776
891
 
777
892
  // src/server/api/fetch.ts
778
893
  import ky from "ky";
@@ -816,10 +931,10 @@ async function fetchJson(url, init) {
816
931
 
817
932
  // src/server/bugs.ts
818
933
  function loadZgoalConfig(root) {
819
- const file = path4.join(root, ".zgoal", "config.yaml");
820
- if (!fs5.existsSync(file)) return null;
934
+ const file = path5.join(root, ".zgoal", "config.yaml");
935
+ if (!fs6.existsSync(file)) return null;
821
936
  const kv = {};
822
- for (const line of fs5.readFileSync(file, "utf8").split("\n")) {
937
+ for (const line of fs6.readFileSync(file, "utf8").split("\n")) {
823
938
  const m = line.match(/^\s*([A-Za-z_]\w*)\s*:\s*(.+?)\s*$/);
824
939
  if (m && !m[2].startsWith("#")) kv[m[1]] = m[2].replace(/^["']|["']$/g, "");
825
940
  }
@@ -903,8 +1018,8 @@ var apply3 = {
903
1018
  };
904
1019
 
905
1020
  // src/server/review-store.ts
906
- import fs6 from "fs";
907
- import path5 from "path";
1021
+ import fs7 from "fs";
1022
+ import path6 from "path";
908
1023
  import YAML from "yaml";
909
1024
  var REVIEW_FILE = "review.yaml";
910
1025
  var ReviewStore = class {
@@ -913,15 +1028,15 @@ var ReviewStore = class {
913
1028
  onChange;
914
1029
  constructor(root, onChange) {
915
1030
  this.root = root;
916
- this.file = path5.join(root, REVIEW_FILE);
1031
+ this.file = path6.join(root, REVIEW_FILE);
917
1032
  this.onChange = onChange;
918
1033
  }
919
1034
  exists() {
920
- return fs6.existsSync(this.file);
1035
+ return fs7.existsSync(this.file);
921
1036
  }
922
1037
  read() {
923
1038
  try {
924
- const parsed = YAML.parse(fs6.readFileSync(this.file, "utf8"));
1039
+ const parsed = YAML.parse(fs7.readFileSync(this.file, "utf8"));
925
1040
  if (!parsed || !Array.isArray(parsed.items)) return { status: "draft", items: [] };
926
1041
  return parsed;
927
1042
  } catch {
@@ -929,7 +1044,7 @@ var ReviewStore = class {
929
1044
  }
930
1045
  }
931
1046
  write(data) {
932
- fs6.writeFileSync(this.file, YAML.stringify(data), "utf8");
1047
+ fs7.writeFileSync(this.file, YAML.stringify(data), "utf8");
933
1048
  this.onChange?.();
934
1049
  }
935
1050
  updateItem(id, patch) {
@@ -953,7 +1068,7 @@ var ReviewStore = class {
953
1068
  }
954
1069
  docs() {
955
1070
  try {
956
- return fs6.readdirSync(this.root).filter((f) => /\.(md|markdown)$/i.test(f) && fs6.statSync(path5.join(this.root, f)).isFile()).sort();
1071
+ return fs7.readdirSync(this.root).filter((f) => /\.(md|markdown)$/i.test(f) && fs7.statSync(path6.join(this.root, f)).isFile()).sort();
957
1072
  } catch {
958
1073
  return [];
959
1074
  }
@@ -1025,8 +1140,8 @@ async function readBody2(req) {
1025
1140
  }
1026
1141
 
1027
1142
  // src/plugins/apply/scan.ts
1028
- import fs7 from "fs";
1029
- import path6 from "path";
1143
+ import fs8 from "fs";
1144
+ import path7 from "path";
1030
1145
  function countTasks(md) {
1031
1146
  const all = (md.match(/^\s*-\s*\[[ xX]\]\s*/gm) || []).length;
1032
1147
  const done = (md.match(/^\s*-\s*\[[xX]\]\s*/gm) || []).length;
@@ -1034,37 +1149,37 @@ function countTasks(md) {
1034
1149
  }
1035
1150
  function readText(p) {
1036
1151
  try {
1037
- return fs7.readFileSync(p, "utf8");
1152
+ return fs8.readFileSync(p, "utf8");
1038
1153
  } catch {
1039
1154
  return "";
1040
1155
  }
1041
1156
  }
1042
1157
  function scanApplyChanges(root) {
1043
- const changesDir = path6.join(root, "openspec", "changes");
1044
- if (!fs7.existsSync(changesDir)) return [];
1158
+ const changesDir = path7.join(root, "openspec", "changes");
1159
+ if (!fs8.existsSync(changesDir)) return [];
1045
1160
  const out = [];
1046
- for (const ent of fs7.readdirSync(changesDir, { withFileTypes: true })) {
1161
+ for (const ent of fs8.readdirSync(changesDir, { withFileTypes: true })) {
1047
1162
  if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "archive") continue;
1048
- const dir = path6.join(changesDir, ent.name);
1049
- const tasks = readText(path6.join(dir, "tasks.md"));
1163
+ const dir = path7.join(changesDir, ent.name);
1164
+ const tasks = readText(path7.join(dir, "tasks.md"));
1050
1165
  const { total, done } = countTasks(tasks);
1051
1166
  out.push({
1052
1167
  name: ent.name,
1053
1168
  path: `openspec/changes/${ent.name}`,
1054
1169
  total,
1055
1170
  done,
1056
- hasProposal: fs7.existsSync(path6.join(dir, "proposal.md")),
1057
- hasDesign: fs7.existsSync(path6.join(dir, "design.md"))
1171
+ hasProposal: fs8.existsSync(path7.join(dir, "proposal.md")),
1172
+ hasDesign: fs8.existsSync(path7.join(dir, "design.md"))
1058
1173
  });
1059
1174
  }
1060
1175
  out.sort((a, b) => a.name.localeCompare(b.name));
1061
1176
  return out;
1062
1177
  }
1063
1178
  function readApplyChange(root, name) {
1064
- const dir = path6.join(root, "openspec", "changes", name);
1065
- const proposal = readText(path6.join(dir, "proposal.md"));
1066
- const design = readText(path6.join(dir, "design.md"));
1067
- const tasks = readText(path6.join(dir, "tasks.md"));
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"));
1068
1183
  const { total, done } = countTasks(tasks);
1069
1184
  return {
1070
1185
  name,
@@ -1118,8 +1233,8 @@ var apply5 = {
1118
1233
  };
1119
1234
 
1120
1235
  // src/server/design-assets.ts
1121
- import fs8 from "fs";
1122
- import path7 from "path";
1236
+ import fs9 from "fs";
1237
+ import path8 from "path";
1123
1238
  var PAGE_EXTS = [".html", ".htm"];
1124
1239
  var ICON_EXTS = [".svg", ".png", ".ico", ".jpg", ".jpeg", ".gif", ".webp"];
1125
1240
  var VIDEO_EXTS = [".mp4", ".webm", ".mov", ".ogg", ".ogv"];
@@ -1136,18 +1251,21 @@ function categorize(rel, ext) {
1136
1251
  if (FONT_EXTS.includes(ext)) return "font";
1137
1252
  if (ICON_EXTS.includes(ext)) return "icon";
1138
1253
  if (PAGE_EXTS.includes(ext)) return "page";
1139
- if (CODE_EXTS.includes(ext)) return TOKEN_RE.test(rel) && (ext === ".css" || ext === ".json") ? "token" : "code";
1254
+ if (CODE_EXTS.includes(ext)) {
1255
+ if (TOKEN_RE.test(rel) && (ext === ".css" || ext === ".json")) return "token";
1256
+ return null;
1257
+ }
1140
1258
  return "other";
1141
1259
  }
1142
1260
  function scanAssets(root) {
1143
1261
  const out = {};
1144
- const keys = ["page", "component", "icon", "token", "md", "video", "audio", "pdf", "code", "font", "other"];
1262
+ const keys = ["page", "component", "icon", "token", "md", "video", "audio", "pdf", "font", "other"];
1145
1263
  for (const k of keys) out[k] = [];
1146
1264
  const SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "coverage", ".next", ".cache"]);
1147
1265
  function walk(dir, rel) {
1148
1266
  let ents;
1149
1267
  try {
1150
- ents = fs8.readdirSync(dir, { withFileTypes: true });
1268
+ ents = fs9.readdirSync(dir, { withFileTypes: true });
1151
1269
  } catch {
1152
1270
  return;
1153
1271
  }
@@ -1155,12 +1273,12 @@ function scanAssets(root) {
1155
1273
  if (ent.name.startsWith(".") || SKIP.has(ent.name)) continue;
1156
1274
  const r = rel ? `${rel}/${ent.name}` : ent.name;
1157
1275
  if (ent.isDirectory()) {
1158
- walk(path7.join(dir, ent.name), r);
1276
+ walk(path8.join(dir, ent.name), r);
1159
1277
  continue;
1160
1278
  }
1161
- const ext = path7.extname(ent.name).toLowerCase();
1279
+ const ext = path8.extname(ent.name).toLowerCase();
1162
1280
  const t = categorize(r, ext);
1163
- out[t].push({ path: r, name: ent.name, ext, type: t });
1281
+ if (t) out[t].push({ path: r, name: ent.name, ext, type: t });
1164
1282
  }
1165
1283
  }
1166
1284
  walk(root, "");
@@ -1191,8 +1309,86 @@ var apply7 = {
1191
1309
  }
1192
1310
  };
1193
1311
 
1312
+ // src/plugins/stats/index.ts
1313
+ import fs10 from "fs";
1314
+ import path9 from "path";
1315
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", ".cache"]);
1316
+ function scan(root) {
1317
+ const stats = {
1318
+ root: path9.basename(root),
1319
+ files: 0,
1320
+ dirs: 0,
1321
+ totalSize: 0,
1322
+ byExt: [],
1323
+ markdown: 0,
1324
+ openspec: { active: 0, archived: 0 },
1325
+ hasJust: fs10.existsSync(path9.join(root, "justfile"))
1326
+ };
1327
+ const extMap = /* @__PURE__ */ new Map();
1328
+ const walk = (dir) => {
1329
+ let ents;
1330
+ try {
1331
+ ents = fs10.readdirSync(dir, { withFileTypes: true });
1332
+ } catch {
1333
+ return;
1334
+ }
1335
+ for (const ent of ents) {
1336
+ if (ent.name.startsWith(".") || SKIP_DIRS.has(ent.name)) continue;
1337
+ const abs = path9.join(dir, ent.name);
1338
+ if (ent.isDirectory()) {
1339
+ stats.dirs++;
1340
+ walk(abs);
1341
+ continue;
1342
+ }
1343
+ stats.files++;
1344
+ try {
1345
+ stats.totalSize += fs10.statSync(abs).size;
1346
+ } catch {
1347
+ }
1348
+ const ext = path9.extname(ent.name).toLowerCase() || "(\u65E0\u6269\u5C55\u540D)";
1349
+ extMap.set(ext, (extMap.get(ext) ?? 0) + 1);
1350
+ if (ext === ".md" || ext === ".markdown") stats.markdown++;
1351
+ }
1352
+ };
1353
+ walk(root);
1354
+ const changesDir = path9.join(root, "openspec", "changes");
1355
+ if (fs10.existsSync(changesDir)) {
1356
+ for (const ent of fs10.readdirSync(changesDir, { withFileTypes: true })) {
1357
+ if (!ent.isDirectory() || ent.name.startsWith(".")) continue;
1358
+ if (ent.name === "archive") {
1359
+ try {
1360
+ stats.openspec.archived = fs10.readdirSync(path9.join(changesDir, "archive"), { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).length;
1361
+ } catch {
1362
+ }
1363
+ } else stats.openspec.active++;
1364
+ }
1365
+ }
1366
+ stats.byExt = Array.from(extMap.entries()).map(([ext, count]) => ({ ext, count })).sort((a, b) => b.count - a.count).slice(0, 10);
1367
+ return stats;
1368
+ }
1369
+ var apply8 = {
1370
+ inject: ["server", "dashboard"],
1371
+ apply(ctx, config) {
1372
+ ctx.inject(["server", "dashboard"], () => {
1373
+ const server = ctx.server;
1374
+ const dashboard = ctx.dashboard;
1375
+ if (!server?.route || !dashboard?.register) return;
1376
+ dashboard.register({
1377
+ mode: "stats",
1378
+ label: "\u9879\u76EE\u7EDF\u8BA1",
1379
+ icon: "\u{1F4CA}",
1380
+ description: "\u9879\u76EE\u6587\u4EF6\u7EDF\u8BA1 \xB7 \u626B\u63CF\u751F\u6210"
1381
+ });
1382
+ server.route("/__stats/data", (_req, res) => {
1383
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
1384
+ res.end(JSON.stringify(scan(config.root)));
1385
+ });
1386
+ });
1387
+ }
1388
+ };
1389
+
1194
1390
  // src/cli.ts
1195
- var __dirname2 = path8.dirname(fileURLToPath2(import.meta.url));
1391
+ var __dirname2 = path10.dirname(fileURLToPath2(import.meta.url));
1196
1392
  function parseArgs() {
1197
1393
  const args = process.argv.slice(2);
1198
1394
  const opts = {};
@@ -1214,6 +1410,7 @@ function parseArgs() {
1214
1410
  port: typeof opts.port === "string" ? Number(opts.port) : 4190,
1215
1411
  open: !!opts.open,
1216
1412
  page: typeof opts.page === "string" ? opts.page : null,
1413
+ restart: !!opts.restart,
1217
1414
  plugins: typeof opts.plugins === "string" ? opts.plugins : null
1218
1415
  };
1219
1416
  }
@@ -1240,7 +1437,7 @@ async function loadExternal(ctx, dir, root) {
1240
1437
  if (!ent.isDirectory()) continue;
1241
1438
  const candidates = tsxLoaded ? ["index.ts", "index.js", "index.mjs"] : ["index.js", "index.mjs"];
1242
1439
  for (const name of candidates) {
1243
- const p = path8.join(dir, ent.name, name);
1440
+ const p = path10.join(dir, ent.name, name);
1244
1441
  if (await pathExists(p)) {
1245
1442
  try {
1246
1443
  const mod = await import(p);
@@ -1255,8 +1452,8 @@ async function loadExternal(ctx, dir, root) {
1255
1452
  const m = ctx.dashboard.get(ent.name);
1256
1453
  if (m && m.mode === ent.name) {
1257
1454
  const patch = { external: true };
1258
- const webDir = path8.join(dir, ent.name, "web");
1259
- if (await pathExists(path8.join(webDir, "index.html"))) {
1455
+ const webDir = path10.join(dir, ent.name, "web");
1456
+ if (await pathExists(path10.join(webDir, "index.html"))) {
1260
1457
  ctx.server.static(`${PLUGIN_STATIC_PREFIX}${ent.name}/`, webDir);
1261
1458
  if (!m.viewerUrl) patch.viewerUrl = `${PLUGIN_STATIC_PREFIX}${ent.name}/`;
1262
1459
  }
@@ -1275,15 +1472,36 @@ async function loadExternal(ctx, dir, root) {
1275
1472
  }
1276
1473
  async function main() {
1277
1474
  const args = parseArgs();
1278
- const root = path8.resolve(args.dir);
1279
- const appDir = path8.resolve(__dirname2, "web");
1475
+ const root = path10.resolve(args.dir);
1476
+ const existing = await findReusable(root);
1477
+ if (existing && !args.restart) {
1478
+ const u = `http://localhost:${existing.port}` + (args.page ? `#${args.page}` : "");
1479
+ if (args.open) openUrl(u);
1480
+ console.log(`[zdashboard] \u5DF2\u590D\u7528\u5B9E\u4F8B ${u}\uFF08--restart \u53EF\u5F3A\u5236\u91CD\u5F00\uFF09`);
1481
+ await new Promise((resolve) => process.stdout.write("", () => resolve()));
1482
+ process.exit(0);
1483
+ }
1484
+ if (existing && args.restart) {
1485
+ console.log(`[zdashboard] --restart\uFF1A\u505C\u6B62\u65E7\u5B9E\u4F8B pid=${existing.pid}`);
1486
+ await stopInstance(existing);
1487
+ }
1488
+ const appDir = path10.resolve(__dirname2, "web");
1280
1489
  const det = await detect(root);
1281
1490
  const ctx = new Context();
1282
- ctx.plugin(ServerService, { root, appDir, port: args.port, open: args.open, detect: det, page: args.page });
1491
+ ctx.plugin(ServerService, {
1492
+ root,
1493
+ appDir,
1494
+ port: args.port,
1495
+ open: args.open,
1496
+ detect: det,
1497
+ page: args.page,
1498
+ onListen: (port) => writeRecord(root, port)
1499
+ });
1283
1500
  ctx.plugin(ReloadService, { root });
1284
1501
  ctx.plugin(apply, { root });
1285
1502
  ctx.plugin(DashboardService);
1286
1503
  const plugins = [
1504
+ { name: "stats", apply: apply8 },
1287
1505
  { name: "just", apply: apply2 },
1288
1506
  { name: "bugs", apply: apply3 },
1289
1507
  { name: "review", apply: apply4 },
@@ -1299,8 +1517,17 @@ async function main() {
1299
1517
  }
1300
1518
  }
1301
1519
  if (args.plugins) {
1302
- await loadExternal(ctx, path8.resolve(args.plugins), root);
1520
+ await loadExternal(ctx, path10.resolve(args.plugins), root);
1303
1521
  }
1522
+ const shutdown = () => {
1523
+ try {
1524
+ clearRecord(root);
1525
+ } catch {
1526
+ }
1527
+ process.exit(0);
1528
+ };
1529
+ process.on("SIGTERM", shutdown);
1530
+ process.on("SIGINT", shutdown);
1304
1531
  return ctx;
1305
1532
  }
1306
1533
  await main().catch((e) => {