@kaydash9999/banker-plugins 0.23.2 → 0.24.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 (78) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +56 -0
  4. package/README.md +14 -6
  5. package/codex/manifest.json +6 -2
  6. package/codex/skills/teamwork/SKILL.md +19 -6
  7. package/codex/skills/teamwork/scripts/teamwork.mjs +87 -22
  8. package/codex/transform-matrix.md +6 -2
  9. package/package.json +1 -1
  10. package/skills/build-testbox/SKILL.md +213 -0
  11. package/skills/build-testbox/scripts/args.mjs +156 -0
  12. package/skills/build-testbox/scripts/browser-cell.mjs +181 -0
  13. package/skills/build-testbox/scripts/browsers.mjs +164 -0
  14. package/skills/build-testbox/scripts/containers.mjs +237 -0
  15. package/skills/build-testbox/scripts/detect.mjs +166 -0
  16. package/skills/build-testbox/scripts/proc.mjs +91 -0
  17. package/skills/build-testbox/scripts/report.mjs +109 -0
  18. package/skills/build-testbox/scripts/setup.mjs +144 -0
  19. package/skills/build-testbox/scripts/testbox.mjs +148 -0
  20. package/skills/git-init/SKILL.md +152 -0
  21. package/skills/git-init/scripts/git-init.mjs +440 -0
  22. package/skills/git-init/scripts/marker-block.mjs +311 -0
  23. package/skills/git-init/scripts/payload-scan.mjs +186 -0
  24. package/skills/init-dir/SKILL.md +172 -0
  25. package/skills/init-dir/scripts/init-dir.mjs +308 -0
  26. package/skills/init-dir/scripts/marker-block.mjs +311 -0
  27. package/skills/init-dir/scripts/templates.mjs +100 -0
  28. package/skills/init-dir/templates/KB/README.md +15 -0
  29. package/skills/init-dir/templates/docs/README.md +14 -0
  30. package/skills/init-dir/templates/logs/README.md +14 -0
  31. package/skills/init-dir/templates/logs/gitignore +3 -0
  32. package/skills/init-dir/templates/mockup/README.md +7 -0
  33. package/skills/init-dir/templates/scripts/README.md +36 -0
  34. package/skills/init-dir/templates/scripts/lib/service.mjs +540 -0
  35. package/skills/init-dir/templates/scripts/restart.cmd +13 -0
  36. package/skills/init-dir/templates/scripts/restart.sh +10 -0
  37. package/skills/init-dir/templates/scripts/service.conf +5 -0
  38. package/skills/init-dir/templates/scripts/start.cmd +13 -0
  39. package/skills/init-dir/templates/scripts/start.sh +10 -0
  40. package/skills/init-dir/templates/scripts/status.cmd +13 -0
  41. package/skills/init-dir/templates/scripts/status.sh +10 -0
  42. package/skills/init-dir/templates/scripts/stop.cmd +13 -0
  43. package/skills/init-dir/templates/scripts/stop.sh +10 -0
  44. package/skills/init-dir/templates/tests/README.md +15 -0
  45. package/skills/init-dir/templates/tests/run.cmd +13 -0
  46. package/skills/init-dir/templates/tests/run.mjs +160 -0
  47. package/skills/init-dir/templates/tests/run.sh +10 -0
  48. package/skills/init-dir/templates/tests/tests.conf +7 -0
  49. package/skills/init-dir/templates/work/README.md +8 -0
  50. package/skills/progress-mon/README.md +11 -9
  51. package/skills/progress-mon/SKILL.md +53 -16
  52. package/skills/progress-mon/assets/fixture/README.md +1 -1
  53. package/skills/progress-mon/assets/fixture/adr.json +35 -0
  54. package/skills/progress-mon/assets/fixture/arch.json +1 -1
  55. package/skills/progress-mon/assets/fixture/flow.json +2 -2
  56. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +1 -1
  57. package/skills/progress-mon/assets/fixture/variants/legacy-init/rules.json +6 -0
  58. package/skills/progress-mon/assets/fixture/variants/legacy-init/state.json +5 -0
  59. package/skills/progress-mon/references/adr.md +177 -0
  60. package/skills/progress-mon/references/authoring.md +13 -5
  61. package/skills/progress-mon/scripts/pm-adr.mjs +585 -0
  62. package/skills/progress-mon/scripts/pm-check.mjs +48 -20
  63. package/skills/progress-mon/scripts/pm-data.mjs +1 -1
  64. package/skills/progress-mon/scripts/pm-html.mjs +1 -0
  65. package/skills/progress-mon/scripts/pm-memory.mjs +124 -29
  66. package/skills/progress-mon/scripts/pm-render.mjs +6 -4
  67. package/skills/progress-mon/scripts/pm-server.mjs +82 -30
  68. package/skills/progress-mon/scripts/pm-sources.mjs +1 -0
  69. package/skills/progress-mon/scripts/progress-mon.mjs +3 -2
  70. package/skills/tune-tmux/SKILL.md +235 -0
  71. package/skills/tune-tmux/scripts/app-notes.mjs +38 -0
  72. package/skills/tune-tmux/scripts/marker-block.mjs +311 -0
  73. package/skills/tune-tmux/scripts/psmux-caps.json +71 -0
  74. package/skills/tune-tmux/scripts/tmux-config.mjs +251 -0
  75. package/skills/tune-tmux/scripts/tune-tmux.mjs +284 -0
  76. package/skills/ultra-chain/SKILL.md +52 -22
  77. package/skills/ultra-chain/references/chain-ledger.mjs +93 -11
  78. package/skills/ultra-chain/references/chain-report.mjs +13 -2
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * service.mjs: starts, stops and reports one background service of this project (made by banker init-dir).
4
+ *
5
+ * ./scripts/start.sh | stop.sh | status.sh | restart.sh (Windows: the .cmd files of the same names)
6
+ * node scripts/lib/service.mjs start|stop|status|restart
7
+ *
8
+ * Settings: scripts/service.conf, `name=` (default app) and `command=` (run through the shell, in the project
9
+ * folder). `start` launches a detached supervisor that runs the command as its child and writes the child's
10
+ * stdout to logs/<name>.out.log and stderr to logs/<name>.err.log. Before a write that would take a file past
11
+ * 100MB it rotates: <name>.out.log becomes <name>.out.1.log and so on, at most 7 files per stream with the
12
+ * current one. If a rotation fails (a reader holds a file open on Windows) a `service:` warning goes to the
13
+ * stderr log and writing goes on in the current file. logs/<name>.pid holds {supervisor, child, startedAt, command}.
14
+ *
15
+ * Before a recorded PID counts as this service (already running, status, a signal from stop) it is checked: a pid
16
+ * file older than the last boot is stale, and the process's command line must hold service.mjs and __supervise
17
+ * (the supervisor) or the recorded command (the child). Linux reads it from /proc/<pid>/cmdline, then `ps`; other
18
+ * POSIX systems use `ps`, Windows PowerShell Get-CimInstance. A PID that fails the check gets no signal and the pid
19
+ * file counts as stale. When a live PID's command line cannot be read at all (no /proc, no ps), start, stop, status
20
+ * and restart print "확인 불가: <사유>", send no signal, keep the pid file and exit 1.
21
+ *
22
+ * `stop` sends SIGTERM to the supervisor, which passes it to the child's process group; after 10 seconds both
23
+ * are killed. With the supervisor gone, the child is stopped directly. Windows uses taskkill /T and, when the
24
+ * grace time has passed, taskkill /T /F (stop and the supervisor alike).
25
+ * If the supervisor dies the child may die of SIGPIPE at its next write; `status` reports both separately.
26
+ * On Windows the child is the shell, which dies with the supervisor (its job object); what the shell ran lives on
27
+ * and counts as the child when its parent is the recorded shell PID and its command line holds the command.
28
+ *
29
+ * Exit status: 0 done (status: supervisor and child running), 1 failed (died within the start check, could not
30
+ * stop, status found it not fully running), 2 settings (command= empty, bad name, usage), 3 environment (the
31
+ * wrappers did not find Node 18 or later).
32
+ *
33
+ * Two `start` runs at once launch one supervisor: start holds logs/<name>.start.lock (made with O_EXCL, holding its
34
+ * pid and time, removed when it ends). A later start waits for it, then sees the service running. A lock whose pid
35
+ * is dead, is another program, or is older than 30 seconds plus the start check is taken over. A link or a folder
36
+ * at the lock's name is refused (exit 1).
37
+ *
38
+ * Environment (tuning): SERVICE_LOG_ROTATE_BYTES (default 104857600), SERVICE_STOP_GRACE_MS (10000),
39
+ * SERVICE_START_CHECK_MS (2000).
40
+ */
41
+ import { spawn, spawnSync } from "node:child_process";
42
+ import { closeSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
43
+ import { uptime } from "node:os";
44
+ import { dirname, join } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+
47
+ const SELF = fileURLToPath(import.meta.url);
48
+ const SCRIPTS = dirname(dirname(SELF));
49
+ const ROOT = dirname(SCRIPTS);
50
+ const LOGS = join(ROOT, "logs");
51
+ const KEEP = 7;
52
+ const WIN = process.platform === "win32";
53
+ const USAGE = "사용법: service.mjs start|stop|status|restart";
54
+
55
+ const envInt = (name, fallback) => {
56
+ const n = Number.parseInt(process.env[name] ?? "", 10);
57
+ return Number.isFinite(n) && n > 0 ? n : fallback;
58
+ };
59
+ const ROTATE_BYTES = envInt("SERVICE_LOG_ROTATE_BYTES", 100 * 1024 * 1024);
60
+ const STOP_GRACE_MS = envInt("SERVICE_STOP_GRACE_MS", 10000);
61
+ const START_CHECK_MS = envInt("SERVICE_START_CHECK_MS", 2000);
62
+ const BOOT_SLACK_MS = 5000;
63
+ const LOCK_STALE_MS = 30000 + START_CHECK_MS;
64
+ const LOCK_WAIT_MS = 5000 + START_CHECK_MS;
65
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
66
+
67
+ function parseConf(text) {
68
+ const conf = { name: "app", command: "" };
69
+ for (const raw of text.split(/\r?\n/)) {
70
+ const line = raw.trim();
71
+ const eq = line.indexOf("=");
72
+ if (line.startsWith("#") || eq <= 0) continue;
73
+ conf[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
74
+ }
75
+ return conf;
76
+ }
77
+
78
+ // The settings, or { error } with exit status 2. needCommand: start, restart and the supervisor need command=.
79
+ function readConf(needCommand) {
80
+ const file = join(SCRIPTS, "service.conf");
81
+ if (!existsSync(file)) return { error: `설정 파일 없음: ${file}` };
82
+ const conf = parseConf(readFileSync(file, "utf8"));
83
+ if (!/^[A-Za-z0-9._-]+$/.test(conf.name)) return { error: `service.conf 의 name 은 영문, 숫자, . _ - 만 씀: ${conf.name}` };
84
+ if (needCommand && conf.command === "") return { error: `service.conf 의 command= 가 비어 있음: ${file}` };
85
+ return conf;
86
+ }
87
+
88
+ const pathsOf = (name) => ({
89
+ pid: join(LOGS, `${name}.pid`),
90
+ out: join(LOGS, `${name}.out.log`),
91
+ err: join(LOGS, `${name}.err.log`),
92
+ });
93
+ const nth = (file, i) => (i === 0 ? file : file.replace(/\.log$/, `.${i}.log`));
94
+
95
+ function readPid(file) {
96
+ try {
97
+ const v = JSON.parse(readFileSync(file, "utf8"));
98
+ return v && typeof v === "object" ? v : null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function writePid(file, value) {
105
+ const tmp = `${file}.${process.pid}.tmp`;
106
+ writeFileSync(tmp, `${JSON.stringify(value)}\n`);
107
+ renameSync(tmp, file);
108
+ }
109
+
110
+ // Removes the pid file when it still names this supervisor.
111
+ function dropPid(file, supervisor) {
112
+ if (readPid(file)?.supervisor === supervisor) rmSync(file, { force: true });
113
+ }
114
+
115
+ function alive(pid) {
116
+ if (!Number.isInteger(pid) || pid <= 0) return false;
117
+ try {
118
+ process.kill(pid, 0);
119
+ return true;
120
+ } catch (e) {
121
+ return e.code === "EPERM";
122
+ }
123
+ }
124
+
125
+ // The child runs in its own process group on POSIX: the group is alive while any member is.
126
+ function groupAlive(pid) {
127
+ if (WIN || !Number.isInteger(pid) || pid <= 0) return alive(pid);
128
+ try {
129
+ process.kill(-pid, 0);
130
+ return true;
131
+ } catch (e) {
132
+ return e.code === "EPERM" || alive(pid);
133
+ }
134
+ }
135
+
136
+ function taskkill(pid, force) {
137
+ spawnSync("taskkill", ["/T", ...(force ? ["/F"] : []), "/PID", String(pid)], { stdio: "ignore", windowsHide: true });
138
+ }
139
+
140
+ // The child's whole tree: its process group on POSIX, taskkill /T on Windows (/F for SIGKILL).
141
+ function signalGroup(pid, signal) {
142
+ if (!Number.isInteger(pid) || pid <= 0) return;
143
+ if (WIN) return taskkill(pid, signal === "SIGKILL");
144
+ for (const target of [-pid, pid]) {
145
+ try {
146
+ process.kill(target, signal);
147
+ return;
148
+ } catch {
149
+ // gone already, or no group: try the next form
150
+ }
151
+ }
152
+ }
153
+
154
+ // Moves file.5 to file.6 and so on down to file to file.1; the oldest (file.6) goes.
155
+ function shift(file) {
156
+ rmSync(nth(file, KEEP - 1), { force: true });
157
+ for (let i = KEEP - 2; i >= 0; i--) if (existsSync(nth(file, i))) renameSync(nth(file, i), nth(file, i + 1));
158
+ }
159
+
160
+ // A log file that rotates before a write would take it past ROTATE_BYTES. A failed rotation calls warn once until
161
+ // a rotation works again.
162
+ function openLog(file, warn) {
163
+ let fd = openSync(file, "a");
164
+ let size = statSync(file).size;
165
+ let warned = false;
166
+ const rotate = () => {
167
+ closeSync(fd);
168
+ let failure = null;
169
+ try {
170
+ shift(file);
171
+ } catch (e) {
172
+ // a reader holds a file open (Windows): keep writing to the current one and try again at the next write
173
+ failure = e;
174
+ } finally {
175
+ fd = openSync(file, "a");
176
+ size = statSync(file).size;
177
+ }
178
+ if (!failure) warned = false;
179
+ else if (!warned) {
180
+ warned = true;
181
+ warn(`로그 로테이션 실패(${file}: ${failure.code || failure.message}). 현재 파일에 이어 씀`);
182
+ }
183
+ };
184
+ const write = (chunk) => {
185
+ for (let at = 0; at < chunk.length; ) {
186
+ const piece = Math.min(chunk.length - at, ROTATE_BYTES);
187
+ if (size > 0 && size + piece > ROTATE_BYTES) rotate();
188
+ writeSync(fd, chunk, at, piece);
189
+ size += piece;
190
+ at += piece;
191
+ }
192
+ };
193
+ return { write, close: () => closeSync(fd) };
194
+ }
195
+
196
+ function supervise(conf) {
197
+ const p = pathsOf(conf.name);
198
+ let err = null;
199
+ // The supervisor has no terminal (stdio ignored), so its warnings go to the stderr log as `service:` lines.
200
+ const warn = (text) => err?.write(Buffer.from(`service: ${text}\n`));
201
+ const out = openLog(p.out, warn);
202
+ err = openLog(p.err, warn);
203
+ // Not detached on Windows: a detached shell has no console, so what it runs gets a new console of its own (its
204
+ // output goes there, not into the logs). See orphanOf for what this costs when the supervisor dies.
205
+ const child = spawn(conf.command, { shell: true, cwd: ROOT, detached: !WIN, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
206
+ child.stdout.on("data", (c) => out.write(c));
207
+ child.stderr.on("data", (c) => err.write(c));
208
+ child.on("error", (e) => warn(e.message));
209
+ writePid(p.pid, { supervisor: process.pid, child: child.pid ?? null, startedAt: new Date().toISOString(), command: conf.command });
210
+ let killTimer = null;
211
+ const term = () => {
212
+ signalGroup(child.pid, "SIGTERM");
213
+ killTimer ??= setTimeout(() => signalGroup(child.pid, "SIGKILL"), STOP_GRACE_MS);
214
+ };
215
+ process.on("SIGTERM", term);
216
+ process.on("SIGINT", term);
217
+ if (!WIN) process.on("SIGHUP", () => {});
218
+ child.on("close", (code) => {
219
+ clearTimeout(killTimer);
220
+ out.close();
221
+ err.close();
222
+ dropPid(p.pid, process.pid);
223
+ process.exit(code ?? 1);
224
+ });
225
+ }
226
+
227
+ function tail(file, lines) {
228
+ try {
229
+ return readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean).slice(-lines);
230
+ } catch {
231
+ return [];
232
+ }
233
+ }
234
+
235
+ // Linux: /proc/<pid>/cmdline with its NULs as spaces; { text: "" } when the process is gone, null without /proc.
236
+ function procCommandLine(pid) {
237
+ if (process.platform !== "linux" || !existsSync("/proc/self/cmdline")) return null;
238
+ try {
239
+ return { text: readFileSync(`/proc/${pid}/cmdline`).toString("utf8").replace(/\0/g, " ").trim() };
240
+ } catch (e) {
241
+ return e.code === "ENOENT" ? { text: "" } : null;
242
+ }
243
+ }
244
+
245
+ // The command line of a process: { text } ("" when it is gone) or { why } when it cannot be read.
246
+ function commandLine(pid) {
247
+ return procCommandLine(pid) ?? toolCommandLine(pid);
248
+ }
249
+
250
+ // `ps` on POSIX, PowerShell on Windows.
251
+ function toolCommandLine(pid) {
252
+ const r = WIN
253
+ ? spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: "utf8", windowsHide: true, timeout: 20000 })
254
+ : spawnSync("ps", ["-ww", "-o", "args=", "-p", String(pid)], { encoding: "utf8", timeout: 10000 });
255
+ if (r.error) return { why: r.error.code || r.error.message };
256
+ if (r.status === 0) return { text: r.stdout.trim() };
257
+ if (!WIN && r.status === 1 && !r.stdout.trim() && !r.stderr.trim()) return { text: "" };
258
+ return { why: r.stderr.trim().split(/\r?\n/)[0] || `종료 ${r.status}` };
259
+ }
260
+
261
+ // Quotes, backslashes and runs of spaces differ between a shell command and the argv a process shows.
262
+ const squash = (s) => String(s).replace(/["'\\]/g, "").replace(/\s+/g, " ").trim();
263
+ const isSupervisor = (text) => text.includes("service.mjs") && text.includes("__supervise");
264
+ const isChild = (command) => (text) => typeof command === "string" && squash(command) !== "" && squash(text).includes(squash(command));
265
+
266
+ // { ok: true } this service's, {} another process, { unknown } its command line could not be read.
267
+ function confirm(pid, label, test) {
268
+ const line = commandLine(pid);
269
+ if (line.why) return { unknown: `${label} PID ${pid} 의 명령줄을 읽지 못함(${line.why})` };
270
+ if (test(line.text)) return { ok: true };
271
+ console.error(`${label} PID ${pid} 는 이 서비스가 아님(명령줄이 기록과 다름): 신호를 보내지 않고 낡은 PID 파일로 봄`);
272
+ return {};
273
+ }
274
+
275
+ // Why the pid file is older than the last boot (its PIDs then belong to other processes), or null.
276
+ function staleWhy(cur) {
277
+ const at = Date.parse(cur.startedAt);
278
+ if (Number.isNaN(at)) return "startedAt 없음";
279
+ return at < Date.now() - uptime() * 1000 - BOOT_SLACK_MS ? `startedAt ${cur.startedAt} 이 마지막 부팅보다 이름` : null;
280
+ }
281
+
282
+ // The recorded PIDs that are this service's: { supervisor, child, unknown }, each PID or null. A live PID that does
283
+ // not count says why on stderr and gets no signal. The child of a confirmed supervisor counts with it. unknown is
284
+ // why a live PID could not be checked: the callers then stop with exit 1 and keep the pid file.
285
+ function owned(cur) {
286
+ const none = { supervisor: null, child: null, unknown: null };
287
+ if (!cur) return none;
288
+ const orphan = !alive(cur.supervisor) && !groupAlive(cur.child) ? orphanOf(cur) : null;
289
+ const rec = orphan ? { ...cur, child: orphan } : cur;
290
+ if (!alive(rec.supervisor) && !groupAlive(rec.child)) return none;
291
+ const stale = staleWhy(rec);
292
+ if (stale) {
293
+ console.error(`PID 파일이 낡음(${stale}): 기록된 PID 에 신호를 보내지 않음`);
294
+ return none;
295
+ }
296
+ return checkLive(rec);
297
+ }
298
+
299
+ // Windows: the child is the shell (cmd.exe) in the supervisor's job object, so it dies with the supervisor, while
300
+ // what it ran has left the job and lives on with the dead shell's PID as its parent. That process is the child
301
+ // when its command line holds the recorded command. null elsewhere, or when none is found or readable.
302
+ function orphanOf(cur) {
303
+ if (!WIN || !Number.isInteger(cur.child) || cur.child <= 0) return null;
304
+ const script = `Get-CimInstance Win32_Process -Filter "ParentProcessId=${cur.child}" | ForEach-Object { [string]$_.ProcessId + ' ' + $_.CommandLine }`;
305
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, timeout: 20000 });
306
+ if (r.error || r.status !== 0) return null;
307
+ const lines = r.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
308
+ const hit = lines.find((l) => isChild(cur.command)(l.slice(l.indexOf(" ") + 1)));
309
+ return hit ? Number.parseInt(hit, 10) : null;
310
+ }
311
+
312
+ function checkLive(cur) {
313
+ const sup = alive(cur.supervisor) ? confirm(cur.supervisor, "감독", isSupervisor) : {};
314
+ const kid = !groupAlive(cur.child) ? {} : sup.ok ? { ok: true } : confirm(cur.child, "자식", isChild(cur.command));
315
+ return { supervisor: sup.ok ? cur.supervisor : null, child: kid.ok ? cur.child : null, unknown: sup.unknown ?? kid.unknown ?? null };
316
+ }
317
+
318
+ function unverifiable(own) {
319
+ console.error(`확인 불가: ${own.unknown}. 신호를 보내지 않고 PID 파일을 남김`);
320
+ return 1;
321
+ }
322
+
323
+ const readText = (file) => {
324
+ try {
325
+ return readFileSync(file, "utf8");
326
+ } catch {
327
+ return null;
328
+ }
329
+ };
330
+ const lockInfo = (text) => {
331
+ try {
332
+ return JSON.parse(text);
333
+ } catch {
334
+ return null;
335
+ }
336
+ };
337
+ const holders = new Map();
338
+
339
+ // Whether the lock (text as read) belongs to a start still at work: its pid alive, younger than LOCK_STALE_MS, and
340
+ // the pid's command line holds service.mjs (read once per lock; unreadable counts as its own, the age decides).
341
+ function lockBusy(text, st) {
342
+ const info = lockInfo(text);
343
+ const at = Date.parse(info?.at);
344
+ if (Date.now() - (Number.isNaN(at) ? st.mtimeMs : at) > LOCK_STALE_MS) return false;
345
+ if (!info) return true; // just made, its pid not written yet
346
+ if (!alive(info.pid)) return false;
347
+ if (!holders.has(text)) {
348
+ const line = commandLine(info.pid);
349
+ holders.set(text, line.why !== undefined || line.text.includes("service.mjs"));
350
+ }
351
+ return holders.get(text);
352
+ }
353
+
354
+ // Moves a stale lock aside and removes it. When what was moved is not the lock judged stale (another start took
355
+ // over first and made a fresh one), it goes back unless a newer lock is there.
356
+ function takeOver(file, seen) {
357
+ const aside = `${file}.${process.pid}.stale`;
358
+ try {
359
+ renameSync(file, aside);
360
+ } catch {
361
+ return; // gone or taken already: the next try sees
362
+ }
363
+ if (readText(aside) !== seen) {
364
+ try {
365
+ linkSync(aside, file);
366
+ } catch {
367
+ // a newer lock is there, or no hard links on this file system
368
+ }
369
+ }
370
+ rmSync(aside, { force: true });
371
+ }
372
+
373
+ // One try at the lock: { text } held, { error } refused, or {} busy.
374
+ function tryLock(file) {
375
+ const text = `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`;
376
+ try {
377
+ const fd = openSync(file, "wx");
378
+ try {
379
+ writeSync(fd, text);
380
+ } finally {
381
+ closeSync(fd);
382
+ }
383
+ return { text };
384
+ } catch (e) {
385
+ const st = lstatSync(file, { throwIfNoEntry: false });
386
+ if (!st && e.code !== "EEXIST") throw e;
387
+ if (st && !st.isFile()) return { error: `시작 잠금 자리에 일반 파일이 아닌 것(링크나 폴더)이 있어 거부: ${file}` };
388
+ const seen = st ? readText(file) : null;
389
+ if (st && seen !== null && !lockBusy(seen, st)) takeOver(file, seen);
390
+ return {};
391
+ }
392
+ }
393
+
394
+ // logs/<name>.start.lock, waiting up to LOCK_WAIT_MS for another start: { text } or { error }.
395
+ async function takeLock(file) {
396
+ for (const until = Date.now() + LOCK_WAIT_MS; ; await sleep(100)) {
397
+ const r = tryLock(file);
398
+ if (r.text || r.error) return r;
399
+ if (Date.now() >= until) return { error: `다른 start 가 ${LOCK_WAIT_MS}ms 넘게 진행 중: ${file}. 잠시 뒤 다시 실행하세요` };
400
+ }
401
+ }
402
+
403
+ async function start(conf) {
404
+ mkdirSync(LOGS, { recursive: true });
405
+ const lock = join(LOGS, `${conf.name}.start.lock`);
406
+ const held = await takeLock(lock);
407
+ if (held.error) return say(console.error, held.error, 1);
408
+ try {
409
+ return await startLocked(conf);
410
+ } finally {
411
+ if (readText(lock) === held.text) rmSync(lock, { force: true });
412
+ }
413
+ }
414
+
415
+ async function startLocked(conf) {
416
+ const p = pathsOf(conf.name);
417
+ const cur = readPid(p.pid);
418
+ const own = owned(cur);
419
+ if (own.unknown) return unverifiable(own);
420
+ if (own.supervisor) {
421
+ console.log(`이미 실행 중: ${conf.name} (감독 PID ${cur.supervisor}, 자식 PID ${cur.child})`);
422
+ return 0;
423
+ }
424
+ if (own.child) {
425
+ console.error(`감독 없이 자식이 살아 있음(PID ${own.child}). 먼저 stop 을 실행하세요`);
426
+ return 1;
427
+ }
428
+ rmSync(p.pid, { force: true });
429
+ const sup = spawn(process.execPath, [SELF, "__supervise"], { cwd: ROOT, detached: true, stdio: "ignore", windowsHide: true });
430
+ sup.unref();
431
+ await sleep(START_CHECK_MS);
432
+ const now = readPid(p.pid);
433
+ if (now?.supervisor === sup.pid && alive(now.supervisor) && groupAlive(now.child)) {
434
+ console.log(`시작함: ${conf.name} (감독 PID ${now.supervisor}, 자식 PID ${now.child})`);
435
+ return 0;
436
+ }
437
+ console.error(`시작 뒤 ${START_CHECK_MS}ms 안에 멈춤: ${conf.name}. 로그: ${p.err}`);
438
+ for (const line of tail(p.err, 5)) console.error(` ${line}`);
439
+ return 1;
440
+ }
441
+
442
+ function kill(pid, signal) {
443
+ try {
444
+ process.kill(pid, signal);
445
+ } catch {
446
+ // gone already
447
+ }
448
+ }
449
+
450
+ // Asks the supervisor (or, with it gone, the child) to stop; force kills both. own holds only confirmed PIDs.
451
+ function terminate(own, force) {
452
+ if (WIN) return taskkill(own.supervisor ?? own.child, force);
453
+ if (!force) return own.supervisor ? kill(own.supervisor, "SIGTERM") : signalGroup(own.child, "SIGTERM");
454
+ if (own.child) signalGroup(own.child, "SIGKILL");
455
+ if (own.supervisor) kill(own.supervisor, "SIGKILL");
456
+ }
457
+
458
+ async function waitGone(own, ms) {
459
+ for (const until = Date.now() + ms; Date.now() < until; await sleep(100)) {
460
+ if (!alive(own.supervisor) && !groupAlive(own.child)) return true;
461
+ }
462
+ return !alive(own.supervisor) && !groupAlive(own.child);
463
+ }
464
+
465
+ async function stop(conf) {
466
+ const p = pathsOf(conf.name);
467
+ const cur = readPid(p.pid);
468
+ const own = owned(cur);
469
+ if (own.unknown) return unverifiable(own);
470
+ if (!own.supervisor && !own.child) {
471
+ rmSync(p.pid, { force: true });
472
+ console.log(`실행 중 아님: ${conf.name}`);
473
+ return 0;
474
+ }
475
+ terminate(own, false);
476
+ const gone = (await waitGone(own, STOP_GRACE_MS)) || (terminate(own, true), await waitGone(own, 3000));
477
+ if (!gone) {
478
+ console.error(`멈추지 못함: ${conf.name} (감독 PID ${cur.supervisor}, 자식 PID ${own.child ?? cur.child})`);
479
+ return 1;
480
+ }
481
+ rmSync(p.pid, { force: true });
482
+ console.log(`멈춤: ${conf.name}`);
483
+ return 0;
484
+ }
485
+
486
+ function logLine(file) {
487
+ const files = Array.from({ length: KEEP }, (_, i) => nth(file, i)).filter((f) => existsSync(f));
488
+ const bytes = files.reduce((sum, f) => sum + statSync(f).size, 0);
489
+ return `${file} (파일 ${files.length}개, 합계 ${bytes} 바이트)`;
490
+ }
491
+
492
+ function status(conf) {
493
+ const p = pathsOf(conf.name);
494
+ const cur = readPid(p.pid) ?? {};
495
+ const own = owned(cur);
496
+ if (own.unknown) return unverifiable(own);
497
+ const sup = own.supervisor !== null;
498
+ const child = own.child !== null;
499
+ console.log(`서비스: ${conf.name}`);
500
+ console.log(`감독: ${sup ? `실행 중 (PID ${cur.supervisor})` : "없음"}`);
501
+ console.log(`자식: ${child ? `실행 중 (PID ${own.child})` : "없음"}`);
502
+ console.log(`표준 출력 로그: ${logLine(p.out)}`);
503
+ console.log(`표준 오류 로그: ${logLine(p.err)}`);
504
+ return sup && child ? 0 : 1;
505
+ }
506
+
507
+ async function restart(conf) {
508
+ const code = await stop(conf);
509
+ return code === 0 ? start(conf) : code;
510
+ }
511
+
512
+ const COMMANDS = { start, stop, status, restart };
513
+ const NEEDS_COMMAND = new Set(["start", "restart", "__supervise"]);
514
+
515
+ const HELP = new Set(["--help", "-h"]);
516
+ const say = (write, text, code) => {
517
+ write(text);
518
+ return code;
519
+ };
520
+
521
+ async function main(argv) {
522
+ const cmd = argv[0];
523
+ if (HELP.has(cmd)) return say(console.log, USAGE, 0);
524
+ if (!Object.hasOwn(COMMANDS, cmd ?? "") && cmd !== "__supervise") return say(console.error, USAGE, 2);
525
+ const conf = readConf(NEEDS_COMMAND.has(cmd));
526
+ if (conf.error) return say(console.error, conf.error, 2);
527
+ if (cmd !== "__supervise") return COMMANDS[cmd](conf);
528
+ supervise(conf);
529
+ return null;
530
+ }
531
+
532
+ main(process.argv.slice(2)).then(
533
+ (code) => {
534
+ if (code !== null) process.exitCode = code;
535
+ },
536
+ (e) => {
537
+ console.error(`service: ${e.message}`);
538
+ process.exitCode = 1;
539
+ },
540
+ );
@@ -0,0 +1,13 @@
1
+ @echo off
2
+ rem scripts\restart.cmd: Windows form of ./scripts/restart.sh (banker init-dir)
3
+ rem Exit: 0 done, 1 failed, 2 no settings, 3 Node 18 or later not found
4
+ setlocal
5
+ set "NODE_BIN=%NODE%"
6
+ if "%NODE_BIN%"=="" set "NODE_BIN=node"
7
+ "%NODE_BIN%" -e "process.exit(Number(process.versions.node.split('.')[0]) >= 18 ? 0 : 1)" >nul 2>&1
8
+ if errorlevel 1 (
9
+ echo Node 18 or later is required: %NODE_BIN% 1>&2
10
+ exit /b 3
11
+ )
12
+ "%NODE_BIN%" "%~dp0lib\service.mjs" restart %*
13
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ # ./scripts/restart.sh: stop 뒤 start (banker init-dir)
3
+ # 종료 코드: 0 성공, 1 실패, 2 설정 없음, 3 Node 18 이상 없음
4
+ dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || exit 1
5
+ node_bin=${NODE:-node}
6
+ if ! "$node_bin" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 18 ? 0 : 1)' >/dev/null 2>&1; then
7
+ echo "Node 18 이상 필요: $node_bin" >&2
8
+ exit 3
9
+ fi
10
+ exec "$node_bin" "$dir/lib/service.mjs" restart "$@"
@@ -0,0 +1,5 @@
1
+ # 서비스 설정(banker init-dir). ./scripts/start.sh 가 읽음
2
+ # name: 로그와 PID 파일 이름(영문, 숫자, . _ - 만)
3
+ name=app
4
+ # command: 프로젝트 폴더에서 셸로 실행할 명령 한 줄. 비어 있으면 start 가 종료 2
5
+ command=
@@ -0,0 +1,13 @@
1
+ @echo off
2
+ rem scripts\start.cmd: Windows form of ./scripts/start.sh (banker init-dir)
3
+ rem Exit: 0 done, 1 failed, 2 no settings, 3 Node 18 or later not found
4
+ setlocal
5
+ set "NODE_BIN=%NODE%"
6
+ if "%NODE_BIN%"=="" set "NODE_BIN=node"
7
+ "%NODE_BIN%" -e "process.exit(Number(process.versions.node.split('.')[0]) >= 18 ? 0 : 1)" >nul 2>&1
8
+ if errorlevel 1 (
9
+ echo Node 18 or later is required: %NODE_BIN% 1>&2
10
+ exit /b 3
11
+ )
12
+ "%NODE_BIN%" "%~dp0lib\service.mjs" start %*
13
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ # ./scripts/start.sh: scripts/service.conf 의 command 를 감독 프로세스로 시작 (banker init-dir)
3
+ # 종료 코드: 0 성공, 1 실패, 2 설정 없음, 3 Node 18 이상 없음
4
+ dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || exit 1
5
+ node_bin=${NODE:-node}
6
+ if ! "$node_bin" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 18 ? 0 : 1)' >/dev/null 2>&1; then
7
+ echo "Node 18 이상 필요: $node_bin" >&2
8
+ exit 3
9
+ fi
10
+ exec "$node_bin" "$dir/lib/service.mjs" start "$@"
@@ -0,0 +1,13 @@
1
+ @echo off
2
+ rem scripts\status.cmd: Windows form of ./scripts/status.sh (banker init-dir)
3
+ rem Exit: 0 done, 1 failed, 2 no settings, 3 Node 18 or later not found
4
+ setlocal
5
+ set "NODE_BIN=%NODE%"
6
+ if "%NODE_BIN%"=="" set "NODE_BIN=node"
7
+ "%NODE_BIN%" -e "process.exit(Number(process.versions.node.split('.')[0]) >= 18 ? 0 : 1)" >nul 2>&1
8
+ if errorlevel 1 (
9
+ echo Node 18 or later is required: %NODE_BIN% 1>&2
10
+ exit /b 3
11
+ )
12
+ "%NODE_BIN%" "%~dp0lib\service.mjs" status %*
13
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ # ./scripts/status.sh: 감독과 자식의 실행 여부, 로그 크기를 따로 보고 (banker init-dir)
3
+ # 종료 코드: 0 성공, 1 실패, 2 설정 없음, 3 Node 18 이상 없음
4
+ dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || exit 1
5
+ node_bin=${NODE:-node}
6
+ if ! "$node_bin" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 18 ? 0 : 1)' >/dev/null 2>&1; then
7
+ echo "Node 18 이상 필요: $node_bin" >&2
8
+ exit 3
9
+ fi
10
+ exec "$node_bin" "$dir/lib/service.mjs" status "$@"
@@ -0,0 +1,13 @@
1
+ @echo off
2
+ rem scripts\stop.cmd: Windows form of ./scripts/stop.sh (banker init-dir)
3
+ rem Exit: 0 done, 1 failed, 2 no settings, 3 Node 18 or later not found
4
+ setlocal
5
+ set "NODE_BIN=%NODE%"
6
+ if "%NODE_BIN%"=="" set "NODE_BIN=node"
7
+ "%NODE_BIN%" -e "process.exit(Number(process.versions.node.split('.')[0]) >= 18 ? 0 : 1)" >nul 2>&1
8
+ if errorlevel 1 (
9
+ echo Node 18 or later is required: %NODE_BIN% 1>&2
10
+ exit /b 3
11
+ )
12
+ "%NODE_BIN%" "%~dp0lib\service.mjs" stop %*
13
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ # ./scripts/stop.sh: 서비스와 감독 프로세스를 멈춤 (banker init-dir)
3
+ # 종료 코드: 0 성공, 1 실패, 2 설정 없음, 3 Node 18 이상 없음
4
+ dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || exit 1
5
+ node_bin=${NODE:-node}
6
+ if ! "$node_bin" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 18 ? 0 : 1)' >/dev/null 2>&1; then
7
+ echo "Node 18 이상 필요: $node_bin" >&2
8
+ exit 3
9
+ fi
10
+ exec "$node_bin" "$dir/lib/service.mjs" stop "$@"
@@ -0,0 +1,15 @@
1
+ # tests
2
+
3
+ 단위 시험과 통합 시험 폴더. banker init-dir 이 만든 틀이다.
4
+
5
+ | 폴더 | 둘 것 |
6
+ |---|---|
7
+ | `tests/unit/` | 단위 시험. 외부 서비스 없이 빠르게 끝나는 것 |
8
+ | `tests/integration/` | 통합 시험. 실제 서비스, 파일, 네트워크를 쓰는 것 |
9
+
10
+ - 실행: `./tests/run.sh`(Windows `tests\run.cmd`). 스위트 이름을 주면 그것만(`./tests/run.sh unit`)
11
+ - 스위트 설정: `tests.conf` 에 `<스위트>=<명령>`. 프로젝트 폴더에서 셸로 실행
12
+ - 출력: 스위트마다 한 줄(`PASS unit (1.2s)`, `FAIL integration (0.3s, 종료 1)`). 실패하면 그 출력의 마지막 20줄, 끝에 요약 한 줄
13
+ - 시간 한도: 스위트마다 600초. `tests.conf` 의 `timeout=<초>` 나 환경 변수 `TESTS_TIMEOUT_SECONDS`(우선)로 바꿈. 넘으면 `FAIL <스위트> (600.0s, timeout 600초)` 와 그 스위트의 프로세스 트리 종료. 그래서 `timeout` 은 스위트 이름으로 못 씀
14
+ - 종료 코드: 0 모두 통과, 1 실패 있음, 2 설정 없음(실행할 명령 없음 포함), 3 Node 18 이상 없음
15
+ - 시험은 결과를 간결하게 출력하도록 쓴다. 통과는 조용히, 실패는 무엇이 왜 틀렸는지 한두 줄로