@arshdelight/practi 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +73 -14
  2. package/dist/client.js +49 -6
  3. package/dist/client.js.map +1 -1
  4. package/dist/cmd/blob.js +10 -1
  5. package/dist/cmd/blob.js.map +1 -1
  6. package/dist/cmd/clone.js +15 -10
  7. package/dist/cmd/clone.js.map +1 -1
  8. package/dist/cmd/comment.js +50 -15
  9. package/dist/cmd/comment.js.map +1 -1
  10. package/dist/cmd/config.js +1 -1
  11. package/dist/cmd/config.js.map +1 -1
  12. package/dist/cmd/edit.js +82 -11
  13. package/dist/cmd/edit.js.map +1 -1
  14. package/dist/cmd/gc.js +77 -0
  15. package/dist/cmd/gc.js.map +1 -0
  16. package/dist/cmd/lifecycle.js +63 -13
  17. package/dist/cmd/lifecycle.js.map +1 -1
  18. package/dist/cmd/login.js +13 -4
  19. package/dist/cmd/login.js.map +1 -1
  20. package/dist/cmd/ls.js +41 -2
  21. package/dist/cmd/ls.js.map +1 -1
  22. package/dist/cmd/migrate.js +3 -0
  23. package/dist/cmd/migrate.js.map +1 -1
  24. package/dist/cmd/new.js +52 -6
  25. package/dist/cmd/new.js.map +1 -1
  26. package/dist/cmd/note.js +160 -0
  27. package/dist/cmd/note.js.map +1 -0
  28. package/dist/cmd/pull.js +2 -3
  29. package/dist/cmd/pull.js.map +1 -1
  30. package/dist/cmd/push.js +6 -1
  31. package/dist/cmd/push.js.map +1 -1
  32. package/dist/cmd/remote.js +15 -8
  33. package/dist/cmd/remote.js.map +1 -1
  34. package/dist/cmd/remove.js +42 -0
  35. package/dist/cmd/remove.js.map +1 -0
  36. package/dist/cmd/repair.js +34 -0
  37. package/dist/cmd/repair.js.map +1 -0
  38. package/dist/cmd/search.js +154 -57
  39. package/dist/cmd/search.js.map +1 -1
  40. package/dist/cmd/show.js +96 -9
  41. package/dist/cmd/show.js.map +1 -1
  42. package/dist/cmd/skill.js +2 -3
  43. package/dist/cmd/skill.js.map +1 -1
  44. package/dist/index.js +145 -36
  45. package/dist/index.js.map +1 -1
  46. package/dist/notes.js +121 -0
  47. package/dist/notes.js.map +1 -0
  48. package/dist/oauth.js +3 -0
  49. package/dist/oauth.js.map +1 -1
  50. package/dist/state.js +32 -1
  51. package/dist/state.js.map +1 -1
  52. package/dist/web.js +420 -139
  53. package/dist/web.js.map +1 -1
  54. package/dist/workspace.js +10 -0
  55. package/dist/workspace.js.map +1 -1
  56. package/package.json +3 -2
  57. package/skills/use-practi/SKILL.md +49 -14
  58. package/web-default/app.js +106 -0
  59. package/web-default/detail.html +36 -0
  60. package/web-default/detail.js +871 -0
  61. package/web-default/index.html +28 -0
  62. package/web-default/lang.js +181 -0
  63. package/web-default/marked.min.js +69 -0
  64. package/web-default/style.css +234 -0
package/dist/web.js CHANGED
@@ -1,17 +1,54 @@
1
1
  import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
2
5
  import { spawn } from 'node:child_process';
3
- import { aggregateView, exportSubtree, readBlob, resolveNodeRef, PracticeError } from '@arshdelight/pop-sdk';
6
+ import { aggregateView, computeNodeHash, exportSubtree, readBlob, resolveNodeRef, PracticeError } from '@arshdelight/pop-sdk';
4
7
  import { defaultDataDir, loadState } from './state.js';
5
- import { openWorkspace } from './workspace.js';
6
- import { nodeTag, shortHash } from './render.js';
8
+ import { nodeFileTime, openWorkspace } from './workspace.js';
9
+ import { loadNotes, subtreeHashes, insertNote, updateNote, removeNote, NOTES_FILE } from './notes.js';
10
+ import { runNew } from './cmd/new.js';
11
+ import { runPull } from './cmd/pull.js';
12
+ import { runPush } from './cmd/push.js';
13
+ import { runLogin, runLogout, runMe } from './cmd/login.js';
14
+ /** 内置默认前端(随包分发的普通文件):dist/../web-default 与 src/../web-default 都指向 cli/web-default */
15
+ const DEFAULT_WEB_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web-default');
16
+ /** workspace 里的覆盖目录名:里面的文件按文件级覆盖默认前端(不存在的文件回落默认) */
17
+ const WEB_OVERRIDE_DIR = 'web';
18
+ const STATIC_TYPES = {
19
+ '.html': 'text/html; charset=utf-8',
20
+ '.css': 'text/css; charset=utf-8',
21
+ '.js': 'text/javascript; charset=utf-8',
22
+ '.mjs': 'text/javascript; charset=utf-8',
23
+ '.json': 'application/json; charset=utf-8',
24
+ '.svg': 'image/svg+xml',
25
+ '.png': 'image/png',
26
+ '.ico': 'image/x-icon',
27
+ };
28
+ /** live reload 客户端:连 SSE,收到 reload 整页刷新(只读页面无状态,整刷即热更);
29
+ * 收到 notes(笔记文件变更,服务器侧已不对其整刷)转发为 DOM 事件,详情页自行局部更新 */
30
+ const LR_SCRIPT = "(function(){var es=new EventSource('/_lr');es.addEventListener('reload',function(){location.reload()});es.addEventListener('notes',function(){document.dispatchEvent(new CustomEvent('practi:notes'))});})();";
31
+ function injectLiveReload(html) {
32
+ const tag = '<script src="/_lr.js" defer></script>';
33
+ return /<\/body>/i.test(html) ? html.replace(/<\/body>/i, `${tag}</body>`) : html + tag;
34
+ }
7
35
  export function runWeb(opts) {
8
36
  const dataDir = opts.dataDir ?? defaultDataDir();
9
37
  // validate the data dir up front so a typo fails fast instead of on first request
10
- const ws = openWorkspace(dataDir);
38
+ openWorkspace(dataDir);
11
39
  const url = `http://127.0.0.1:${opts.port}/`;
40
+ // live reload:SSE 客户端池 + 文件/数据监听。监听面 = 整个 data dir(nodes/、
41
+ // 覆盖目录 web/、practi.json 全覆盖:另一终端 practi new/pull 页面也会自动跟新)
42
+ // + 内置默认前端目录(CLI 开发期改默认文件也即时生效)
43
+ const clients = new Set();
44
+ watchLive([dataDir, DEFAULT_WEB_DIR], clients);
45
+ const overrideDir = path.join(dataDir, WEB_OVERRIDE_DIR);
46
+ console.log(`frontend: ${fs.existsSync(overrideDir) ? `workspace override ${overrideDir} (missing files fall back to built-in)` : `built-in default (drop files into ${overrideDir}${path.sep} to customize)`}`);
12
47
  const server = http.createServer((req, res) => {
13
48
  try {
14
- handle(req, res, dataDir, ws);
49
+ // ws 每请求重建:命令写入(本端点的 /api/run,或另一终端的 practi new/pull)
50
+ // 必须立即可见,不能拿启动时的快照过滤数据窗
51
+ handle(req, res, dataDir, openWorkspace(dataDir), clients);
15
52
  }
16
53
  catch (e) {
17
54
  if (e instanceof PracticeError) {
@@ -32,11 +69,36 @@ export function runWeb(opts) {
32
69
  });
33
70
  return 0;
34
71
  }
35
- function handle(req, res, dataDir, ws) {
72
+ function handle(req, res, dataDir, ws, clients) {
36
73
  const pathname = (req.url ?? '/').split('?')[0];
37
74
  const state = loadState(dataDir);
75
+ if (pathname === '/_lr') {
76
+ sseHandshake(req, res, clients);
77
+ return;
78
+ }
79
+ if (pathname === '/_lr.js') {
80
+ send(res, 200, 'text/javascript; charset=utf-8', LR_SCRIPT);
81
+ return;
82
+ }
83
+ if (pathname === '/api/directs') {
84
+ send(res, 200, 'application/json; charset=utf-8', JSON.stringify(directsPayload(dataDir, ws, state), null, 2));
85
+ return;
86
+ }
87
+ if (pathname === '/api/notes') {
88
+ if (req.method === 'POST')
89
+ void handleNoteWrite(req, res, dataDir, ws);
90
+ else
91
+ notesRoute(req, res, dataDir, ws);
92
+ return;
93
+ }
94
+ if (pathname === '/api/run') {
95
+ void handleRun(req, res, dataDir);
96
+ return;
97
+ }
38
98
  if (pathname === '/' || pathname === '/index.html') {
39
- send(res, 200, 'text/html; charset=utf-8', indexHtml(ws, state.direct));
99
+ if (serveStatic(res, 'index.html', dataDir))
100
+ return;
101
+ send(res, 500, 'text/plain; charset=utf-8', 'built-in web frontend missing — reinstall the CLI');
40
102
  return;
41
103
  }
42
104
  const pop = pathname.match(/^\/pop\/(.+)$/);
@@ -48,8 +110,9 @@ function handle(req, res, dataDir, ws) {
48
110
  send(res, 200, 'application/json; charset=utf-8', JSON.stringify(view, null, 2));
49
111
  return;
50
112
  }
51
- const view = aggregateView(resolved, ws.nodes, { full: true });
52
- send(res, 200, 'text/html; charset=utf-8', detailHtml(ws, view));
113
+ if (!serveStatic(res, 'detail.html', dataDir)) {
114
+ send(res, 500, 'text/plain; charset=utf-8', 'built-in web frontend missing — reinstall the CLI');
115
+ }
53
116
  return;
54
117
  }
55
118
  const blob = pathname.match(/^\/blobs\/(sha256:[0-9a-f]{64})$/);
@@ -67,7 +130,11 @@ function handle(req, res, dataDir, ws) {
67
130
  const hash = decodeURIComponent(doc[1]).replace(/\.json$/, '');
68
131
  const node = ws.nodes.get(resolveNodeRef(ws, hash));
69
132
  if (node) {
70
- send(res, 200, 'application/json; charset=utf-8', JSON.stringify(exportSubtree(node, ws.nodes), null, 2));
133
+ // 加法演进:树字段仍在顶层(老前端照常读),nodeIndex 是新增兄弟键(新前端才消费)。
134
+ // 前端文件热更可能跑在服务器 dist 前面、用户覆盖的 web/ 也可能落后 CLI 升级——
135
+ // 数据窗任何改动都必须新旧两端互容,不能用信封换结构
136
+ const tree = exportSubtree(node, ws.nodes);
137
+ send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ ...tree, nodeIndex: buildNodeIndex(tree) }, null, 2));
71
138
  return;
72
139
  }
73
140
  }
@@ -75,12 +142,355 @@ function handle(req, res, dataDir, ws) {
75
142
  send(res, 200, 'text/plain', 'ok');
76
143
  return;
77
144
  }
145
+ // 静态资产兜底(style.css / app.js / 自定义前端文件):放在最后,不与 /pop /blobs /doc 抢路由
146
+ if (serveStatic(res, pathname.slice(1), dataDir))
147
+ return;
78
148
  send(res, 404, 'text/plain; charset=utf-8', 'not found');
79
149
  }
150
+ class ApiError extends Error {
151
+ status;
152
+ constructor(status, message) {
153
+ super(message);
154
+ this.status = status;
155
+ }
156
+ }
157
+ function asStr(v) {
158
+ if (v !== undefined && typeof v !== 'string')
159
+ throw new ApiError(400, 'string argument expected');
160
+ return v;
161
+ }
162
+ const RUNNABLE = {
163
+ new: {
164
+ run: (a, d) => {
165
+ const json = asStr(a.json);
166
+ if (!json || !json.trim())
167
+ throw new ApiError(400, 'new requires args.json (POP document text)');
168
+ return runNew({ dataDir: d, json, positional: [] });
169
+ },
170
+ },
171
+ pull: {
172
+ run: (a, d) => runPull({ dataDir: d, positional: hashArg(a) }),
173
+ },
174
+ push: {
175
+ run: (a, d) => runPush({ dataDir: d, positional: hashArg(a) }),
176
+ },
177
+ login: {
178
+ // OAuth 授权流:web 进程内起 loopback 回调 + 开系统浏览器,请求挂起至授权完成
179
+ run: (a, d) => runLogin({ dataDir: d, noOpen: a.noOpen === true }),
180
+ },
181
+ logout: { run: (_a, d) => runLogout({ dataDir: d }) },
182
+ me: { run: (_a, d) => runMe({ dataDir: d }) },
183
+ };
184
+ function hashArg(a) {
185
+ const hash = asStr(a.hash);
186
+ return hash ? [hash] : [];
187
+ }
188
+ /** 命令输出捕获:命令函数走 console.log/error,调用期间临时替换收集,finally 恢复。
189
+ * 进程级替换在并发下有竞态 → runQueue 互斥,同一时刻只跑一条命令(写操作本就不应并发) */
190
+ let runQueue = Promise.resolve();
191
+ function enqueueRun(fn) {
192
+ const next = runQueue.then(fn, fn);
193
+ runQueue = next.catch(() => { });
194
+ return next;
195
+ }
196
+ async function captureConsole(fn) {
197
+ const out = [];
198
+ const errBuf = [];
199
+ const origLog = console.log;
200
+ const origErr = console.error;
201
+ console.log = (...a) => void out.push(a.join(' '));
202
+ console.error = (...a) => void errBuf.push(a.join(' '));
203
+ try {
204
+ return { code: await fn(), out: out.join('\n'), err: errBuf.join('\n') };
205
+ }
206
+ finally {
207
+ console.log = origLog;
208
+ console.error = origErr;
209
+ }
210
+ }
211
+ /** 安全闸:浏览器跨站 POST 必带 Origin;Origin 的 host 必须等于请求的 Host 头
212
+ * (即 127.0.0.1:port 本身),否则 403——挡 CSRF 与 DNS rebinding */
213
+ function sameOrigin(req) {
214
+ const origin = req.headers.origin;
215
+ const host = req.headers.host;
216
+ if (typeof origin !== 'string' || typeof host !== 'string')
217
+ return false;
218
+ try {
219
+ return new URL(origin).host === host;
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ }
225
+ async function handleRun(req, res, dataDir) {
226
+ const fail = (status, message) => send(res, status, 'application/json; charset=utf-8', JSON.stringify({ error: message }));
227
+ try {
228
+ if (req.method !== 'POST')
229
+ throw new ApiError(405, 'POST only');
230
+ if (!sameOrigin(req))
231
+ throw new ApiError(403, 'same-origin POST required (Origin header missing or foreign)');
232
+ const ctype = String(req.headers['content-type'] ?? '');
233
+ if (!ctype.startsWith('application/json'))
234
+ throw new ApiError(415, 'content-type must be application/json');
235
+ const body = await readBody(req, 2 * 1024 * 1024);
236
+ let parsed;
237
+ try {
238
+ parsed = JSON.parse(body);
239
+ }
240
+ catch {
241
+ throw new ApiError(400, 'request body is not valid JSON');
242
+ }
243
+ const spec = typeof parsed.cmd === 'string' ? RUNNABLE[parsed.cmd] : undefined;
244
+ if (!spec)
245
+ throw new ApiError(400, `unknown cmd — runnable: ${Object.keys(RUNNABLE).join(', ')}`);
246
+ const args = parsed.args ?? {};
247
+ const result = await enqueueRun(() => captureConsole(() => spec.run(args, dataDir)).catch(e => {
248
+ // 参数校验错误透传为 4xx;命令自身的异常对齐 CLI 顶层语义(打印 + 退出码 1),
249
+ // 命令失败是结果不是服务器错误,不该是 500
250
+ if (e instanceof ApiError)
251
+ throw e;
252
+ return { code: 1, out: '', err: e instanceof Error ? e.message : String(e) };
253
+ }));
254
+ send(res, 200, 'application/json; charset=utf-8', JSON.stringify(result));
255
+ }
256
+ catch (e) {
257
+ if (e instanceof ApiError)
258
+ fail(e.status, e.message);
259
+ else
260
+ fail(500, e instanceof Error ? e.message : String(e));
261
+ }
262
+ }
263
+ async function readBody(req, limit) {
264
+ return new Promise((resolve, reject) => {
265
+ const chunks = [];
266
+ let size = 0;
267
+ req.on('data', (c) => {
268
+ size += c.length;
269
+ if (size > limit) {
270
+ reject(new ApiError(413, 'request body too large'));
271
+ req.destroy();
272
+ return;
273
+ }
274
+ chunks.push(c);
275
+ });
276
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
277
+ req.on('error', reject);
278
+ });
279
+ }
280
+ /** 节点指纹登记簿(哈希→树内 children 下标路径),/doc 数据窗附带:前端把 inputs.from
281
+ * 反解成 #编号引用用。与 hub 的 node_index 物化列同一思路——内容寻址下哈希不在树里,
282
+ * 引用靠旁路登记。根(空路径)不成节、无编号,不登记(from 指向根按未命中回落哈希)。 */
283
+ function buildNodeIndex(tree) {
284
+ const index = {};
285
+ const visit = (n, p) => {
286
+ if (p.length > 0)
287
+ index[computeNodeHash(n)] = p;
288
+ const kids = Array.isArray(n.children) ? n.children : [];
289
+ kids.forEach((c, i) => visit(c, [...p, i]));
290
+ };
291
+ visit(tree, []);
292
+ return index;
293
+ }
80
294
  function send(res, code, type, body) {
81
295
  res.writeHead(code, { 'content-type': type });
82
296
  res.end(body);
83
297
  }
298
+ /** 静态文件:workspace 的 web/ 按文件覆盖,缺失文件回落内置默认。
299
+ * 拒绝 `..` 段做路径遍历防护;HTML 响应注入 live reload 脚本(对默认与自定义前端一视同仁) */
300
+ function serveStatic(res, rel, dataDir) {
301
+ const parts = rel.split('/').filter(Boolean);
302
+ if (parts.length === 0 || parts.includes('..'))
303
+ return false;
304
+ const file = [path.join(dataDir, WEB_OVERRIDE_DIR, ...parts), path.join(DEFAULT_WEB_DIR, ...parts)]
305
+ .find(f => fs.existsSync(f) && fs.statSync(f).isFile());
306
+ if (!file)
307
+ return false;
308
+ const type = STATIC_TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
309
+ const body = fs.readFileSync(file);
310
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'no-cache' });
311
+ if (type.startsWith('text/html')) {
312
+ res.end(injectLiveReload(body.toString('utf8')));
313
+ }
314
+ else {
315
+ res.end(body);
316
+ }
317
+ return true;
318
+ }
319
+ /** 目录数据窗:direct 列表的名称/描述/统计 + 数据目录路径(前端头部展示用)。
320
+ * claimedAt=声明值(state.claims 盖戳);addedAt=生效值,未盖戳的老数据回落节点文件
321
+ * mtime(推测值,前端弱化显示)——声明与推测不混装一个字段 */
322
+ function directsPayload(dataDir, ws, state) {
323
+ const docs = state.direct
324
+ .filter(h => ws.nodes.has(h))
325
+ .map(h => {
326
+ const v = aggregateView(h, ws.nodes);
327
+ const claimedAt = state.claims?.[h] ?? null;
328
+ return {
329
+ hash: v.hash,
330
+ name: v.name,
331
+ description: v.description,
332
+ type: v.type,
333
+ op: v.type === 'practice' ? v.op : null,
334
+ steps: v.steps.length,
335
+ // 全树节点数(含根,=详情页侧栏 countNodes 口径);steps 保留给既有消费者(加法演进)
336
+ nodes: subtreeHashes(ws, h).size,
337
+ outputs: v.outputs.length,
338
+ claimedAt,
339
+ addedAt: claimedAt ?? nodeFileTime(dataDir, h),
340
+ };
341
+ });
342
+ return { dataDir, docs };
343
+ }
344
+ /** POST /api/notes:note 的第二个写入门(官方前端侧栏编辑用;CLI practi note 仍是第一门)。
345
+ * 本地个人数据,无审核诉求,但 CSRF 闸与 /api/run 同规格(sameOrigin + application/json)。
346
+ * body={op:'add',hash,content} | {op:'edit',id,content} | {op:'delete',id}。 */
347
+ async function handleNoteWrite(req, res, dataDir, ws) {
348
+ const fail = (status, message) => send(res, status, 'application/json; charset=utf-8', JSON.stringify({ error: message }));
349
+ try {
350
+ if (req.method !== 'POST')
351
+ throw new ApiError(405, 'POST only');
352
+ if (!sameOrigin(req))
353
+ throw new ApiError(403, 'same-origin POST required (Origin header missing or foreign)');
354
+ const ctype = String(req.headers['content-type'] ?? '');
355
+ if (!ctype.startsWith('application/json'))
356
+ throw new ApiError(415, 'content-type must be application/json');
357
+ const body = await readBody(req, 256 * 1024);
358
+ let parsed;
359
+ try {
360
+ parsed = JSON.parse(body);
361
+ }
362
+ catch {
363
+ throw new ApiError(400, 'request body is not valid JSON');
364
+ }
365
+ const content = typeof parsed.content === 'string' ? parsed.content : '';
366
+ const okBody = (note) => send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ note }, null, 2));
367
+ switch (parsed.op) {
368
+ case 'add': {
369
+ if (!content.trim())
370
+ throw new ApiError(400, 'content required');
371
+ const ref = typeof parsed.hash === 'string' ? parsed.hash : '';
372
+ let hash;
373
+ try {
374
+ hash = resolveNodeRef(ws, ref);
375
+ }
376
+ catch (e) {
377
+ throw new ApiError(404, e instanceof PracticeError ? `${e.code}: ${e.message}` : String(e));
378
+ }
379
+ return void okBody(insertNote(dataDir, hash, content));
380
+ }
381
+ case 'edit': {
382
+ if (!content.trim())
383
+ throw new ApiError(400, 'content required');
384
+ const id = typeof parsed.id === 'string' ? parsed.id : '';
385
+ const m = updateNote(dataDir, id, content);
386
+ if (!m.ok)
387
+ throw mutationError(id, m);
388
+ return void okBody(m.note);
389
+ }
390
+ case 'delete': {
391
+ const id = typeof parsed.id === 'string' ? parsed.id : '';
392
+ const m = removeNote(dataDir, id);
393
+ if (!m.ok)
394
+ throw mutationError(id, m);
395
+ return void send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ deleted: true, id: m.note.id }, null, 2));
396
+ }
397
+ default:
398
+ throw new ApiError(400, 'op must be add | edit | delete');
399
+ }
400
+ }
401
+ catch (e) {
402
+ if (e instanceof ApiError)
403
+ fail(e.status, e.message);
404
+ else
405
+ fail(500, e instanceof Error ? e.message : String(e));
406
+ }
407
+ }
408
+ function mutationError(id, m) {
409
+ return m.reason === 'not_found'
410
+ ? new ApiError(404, `note "${id}" not found`)
411
+ : new ApiError(400, `note id prefix "${id}" matches ${m.matches} notes`);
412
+ }
413
+ /** 笔记数据窗:GET /api/notes?ref=<hash> → 该节点子树内的本地笔记(存入顺序=时间正序)。
414
+ * 独立只读端点、纯加法(不碰既有数据窗);写入口在 CLI(practi note)。ref 缺失 400、
415
+ * 解析不到 404——错误对齐其它路由的纯文本口径。notes.json 在 dataDir 下,live reload
416
+ * 天然覆盖:另一终端 note add 后页面自动刷新出笔记 */
417
+ function notesRoute(req, res, dataDir, ws) {
418
+ const ref = new URL(req.url ?? '/', 'http://localhost').searchParams.get('ref');
419
+ if (!ref) {
420
+ send(res, 400, 'text/plain; charset=utf-8', 'E_ARGS: missing ?ref=<hash>');
421
+ return;
422
+ }
423
+ let hash;
424
+ try {
425
+ hash = resolveNodeRef(ws, ref);
426
+ }
427
+ catch (e) {
428
+ const msg = e instanceof PracticeError ? `${e.code}: ${e.message}` : String(e);
429
+ send(res, 404, 'text/plain; charset=utf-8', msg);
430
+ return;
431
+ }
432
+ const set = subtreeHashes(ws, hash);
433
+ const notes = loadNotes(dataDir).notes.filter(n => set.has(n.hash));
434
+ send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ notes }, null, 2));
435
+ }
436
+ function sseHandshake(req, res, clients) {
437
+ res.writeHead(200, {
438
+ 'content-type': 'text/event-stream',
439
+ 'cache-control': 'no-cache',
440
+ connection: 'keep-alive',
441
+ });
442
+ res.write('retry: 1000\n\n');
443
+ clients.add(res);
444
+ req.on('close', () => clients.delete(res));
445
+ }
446
+ /** 监听 roots(递归;平台不支持时降级单层,再失败静默跳过——live reload 是锦上添花,不致命)。
447
+ * 事件去抖:一次保存或一条 practi 命令会喷一串文件事件,合并成一次广播。
448
+ * notes.json 例外:不触发整页 reload(写字段的页面自己会局部更新,整刷会白屏闪烁),
449
+ * 改发轻量 notes 事件——前端只重拉笔记数据重绘右栏,内容区/滚动位/草稿全程不动 */
450
+ function watchLive(roots, clients) {
451
+ let reloadTimer = null;
452
+ let notesTimer = null;
453
+ const broadcast = (event) => {
454
+ if (clients.size === 0)
455
+ return;
456
+ for (const res of clients)
457
+ res.write(`event: ${event}\ndata: 1\n\n`);
458
+ };
459
+ const onChange = (_event, filename) => {
460
+ const isNotes = filename !== null && path.basename(filename) === NOTES_FILE;
461
+ const unknown = filename === null; // 个别平台不给文件名:保守起见两种事件都发——宁多推一次不漏
462
+ if (!isNotes || unknown) {
463
+ if (reloadTimer === null) {
464
+ reloadTimer = setTimeout(() => { reloadTimer = null; broadcast('reload'); }, 150);
465
+ }
466
+ }
467
+ if (isNotes || unknown) {
468
+ if (notesTimer === null) {
469
+ notesTimer = setTimeout(() => { notesTimer = null; broadcast('notes'); }, 150);
470
+ }
471
+ }
472
+ };
473
+ for (const root of roots) {
474
+ if (!fs.existsSync(root))
475
+ continue;
476
+ try {
477
+ fs.watch(root, { recursive: true }, onChange);
478
+ }
479
+ catch {
480
+ try {
481
+ fs.watch(root, onChange);
482
+ }
483
+ catch {
484
+ // fs.watch 完全不可用:live reload 静默失效,其余功能不受影响
485
+ }
486
+ }
487
+ }
488
+ // 保活注释行:防空闲 SSE 连接被掐(本地一般无此问题,防御性)
489
+ setInterval(() => {
490
+ for (const res of clients)
491
+ res.write(': ping\n\n');
492
+ }, 25000).unref();
493
+ }
84
494
  function sendBytes(res, type, body) {
85
495
  res.writeHead(200, { 'content-type': type, 'cache-control': 'public, max-age=31536000, immutable' });
86
496
  res.end(body);
@@ -111,133 +521,4 @@ export function openBrowser(url) {
111
521
  const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
112
522
  spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref();
113
523
  }
114
- const CSS = `
115
- :root { color-scheme: light; }
116
- * { box-sizing: border-box; }
117
- body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0; color: #1a1a1a; background: #fafafa; }
118
- header { background: #111; color: #fff; padding: 14px 24px; display: flex; gap: 16px; align-items: baseline; }
119
- header .brand { font-weight: 700; letter-spacing: .5px; }
120
- header .sub { color: #999; font-size: 13px; }
121
- main { max-width: 860px; margin: 24px auto; padding: 0 24px 60px; }
122
- a { color: #0b57d0; text-decoration: none; }
123
- a:hover { text-decoration: underline; }
124
- .card { background: #fff; border: 1px solid #e3e3e3; border-radius: 8px; padding: 14px 18px; margin-bottom: 12px; }
125
- .card .title { font-weight: 600; font-size: 16px; }
126
- .tag { font-size: 12px; color: #666; background: #f0f0f0; border-radius: 4px; padding: 1px 6px; margin-left: 6px; }
127
- .hash { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 12px; color: #888; }
128
- pre { background: #fff; border: 1px solid #e3e3e3; border-radius: 8px; padding: 14px; overflow-x: auto; font-size: 13px; }
129
- table { border-collapse: collapse; width: 100%; font-size: 14px; }
130
- th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid #eee; }
131
- th { color: #666; font-weight: 600; }
132
- .muted { color: #888; }
133
- .breadcrumb { margin-bottom: 12px; font-size: 13px; }
134
- .section { margin-top: 22px; }
135
- .section h2 { font-size: 14px; text-transform: uppercase; letter-spacing: .4px; color: #666; margin: 0 0 8px; }
136
- .step { padding: 2px 0; }
137
- .step .n { color: #999; }
138
- .stepbody { margin: 6px 0 12px 0; padding-left: 20px; }
139
- .prose { font-size: 14px; color: #333; }
140
- .prose figure { margin: 10px 0; }
141
- .prose img { max-width: 100%; border: 1px solid #e3e3e3; border-radius: 6px; }
142
- .prose figcaption { font-size: 12px; color: #888; text-align: center; margin-top: 4px; }
143
- `;
144
- function page(title, body, dataDir) {
145
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title>
146
- <style>${CSS}</style></head><body>
147
- <header><span class="brand">POP</span><span class="sub">local registry</span><span class="sub">${dataDir}</span></header>
148
- <main>${body}</main></body></html>`;
149
- }
150
- function indexHtml(ws, direct) {
151
- const roots = direct.filter(h => ws.nodes.has(h));
152
- const cards = roots
153
- .map(h => {
154
- const view = aggregateView(h, ws.nodes);
155
- return `<div class="card">
156
- <div class="title"><a href="/pop/${encodeURIComponent(h)}">${escapeHtml(view.name)}</a><span class="tag">${view.type === 'practice' ? `practice·${view.op}` : 'action'}</span></div>
157
- <div class="hash">${view.hash}</div>
158
- ${view.description ? `<div class="muted">${escapeHtml(view.description)}</div>` : ''}
159
- <div class="muted">${view.steps.length} step${view.steps.length === 1 ? '' : 's'} · ${view.flow.length} flow edge${view.flow.length === 1 ? '' : 's'} · ${view.outputs.length} output${view.outputs.length === 1 ? '' : 's'}</div>
160
- </div>`;
161
- })
162
- .join('\n');
163
- const empty = roots.length === 0 ? '<p class="muted">no direct pops — create one with <code>pop new</code></p>' : '';
164
- return page('POP — local registry', `<h1>Direct pops</h1>${empty}${cards}`, ws.root);
165
- }
166
- function detailHtml(ws, view) {
167
- const esc = escapeHtml;
168
- const steps = view.steps
169
- .map(s => {
170
- const n = ws.nodes.get(s.refHash);
171
- const tag = n ? nodeTag(n) : '';
172
- const body = s.content && s.content.trim() ? `<div class="stepbody">${renderContent(s.content, n, ws)}</div>` : '';
173
- return `<div class="step"><span class="n">${'&nbsp;&nbsp;'.repeat(s.depth)}</span>${esc(s.name)} ${tag ? `<span class="tag">${esc(tag)}</span>` : ''}${s.note ? ` <span class="muted">(${esc(s.note)})</span>` : ''}${body}</div>`;
174
- })
175
- .join('\n');
176
- const flowRows = view.flow
177
- .map(e => `<tr><td>${esc(e.name)}</td><td class="hash">${shortHash(e.fromHash)}</td><td>${esc(e.fromName)}</td><td class="hash">${shortHash(e.toHash)}</td><td>${esc(e.toName)}</td></tr>`)
178
- .join('\n');
179
- const inputs = view.inputs.map(d => `<tr><td>${esc(d.name)}</td><td>${d.spec ? esc(d.spec) : ''}</td><td class="hash">${shortHash(d.refHash)}</td></tr>`).join('\n');
180
- const outputs = view.outputs.map(d => `<tr><td>${esc(d.name)}</td><td>${d.spec ? esc(d.spec) : ''}</td><td class="hash">${shortHash(d.refHash)}</td></tr>`).join('\n');
181
- const atts = view.attachments.map(a => `<tr><td>${esc(a.name)}</td><td>${a.mime ? esc(a.mime) : ''}</td><td>${a.size ?? ''}</td><td class="hash">${shortHash(a.hash)}</td></tr>`).join('\n');
182
- const revs = (view.revisions ?? []).map(r => `<tr><td>${esc(r.when)}</td><td>${esc(r.what)}</td>${r.from ? `<td class="hash">${shortHash(r.from)}</td>` : '<td></td>'}</tr>`).join('\n');
183
- return page(`${view.name} — POP`, `
184
- <div class="breadcrumb"><a href="/">← all pops</a></div>
185
- <h1>${esc(view.name)} <span class="tag">${view.type === 'practice' ? `practice·${view.op}` : 'action'}</span></h1>
186
- <div class="hash">${view.hash}</div>
187
- ${view.description ? `<p>${esc(view.description)}</p>` : ''}
188
- ${view.content && view.content.trim() ? `<div class="section"><h2>Content</h2>${renderContent(view.content, ws.nodes.get(view.hash), ws)}</div>` : ''}
189
- <div class="section"><h2>Steps (${view.steps.length})</h2>${steps || '<p class="muted">—</p>'}</div>
190
- ${flowRows ? `<div class="section"><h2>Flow (${view.flow.length})</h2><table><tr><th>name</th><th>from</th><th></th><th>to</th><th></th></tr>${flowRows}</table></div>` : ''}
191
- ${inputs ? `<div class="section"><h2>Declared inputs (needs)</h2><table><tr><th>name</th><th>spec</th><th>node</th></tr>${inputs}</table></div>` : ''}
192
- ${outputs ? `<div class="section"><h2>Declared outputs (produces)</h2><table><tr><th>name</th><th>spec</th><th>node</th></tr>${outputs}</table></div>` : ''}
193
- ${atts ? `<div class="section"><h2>Attachments</h2><table><tr><th>name</th><th>mime</th><th>size</th><th>hash</th></tr>${atts}</table></div>` : ''}
194
- ${revs ? `<div class="section"><h2>Revisions</h2><table><tr><th>when</th><th>what</th><th>from</th></tr>${revs}</table></div>` : ''}
195
- <div class="section"><h2>Links</h2>
196
- <a href="/pop/${encodeURIComponent(view.hash)}.json">StandardView JSON</a> ·
197
- <a href="/doc/${encodeURIComponent(view.hash)}.json">document JSON</a>
198
- </div>
199
- `, ws.root);
200
- }
201
- function escapeHtml(s) {
202
- return s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
203
- }
204
- /**
205
- * Render action content for the web UI: markdown image refs `![caption](name)`
206
- * become real <figure><img> (name → attachment url or a local /blobs/:hash);
207
- * http(s) URL targets are used as-is (external refs, spec §5.1); unresolved
208
- * refs stay literal. Fenced code blocks are treated as prose-exempt (§5.1).
209
- */
210
- function renderContent(content, node, ws) {
211
- const blocks = [];
212
- const prose = content.replace(/```[\s\S]*?```/g, m => {
213
- blocks.push(m);
214
- return `\u0000B${blocks.length - 1}\u0000`;
215
- });
216
- const parts = [];
217
- let last = 0;
218
- for (const m of prose.matchAll(/!\[([^\]]*)\]\(([^)]*)\)/g)) {
219
- parts.push(escapeHtml(prose.slice(last, m.index)));
220
- const caption = m[1];
221
- const target = m[2];
222
- const url = mediaUrl(target, node, ws);
223
- if (url === null) {
224
- parts.push(escapeHtml(m[0]));
225
- }
226
- else {
227
- const cap = escapeHtml(caption);
228
- parts.push(`<figure><img src="${escapeHtml(url)}" alt="${cap}"><figcaption>${cap}</figcaption></figure>`);
229
- }
230
- last = m.index + m[0].length;
231
- }
232
- parts.push(escapeHtml(prose.slice(last)));
233
- return `<div class="prose">${parts.join('').replace(/\u0000B(\d+)\u0000/g, (_, i) => `<pre>${escapeHtml(blocks[Number(i)])}</pre>`)}</div>`;
234
- }
235
- function mediaUrl(target, node, ws) {
236
- if (/^https?:\/\//i.test(target))
237
- return target;
238
- if (!node || node.type !== 'action')
239
- return null;
240
- const a = (node.attachments ?? []).find(x => x.name === target);
241
- return a ? (a.url ?? `/blobs/${a.hash}`) : null;
242
- }
243
524
  //# sourceMappingURL=web.js.map