@kaydash9999/banker-plugins 0.22.0 → 0.23.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 (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +41 -0
  4. package/PRIVACY.md +1 -0
  5. package/README.md +39 -13
  6. package/bin/banker.js +118 -22
  7. package/bin/lib/codex-hooks.mjs +116 -18
  8. package/bin/lib/codex-names.mjs +4 -0
  9. package/bin/lib/codex-routes.mjs +93 -1
  10. package/bin/lib/command-skill.cjs +37 -6
  11. package/codex/command-skills/front-qa/SKILL.md +1 -1
  12. package/codex/command-skills/setup/SKILL.md +4 -4
  13. package/codex/hooks.json +5 -5
  14. package/codex/manifest.json +18 -15
  15. package/codex/skills/progress/scripts/progress.mjs +44 -14
  16. package/codex/skills/teamwork/SKILL.md +137 -0
  17. package/codex/skills/teamwork/scripts/teamwork.mjs +957 -0
  18. package/codex/transform-matrix.md +5 -3
  19. package/hooks/obsidize-hook.mjs +75 -32
  20. package/hooks/telemetry-count-codex.mjs +238 -57
  21. package/hooks/update-fetch.mjs +2 -1
  22. package/hooks/update-notify.mjs +82 -20
  23. package/package.json +3 -1
  24. package/skills/3d-intro-build/SKILL.md +101 -20
  25. package/skills/3d-intro-build/references/NOTICE.md +9 -2
  26. package/skills/3d-intro-build/references/assemble.mjs +113 -22
  27. package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
  28. package/skills/3d-intro-build/references/clip-post.mjs +443 -0
  29. package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
  30. package/skills/3d-intro-build/references/intro-fixes.css +11 -0
  31. package/skills/3d-intro-build/references/prompts.md +51 -4
  32. package/skills/3d-intro-build/references/scrub-engine.js +1 -0
  33. package/skills/3d-intro-build/references/step-nav.css +29 -0
  34. package/skills/3d-intro-build/references/step-nav.js +277 -106
  35. package/skills/3d-intro-build/references/verify-intro.mjs +373 -32
  36. package/skills/3d-intro-build/references/verify.md +6 -0
  37. package/skills/3d-intro-build/references/video-pool.mjs +142 -12
  38. package/skills/3d-intro-setup/SKILL.md +20 -7
  39. package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
  40. package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
  41. package/skills/all-in-one/SKILL.md +125 -86
  42. package/skills/omc-patch/SKILL.md +1 -1
  43. package/skills/payload-mon/SKILL.md +1 -1
  44. package/skills/play-qa/SKILL.md +6 -4
  45. package/skills/progress-mon/README.md +162 -0
  46. package/skills/progress-mon/SKILL.md +165 -0
  47. package/skills/progress-mon/assets/fixture/README.md +1 -0
  48. package/skills/progress-mon/assets/fixture/arch.json +33 -0
  49. package/skills/progress-mon/assets/fixture/commits.json +7 -0
  50. package/skills/progress-mon/assets/fixture/config.json +30 -0
  51. package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
  52. package/skills/progress-mon/assets/fixture/flow.json +44 -0
  53. package/skills/progress-mon/assets/fixture/items.json +14 -0
  54. package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
  55. package/skills/progress-mon/assets/fixture/reqs.json +11 -0
  56. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
  57. package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
  58. package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
  59. package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
  60. package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
  61. package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
  62. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
  63. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
  64. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
  65. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
  66. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
  67. package/skills/progress-mon/references/arch.md +280 -0
  68. package/skills/progress-mon/references/authoring.md +199 -0
  69. package/skills/progress-mon/references/config.md +115 -0
  70. package/skills/progress-mon/references/flow.md +75 -0
  71. package/skills/progress-mon/references/inventory.md +58 -0
  72. package/skills/progress-mon/references/items.md +74 -0
  73. package/skills/progress-mon/references/req-spec.md +74 -0
  74. package/skills/progress-mon/references/reqs.md +30 -0
  75. package/skills/progress-mon/scripts/index.js +31 -0
  76. package/skills/progress-mon/scripts/pm-check.mjs +526 -0
  77. package/skills/progress-mon/scripts/pm-config.mjs +341 -0
  78. package/skills/progress-mon/scripts/pm-data.mjs +394 -0
  79. package/skills/progress-mon/scripts/pm-html.mjs +77 -0
  80. package/skills/progress-mon/scripts/pm-memory.mjs +308 -0
  81. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  82. package/skills/progress-mon/scripts/pm-server.mjs +735 -0
  83. package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
  84. package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
  85. package/skills/ralph-qa/SKILL.md +104 -15
  86. package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
  87. package/skills/ralph-qa/references/payload-scan.mjs +19 -2
  88. package/skills/ralph-qa/references/run-folder.mjs +299 -0
  89. package/skills/ralph-qa/references/seat-payload.mjs +60 -13
  90. package/skills/ralph-qa/references/seat-run.mjs +159 -155
  91. package/skills/remains/SKILL.md +1 -0
  92. package/skills/remains/scripts/boxes.mjs +6 -2
  93. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
  94. package/skills/setup-mcp/SKILL.md +1 -1
  95. package/skills/setup-omc-hud/SKILL.md +2 -1
  96. package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
  97. package/skills/ultra-chain/SKILL.md +32 -14
  98. package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
  99. package/skills/ultra-init/SKILL.md +30 -22
  100. package/skills/ultraqa/NOTICE.md +34 -0
  101. package/skills/ultraqa/SKILL.md +196 -0
  102. package/skills/ultraqa/references/gate-evidence.mjs +457 -0
  103. package/skills/ultraqa/references/run-folder.mjs +299 -0
  104. package/skills/ultraqa/references/tmux-drive.md +52 -0
  105. package/skills/ultraqa/references/uqa-run.mjs +339 -0
  106. package/skills/update-banker/SKILL.md +1 -1
@@ -0,0 +1,735 @@
1
+ // pm-server.mjs: serve, start, stop, status. 바인드, Host 검사, 신원 경로, 상태 파일, 포트 선택, 자기 탐침, 로그.
2
+ // 서버는 렌더 결과만 응답한다. 자료 폴더의 파일은 어떤 경로로도 서빙하지 않는다.
3
+ import fs from 'node:fs';
4
+ import http from 'node:http';
5
+ import path from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ import { privateIPv4, dataLabel, loadConfig, writeAtomic, readRegular, UsageError } from './pm-config.mjs';
8
+ import { workflowRoots } from './pm-sources.mjs';
9
+ import { memoryWarnings } from './pm-memory.mjs';
10
+
11
+ const APP = 'progress-mon';
12
+ export const ID_PATH = '/__progress-mon/id';
13
+ const LOOPBACK = '127.0.0.1';
14
+ const PAGE_PATHS = new Set(['/', '/index.html']);
15
+ const LOG_MAX = 1024 * 1024;
16
+ const PORT_TRIES = 100;
17
+ const START_WAIT_MS = 5000;
18
+ const ASK_TIMEOUT_MS = 1500;
19
+ const BUSY_WAIT_MS = 10000;
20
+ const STOP_WAIT_MS = 3000;
21
+
22
+ export const SECURITY_HEADERS = {
23
+ 'cache-control': 'no-store',
24
+ 'x-content-type-options': 'nosniff',
25
+ 'referrer-policy': 'no-referrer',
26
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; img-src data:",
27
+ };
28
+ export const ERROR_BODY = '오류. server.log 확인';
29
+
30
+ // 거부 문구(--host). 사설 IPv4 만 받는다.
31
+ export const hostRefusal = (ip) => `--host ${ip} 거부. 사설 IPv4(10/8, 172.16/12, 192.168/16)만 받음`;
32
+
33
+ // Host 머리 정확 일치: 127.0.0.1, localhost, [::1], 바인드한 IP. 모두 같은 포트. 대소문자 무시.
34
+ export function hostAllowed(hostHeader, port, ips = []) {
35
+ const h = String(hostHeader || '').trim().toLowerCase();
36
+ if (!h) return false;
37
+ const ok = [LOOPBACK, 'localhost', '[::1]', ...ips].map((x) => `${String(x).toLowerCase()}:${port}`);
38
+ return ok.includes(h);
39
+ }
40
+
41
+ // 로그에 남길 값: 제어 문자 제거 뒤 80자.
42
+ export const cleanForLog = (v) => String(v ?? '').replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 80);
43
+
44
+ const isLoopbackAddr = (a) => a === LOOPBACK || a === '::1' || a === '::ffff:127.0.0.1';
45
+ const validPid = (pid) => Number.isInteger(pid) && pid > 1;
46
+ const validPort = (p) => Number.isInteger(p) && p > 0 && p <= 65535;
47
+ const iso = (ms) => new Date(ms).toISOString();
48
+ const urlOf = (ip, port) => `http://${ip}:${port}/`;
49
+
50
+ export const statePath = (dataDir) => path.join(dataDir, 'server.json');
51
+ export const logPath = (dataDir) => path.join(dataDir, 'server.log');
52
+
53
+ export function readState(dataDir) {
54
+ try {
55
+ return JSON.parse(readRegular(statePath(dataDir)));
56
+ } catch (e) {
57
+ return e.code === 'ENOENT' ? null : { broken: true };
58
+ }
59
+ }
60
+
61
+ // 상태 파일 쓰기(pm-config.mjs 의 원자적 쓰기). 실패는 UsageError.
62
+ export const writeState = (dataDir, info) => writeAtomic(statePath(dataDir), JSON.stringify(info) + '\n');
63
+
64
+ function dropState(dataDir) {
65
+ try {
66
+ fs.rmSync(statePath(dataDir), { force: true });
67
+ } catch {
68
+ // 지울 수 없으면 남김. 다음 start 가 낡은 기록으로 판정한다.
69
+ }
70
+ }
71
+
72
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0; // Windows 에는 없음. 거기서는 lstat 만으로 링크를 거른다
73
+ const NONBLOCK = fs.constants.O_NONBLOCK || 0;
74
+
75
+ // server.log 가 링크나 일반 파일이 아닌 것(FIFO 등)이면 참. 없으면 거짓.
76
+ function oddLog(file) {
77
+ try {
78
+ return !fs.lstatSync(file).isFile();
79
+ } catch (e) {
80
+ return e.code !== 'ENOENT';
81
+ }
82
+ }
83
+
84
+ // server.log 의 fd. 기본은 덧붙이기, append 가 거짓이면 처음부터 다시 쓰기(Windows 의 덧붙이기 fd 는 자를 수 없음).
85
+ // 링크와 일반 파일 아닌 것은 열지 않는다(POSIX 는 O_NOFOLLOW, 연 뒤 fstat 다시 확인).
86
+ function openLog(file, append = true) {
87
+ if (oddLog(file)) throw Object.assign(new Error('server.log 가 일반 파일 아님'), { code: 'EINVAL' });
88
+ const { O_WRONLY, O_RDWR, O_APPEND, O_CREAT } = fs.constants;
89
+ const fd = fs.openSync(file, (append ? O_WRONLY | O_APPEND : O_RDWR) | O_CREAT | NOFOLLOW | NONBLOCK, 0o600);
90
+ if (!fs.fstatSync(fd).isFile()) {
91
+ fs.closeSync(fd);
92
+ throw Object.assign(new Error('server.log 가 일반 파일 아님'), { code: 'EINVAL' });
93
+ }
94
+ return fd;
95
+ }
96
+
97
+ // 한도를 넘은 server.log 를 server.log.1 로 옮기고 새 fd 를 돌려준다(rename 은 링크를 따라가지 않음).
98
+ // 옮기지 못하면(그 자리에 폴더 등) 로그를 버리지 않도록 연 fd(일반 파일로 확인함)를 비우고 계속 쓰며, 그 사실을 onWarn 으로 한 번 알린다.
99
+ function rotateLog(file, fd, warn) {
100
+ fs.closeSync(fd);
101
+ try {
102
+ fs.renameSync(file, `${file}.1`);
103
+ return openLog(file);
104
+ } catch (e) {
105
+ const again = openLog(file, false);
106
+ try {
107
+ fs.ftruncateSync(again, 0);
108
+ } catch (t) {
109
+ fs.closeSync(again);
110
+ throw t;
111
+ }
112
+ warn(`server.log 를 server.log.1 로 옮기지 못함(${e.code || e.message}). 대신 server.log 를 비우고 계속 기록. server.log.1 자리를 치우면 다시 옮김`);
113
+ return again;
114
+ }
115
+ }
116
+
117
+ // server.log 한 줄 덧붙이기. 덧붙이면 max(1 MB)를 넘을 때 먼저 server.log.1 로 옮긴다(대상을 자르지 않음).
118
+ // 링크이거나 일반 파일이 아니면 쓰지 않는다. 옮기지 못한 일은 onWarn(서버의 status 경고)과 로그의 경고 줄로 한 번 알린다. 실패해도 서버는 계속.
119
+ export function makeLogger(dataDir, max = LOG_MAX, onWarn = () => {}) {
120
+ const file = logPath(dataDir);
121
+ let warned = false;
122
+ let pending = ''; // 다음 줄 앞에 남길 경고 한 줄
123
+ const warn = (w) => {
124
+ if (warned) return;
125
+ warned = true;
126
+ onWarn(w);
127
+ pending = `${new Date().toISOString()} 경고: ${w}\n`;
128
+ };
129
+ return (msg) => {
130
+ const line = `${new Date().toISOString()} ${msg}\n`;
131
+ let fd = null;
132
+ try {
133
+ fd = openLog(file);
134
+ if (fs.fstatSync(fd).size + Buffer.byteLength(line) > max) {
135
+ const old = fd;
136
+ fd = null;
137
+ fd = rotateLog(file, old, warn);
138
+ }
139
+ if (pending) fs.writeSync(fd, pending);
140
+ pending = '';
141
+ fs.writeSync(fd, line);
142
+ } catch {
143
+ // 로그를 못 써도 응답은 계속
144
+ } finally {
145
+ if (fd !== null) fs.closeSync(fd);
146
+ }
147
+ };
148
+ }
149
+
150
+ // 요청 처리기. srv: { port, ips, render(tab, now), log, stats, now() }. port 와 ips 는 요청 때 읽는다.
151
+ export function createHandler(srv) {
152
+ const send = (req, res, status, body, type = 'text/plain; charset=utf-8', extra = {}) => {
153
+ res.writeHead(status, { 'content-type': type, ...SECURITY_HEADERS, ...extra });
154
+ res.end(req.method === 'HEAD' ? undefined : body);
155
+ };
156
+ return (req, res) => {
157
+ const raw = String(req.url || '/');
158
+ const pathname = raw.split('?')[0];
159
+ const note = (status) => srv.log(`${status} ${cleanForLog(req.method)} ${cleanForLog(pathname)} host=${cleanForLog(req.headers.host)}`);
160
+ if (!hostAllowed(req.headers.host, srv.port, srv.ips)) {
161
+ note(403);
162
+ return send(req, res, 403, '거부된 Host');
163
+ }
164
+ // Host 검사를 통과한 요청만 센다(403 은 status 의 요청 수에 넣지 않음). 신원 경로도 세지 않는다.
165
+ if (pathname !== ID_PATH) {
166
+ srv.stats.requests++;
167
+ srv.stats.lastRequestAt = iso(Date.now());
168
+ }
169
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
170
+ note(405);
171
+ return send(req, res, 405, '허용하지 않는 메서드', undefined, { allow: 'GET, HEAD' });
172
+ }
173
+ if (pathname === ID_PATH) {
174
+ const body = isLoopbackAddr(req.socket.localAddress)
175
+ ? { app: APP, pid: process.pid, startedAt: srv.stats.startedAt, requests: srv.stats.requests, lastRequestAt: srv.stats.lastRequestAt, lastErrorAt: srv.stats.lastErrorAt, logWarning: srv.stats.logWarning }
176
+ : { app: APP };
177
+ return send(req, res, 200, JSON.stringify(body), 'application/json; charset=utf-8');
178
+ }
179
+ if (!PAGE_PATHS.has(pathname)) {
180
+ note(404);
181
+ return send(req, res, 404, '없음');
182
+ }
183
+ let tab = null;
184
+ try {
185
+ tab = new URL(raw, 'http://x').searchParams.get('tab');
186
+ } catch {
187
+ tab = null;
188
+ }
189
+ let html;
190
+ try {
191
+ html = srv.render(tab, srv.now());
192
+ } catch (e) {
193
+ srv.stats.lastErrorAt = iso(Date.now());
194
+ srv.log(`500 ${cleanForLog(req.method)} ${cleanForLog(pathname)} 렌더 오류\n${(e && e.stack) || e}`);
195
+ return send(req, res, 500, ERROR_BODY);
196
+ }
197
+ send(req, res, 200, html, 'text/html; charset=utf-8');
198
+ };
199
+ }
200
+
201
+ // 실제 리스너 하나. 오류는 코드가 든 예외로.
202
+ function listenHttp(handler) {
203
+ return (port, host) =>
204
+ new Promise((resolve, reject) => {
205
+ const s = http.createServer(handler);
206
+ const onError = (e) => {
207
+ s.off('listening', onListening);
208
+ reject(e);
209
+ };
210
+ const onListening = () => {
211
+ s.off('error', onError);
212
+ resolve(s);
213
+ };
214
+ s.once('error', onError);
215
+ s.once('listening', onListening);
216
+ s.listen(port, host);
217
+ });
218
+ }
219
+
220
+ const closeAll = (servers) => Promise.all(servers.map((s) => new Promise((r) => s.close(() => r()))));
221
+
222
+ // 포트 선택과 바인드(계획 3절). hosts: [{ ip, from: 'arg' | 'config' }].
223
+ // listen(port, host) 는 서버를 돌려주거나 code 가 든 예외를 던진다. probe(port) 가 거짓이면 그 포트는 다른 프로그램.
224
+ // 돌려줌: { port, servers, ips, notes }. 실패는 UsageError.
225
+ export async function bindServers({ port, strict, hosts = [], listen, probe = async () => true }) {
226
+ const notes = [];
227
+ const busy = () => new UsageError(`포트 ${port} 사용 중(--port-strict). 다른 포트를 고르지 않음`);
228
+ const last = strict ? port : Math.min(65535, port + PORT_TRIES - 1);
229
+ const skipped = new Set();
230
+ for (let p = port; p <= last; p++) {
231
+ let main;
232
+ try {
233
+ main = await listen(p, LOOPBACK);
234
+ } catch (e) {
235
+ if (e.code === 'EADDRINUSE' || e.code === 'EACCES') {
236
+ if (strict) throw e.code === 'EADDRINUSE' ? busy() : new UsageError(`포트 ${p} 를 열 권한 없음(${e.code})`);
237
+ continue;
238
+ }
239
+ throw new UsageError(`포트 ${p} 바인드 실패(${e.code || e.message})`);
240
+ }
241
+ const servers = [main];
242
+ const ips = [];
243
+ let retry = false;
244
+ for (const h of hosts) {
245
+ if (skipped.has(h.ip)) continue;
246
+ try {
247
+ servers.push(await listen(p, h.ip));
248
+ ips.push(h.ip);
249
+ } catch (e) {
250
+ if (e.code === 'EADDRINUSE') {
251
+ await closeAll(servers);
252
+ if (strict) throw busy();
253
+ retry = true;
254
+ break;
255
+ }
256
+ if (e.code === 'EADDRNOTAVAIL' && h.from === 'config') {
257
+ skipped.add(h.ip);
258
+ notes.push(`경고: config.hosts 의 ${h.ip} 는 이 PC 주소가 아님. 그 주소는 열지 않고 계속`);
259
+ continue;
260
+ }
261
+ await closeAll(servers);
262
+ if (e.code === 'EADDRNOTAVAIL') throw new UsageError(`--host ${h.ip} 는 이 PC 주소가 아님`);
263
+ throw new UsageError(`${h.ip}:${p} 바인드 실패(${e.code || e.message})`);
264
+ }
265
+ }
266
+ if (retry) continue;
267
+ if (!(await probe(p))) {
268
+ // 같은 포트를 다른 프로그램이 넓은 주소로 쥐고 있어 우리 서버가 답하지 않음
269
+ await closeAll(servers);
270
+ if (strict) throw busy();
271
+ continue;
272
+ }
273
+ if (p !== port) notes.push(`알림: 포트 ${port} 사용 중이라 ${p} 사용`);
274
+ for (const ip of ips) notes.push(`경고: ${ip} 에 바인드. 같은 네트워크의 기기가 화면을 볼 수 있음. 끝나면 stop 으로 닫음`);
275
+ return { port: p, servers, ips, notes };
276
+ }
277
+ throw new UsageError(`포트 ${port}~${last} 모두 사용 중`);
278
+ }
279
+
280
+ // 신원 경로 요청. 돌려줌: { kind: 'ok', info } | { kind: 'refused' } | { kind: 'timeout' } | { kind: 'other' }
281
+ export function askIdentity(port, timeoutMs = ASK_TIMEOUT_MS) {
282
+ return new Promise((resolve) => {
283
+ let done = false;
284
+ const end = (v) => {
285
+ if (!done) {
286
+ done = true;
287
+ resolve(v);
288
+ }
289
+ };
290
+ const req = http.get({ host: LOOPBACK, port, path: ID_PATH, headers: { host: `${LOOPBACK}:${port}` }, timeout: timeoutMs, agent: false }, (res) => {
291
+ let body = '';
292
+ res.setEncoding('utf8');
293
+ res.on('data', (c) => {
294
+ if (body.length < 4096) body += c;
295
+ });
296
+ res.on('end', () => {
297
+ try {
298
+ const info = JSON.parse(body);
299
+ end(res.statusCode === 200 && info && info.app === APP ? { kind: 'ok', info } : { kind: 'other' });
300
+ } catch {
301
+ end({ kind: 'other' });
302
+ }
303
+ });
304
+ res.on('error', () => end({ kind: 'other' }));
305
+ });
306
+ req.on('timeout', () => {
307
+ req.destroy();
308
+ end({ kind: 'timeout' });
309
+ });
310
+ req.on('error', (e) => end(e.code === 'ECONNREFUSED' ? { kind: 'refused' } : { kind: 'other' }));
311
+ });
312
+ }
313
+
314
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
315
+
316
+ // 상태 파일의 서버가 지금 그 포트에서 그 pid 로 답하는지.
317
+ // 신원 응답이 시간을 넘겨도 상태 파일의 pid 가 살아 있으면 렌더가 느린(큰 저장소의 첫 git log 등) 서버로 보고
318
+ // BUSY_WAIT_MS 까지 한 번 더 기다린다. 렌더는 동기라 그동안 신원 요청이 줄을 서기 때문(살아 있는 서버를 없다고 보지 않게).
319
+ async function runningServer(dataDir) {
320
+ const st = readState(dataDir);
321
+ if (!st || st.broken || !validPid(st.pid) || !validPort(st.port)) return null;
322
+ let r = await askIdentity(st.port);
323
+ if (r.kind === 'timeout' && alive(st.pid)) r = await askIdentity(st.port, BUSY_WAIT_MS);
324
+ return r.kind === 'ok' && r.info.pid === st.pid ? { st, info: r.info } : null;
325
+ }
326
+
327
+ function hostList(config, host) {
328
+ const out = [];
329
+ for (const ip of config.hosts || []) if (!out.some((h) => h.ip === ip)) out.push({ ip, from: 'config' });
330
+ if (host && !out.some((h) => h.ip === host)) out.push({ ip: host, from: 'arg' });
331
+ else if (host) out.find((h) => h.ip === host).from = 'arg';
332
+ return out;
333
+ }
334
+
335
+ // 서버를 다시 띄워야 적용되는 config 값(나머지는 요청마다 다시 읽음). 상태 파일에 남겨 status 가 바뀜을 알린다.
336
+ const restartValues = (config) => ({ port: config.port, portStrict: config.portStrict, hosts: [...(config.hosts || [])].sort() });
337
+
338
+ // 상태 파일의 다른 서버가 살아 있으면 이 서버는 상태 파일을 덮지 않고 닫는다(끌 수 없는 서버가 남지 않게).
339
+ async function refuseSecond(dataDir) {
340
+ const run = await runningServer(dataDir);
341
+ if (run && run.st.pid !== process.pid) throw new UsageError(`이미 실행 중인 서버가 있음: ${urlOf(LOOPBACK, run.st.port)} (PID ${run.st.pid}). 이 서버는 닫음`);
342
+ }
343
+
344
+ // 앞에서 도는 서버. 돌려줌: 닫기 함수가 든 객체. 실패는 UsageError.
345
+ // opts: { port, portStrict, host }, io: { out(line), err(line) }
346
+ export async function serve(ctx, config, opts, io, { render } = {}) {
347
+ if (opts.host !== undefined && !privateIPv4(opts.host)) throw new UsageError(hostRefusal(opts.host));
348
+ const stats = { startedAt: iso(Date.now()), requests: 0, lastRequestAt: null, lastErrorAt: null, logWarning: null };
349
+ const log = makeLogger(ctx.dataDir, LOG_MAX, (w) => (stats.logWarning = w));
350
+ const fail = (e) => {
351
+ log(`오류: ${e.message}`);
352
+ throw e;
353
+ };
354
+ const fixedNow = process.env.PROGRESS_MON_NOW ? Date.parse(process.env.PROGRESS_MON_NOW) : NaN;
355
+ if (!render) {
356
+ const { liveRender } = await import('./pm-render.mjs');
357
+ render = liveRender(ctx, config);
358
+ }
359
+ const srv = {
360
+ port: 0,
361
+ ips: [],
362
+ log,
363
+ stats,
364
+ render,
365
+ now: () => (Number.isFinite(fixedNow) ? fixedNow : Date.now()),
366
+ };
367
+ const handler = createHandler(srv);
368
+ const port = opts.port ?? config.port;
369
+ const strict = Boolean(opts.portStrict || config.portStrict);
370
+ let bound;
371
+ try {
372
+ await refuseSecond(ctx.dataDir); // 바인드 전에도 본다(다른 서버가 있으면 옆 포트 알림 없이 바로 닫음)
373
+ bound = await bindServers({
374
+ port,
375
+ strict,
376
+ hosts: hostList(config, opts.host),
377
+ listen: listenHttp(handler),
378
+ probe: async (p) => {
379
+ srv.port = p;
380
+ const r = await askIdentity(p);
381
+ return r.kind === 'ok' && r.info.pid === process.pid;
382
+ },
383
+ });
384
+ } catch (e) {
385
+ return fail(e);
386
+ }
387
+ srv.port = bound.port;
388
+ srv.ips = bound.ips;
389
+ const hosts = [LOOPBACK, ...bound.ips];
390
+ for (const n of bound.notes) {
391
+ log(n);
392
+ io.out(n);
393
+ }
394
+ log(`시작 port=${bound.port} 주소=${hosts.join(',')} PID ${process.pid}`);
395
+ for (const s of bound.servers)
396
+ s.on('error', (e) => {
397
+ log(`오류: 리스너 오류(${e.code || e.message})`);
398
+ process.exit(1);
399
+ });
400
+ try {
401
+ await refuseSecond(ctx.dataDir);
402
+ await writeState(ctx.dataDir, { port: bound.port, pid: process.pid, hosts, project: ctx.project, startedAt: stats.startedAt, applied: restartValues(config) });
403
+ } catch (e) {
404
+ await closeAll(bound.servers);
405
+ return fail(e);
406
+ }
407
+ io.out(`시작함: ${urlOf(LOOPBACK, bound.port)}`);
408
+ for (const ip of bound.ips) io.out(`추가 주소: ${urlOf(ip, bound.port)}`);
409
+ const close = async () => {
410
+ await closeAll(bound.servers);
411
+ const st = readState(ctx.dataDir);
412
+ if (st && st.pid === process.pid) dropState(ctx.dataDir);
413
+ };
414
+ return { port: bound.port, ips: bound.ips, close };
415
+ }
416
+
417
+ // server.log 의 off 바이트 뒤 줄 가운데 머리(오류:, 알림:, 경고:)로 시작하는 것. 시각 칸은 뺀다.
418
+ // 링크이거나 일반 파일이 아니면 읽지 않는다.
419
+ export function logLinesSince(dataDir, off, heads) {
420
+ let text = '';
421
+ try {
422
+ if (oddLog(logPath(dataDir))) return [];
423
+ const buf = readRegular(logPath(dataDir), null);
424
+ text = buf.subarray(buf.length < off ? 0 : off).toString('utf8');
425
+ } catch {
426
+ return [];
427
+ }
428
+ return text
429
+ .split('\n')
430
+ .map((l) => l.replace(/^\S+ /, ''))
431
+ .filter((l) => heads.some((h) => l.startsWith(h)));
432
+ }
433
+
434
+ // 기존 config.json 의 hosts 에 주소 추가. 서버는 이미 떠 있으므로 읽기, 쓰기 실패는 경고만. 깨진 파일은 고치지 않는다.
435
+ export async function saveHost(ctx, ip, io, fsx = fs) {
436
+ const file = path.join(ctx.dataDir, 'config.json');
437
+ let raw = {};
438
+ try {
439
+ raw = JSON.parse(readRegular(file));
440
+ } catch (e) {
441
+ if (e.code !== 'ENOENT') {
442
+ io.err(`경고: config.json 을 읽지 못해 --host ${ip} 를 저장하지 않음`);
443
+ return;
444
+ }
445
+ }
446
+ const hosts = Array.isArray(raw.hosts) ? raw.hosts : [];
447
+ if (hosts.includes(ip)) return;
448
+ raw.hosts = [...hosts, ip];
449
+ try {
450
+ await writeAtomic(file, JSON.stringify(raw, null, 2) + '\n', fsx);
451
+ } catch (e) {
452
+ io.err(`경고: --host ${ip} 를 config.json 에 저장하지 못함(${e.message}). 서버는 계속`);
453
+ }
454
+ }
455
+
456
+ // ---------- start 잠금 ----------
457
+ // 동시에 부른 start 가 서버를 둘 띄우지 않게 start 전체를 server.json.lock 으로 감싼다(wx: 둘 가운데 하나만 만듦,
458
+ // 그 자리의 링크는 따라가지 않고 EEXIST). 못 잡은 쪽은 기다렸다가 잡고, 그 안에서 떠 있는 서버를 다시 보아 재사용한다.
459
+ // 낡은 잠금(쥔 프로세스가 죽음, 또는 START_LOCK_STALE_MS 넘게 남음, 링크나 일반 파일 아님)은 넘겨받기 표지
460
+ // server.json.lock.taking 을 wx 로 잡은 쪽만, 그 안에서 다시 판정한 뒤 지우고 새로 만든다.
461
+ const START_LOCK = 'server.json.lock';
462
+ const START_LOCK_STALE_MS = 30000;
463
+ const START_LOCK_WAIT_MS = 15000;
464
+
465
+ // 잠금이 있는 것처럼 기다릴 wx 실패. Windows 는 다른 프로세스가 지우는 중인 파일(delete pending) 자리에 EEXIST 대신
466
+ // EPERM(또는 EACCES, EBUSY)을 낸다. 잠금을 돌려주는 순간과 겹친 start 가 실패하지 않게 한다(items.json 잠금과 같은 규칙).
467
+ const LOCK_BUSY = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
468
+
469
+ // 돌려줌: 만들었으면 null, 아니면 그 까닭 코드. 그 밖 오류는 UsageError.
470
+ function makeLockFile(file) {
471
+ try {
472
+ fs.writeFileSync(file, `${process.pid} ${iso(Date.now())}\n`, { flag: 'wx', mode: 0o600 });
473
+ return null;
474
+ } catch (e) {
475
+ if (LOCK_BUSY.has(e.code)) return e.code;
476
+ throw new UsageError(`잠금 ${path.basename(file)} 을 만들지 못함(${e.code || e.message})`);
477
+ }
478
+ }
479
+
480
+ const lockPid = (file) => {
481
+ try {
482
+ const pid = Number(readRegular(file).split(' ')[0]);
483
+ return validPid(pid) ? pid : null;
484
+ } catch {
485
+ return null;
486
+ }
487
+ };
488
+
489
+ const alive = (pid) => {
490
+ try {
491
+ process.kill(pid, 0);
492
+ return true;
493
+ } catch (e) {
494
+ return e.code === 'EPERM';
495
+ }
496
+ };
497
+
498
+ // 지금 낡았는지. 없으면 거짓(다시 만들면 됨). pid 를 아직 못 읽는 잠금(막 만들어 쓰는 중)은 시간으로만 본다.
499
+ function staleLock(file, now) {
500
+ let st;
501
+ try {
502
+ st = fs.lstatSync(file);
503
+ } catch {
504
+ return false;
505
+ }
506
+ if (!st.isFile() || now - st.mtimeMs > START_LOCK_STALE_MS) return true;
507
+ const pid = lockPid(file);
508
+ return pid !== null && !alive(pid);
509
+ }
510
+
511
+ function takeOverLock(file, now) {
512
+ const taking = `${file}.taking`;
513
+ if (makeLockFile(taking) !== null) {
514
+ // 넘겨받던 start 가 죽어 남은 표지는 START_LOCK_STALE_MS 뒤 치운다(이번은 기다리고 다음에 넘겨받음)
515
+ try {
516
+ if (now - fs.lstatSync(taking).mtimeMs > START_LOCK_STALE_MS) fs.rmSync(taking, { force: true });
517
+ } catch {
518
+ // 그 사이 지워짐
519
+ }
520
+ return false;
521
+ }
522
+ try {
523
+ if (!staleLock(file, now)) return false;
524
+ fs.rmSync(file, { force: true });
525
+ return makeLockFile(file) === null;
526
+ } finally {
527
+ fs.rmSync(taking, { force: true });
528
+ }
529
+ }
530
+
531
+ // start 잠금을 잡을 때까지 waitMs 기다린다. 돌려줌: 잠금 경로. 시간 초과는 UsageError: 잠금 파일이 한 번이라도 보였으면
532
+ // 다른 start, 끝내 없었으면(EPERM 등이 계속, 권한 등) 만들지 못함.
533
+ export async function takeStartLock(dataDir, waitMs = START_LOCK_WAIT_MS) {
534
+ const file = path.join(dataDir, START_LOCK);
535
+ const until = Date.now() + waitMs;
536
+ let seen = false;
537
+ for (;;) {
538
+ const code = makeLockFile(file);
539
+ if (code === null || (staleLock(file, Date.now()) && takeOverLock(file, Date.now()))) return file;
540
+ seen = seen || code === 'EEXIST' || fs.existsSync(file);
541
+ if (Date.now() >= until) {
542
+ if (!seen) throw new UsageError(`잠금 ${START_LOCK} 을 만들지 못함(${code})`);
543
+ throw new UsageError(`다른 start 가 끝나지 않음(잠금 ${START_LOCK}, pid ${lockPid(file) ?? '알 수 없음'}). 30초 뒤 다시 실행. 그 start 가 끝났는데도 남아 있으면 잠금 파일을 지운 뒤 다시 실행`);
544
+ }
545
+ await sleep(100);
546
+ }
547
+ }
548
+
549
+ // 잠금은 이 프로세스의 것일 때만 돌려준다.
550
+ export function giveBackLock(file) {
551
+ if (lockPid(file) === process.pid) fs.rmSync(file, { force: true });
552
+ }
553
+
554
+ // serve 를 분리 실행하고 자기 탐침으로 최대 5초 기다린다. 동시에 부른 start 는 잠금 순서대로, 뒤쪽은 앞쪽 서버를 재사용한다.
555
+ // 돌려줌: 종료 코드.
556
+ export async function start(ctx, config, opts, io, deps) {
557
+ if (opts.host !== undefined && !privateIPv4(opts.host)) throw new UsageError(hostRefusal(opts.host));
558
+ const lock = await takeStartLock(ctx.dataDir);
559
+ try {
560
+ return await startLocked(ctx, config, opts, io, deps);
561
+ } finally {
562
+ giveBackLock(lock);
563
+ }
564
+ }
565
+
566
+ async function startLocked(ctx, config, opts, io, { cliPath }) {
567
+ const port = opts.port ?? config.port;
568
+ const strict = Boolean(opts.portStrict || config.portStrict);
569
+ const run = await runningServer(ctx.dataDir);
570
+ if (run) {
571
+ const { st } = run;
572
+ if (strict && st.port !== port) throw new UsageError(`포트 ${port} 가 아닌 ${st.port} 에서 실행 중(--port-strict). stop 먼저`);
573
+ if (opts.host !== undefined && !(st.hosts || []).includes(opts.host)) throw new UsageError(`실행 중 서버의 주소(${(st.hosts || []).join(', ')})에 ${opts.host} 가 없음. stop 먼저`);
574
+ io.out(`이미 실행 중(재사용): ${urlOf(LOOPBACK, st.port)}`);
575
+ return 0;
576
+ }
577
+ let off = 0;
578
+ try {
579
+ off = fs.statSync(logPath(ctx.dataDir)).size;
580
+ } catch {
581
+ off = 0;
582
+ }
583
+ const args = [cliPath, 'serve', '--port', String(port), '--project', ctx.project, '--data', ctx.dataDir, '--runtime', ctx.runtime];
584
+ if (strict) args.push('--port-strict');
585
+ if (opts.host !== undefined) args.push('--host', opts.host);
586
+ const child = spawn(process.execPath, args, { cwd: ctx.project, detached: true, stdio: 'ignore', windowsHide: true });
587
+ let exited = false;
588
+ child.on('exit', () => (exited = true));
589
+ child.on('error', () => (exited = true));
590
+ const until = Date.now() + START_WAIT_MS;
591
+ while (Date.now() < until) {
592
+ await sleep(100);
593
+ if (exited) break;
594
+ const st = readState(ctx.dataDir);
595
+ if (!st || st.pid !== child.pid || !validPort(st.port)) continue;
596
+ const r = await askIdentity(st.port);
597
+ if (r.kind !== 'ok' || r.info.pid !== child.pid) continue;
598
+ for (const l of logLinesSince(ctx.dataDir, off, ['알림:', '경고:'])) io.out(l);
599
+ io.out(`시작함: ${urlOf(LOOPBACK, st.port)}`);
600
+ for (const ip of (st.hosts || []).filter((h) => h !== LOOPBACK)) io.out(`추가 주소: ${urlOf(ip, st.port)}`);
601
+ if (opts.host !== undefined) await saveHost(ctx, opts.host, io);
602
+ child.unref();
603
+ return 0;
604
+ }
605
+ if (!exited) {
606
+ try {
607
+ process.kill(child.pid);
608
+ } catch {
609
+ // 이미 끝남
610
+ }
611
+ io.err('오류: 서버가 5초 안에 답하지 않음. server.log 확인');
612
+ return 1;
613
+ }
614
+ const errs = logLinesSince(ctx.dataDir, off, ['오류:']);
615
+ io.err(errs.length ? errs[errs.length - 1] : '오류: 서버 시작 실패. server.log 확인');
616
+ return 1;
617
+ }
618
+
619
+ function portOpen(port) {
620
+ return askIdentity(port, 500).then((r) => r.kind !== 'refused');
621
+ }
622
+
623
+ // 정지 규칙(계획 9.2절). 돌려줌: 종료 코드.
624
+ export async function stop(ctx, io) {
625
+ if (!ctx.exists) {
626
+ io.out(`이미 꺼짐(자료 폴더 없음: ${dataLabel(ctx)})`);
627
+ return 0;
628
+ }
629
+ const st = readState(ctx.dataDir);
630
+ if (!st) {
631
+ io.out('이미 꺼짐(상태 파일 없음)');
632
+ return 0;
633
+ }
634
+ if (st.broken || !validPid(st.pid) || !validPort(st.port)) {
635
+ io.err('오류: 상태 파일 server.json 이 올바르지 않음. 그대로 둠');
636
+ return 1;
637
+ }
638
+ let r = await askIdentity(st.port);
639
+ // runningServer 와 같은 확인: 렌더가 느린 서버는 pid 가 살아 있으면 BUSY_WAIT_MS 까지 기다린다(PID 대조는 아래 그대로)
640
+ if (r.kind === 'timeout' && alive(st.pid)) r = await askIdentity(st.port, BUSY_WAIT_MS);
641
+ if (r.kind === 'refused') {
642
+ dropState(ctx.dataDir);
643
+ io.out(`이미 꺼짐(포트 ${st.port} 연결 거부). 상태 파일 삭제`);
644
+ return 0;
645
+ }
646
+ if (r.kind === 'timeout') {
647
+ io.err(`오류: 포트 ${st.port} 응답 시간 초과(${alive(st.pid) ? '11.5' : '1.5'}초). 상태 파일 그대로 둠`);
648
+ return 1;
649
+ }
650
+ if (r.kind === 'other') {
651
+ io.err(`오류: 포트 ${st.port} 에 다른 프로그램이 답함. 상태 파일 그대로 둠`);
652
+ return 1;
653
+ }
654
+ if (r.info.pid !== st.pid) {
655
+ io.err(`오류: 포트 ${st.port} 의 서버 PID ${r.info.pid} 가 상태 파일 PID ${st.pid} 와 다름. 상태 파일 그대로 둠`);
656
+ return 1;
657
+ }
658
+ for (const sig of ['SIGTERM', 'SIGKILL']) {
659
+ try {
660
+ process.kill(st.pid, sig);
661
+ } catch {
662
+ // 이미 끝남
663
+ }
664
+ const until = Date.now() + STOP_WAIT_MS;
665
+ while (Date.now() < until && (await portOpen(st.port))) await sleep(100);
666
+ if (!(await portOpen(st.port))) {
667
+ dropState(ctx.dataDir);
668
+ io.out(`정지함: ${urlOf(LOOPBACK, st.port)} (PID ${st.pid}). 상태 파일 삭제`);
669
+ return 0;
670
+ }
671
+ }
672
+ io.err(`오류: 정지 시간 초과. 포트 ${st.port} 가 아직 열림. 상태 파일 그대로 둠`);
673
+ return 1;
674
+ }
675
+
676
+ // 탭 자료 유무 줄. 읽지 못하거나 JSON 이 아닌 파일은 깨짐(check 로 확인).
677
+ function tabData(dataDir) {
678
+ const has = (f) => {
679
+ try {
680
+ JSON.parse(readRegular(path.join(dataDir, f)));
681
+ return '있음';
682
+ } catch (e) {
683
+ return e.code === 'ENOENT' ? '없음' : '깨짐';
684
+ }
685
+ };
686
+ return `진척도 ${has('items.json')}, 구현요건 ${has('req-spec.json')}, workflow ${has('flow.json')}, 구성도 ${has('arch.json')}`;
687
+ }
688
+
689
+ // 서버를 띄운 뒤 config.json 의 port, portStrict, hosts 가 바뀌었으면 경고 한 줄(그 값은 다시 띄워야 적용).
690
+ function restartWarning(ctx, st, io) {
691
+ const { config, errors } = loadConfig(ctx);
692
+ if (errors.length || !st.applied) return;
693
+ const now = restartValues(config);
694
+ const changed = Object.keys(now).filter((k) => JSON.stringify(now[k]) !== JSON.stringify(st.applied[k]));
695
+ if (changed.length) io.err(`경고: 서버를 띄운 뒤 config.json 의 ${changed.join(', ')} 가 바뀜. 적용하려면 stop 뒤 start`);
696
+ }
697
+
698
+ // 다시 잰 규칙 로드 한도와 override 가려짐 경고(state.json 의 memory), workflowDirs auto 0개. stderr 로.
699
+ function statusWarnings(ctx, io) {
700
+ let memory = null;
701
+ try {
702
+ memory = JSON.parse(readRegular(path.join(ctx.dataDir, 'state.json'))).memory;
703
+ } catch {
704
+ memory = null;
705
+ }
706
+ for (const l of memoryWarnings(memory, ctx.project)) io.err(l);
707
+ const { config } = loadConfig(ctx);
708
+ if (config.sources.workflowDirs === 'auto' && !workflowRoots(config, ctx).length) io.err('WARN workflowDirs auto: 찾은 폴더 0');
709
+ }
710
+
711
+ // 실행 여부. 돌려줌: 0 실행 중, 3 꺼짐.
712
+ export async function status(ctx, io) {
713
+ const label = dataLabel(ctx);
714
+ if (!ctx.exists) {
715
+ io.out(`꺼짐(자료 폴더 없음: ${label})`);
716
+ return 3;
717
+ }
718
+ const run = await runningServer(ctx.dataDir);
719
+ if (!run) {
720
+ io.out(`꺼짐(자료 폴더: ${label})`);
721
+ statusWarnings(ctx, io);
722
+ return 3;
723
+ }
724
+ const { st, info } = run;
725
+ io.out(`실행 중: ${urlOf(LOOPBACK, st.port)} (PID ${st.pid})`);
726
+ io.out(`주소: ${(st.hosts || [LOOPBACK]).join(', ')}`);
727
+ io.out(`자료 폴더: ${label}`);
728
+ io.out(`탭 자료: ${tabData(ctx.dataDir)}`);
729
+ io.out(`시작: ${info.startedAt || st.startedAt || '알 수 없음'}`);
730
+ io.out(`요청: ${info.requests ?? 0}, 마지막 요청: ${info.lastRequestAt || '없음'}, 마지막 오류: ${info.lastErrorAt || '없음'}`);
731
+ if (typeof info.logWarning === 'string') io.err(`경고: ${info.logWarning.replace(/[\u0000-\u001f\u007f]/g, '')}`);
732
+ restartWarning(ctx, st, io);
733
+ statusWarnings(ctx, io);
734
+ return 0;
735
+ }