zdashboard 2.1.0 → 2.2.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/README.md +23 -1
- package/dist/cli.js +454 -298
- package/dist/cli.js.map +1 -1
- package/dist/web/assets/{MdViewer-C8ZG_xn1.js → MdViewer-qvXbO8IA.js} +1 -1
- package/dist/web/assets/Workspace-D96dCoOA.js +1 -0
- package/dist/web/assets/git-branch-DJLd5kDE.js +11 -0
- package/dist/web/assets/{index-DkOVCCZ3.js → index-15cLfCw4.js} +21 -21
- package/dist/web/assets/{index-B9eiA0v2.js → index-6FGn10zY.js} +1 -1
- package/dist/web/assets/index-CYqm3unP.css +1 -0
- package/dist/web/assets/{katex.min-DAmb8ZJI.js → katex.min-D_D1u30F.js} +1 -1
- package/dist/web/assets/web-BUNysAkV.js +7 -0
- package/dist/web/assets/web-BeuJXCg2.js +26 -0
- package/dist/web/assets/{web-CXHaDUKR.js → web-BnZ_68eg.js} +11 -11
- package/dist/web/assets/web-C-O8qmKu.js +31 -0
- package/dist/web/assets/{web-Y4h9oNHC.js → web-CJq1r_vk.js} +1 -1
- package/dist/web/assets/web-DP_CBpB2.js +2 -0
- package/dist/web/assets/web-Xpqif6fn.js +32 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/Workspace-Cz1A_IXM.js +0 -1
- package/dist/web/assets/file-text-DqCOBCjz.js +0 -6
- package/dist/web/assets/index-D58_bWw5.css +0 -1
- package/dist/web/assets/web-8ZBVzAEO.js +0 -26
- package/dist/web/assets/web-CEyEoz0i.js +0 -30
- package/dist/web/assets/web-CrX-i-rZ.js +0 -7
- package/dist/web/assets/web-D_b3sVrp.js +0 -31
- package/dist/web/assets/web-M4KeFgZw.js +0 -2
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
|
-
|
|
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 =
|
|
22
|
-
const hasDocs =
|
|
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 =
|
|
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
|
|
34
|
-
import
|
|
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.
|
|
165
|
+
version: "2.2.0",
|
|
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
|
|
128
|
-
import
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 === "/" || !
|
|
354
|
-
const fp =
|
|
355
|
-
if (fp.indexOf(dir +
|
|
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 =
|
|
395
|
-
if (url.indexOf("/__app/") === 0) fp2 =
|
|
396
|
-
if (fp2 !== this.appDir && fp2.indexOf(this.appDir +
|
|
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 =
|
|
526
|
+
if (url === "/") fp2 = path4.join(this.appDir, "index.html");
|
|
401
527
|
return this.serveFile(fp2, res, false);
|
|
402
528
|
}
|
|
403
|
-
const fp =
|
|
404
|
-
if (fp !== this.root && fp.indexOf(this.root +
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
530
|
-
import
|
|
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 =
|
|
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(
|
|
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 &&
|
|
555
|
-
const changesDir =
|
|
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
|
|
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(
|
|
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 =
|
|
564
|
-
if (
|
|
565
|
-
for (const ent of
|
|
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(
|
|
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 =
|
|
574
|
-
if (
|
|
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 &&
|
|
580
|
-
const docs = walkFiles(
|
|
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
|
|
715
|
+
for (const ent of fs6.readdirSync(root, { withFileTypes: true })) {
|
|
587
716
|
if (ent.name.startsWith(".") || skip.has(ent.name)) continue;
|
|
588
|
-
const ext =
|
|
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) {
|
|
@@ -625,6 +754,84 @@ var apply = {
|
|
|
625
754
|
}
|
|
626
755
|
};
|
|
627
756
|
|
|
757
|
+
// src/core/worktrees.ts
|
|
758
|
+
import { execFile as execFile2 } from "child_process";
|
|
759
|
+
var GIT_TIMEOUT_MS = 5e3;
|
|
760
|
+
var ZWORKTREE_SEGMENT = ".zworktree/";
|
|
761
|
+
function runGit(args, cwd) {
|
|
762
|
+
return new Promise((resolve) => {
|
|
763
|
+
execFile2("git", args, { cwd, timeout: GIT_TIMEOUT_MS }, (err, stdout) => {
|
|
764
|
+
if (err) return resolve("");
|
|
765
|
+
resolve(stdout);
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
async function listWorktrees(root) {
|
|
770
|
+
const raw = await runGit(["worktree", "list", "--porcelain"], root);
|
|
771
|
+
if (!raw) return [];
|
|
772
|
+
const entries = [];
|
|
773
|
+
const lines = raw.split("\n");
|
|
774
|
+
let current = {};
|
|
775
|
+
for (const line of lines) {
|
|
776
|
+
const m = line.match(/^(worktree|HEAD|branch|detached)\s+(.+)$/);
|
|
777
|
+
if (!m) continue;
|
|
778
|
+
const [, key, val] = m;
|
|
779
|
+
if (key === "worktree") {
|
|
780
|
+
if (current.path && current.path.includes(ZWORKTREE_SEGMENT)) {
|
|
781
|
+
entries.push({
|
|
782
|
+
path: current.path,
|
|
783
|
+
name: current.path.split(ZWORKTREE_SEGMENT).pop() ?? "",
|
|
784
|
+
branch: current.branch ?? "",
|
|
785
|
+
head: current.head ?? "",
|
|
786
|
+
dirty: false
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
current = { path: val, head: "", branch: "" };
|
|
790
|
+
} else if (key === "HEAD") {
|
|
791
|
+
current.head = val;
|
|
792
|
+
} else if (key === "branch") {
|
|
793
|
+
current.branch = val.replace(/^refs\/heads\//, "");
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (current.path && current.path.includes(ZWORKTREE_SEGMENT)) {
|
|
797
|
+
entries.push({
|
|
798
|
+
path: current.path,
|
|
799
|
+
name: current.path.split(ZWORKTREE_SEGMENT).pop() ?? "",
|
|
800
|
+
branch: current.branch ?? "",
|
|
801
|
+
head: current.head ?? "",
|
|
802
|
+
dirty: false
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
await Promise.all(
|
|
806
|
+
entries.map(async (entry) => {
|
|
807
|
+
try {
|
|
808
|
+
const statusOut = await runGit(["status", "--porcelain"], entry.path);
|
|
809
|
+
entry.dirty = statusOut.trim().length > 0;
|
|
810
|
+
} catch {
|
|
811
|
+
entry.dirty = false;
|
|
812
|
+
}
|
|
813
|
+
})
|
|
814
|
+
);
|
|
815
|
+
return entries;
|
|
816
|
+
}
|
|
817
|
+
var apply2 = {
|
|
818
|
+
inject: ["server"],
|
|
819
|
+
apply(ctx, config) {
|
|
820
|
+
const server = ctx.server;
|
|
821
|
+
if (!server?.route) return;
|
|
822
|
+
server.route("/__worktrees", async (_req, res) => {
|
|
823
|
+
try {
|
|
824
|
+
const entries = await listWorktrees(config.root);
|
|
825
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
|
|
826
|
+
res.end(JSON.stringify(entries));
|
|
827
|
+
} catch {
|
|
828
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
|
|
829
|
+
res.end(JSON.stringify([]));
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
|
|
628
835
|
// src/core/manifest.ts
|
|
629
836
|
import { Service as Service3 } from "cordis";
|
|
630
837
|
var DashboardService = class extends Service3 {
|
|
@@ -651,17 +858,11 @@ var DashboardService = class extends Service3 {
|
|
|
651
858
|
};
|
|
652
859
|
|
|
653
860
|
// src/server/just-runner.ts
|
|
654
|
-
import { spawn, execFile as
|
|
861
|
+
import { spawn, execFile as execFile3 } from "child_process";
|
|
655
862
|
var MAX_BUFFER = 1e3;
|
|
656
863
|
var JustRunner = class {
|
|
657
864
|
cwd;
|
|
658
|
-
|
|
659
|
-
recipe = null;
|
|
660
|
-
state = "idle";
|
|
661
|
-
code = null;
|
|
662
|
-
buffer = [];
|
|
663
|
-
pending = "";
|
|
664
|
-
// 行缓冲:块缓冲输出(如 maven)的 chunk 会在行中间断开,攒到 \n 才切行
|
|
865
|
+
tasks = /* @__PURE__ */ new Map();
|
|
665
866
|
clients = /* @__PURE__ */ new Set();
|
|
666
867
|
recipesCache = null;
|
|
667
868
|
constructor(cwd) {
|
|
@@ -670,7 +871,7 @@ var JustRunner = class {
|
|
|
670
871
|
recipes() {
|
|
671
872
|
if (this.recipesCache) return Promise.resolve(this.recipesCache);
|
|
672
873
|
return new Promise((resolve) => {
|
|
673
|
-
|
|
874
|
+
execFile3("just", ["--list", "--unsorted"], { cwd: this.cwd, maxBuffer: 1 << 20, timeout: 8e3 }, (err, stdout) => {
|
|
674
875
|
if (err) {
|
|
675
876
|
resolve([]);
|
|
676
877
|
return;
|
|
@@ -695,26 +896,25 @@ var JustRunner = class {
|
|
|
695
896
|
}
|
|
696
897
|
subscribe(fn) {
|
|
697
898
|
this.clients.add(fn);
|
|
698
|
-
for (const
|
|
699
|
-
|
|
899
|
+
for (const t of this.tasks.values()) {
|
|
900
|
+
for (const text of t.buffer) fn({ type: "log", recipe: t.recipe, text });
|
|
901
|
+
fn({ type: "state", recipe: t.recipe, state: t.state, code: t.code, startedAt: t.startedAt });
|
|
902
|
+
}
|
|
700
903
|
return () => this.clients.delete(fn);
|
|
701
904
|
}
|
|
702
905
|
emit(ev) {
|
|
703
906
|
for (const fn of this.clients) fn(ev);
|
|
704
907
|
}
|
|
705
|
-
|
|
706
|
-
return {
|
|
908
|
+
list() {
|
|
909
|
+
return Array.from(this.tasks.values(), (t) => ({ recipe: t.recipe, state: t.state, code: t.code, startedAt: t.startedAt }));
|
|
707
910
|
}
|
|
708
|
-
/** 启动 recipe(
|
|
911
|
+
/** 启动 recipe:同名先停旧进程(重启语义),不影响其他任务;调用方须先用 recipes() 校验名字 */
|
|
709
912
|
start(recipe) {
|
|
710
|
-
this.
|
|
711
|
-
|
|
712
|
-
this.
|
|
713
|
-
this.
|
|
714
|
-
this.
|
|
715
|
-
this.pending = "";
|
|
716
|
-
this.emit({ type: "clear" });
|
|
717
|
-
this.emit({ type: "state", state: "running", recipe, code: null });
|
|
913
|
+
this.killOne(recipe);
|
|
914
|
+
const task = { recipe, child: null, state: "running", code: null, startedAt: Date.now(), buffer: [], pending: "" };
|
|
915
|
+
this.tasks.set(recipe, task);
|
|
916
|
+
this.emit({ type: "clear", recipe });
|
|
917
|
+
this.emit({ type: "state", recipe, state: "running", code: null, startedAt: task.startedAt });
|
|
718
918
|
const child = spawn("just", [recipe], {
|
|
719
919
|
cwd: this.cwd,
|
|
720
920
|
shell: true,
|
|
@@ -727,47 +927,62 @@ var JustRunner = class {
|
|
|
727
927
|
CI: ""
|
|
728
928
|
}
|
|
729
929
|
});
|
|
730
|
-
|
|
930
|
+
task.child = child;
|
|
931
|
+
const isStale = () => this.tasks.get(recipe) !== task;
|
|
731
932
|
const push = (d) => {
|
|
732
|
-
|
|
933
|
+
if (isStale()) return;
|
|
934
|
+
task.pending += d.toString();
|
|
733
935
|
let idx;
|
|
734
|
-
while ((idx =
|
|
735
|
-
const line =
|
|
736
|
-
|
|
737
|
-
this.pushLine(line);
|
|
936
|
+
while ((idx = task.pending.indexOf("\n")) >= 0) {
|
|
937
|
+
const line = task.pending.slice(0, idx + 1);
|
|
938
|
+
task.pending = task.pending.slice(idx + 1);
|
|
939
|
+
this.pushLine(task, line);
|
|
738
940
|
}
|
|
739
941
|
};
|
|
740
942
|
child.stdout?.on("data", push);
|
|
741
943
|
child.stderr?.on("data", push);
|
|
742
944
|
child.on("error", (err) => {
|
|
743
|
-
this.pushLine(`[zdashboard] spawn error: ${err.message}
|
|
945
|
+
this.pushLine(task, `[zdashboard] spawn error: ${err.message}
|
|
744
946
|
`);
|
|
745
947
|
});
|
|
746
|
-
child.on("exit", (code) => {
|
|
747
|
-
if (
|
|
748
|
-
this.pushLine(
|
|
749
|
-
this.pending = "";
|
|
948
|
+
child.on("exit", (code, signal) => {
|
|
949
|
+
if (task.pending && !isStale()) {
|
|
950
|
+
this.pushLine(task, task.pending + "\n");
|
|
750
951
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
952
|
+
task.pending = "";
|
|
953
|
+
task.child = null;
|
|
954
|
+
task.state = "exited";
|
|
955
|
+
task.code = code ?? 0;
|
|
956
|
+
task.signal = signal ?? void 0;
|
|
957
|
+
if (isStale()) return;
|
|
958
|
+
this.emit({ type: "state", recipe, state: "exited", code: task.code, startedAt: task.startedAt, signal: task.signal });
|
|
755
959
|
});
|
|
756
960
|
}
|
|
757
|
-
pushLine(line) {
|
|
758
|
-
|
|
759
|
-
if (
|
|
760
|
-
this.emit({ type: "log", text: line });
|
|
961
|
+
pushLine(task, line) {
|
|
962
|
+
task.buffer.push(line);
|
|
963
|
+
if (task.buffer.length > MAX_BUFFER) task.buffer.shift();
|
|
964
|
+
this.emit({ type: "log", recipe: task.recipe, text: line });
|
|
761
965
|
}
|
|
762
|
-
|
|
763
|
-
|
|
966
|
+
/** 停单个任务;不传 recipe 停全部 */
|
|
967
|
+
stop(recipe) {
|
|
968
|
+
if (recipe === void 0) {
|
|
969
|
+
for (const t of this.tasks.values()) this.killOne(t.recipe);
|
|
970
|
+
} else this.killOne(recipe);
|
|
764
971
|
}
|
|
765
972
|
restart(recipe) {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const
|
|
973
|
+
if (this.tasks.has(recipe)) this.start(recipe);
|
|
974
|
+
}
|
|
975
|
+
/** 清空某任务的日志缓冲并广播(真源清除,重连重放不会复活) */
|
|
976
|
+
clear(recipe) {
|
|
977
|
+
const task = this.tasks.get(recipe);
|
|
978
|
+
if (!task) return;
|
|
979
|
+
task.buffer = [];
|
|
980
|
+
task.pending = "";
|
|
981
|
+
this.emit({ type: "clear", recipe });
|
|
982
|
+
}
|
|
983
|
+
killOne(recipe) {
|
|
984
|
+
const task = this.tasks.get(recipe);
|
|
985
|
+
const child = task?.child;
|
|
771
986
|
if (child?.pid) {
|
|
772
987
|
try {
|
|
773
988
|
if (process.platform === "win32") spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"]);
|
|
@@ -775,12 +990,14 @@ var JustRunner = class {
|
|
|
775
990
|
} catch {
|
|
776
991
|
}
|
|
777
992
|
}
|
|
778
|
-
|
|
993
|
+
if (task) {
|
|
994
|
+
task.child = null;
|
|
995
|
+
}
|
|
779
996
|
}
|
|
780
997
|
};
|
|
781
998
|
|
|
782
999
|
// src/plugins/just/index.ts
|
|
783
|
-
var
|
|
1000
|
+
var apply3 = {
|
|
784
1001
|
inject: ["server", "dashboard"],
|
|
785
1002
|
apply(ctx, config) {
|
|
786
1003
|
const root = config.root;
|
|
@@ -788,6 +1005,7 @@ var apply2 = {
|
|
|
788
1005
|
if (!ctx.server?.route) return;
|
|
789
1006
|
const runner = new JustRunner(root);
|
|
790
1007
|
ctx.effect(() => () => runner.stop());
|
|
1008
|
+
ctx.dashboard.register({ mode: "just", label: "Just Runner", icon: "\u{1F4DC}", description: "Just \u591A\u4EFB\u52A1\u5E76\u53D1\u6267\u884C\u4E0E\u65E5\u5FD7" });
|
|
791
1009
|
ctx.server.route("/__just/recipes", async (_req, res) => {
|
|
792
1010
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" });
|
|
793
1011
|
try {
|
|
@@ -805,7 +1023,7 @@ var apply2 = {
|
|
|
805
1023
|
});
|
|
806
1024
|
return unsub;
|
|
807
1025
|
});
|
|
808
|
-
|
|
1026
|
+
const handleAction = async (req, res, act) => {
|
|
809
1027
|
if (req.headers["x-stop-token"] !== ctx.server.stopToken) {
|
|
810
1028
|
res.writeHead(403);
|
|
811
1029
|
res.end("forbidden");
|
|
@@ -817,61 +1035,45 @@ var apply2 = {
|
|
|
817
1035
|
recipe = JSON.parse(body || "{}").recipe;
|
|
818
1036
|
} catch {
|
|
819
1037
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
res.writeHead(
|
|
823
|
-
res.end('{"
|
|
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");
|
|
850
|
-
return;
|
|
851
|
-
}
|
|
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"}');
|
|
1038
|
+
if (act === "clear") {
|
|
1039
|
+
if (recipe) runner.clear(recipe);
|
|
1040
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
1041
|
+
res.end('{"ok":true}');
|
|
862
1042
|
return;
|
|
863
1043
|
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1044
|
+
if (act !== "stop") {
|
|
1045
|
+
if (!recipe) {
|
|
1046
|
+
res.writeHead(400);
|
|
1047
|
+
res.end('{"error":"no recipe"}');
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const recipes = await runner.recipes();
|
|
1051
|
+
if (!recipes.some((r) => r.name === recipe)) {
|
|
1052
|
+
res.writeHead(403);
|
|
1053
|
+
res.end('{"error":"unknown recipe"}');
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
runner.start(recipe);
|
|
1057
|
+
} else {
|
|
1058
|
+
runner.stop(recipe || void 0);
|
|
869
1059
|
}
|
|
870
|
-
runner.restart(target);
|
|
871
1060
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
872
|
-
res.end(JSON.stringify(runner.
|
|
873
|
-
}
|
|
874
|
-
|
|
1061
|
+
res.end(JSON.stringify(runner.list()));
|
|
1062
|
+
};
|
|
1063
|
+
const routeAction = (name) => {
|
|
1064
|
+
ctx.server.route(`/__just/${name}`, (req, res) => {
|
|
1065
|
+
handleAction(req, res, name).catch(() => {
|
|
1066
|
+
if (!res.headersSent) {
|
|
1067
|
+
res.writeHead(500);
|
|
1068
|
+
res.end('{"error":"internal"}');
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
};
|
|
1073
|
+
routeAction("start");
|
|
1074
|
+
routeAction("stop");
|
|
1075
|
+
routeAction("restart");
|
|
1076
|
+
routeAction("clear");
|
|
875
1077
|
});
|
|
876
1078
|
}
|
|
877
1079
|
};
|
|
@@ -882,122 +1084,12 @@ async function readBody(req) {
|
|
|
882
1084
|
data += c;
|
|
883
1085
|
});
|
|
884
1086
|
req.on("end", () => resolve(data));
|
|
1087
|
+
req.on("error", () => resolve(""));
|
|
885
1088
|
});
|
|
886
1089
|
}
|
|
887
1090
|
|
|
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
1091
|
// src/plugins/bugs/index.ts
|
|
1000
|
-
var
|
|
1092
|
+
var apply4 = {
|
|
1001
1093
|
inject: ["server", "dashboard"],
|
|
1002
1094
|
apply(ctx, config) {
|
|
1003
1095
|
const root = config.root;
|
|
@@ -1021,15 +1113,16 @@ var apply3 = {
|
|
|
1021
1113
|
import fs7 from "fs";
|
|
1022
1114
|
import path6 from "path";
|
|
1023
1115
|
import YAML from "yaml";
|
|
1024
|
-
var
|
|
1116
|
+
var REVIEW_CANDIDATES = [".zdev/review.yaml", "review.yaml"];
|
|
1025
1117
|
var ReviewStore = class {
|
|
1026
1118
|
root;
|
|
1027
1119
|
file;
|
|
1028
1120
|
onChange;
|
|
1029
1121
|
constructor(root, onChange) {
|
|
1030
1122
|
this.root = root;
|
|
1031
|
-
this.file = path6.join(root, REVIEW_FILE);
|
|
1032
1123
|
this.onChange = onChange;
|
|
1124
|
+
const existing = REVIEW_CANDIDATES.find((rel) => fs7.existsSync(path6.join(root, rel)));
|
|
1125
|
+
this.file = path6.join(root, existing ?? REVIEW_CANDIDATES[0]);
|
|
1033
1126
|
}
|
|
1034
1127
|
exists() {
|
|
1035
1128
|
return fs7.existsSync(this.file);
|
|
@@ -1044,6 +1137,7 @@ var ReviewStore = class {
|
|
|
1044
1137
|
}
|
|
1045
1138
|
}
|
|
1046
1139
|
write(data) {
|
|
1140
|
+
fs7.mkdirSync(path6.dirname(this.file), { recursive: true });
|
|
1047
1141
|
fs7.writeFileSync(this.file, YAML.stringify(data), "utf8");
|
|
1048
1142
|
this.onChange?.();
|
|
1049
1143
|
}
|
|
@@ -1068,7 +1162,9 @@ var ReviewStore = class {
|
|
|
1068
1162
|
}
|
|
1069
1163
|
docs() {
|
|
1070
1164
|
try {
|
|
1071
|
-
|
|
1165
|
+
const zdevDir = path6.join(this.root, ".zdev");
|
|
1166
|
+
if (!fs7.existsSync(zdevDir) || !fs7.statSync(zdevDir).isDirectory()) return [];
|
|
1167
|
+
return fs7.readdirSync(zdevDir).filter((f) => /\.(md|markdown)$/i.test(f) && fs7.statSync(path6.join(zdevDir, f)).isFile()).sort();
|
|
1072
1168
|
} catch {
|
|
1073
1169
|
return [];
|
|
1074
1170
|
}
|
|
@@ -1076,7 +1172,7 @@ var ReviewStore = class {
|
|
|
1076
1172
|
};
|
|
1077
1173
|
|
|
1078
1174
|
// src/plugins/review/index.ts
|
|
1079
|
-
var
|
|
1175
|
+
var apply5 = {
|
|
1080
1176
|
inject: ["server", "dashboard", "reload"],
|
|
1081
1177
|
apply(ctx, config) {
|
|
1082
1178
|
const root = config.root;
|
|
@@ -1154,33 +1250,80 @@ function readText(p) {
|
|
|
1154
1250
|
return "";
|
|
1155
1251
|
}
|
|
1156
1252
|
}
|
|
1253
|
+
function worktreeDir(root, name) {
|
|
1254
|
+
return path7.join(root, ".zworktree", name);
|
|
1255
|
+
}
|
|
1256
|
+
function changeDir(root, name) {
|
|
1257
|
+
return path7.join(root, "openspec", "changes", name);
|
|
1258
|
+
}
|
|
1259
|
+
function parseDependsOn(proposal) {
|
|
1260
|
+
const deps = [];
|
|
1261
|
+
const m = proposal.match(/^##\s+依赖\s*\n([\s\S]*?)(?:\n#{1,6}\s|\n---\s*\n|$)/i);
|
|
1262
|
+
if (!m) return deps;
|
|
1263
|
+
for (const line of m[1].split("\n")) {
|
|
1264
|
+
if (!/^-\s+/.test(line)) continue;
|
|
1265
|
+
const name = line.replace(/^-\s+/, "").trim();
|
|
1266
|
+
if (name) deps.push(name);
|
|
1267
|
+
}
|
|
1268
|
+
return deps;
|
|
1269
|
+
}
|
|
1157
1270
|
function scanApplyChanges(root) {
|
|
1158
1271
|
const changesDir = path7.join(root, "openspec", "changes");
|
|
1159
1272
|
if (!fs8.existsSync(changesDir)) return [];
|
|
1160
1273
|
const out = [];
|
|
1161
1274
|
for (const ent of fs8.readdirSync(changesDir, { withFileTypes: true })) {
|
|
1162
1275
|
if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "archive") continue;
|
|
1163
|
-
const
|
|
1164
|
-
const tasks = readText(path7.join(
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1276
|
+
const wt = worktreeDir(root, ent.name);
|
|
1277
|
+
const tasks = readText(path7.join(wt, "openspec", "changes", ent.name, "tasks.md"));
|
|
1278
|
+
if (!tasks) {
|
|
1279
|
+
const fallback = readText(path7.join(changesDir, ent.name, "tasks.md"));
|
|
1280
|
+
if (fallback) {
|
|
1281
|
+
const { total, done } = countTasks(fallback);
|
|
1282
|
+
const wtChange = path7.join(wt, "openspec", "changes", ent.name);
|
|
1283
|
+
const mainChange = path7.join(changesDir, ent.name);
|
|
1284
|
+
out.push({
|
|
1285
|
+
name: ent.name,
|
|
1286
|
+
path: `openspec/changes/${ent.name}`,
|
|
1287
|
+
total,
|
|
1288
|
+
done,
|
|
1289
|
+
hasProposal: fs8.existsSync(path7.join(wtChange, "proposal.md")) || fs8.existsSync(path7.join(mainChange, "proposal.md")),
|
|
1290
|
+
hasDesign: fs8.existsSync(path7.join(wtChange, "design.md")) || fs8.existsSync(path7.join(mainChange, "design.md")),
|
|
1291
|
+
// worktree 目录存在即算执行中(tasks.md 可能尚未落盘)
|
|
1292
|
+
inWorktree: fs8.existsSync(wtChange)
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
} else {
|
|
1296
|
+
const { total, done } = countTasks(tasks);
|
|
1297
|
+
const wtChange = path7.join(wt, "openspec", "changes", ent.name);
|
|
1298
|
+
const mainChange = path7.join(changesDir, ent.name);
|
|
1299
|
+
out.push({
|
|
1300
|
+
name: ent.name,
|
|
1301
|
+
path: `openspec/changes/${ent.name}`,
|
|
1302
|
+
total,
|
|
1303
|
+
done,
|
|
1304
|
+
// 任一侧存在即算(详情读取是 worktree 优先、主目录兜底,列表标记保持一致)
|
|
1305
|
+
hasProposal: fs8.existsSync(path7.join(wtChange, "proposal.md")) || fs8.existsSync(path7.join(mainChange, "proposal.md")),
|
|
1306
|
+
hasDesign: fs8.existsSync(path7.join(wtChange, "design.md")) || fs8.existsSync(path7.join(mainChange, "design.md")),
|
|
1307
|
+
inWorktree: true
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1174
1310
|
}
|
|
1175
1311
|
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
1176
1312
|
return out;
|
|
1177
1313
|
}
|
|
1178
1314
|
function readApplyChange(root, name) {
|
|
1179
|
-
const
|
|
1180
|
-
const
|
|
1181
|
-
const
|
|
1182
|
-
const
|
|
1315
|
+
const wt = worktreeDir(root, name);
|
|
1316
|
+
const mainChangeDir = changeDir(root, name);
|
|
1317
|
+
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");
|
|
1318
|
+
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");
|
|
1319
|
+
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");
|
|
1320
|
+
const proposal = readText(proposalFrom);
|
|
1321
|
+
const design = readText(designFrom);
|
|
1322
|
+
const tasks = readText(tasksFrom);
|
|
1183
1323
|
const { total, done } = countTasks(tasks);
|
|
1324
|
+
const inWorktree = fs8.existsSync(path7.join(wt, "openspec", "changes", name));
|
|
1325
|
+
const dependsOn = parseDependsOn(proposal);
|
|
1326
|
+
const hasTestStrategy = /^##\s+测试策略\s*$/m.test(design);
|
|
1184
1327
|
return {
|
|
1185
1328
|
name,
|
|
1186
1329
|
path: `openspec/changes/${name}`,
|
|
@@ -1188,14 +1331,17 @@ function readApplyChange(root, name) {
|
|
|
1188
1331
|
done,
|
|
1189
1332
|
hasProposal: !!proposal,
|
|
1190
1333
|
hasDesign: !!design,
|
|
1334
|
+
inWorktree,
|
|
1191
1335
|
proposal,
|
|
1192
1336
|
design,
|
|
1193
|
-
tasks
|
|
1337
|
+
tasks,
|
|
1338
|
+
dependsOn,
|
|
1339
|
+
hasTestStrategy
|
|
1194
1340
|
};
|
|
1195
1341
|
}
|
|
1196
1342
|
|
|
1197
1343
|
// src/plugins/apply/index.ts
|
|
1198
|
-
var
|
|
1344
|
+
var apply6 = {
|
|
1199
1345
|
inject: ["server", "dashboard"],
|
|
1200
1346
|
apply(ctx, config) {
|
|
1201
1347
|
const root = config.root;
|
|
@@ -1286,7 +1432,7 @@ function scanAssets(root) {
|
|
|
1286
1432
|
}
|
|
1287
1433
|
|
|
1288
1434
|
// src/plugins/design/index.ts
|
|
1289
|
-
var
|
|
1435
|
+
var apply7 = {
|
|
1290
1436
|
inject: ["server", "dashboard"],
|
|
1291
1437
|
apply(ctx, config) {
|
|
1292
1438
|
const root = config.root;
|
|
@@ -1302,7 +1448,7 @@ var apply6 = {
|
|
|
1302
1448
|
};
|
|
1303
1449
|
|
|
1304
1450
|
// src/plugins/view/index.ts
|
|
1305
|
-
var
|
|
1451
|
+
var apply8 = {
|
|
1306
1452
|
inject: ["dashboard"],
|
|
1307
1453
|
apply(ctx) {
|
|
1308
1454
|
ctx.dashboard.register({ mode: "view", label: "\u9879\u76EE\u6D4F\u89C8", icon: "\u{1F441}\uFE0F", description: "openspec / docs / \u6587\u6863\u9884\u89C8" });
|
|
@@ -1366,7 +1512,7 @@ function scan(root) {
|
|
|
1366
1512
|
stats.byExt = Array.from(extMap.entries()).map(([ext, count]) => ({ ext, count })).sort((a, b) => b.count - a.count).slice(0, 10);
|
|
1367
1513
|
return stats;
|
|
1368
1514
|
}
|
|
1369
|
-
var
|
|
1515
|
+
var apply9 = {
|
|
1370
1516
|
inject: ["server", "dashboard"],
|
|
1371
1517
|
apply(ctx, config) {
|
|
1372
1518
|
ctx.inject(["server", "dashboard"], () => {
|
|
@@ -1389,9 +1535,11 @@ var apply8 = {
|
|
|
1389
1535
|
|
|
1390
1536
|
// src/cli.ts
|
|
1391
1537
|
var __dirname2 = path10.dirname(fileURLToPath2(import.meta.url));
|
|
1538
|
+
var DEFAULT_PORT = 4190;
|
|
1392
1539
|
function parseArgs() {
|
|
1393
1540
|
const args = process.argv.slice(2);
|
|
1394
1541
|
const opts = {};
|
|
1542
|
+
let portExplicit = false;
|
|
1395
1543
|
for (let i = 0; i < args.length; i++) {
|
|
1396
1544
|
const a = args[i];
|
|
1397
1545
|
if (a.startsWith("--")) {
|
|
@@ -1403,11 +1551,13 @@ function parseArgs() {
|
|
|
1403
1551
|
} else {
|
|
1404
1552
|
opts[key] = true;
|
|
1405
1553
|
}
|
|
1554
|
+
if (key === "port") portExplicit = true;
|
|
1406
1555
|
}
|
|
1407
1556
|
}
|
|
1408
1557
|
return {
|
|
1409
1558
|
dir: typeof opts.dir === "string" ? opts.dir : process.cwd(),
|
|
1410
|
-
port: typeof opts.port === "string" ? Number(opts.port) :
|
|
1559
|
+
port: typeof opts.port === "string" ? Number(opts.port) : DEFAULT_PORT,
|
|
1560
|
+
portExplicit,
|
|
1411
1561
|
open: !!opts.open,
|
|
1412
1562
|
page: typeof opts.page === "string" ? opts.page : null,
|
|
1413
1563
|
restart: !!opts.restart,
|
|
@@ -1481,33 +1631,39 @@ async function main() {
|
|
|
1481
1631
|
await new Promise((resolve) => process.stdout.write("", () => resolve()));
|
|
1482
1632
|
process.exit(0);
|
|
1483
1633
|
}
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1634
|
+
const oldRecord = existing && args.restart ? existing : null;
|
|
1635
|
+
if (oldRecord) {
|
|
1636
|
+
console.log(`[zdashboard] --restart\uFF1A\u505C\u6B62\u65E7\u5B9E\u4F8B pid=${oldRecord.pid}`);
|
|
1637
|
+
await stopInstance(oldRecord);
|
|
1487
1638
|
}
|
|
1488
1639
|
const appDir = path10.resolve(__dirname2, "web");
|
|
1640
|
+
const startPort = oldRecord && !args.portExplicit ? oldRecord.port : args.port;
|
|
1641
|
+
const zdevDir = path10.join(root, ".zdev");
|
|
1642
|
+
const dataDir = fs11.existsSync(zdevDir) ? ".zdev/" : "";
|
|
1489
1643
|
const det = await detect(root);
|
|
1490
1644
|
const ctx = new Context();
|
|
1491
1645
|
ctx.plugin(ServerService, {
|
|
1492
1646
|
root,
|
|
1493
1647
|
appDir,
|
|
1494
|
-
port:
|
|
1648
|
+
port: startPort,
|
|
1495
1649
|
open: args.open,
|
|
1496
1650
|
detect: det,
|
|
1497
1651
|
page: args.page,
|
|
1652
|
+
dataDir: dataDir || void 0,
|
|
1498
1653
|
onListen: (port) => writeRecord(root, port)
|
|
1499
1654
|
});
|
|
1500
1655
|
ctx.plugin(ReloadService, { root });
|
|
1501
1656
|
ctx.plugin(apply, { root });
|
|
1657
|
+
ctx.plugin(apply2, { root });
|
|
1502
1658
|
ctx.plugin(DashboardService);
|
|
1503
1659
|
const plugins = [
|
|
1504
|
-
{ name: "stats", apply:
|
|
1505
|
-
{ name: "just", apply:
|
|
1506
|
-
{ name: "bugs", apply:
|
|
1507
|
-
{ name: "review", apply:
|
|
1508
|
-
{ name: "apply", apply:
|
|
1509
|
-
{ name: "design", apply:
|
|
1510
|
-
{ name: "view", apply:
|
|
1660
|
+
{ name: "stats", apply: apply9 },
|
|
1661
|
+
{ name: "just", apply: apply3 },
|
|
1662
|
+
{ name: "bugs", apply: apply4 },
|
|
1663
|
+
{ name: "review", apply: apply5 },
|
|
1664
|
+
{ name: "apply", apply: apply6 },
|
|
1665
|
+
{ name: "design", apply: apply7 },
|
|
1666
|
+
{ name: "view", apply: apply8 }
|
|
1511
1667
|
];
|
|
1512
1668
|
for (const p of plugins) {
|
|
1513
1669
|
try {
|