@kaydash9999/banker-plugins 0.16.3 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +14 -11
- package/bin/banker.js +2 -2
- package/codex/manifest.json +2 -1
- package/codex/transform-matrix.md +2 -1
- package/commands/setup.md +2 -2
- package/hooks/progress-plan.mjs +408 -20
- package/hooks/progress.mjs +202 -50
- package/hooks/register.mjs +7 -1
- package/package.json +1 -1
- package/skills/3d-intro-build/references/assemble.mjs +10 -2
- package/skills/3d-intro-build/references/curate.mjs +10 -2
- package/skills/3d-intro-build/references/preview-lib.mjs +2 -0
- package/skills/3d-intro-build/references/serve.mjs +10 -2
- package/skills/3d-intro-build/references/verify-intro.mjs +10 -2
- package/skills/motion-graphic-make/references/curate.mjs +10 -2
- package/skills/motion-graphic-make/references/preview-lib.mjs +2 -0
- package/skills/motion-graphic-make/references/serve.mjs +10 -2
- package/skills/obsidizer/obsidize.mjs +10 -2
- package/skills/omc-patch/SKILL.md +96 -21
- package/skills/omc-patch/scripts/omc-patch.mjs +164 -62
- package/skills/omc-patch/scripts/probe-pm.mjs +192 -0
- package/skills/ultra-chain/SKILL.md +475 -0
- package/skills/ultra-chain/references/capture.mjs +232 -0
- package/skills/ultra-chain/references/chain-ledger.mjs +491 -0
- package/skills/ultra-chain/references/chain-report.mjs +257 -0
- package/skills/ultra-chain/references/preview-lib.mjs +258 -0
- package/skills/ultra-chain/references/serve.mjs +139 -0
- package/skills/vertical-pptx/verify_a4p.mjs +10 -2
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Process view for /banker:ultra-chain. Renders a run folder's ledger (run.json, events.jsonl, task.md) into
|
|
3
|
+
// one self-contained index.html in that folder: the stage timeline, review verdicts, the OS test matrix, the
|
|
4
|
+
// visual board (mock beside actual), the human checklist and the artifacts. No script and no external
|
|
5
|
+
// resource: every text is escaped and links go only to files inside the run folder.
|
|
6
|
+
//
|
|
7
|
+
// node chain-report.mjs <run> [--out <file name>]
|
|
8
|
+
//
|
|
9
|
+
// Exit codes: 0 written, 1 the ledger could not be read or the page not written, 2 usage.
|
|
10
|
+
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { STAGES, readEvents, readRun, runPath, summarize } from "./chain-ledger.mjs";
|
|
14
|
+
|
|
15
|
+
const STATUS_LABEL = {
|
|
16
|
+
pending: "대기", started: "진행 중", done: "완료", failed: "실패", skipped: "건너뜀", stopped: "멈춤", note: "기록",
|
|
17
|
+
running: "진행 중", pass: "통과", fail: "실패", unverified: "미검증", revise: "고칠 것",
|
|
18
|
+
};
|
|
19
|
+
const LABEL = Object.fromEntries(STAGES.map((s) => [s.id, s.label]));
|
|
20
|
+
|
|
21
|
+
export const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
22
|
+
|
|
23
|
+
// A link inside the run folder, or null. Each path segment is percent-encoded.
|
|
24
|
+
export function href(p) {
|
|
25
|
+
const rel = runPath(p);
|
|
26
|
+
return rel ? `./${rel.split("/").map(encodeURIComponent).join("/")}` : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A link only to a file the run folder holds (`ctx.exists`); a folder, a missing file or a note such as
|
|
30
|
+
// "02-spec.md AC-10" stays words, so no link on the page is dead.
|
|
31
|
+
const linkable = (p, ctx) => Boolean(href(p)) && ctx.exists(runPath(p));
|
|
32
|
+
const link = (p, text, ctx) => (linkable(p, ctx) ? `<a href="${esc(href(p))}">${esc(text ?? p)}</a>` : esc(text ?? p ?? ""));
|
|
33
|
+
|
|
34
|
+
export function duration(ms) {
|
|
35
|
+
if (ms === null || ms === undefined) return "";
|
|
36
|
+
const s = Math.round(ms / 1000);
|
|
37
|
+
if (s < 60) return `${s}초`;
|
|
38
|
+
const m = Math.floor(s / 60);
|
|
39
|
+
if (m < 60) return `${m}분 ${s % 60}초`;
|
|
40
|
+
return `${Math.floor(m / 60)}시간 ${m % 60}분`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
44
|
+
export function when(iso) {
|
|
45
|
+
const d = new Date(iso);
|
|
46
|
+
if (!iso || Number.isNaN(d.getTime())) return "";
|
|
47
|
+
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const badge = (status) => `<span class="s s-${esc(status)}">${esc(STATUS_LABEL[status] ?? status)}</span>`;
|
|
51
|
+
|
|
52
|
+
function table(head, rows) {
|
|
53
|
+
if (!rows.length) return '<p class="none">없음</p>';
|
|
54
|
+
const th = head.map((h) => `<th>${esc(h)}</th>`).join("");
|
|
55
|
+
return `<table><thead><tr>${th}</tr></thead><tbody>${rows.map((r) => `<tr>${r.join("")}</tr>`).join("\n")}</tbody></table>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const td = (html, cls) => `<td${cls ? ` class="${cls}"` : ""}>${html}</td>`;
|
|
59
|
+
|
|
60
|
+
// ── sections ─────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
// task.md's first line is the argument line as given and the rest the task: with no flags the two say the
|
|
63
|
+
// same, so the second is left out.
|
|
64
|
+
export function taskText(task) {
|
|
65
|
+
const lines = String(task).split(/\r?\n/);
|
|
66
|
+
return lines.length > 1 && lines[1].trim() && lines[1].trim() === lines[0].trim() ? [lines[0], ...lines.slice(2)].join("\n") : String(task);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderHeader(run, task, sum, generatedAt) {
|
|
70
|
+
const total = sum.stages.reduce((a, s) => a + (s.ms ?? 0), 0);
|
|
71
|
+
const current = sum.current ? `${esc(LABEL[sum.current])} 단계` : "모든 단계 끝";
|
|
72
|
+
const facts = [
|
|
73
|
+
["실행", esc(run.id)], ["상태", `${badge(sum.overall)} ${current}`], ["시작", esc(when(run.createdAt))],
|
|
74
|
+
["단계 시간 합", esc(duration(total))], ["런타임", esc(run.runtime ?? "미상")],
|
|
75
|
+
["기준 커밋", esc((run.baseCommit ?? "").slice(0, 12))], ["브랜치", esc(run.branch ?? "")], ["보고서 생성", esc(when(generatedAt))],
|
|
76
|
+
];
|
|
77
|
+
const dl = facts.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${v}</dd></div>`).join("");
|
|
78
|
+
const ask = task ? `<pre class="task">${esc(taskText(task))}</pre>` : '<p class="none">task.md 없음</p>';
|
|
79
|
+
return `<header><h1>ultra-chain 과정</h1><dl class="facts">${dl}</dl><h2>요청</h2>${ask}</header>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderTimeline(stages) {
|
|
83
|
+
const rows = stages.map((s, i) => [td(String(i)), td(esc(s.label)), td(badge(s.status)), td(esc(when(s.start))), td(esc(when(s.end))),
|
|
84
|
+
td(esc(duration(s.ms))), td(s.rounds ? String(s.rounds) : ""), td(esc(s.summary ?? ""), "wrap")]);
|
|
85
|
+
return `<section><h2>단계</h2>${table(["#", "단계", "상태", "시작", "끝", "걸린 시간", "회차", "요약"], rows)}</section>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A verdict word keeps its own text; only the color says pass (APPROVE, PASS), revise (ITERATE) or fail.
|
|
89
|
+
const VERDICT_TONE = { APPROVE: "pass", PASS: "pass", ITERATE: "revise" };
|
|
90
|
+
const verdictBadge = (word) => `<span class="s s-${VERDICT_TONE[word] ?? "fail"}">${esc(word)}</span>`;
|
|
91
|
+
|
|
92
|
+
function renderVerdict(e) {
|
|
93
|
+
const v = e.verdict;
|
|
94
|
+
const seats = (v.seats ?? []).map((s) => [td(esc(s.id)), td(esc(s.lens ?? "")), td(esc(s.model ?? "")), td(verdictBadge(s.verdict))]);
|
|
95
|
+
const blockers = v.blockers?.length ? `<ul>${v.blockers.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>` : "";
|
|
96
|
+
const round = e.round ? ` ${e.round}회차` : "";
|
|
97
|
+
return `<article class="verdict"><h3>${esc(LABEL[e.stage])}${esc(round)}: ${esc(v.by)} ${verdictBadge(v.result)}</h3>`
|
|
98
|
+
+ `${v.note ? `<p>${esc(v.note)}</p>` : ""}${seats.length ? table(["검토자", "관점", "모델", "판정"], seats) : ""}${blockers}</article>`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderVerdicts(events) {
|
|
102
|
+
const list = events.filter((e) => e.verdict);
|
|
103
|
+
return `<section><h2>검토 판정</h2>${list.length ? list.map(renderVerdict).join("\n") : '<p class="none">없음</p>'}</section>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderTests(events, ctx) {
|
|
107
|
+
const rows = events.flatMap((e) => (e.tests ?? []).map((t) => [td(esc(`${LABEL[e.stage]}${e.round ? ` ${e.round}회차` : ""}`)),
|
|
108
|
+
td(esc(t.where)), td(esc(t.os ?? "")), td(badge(t.status)), td(esc([t.pass, t.fail, t.skipped].map((n) => n ?? "-").join(" / "))),
|
|
109
|
+
td(esc(t.note ?? ""), "wrap"), td(t.log && linkable(t.log, ctx) ? link(t.log, "로그", ctx) : "")]));
|
|
110
|
+
return `<section><h2>시험 (이 머신과 OS 박스)</h2>${table(["단계", "위치", "OS", "결과", "통과 / 실패 / 건너뜀", "비고", "로그"], rows)}</section>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The latest entry per view name: a later visual check replaces an earlier one.
|
|
114
|
+
function latestVisual(events) {
|
|
115
|
+
const byName = new Map();
|
|
116
|
+
for (const e of events) for (const v of e.visual ?? []) byName.set(v.name, v);
|
|
117
|
+
return [...byName.values()];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderShot(p, caption, ctx) {
|
|
121
|
+
const h = href(p);
|
|
122
|
+
if (!h || !ctx.exists(runPath(p))) return `<figure class="shot empty"><figcaption>${esc(caption)}: 없음</figcaption></figure>`;
|
|
123
|
+
return `<figure class="shot"><a href="${esc(h)}"><img src="${esc(h)}" alt="${esc(caption)}" loading="lazy"></a><figcaption>${esc(caption)}</figcaption></figure>`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderVisual(events, ctx) {
|
|
127
|
+
const cards = latestVisual(events).map((v) => {
|
|
128
|
+
const score = v.score === undefined ? "" : ` ${v.score}점`;
|
|
129
|
+
const intended = v.intended ? ' <span class="s s-note">의도한 차이</span>' : "";
|
|
130
|
+
return `<article class="card"><h3>${esc(v.name)}${esc(score)} ${v.verdict ? badge(v.verdict) : ""}${intended}</h3>`
|
|
131
|
+
+ `<div class="pair">${renderShot(v.mock, "목업", ctx)}${renderShot(v.actual, "실제", ctx)}</div>${v.note ? `<p>${esc(v.note)}</p>` : ""}</article>`;
|
|
132
|
+
});
|
|
133
|
+
return `<section><h2>시각 검토</h2>${cards.length ? cards.join("\n") : '<p class="none">없음</p>'}</section>`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// One entry per item and what tells it apart: its ref, or its reason when it has no ref. The same words about
|
|
137
|
+
// two views stay two entries; a bare repeat (no ref, no reason) of an item that has more adds nothing.
|
|
138
|
+
const checkKey = (c) => (c.ref ? `ref:${c.ref}` : `why:${c.why ?? ""}`);
|
|
139
|
+
|
|
140
|
+
export function checklistItems(events) {
|
|
141
|
+
const groups = new Map();
|
|
142
|
+
for (const e of events) {
|
|
143
|
+
for (const c of e.checklist ?? []) {
|
|
144
|
+
const g = groups.get(c.item) ?? new Map();
|
|
145
|
+
if (!g.has(checkKey(c))) g.set(checkKey(c), c);
|
|
146
|
+
groups.set(c.item, g);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return [...groups.values()].flatMap((g) => {
|
|
150
|
+
const full = [...g.entries()].filter(([key]) => key !== "why:").map(([, c]) => c);
|
|
151
|
+
return full.length ? full : [g.get("why:")];
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// An item's ref: a link where it names a file of the run folder, else its own words.
|
|
156
|
+
const refOf = (ref, ctx) => (linkable(ref, ctx) ? link(ref, "근거", ctx) : `<span class="why">근거: ${esc(ref)}</span>`);
|
|
157
|
+
|
|
158
|
+
function renderChecklist(events, ctx) {
|
|
159
|
+
const items = checklistItems(events).map((c) => `<li><b>${esc(c.item)}</b>${c.why ? ` <span class="why">${esc(c.why)}</span>` : ""}${c.ref ? ` ${refOf(c.ref, ctx)}` : ""}</li>`);
|
|
160
|
+
return `<section class="check"><h2>사람이 볼 것 (${items.length})</h2>${items.length ? `<ol>${items.join("")}</ol>` : '<p class="none">없음</p>'}</section>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderArtifacts(events, ctx) {
|
|
164
|
+
const seen = new Map();
|
|
165
|
+
for (const e of events) for (const a of e.artifacts ?? []) if (!seen.has(a.path)) seen.set(a.path, { ...a, stage: e.stage });
|
|
166
|
+
const missing = (a) => (linkable(a.path, ctx) ? "" : ` <span class="why">(파일 아님: ${esc(a.path)})</span>`);
|
|
167
|
+
const rows = [...seen.values()].map((a) => [td(esc(LABEL[a.stage])), td(`${link(a.path, a.label ?? a.path, ctx)}${missing(a)}`)]);
|
|
168
|
+
return `<section><h2>산출물</h2>${table(["단계", "파일"], rows)}</section>`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderLog(events, bad) {
|
|
172
|
+
const rows = events.map((e) => [td(esc(when(e.t))), td(esc(LABEL[e.stage])), td(badge(e.status)), td(Number.isInteger(e.round) ? String(e.round) : ""),
|
|
173
|
+
td(esc(e.summary ?? e.reason ?? ""), "wrap")]);
|
|
174
|
+
const warn = bad.length ? `<p class="warn">읽지 못한 기록 줄: ${esc(bad.join(", "))}</p>` : "";
|
|
175
|
+
return `<section><details><summary>전체 기록 (${events.length}줄)</summary>${warn}${table(["시각", "단계", "상태", "회차", "내용"], rows)}</details></section>`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const CSS = `:root{color-scheme:light dark;--bg:#fff;--fg:#1d1d1f;--mute:#6e6e73;--line:#d2d2d7;--card:#f5f5f7}
|
|
179
|
+
@media (prefers-color-scheme:dark){:root{--bg:#1c1c1e;--fg:#f5f5f7;--mute:#a1a1a6;--line:#3a3a3c;--card:#2c2c2e}}
|
|
180
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 system-ui,-apple-system,"Segoe UI","Noto Sans KR",sans-serif}
|
|
181
|
+
main{max-width:1200px;margin:0 auto;padding:24px}h1{font-size:24px;margin:0 0 12px}h2{font-size:18px;margin:28px 0 10px}h3{font-size:15px;margin:8px 0}
|
|
182
|
+
.facts{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;margin:0}.facts div{background:var(--card);border-radius:8px;padding:8px 10px}
|
|
183
|
+
dt{color:var(--mute);font-size:12px}dd{margin:0}pre.task{white-space:pre-wrap;background:var(--card);border-radius:8px;padding:12px;margin:0}
|
|
184
|
+
table{border-collapse:collapse;width:100%;font-size:14px}th,td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top}
|
|
185
|
+
th{color:var(--mute);font-weight:600}td.wrap{white-space:pre-wrap;word-break:break-word}
|
|
186
|
+
.s{display:inline-block;border-radius:999px;padding:1px 8px;font-size:12px;font-weight:600;background:var(--line)}
|
|
187
|
+
.s-done,.s-pass{background:#d1f2dc;color:#0b6b2e}.s-started,.s-running{background:#d6e8ff;color:#0b4ea2}
|
|
188
|
+
.s-failed,.s-fail,.s-stopped{background:#ffd9d6;color:#a1150b}.s-revise,.s-unverified{background:#ffefc7;color:#7a5200}
|
|
189
|
+
.s-skipped,.s-pending,.s-note{background:var(--line);color:var(--mute)}
|
|
190
|
+
.card,.verdict{background:var(--card);border-radius:10px;padding:10px 14px;margin:10px 0}
|
|
191
|
+
.pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}.shot{margin:0}.shot img{width:100%;height:auto;border:1px solid var(--line);border-radius:6px}
|
|
192
|
+
figcaption{color:var(--mute);font-size:12px}.none{color:var(--mute)}.why{color:var(--mute)}.warn{color:#a1150b}
|
|
193
|
+
.check ol{background:var(--card);border-radius:10px;padding:10px 10px 10px 34px}a{color:#0b63ce}`;
|
|
194
|
+
|
|
195
|
+
// No script, plugin, form or base change may run in the page, whatever a ledger line holds.
|
|
196
|
+
const CSP = "script-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
197
|
+
|
|
198
|
+
// The whole page. generatedAt is the time printed as "보고서 생성". exists(rel) says whether the run folder
|
|
199
|
+
// holds that file; without it every path in the folder counts as one.
|
|
200
|
+
export function renderReport({ run, task, events, bad = [], generatedAt = new Date().toISOString(), exists = () => true }) {
|
|
201
|
+
const sum = summarize(events);
|
|
202
|
+
const ctx = { exists };
|
|
203
|
+
const body = [renderHeader(run, task, sum, generatedAt), renderChecklist(events, ctx), renderTimeline(sum.stages), renderVerdicts(events),
|
|
204
|
+
renderTests(events, ctx), renderVisual(events, ctx), renderArtifacts(events, ctx), renderLog(events, bad)].join("\n");
|
|
205
|
+
return `<!doctype html>\n<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">`
|
|
206
|
+
+ `<meta http-equiv="Content-Security-Policy" content="${CSP}">`
|
|
207
|
+
+ `<title>ultra-chain ${esc(run.id)}</title><style>${CSS}</style></head><body><main>\n${body}\n</main></body></html>\n`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── command line ─────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
export function writeReport(dir, out = "index.html") {
|
|
213
|
+
if (!/^[\w.-]+\.html$/.test(out)) throw new Error(`--out 은 실행 폴더 안의 .html 파일 이름입니다: ${out}`);
|
|
214
|
+
const run = readRun(dir);
|
|
215
|
+
const { events, bad } = readEvents(dir);
|
|
216
|
+
const taskFile = join(dir, "task.md");
|
|
217
|
+
const task = existsSync(taskFile) ? readFileSync(taskFile, "utf8").slice(0, 8000) : null;
|
|
218
|
+
const file = join(dir, out);
|
|
219
|
+
const exists = (rel) => {
|
|
220
|
+
try {
|
|
221
|
+
return statSync(join(dir, rel)).isFile();
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
writeFileSync(file, renderReport({ run, task, events, bad, exists }));
|
|
227
|
+
return file;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function main(argv, out = process.stdout, err = process.stderr) {
|
|
231
|
+
const outAt = argv.indexOf("--out");
|
|
232
|
+
const name = outAt >= 0 ? argv[outAt + 1] : "index.html";
|
|
233
|
+
const rest = outAt >= 0 ? argv.filter((_, i) => i !== outAt && i !== outAt + 1) : argv;
|
|
234
|
+
if (rest.length !== 1 || !name) {
|
|
235
|
+
err.write("usage: node chain-report.mjs <run> [--out <file name>]\n");
|
|
236
|
+
return 2;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
out.write(`${writeReport(resolve(rest[0]), name)}\n`);
|
|
240
|
+
return 0;
|
|
241
|
+
} catch (e) {
|
|
242
|
+
err.write(`chain-report: ${e.message}\n`);
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Run as a command, also when reached through a symlinked path: Node resolves
|
|
248
|
+
// symlinks for import.meta.url but not for process.argv[1].
|
|
249
|
+
const invokedDirectly = () => {
|
|
250
|
+
try {
|
|
251
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
if (invokedDirectly()) process.exitCode = main(process.argv.slice(2));
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared by serve.mjs and curate.mjs: the local servers of the 3d-intro-build skill (mirrored
|
|
3
|
+
* byte-identical into motion-graphic-make by scripts/sync-adapter.js).
|
|
4
|
+
*
|
|
5
|
+
* - A port the system picks (a preferred one first when asked), bound to loopback by default.
|
|
6
|
+
* - A state file recording { pid, port, requestedPort, host, url, dir }: serve.mjs keeps it beside
|
|
7
|
+
* the folder it serves, curate.mjs in the project folder, which it does not serve (it serves
|
|
8
|
+
* listed takes only).
|
|
9
|
+
* - Requests whose Host is a name other than localhost are refused, so a DNS name rebound to this
|
|
10
|
+
* machine cannot read what is served.
|
|
11
|
+
* - A stop that ends only the process answering on that port with the recorded PID (a whole number
|
|
12
|
+
* above 1), then waits for the port to close. Nothing is matched by command line (no pkill -f).
|
|
13
|
+
* - Files open before any header goes out, so one that cannot be read gets 403 and the server
|
|
14
|
+
* stays up, and a download the browser drops closes its file.
|
|
15
|
+
* - An optional Content-Security-Policy header, so a page is checked under the policy it ships with.
|
|
16
|
+
*
|
|
17
|
+
* Runtime: Node >=18 builtins only (node:fs / node:http / node:net / node:path). No shell.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import net from 'node:net';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { pipeline } from 'node:stream';
|
|
24
|
+
|
|
25
|
+
export const LOOPBACK = '127.0.0.1';
|
|
26
|
+
// Answers { pid } so a stop can tell this server from another process on the same port.
|
|
27
|
+
export const PID_ROUTE = '/__preview/pid';
|
|
28
|
+
// As strict as a production site: no inline script or style. The scrub engine fetches its clips
|
|
29
|
+
// from its own origin and plays them from blob: URLs.
|
|
30
|
+
export const STRICT_CSP = [
|
|
31
|
+
"default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self' data: blob:",
|
|
32
|
+
"media-src 'self' blob:", "connect-src 'self'", "font-src 'self'", "object-src 'none'",
|
|
33
|
+
"base-uri 'self'", "frame-ancestors 'none'",
|
|
34
|
+
].join('; ');
|
|
35
|
+
|
|
36
|
+
export const MIME = {
|
|
37
|
+
'.html': 'text/html;charset=utf-8', '.css': 'text/css;charset=utf-8', '.js': 'text/javascript;charset=utf-8',
|
|
38
|
+
'.mjs': 'text/javascript;charset=utf-8', '.json': 'application/json;charset=utf-8', '.png': 'image/png',
|
|
39
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
|
40
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.woff2': 'font/woff2',
|
|
41
|
+
// Notes and logs a report links to open as text in the browser instead of downloading.
|
|
42
|
+
'.md': 'text/plain;charset=utf-8', '.txt': 'text/plain;charset=utf-8', '.log': 'text/plain;charset=utf-8', '.jsonl': 'text/plain;charset=utf-8',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// `strict` is the policy above; any other text is a policy as written; nothing means no header.
|
|
46
|
+
export const cspFrom = (value) => (value ? (value === 'strict' ? STRICT_CSP : String(value)) : null);
|
|
47
|
+
|
|
48
|
+
export function hostWarning(host) {
|
|
49
|
+
if (!host || [LOOPBACK, 'localhost', '::1'].includes(host)) return null;
|
|
50
|
+
return `주의: ${host} 에 바인드합니다. 같은 네트워크의 기기가 아직 공개하지 않은 산출물을 볼 수 있습니다. 끝나면 --stop 으로 닫으세요.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// True for a Host header naming this machine as localhost, by an IP address, or by a name the server
|
|
54
|
+
// was bound to (`names`); any other DNS name fails, which is what a rebinding attack needs.
|
|
55
|
+
export function hostAllowed(hostHeader, names = []) {
|
|
56
|
+
const h = String(hostHeader || '').trim().toLowerCase();
|
|
57
|
+
if (!h) return false;
|
|
58
|
+
if (h.startsWith('[')) return /^\[[0-9a-f:.]+\](:\d+)?$/.test(h);
|
|
59
|
+
const name = h.split(':')[0];
|
|
60
|
+
return name === 'localhost' || /^\d{1,3}(\.\d{1,3}){3}$/.test(name) || names.map((n) => String(n).toLowerCase()).includes(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The request path without its query, or null when its escapes are broken.
|
|
64
|
+
export function decodePath(rawUrl) {
|
|
65
|
+
try { return decodeURIComponent(String(rawUrl || '/').split('?')[0]); } catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The file a request path names inside `dir`, or null when the path leaves it.
|
|
69
|
+
export function resolveInside(dir, urlPath) {
|
|
70
|
+
const fp = path.normalize(path.join(dir, urlPath));
|
|
71
|
+
return fp === dir || fp.startsWith(dir + path.sep) ? fp : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function send(res, status, body, headers = {}) {
|
|
75
|
+
res.writeHead(status, { ...headers, 'Content-Type': 'text/plain;charset=utf-8', 'Cache-Control': 'no-store' });
|
|
76
|
+
res.end(String(body));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Read flags that never wait: a path that has turned into a FIFO opens at once and is then refused.
|
|
80
|
+
export const READ_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0);
|
|
81
|
+
|
|
82
|
+
// An open regular file: { fd, size }, or { status } when there is none (404) or it cannot be read (403).
|
|
83
|
+
function openRegular(fp) {
|
|
84
|
+
let fd;
|
|
85
|
+
try { fd = fs.openSync(fp, READ_FLAGS); } catch (e) { return { status: ['ENOENT', 'ENOTDIR', 'EISDIR'].includes(e.code) ? 404 : 403 }; }
|
|
86
|
+
let st = null;
|
|
87
|
+
try { st = fs.fstatSync(fd); } catch { /* a status the system cannot give (EIO on a network drive) */ }
|
|
88
|
+
if (st && st.isFile()) return { fd, size: st.size };
|
|
89
|
+
fs.closeSync(fd);
|
|
90
|
+
return { status: st ? 404 : 403 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Sends the bytes `start`..`end` of an open file (what the headers declared, even if the file grows
|
|
94
|
+
// meanwhile) and closes it, also when the browser drops the download midway (a pipe would keep the
|
|
95
|
+
// file open). Nothing to send closes the file and ends the response.
|
|
96
|
+
function stream(res, fp, { fd, start, end }) {
|
|
97
|
+
if (end < start) {
|
|
98
|
+
fs.closeSync(fd);
|
|
99
|
+
return void res.end();
|
|
100
|
+
}
|
|
101
|
+
pipeline(fs.createReadStream(fp, { fd, start, end }), res, () => {});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A file, with byte ranges for video (smoother scrubbing). Never cached: a rebuilt page is fresh.
|
|
105
|
+
// The file is opened before any header goes out, so one that cannot be read gets 403 instead of
|
|
106
|
+
// ending the server.
|
|
107
|
+
export function sendFile(req, res, fp, headers = {}) {
|
|
108
|
+
const f = openRegular(fp);
|
|
109
|
+
if (f.status) return send(res, f.status, String(f.status), headers);
|
|
110
|
+
const type = MIME[path.extname(fp).toLowerCase()] || 'application/octet-stream';
|
|
111
|
+
const base = { ...headers, 'Content-Type': type, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' };
|
|
112
|
+
const range = type.startsWith('video/') ? /bytes=(\d+)-(\d*)/.exec(req.headers.range || '') : null;
|
|
113
|
+
if (range) return sendRange(res, fp, f, range, base, headers);
|
|
114
|
+
res.writeHead(200, { ...base, 'Content-Length': f.size });
|
|
115
|
+
stream(res, fp, { fd: f.fd, start: 0, end: f.size - 1 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sendRange(res, fp, f, range, base, headers) {
|
|
119
|
+
const start = Number(range[1]);
|
|
120
|
+
const end = Math.min(range[2] ? Number(range[2]) : f.size - 1, f.size - 1);
|
|
121
|
+
if (start >= f.size || start > end) {
|
|
122
|
+
fs.closeSync(f.fd);
|
|
123
|
+
res.writeHead(416, { ...headers, 'Content-Range': `bytes */${f.size}` });
|
|
124
|
+
return void res.end();
|
|
125
|
+
}
|
|
126
|
+
res.writeHead(206, { ...base, 'Content-Range': `bytes ${start}-${end}/${f.size}`, 'Content-Length': end - start + 1 });
|
|
127
|
+
stream(res, fp, { fd: f.fd, start, end });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// True when the request was the PID question, which this answers.
|
|
131
|
+
export function answersPid(urlPath, res) {
|
|
132
|
+
if (urlPath !== PID_ROUTE) return false;
|
|
133
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
134
|
+
res.end(JSON.stringify({ pid: process.pid }));
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Listens on `port` when given and free, else on a port the system picks. Resolves to the port.
|
|
139
|
+
export function listen(server, { host = LOOPBACK, port } = {}) {
|
|
140
|
+
const attempt = (p) => new Promise((resolve, reject) => {
|
|
141
|
+
const onError = (e) => { server.off('listening', onListening); reject(e); };
|
|
142
|
+
const onListening = () => { server.off('error', onError); resolve(server.address().port); };
|
|
143
|
+
server.once('error', onError);
|
|
144
|
+
server.once('listening', onListening);
|
|
145
|
+
server.listen(p, host);
|
|
146
|
+
});
|
|
147
|
+
if (!port) return attempt(0);
|
|
148
|
+
return attempt(port).catch((e) => (e.code === 'EADDRINUSE' ? attempt(0) : Promise.reject(e)));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The address a person opens: localhost for loopback and the wildcard, IPv6 in brackets.
|
|
152
|
+
export function urlFor(host, port) {
|
|
153
|
+
if (!host || host === LOOPBACK || host === '0.0.0.0') return `http://localhost:${port}/`;
|
|
154
|
+
if (host === '::' || host === '::1') return `http://[::1]:${port}/`;
|
|
155
|
+
return host.includes(':') ? `http://[${host}]:${port}/` : `http://${host}:${port}/`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Where a stop asks a server for its PID: the address it was bound to, a wildcard at loopback.
|
|
159
|
+
export function askHost(host) {
|
|
160
|
+
if (!host || host === '0.0.0.0') return LOOPBACK;
|
|
161
|
+
return host === '::' ? '::1' : host;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Whether the server a state file records answers a start that asks for `port`: one that asks no
|
|
165
|
+
* port, the port it listens on, or the port its own start asked for (busy then, so it got another).
|
|
166
|
+
*/
|
|
167
|
+
export const portMatches = (st, port) => !port || st.port === port || st.requestedPort === port;
|
|
168
|
+
|
|
169
|
+
// The state file of a server for `dir`: beside the folder, so a deployed folder never carries it.
|
|
170
|
+
export const siblingState = (dir) => path.join(path.dirname(dir), `${path.basename(dir)}.server.json`);
|
|
171
|
+
|
|
172
|
+
export function writeState(file, info) {
|
|
173
|
+
fs.writeFileSync(file, JSON.stringify(info, null, 2) + '\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function readState(file) {
|
|
177
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const removeState = (file) => fs.rmSync(file, { force: true });
|
|
181
|
+
|
|
182
|
+
// Removes a state file if it can. One that cannot be removed (a folder made read-only) stays; the
|
|
183
|
+
// next start finds it stale. Stopping and closing go on either way.
|
|
184
|
+
function dropState(file) {
|
|
185
|
+
try { removeState(file); } catch { /* left behind */ }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Removes the state file when the process recorded there is this one.
|
|
189
|
+
export function forgetSelf(file) {
|
|
190
|
+
if (readState(file)?.pid === process.pid) dropState(file);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const NONE = Symbol('none');
|
|
194
|
+
// The PID the server on `port` says it has; NONE when nothing listens; null for another program.
|
|
195
|
+
function askPid(port, timeoutMs, host) {
|
|
196
|
+
return new Promise((resolve) => {
|
|
197
|
+
const req = http.get({ host: askHost(host), port, path: PID_ROUTE, timeout: timeoutMs, headers: { Host: 'localhost' } }, (res) => {
|
|
198
|
+
let body = '';
|
|
199
|
+
res.on('data', (c) => { body += c; });
|
|
200
|
+
res.on('end', () => { try { resolve(JSON.parse(body).pid ?? null); } catch { resolve(null); } });
|
|
201
|
+
});
|
|
202
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
203
|
+
req.on('error', (e) => resolve(e.code === 'ECONNREFUSED' ? NONE : null));
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const portOpen = (port, host) => new Promise((resolve) => {
|
|
208
|
+
const s = net.connect({ host: askHost(host), port }, () => { s.destroy(); resolve(true); });
|
|
209
|
+
s.on('error', () => resolve(false));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
async function waitClosed(port, timeoutMs, host) {
|
|
213
|
+
for (const until = Date.now() + timeoutMs; Date.now() < until;) {
|
|
214
|
+
if (!(await portOpen(port, host))) return true;
|
|
215
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
216
|
+
}
|
|
217
|
+
return !(await portOpen(port, host));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A PID a state file may name: a whole number above 1 (never a process group, never init).
|
|
221
|
+
const validPid = (pid) => Number.isInteger(pid) && pid > 1;
|
|
222
|
+
|
|
223
|
+
/** True when the server a state file records still answers on its port with its PID. */
|
|
224
|
+
export async function isRunning(stateFile) {
|
|
225
|
+
const st = readState(stateFile);
|
|
226
|
+
return Boolean(st && st.port) && validPid(st.pid) && (await askPid(st.port, 1500, st.host)) === st.pid;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function signal(pid, sig) {
|
|
230
|
+
try { process.kill(pid, sig); } catch { /* already gone */ }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Stops the server a state file records. Result reasons: stopped, no-state, invalid-state (a PID
|
|
234
|
+
// that is no process id; the file is left for a person to look at), not-running (the stale record
|
|
235
|
+
// is removed), other-process (left alone), still-open.
|
|
236
|
+
export async function stopServer(stateFile, { timeoutMs = 3000 } = {}) {
|
|
237
|
+
const st = readState(stateFile);
|
|
238
|
+
if (!st || !st.port) return { stopped: false, reason: 'no-state' };
|
|
239
|
+
if (!validPid(st.pid)) return { stopped: false, reason: 'invalid-state' };
|
|
240
|
+
const who = await askPid(st.port, Math.min(timeoutMs, 1500), st.host);
|
|
241
|
+
if (who === NONE) {
|
|
242
|
+
dropState(stateFile);
|
|
243
|
+
return { stopped: false, reason: 'not-running' };
|
|
244
|
+
}
|
|
245
|
+
if (who !== st.pid) return { stopped: false, reason: 'other-process' };
|
|
246
|
+
if (!(await terminate(st, timeoutMs))) return { stopped: false, reason: 'still-open' };
|
|
247
|
+
dropState(stateFile);
|
|
248
|
+
return { stopped: true, reason: 'stopped' };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Ends a server that answered with its PID: SIGTERM, then SIGKILL while its port stays open. True
|
|
252
|
+
// once the port is closed.
|
|
253
|
+
async function terminate(st, timeoutMs) {
|
|
254
|
+
signal(st.pid, 'SIGTERM');
|
|
255
|
+
if (await waitClosed(st.port, timeoutMs, st.host)) return true;
|
|
256
|
+
signal(st.pid, 'SIGKILL');
|
|
257
|
+
return waitClosed(st.port, timeoutMs, st.host);
|
|
258
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Local preview server for an assembled intro (index.html + scrub-engine.js + assets/), so the
|
|
4
|
+
* scroll-scrubbed page is checked in a real browser. The project convention is a self-contained
|
|
5
|
+
* local page on an unused port, never a claude.ai artifact. Shared by 3d-intro-build and
|
|
6
|
+
* motion-graphic-make (scripts/sync-adapter.js keeps the copies identical).
|
|
7
|
+
*
|
|
8
|
+
* Why a server at all: the engine loads each clip with fetch() into a Blob, which file:// cannot
|
|
9
|
+
* serve, and video plays smoother with byte ranges (206).
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node serve.mjs [dir] [--port N] [--csp strict|"<policy>"] [--state file]
|
|
13
|
+
* node serve.mjs --stop [dir] [--state file]
|
|
14
|
+
* dir folder to serve (default: the current one)
|
|
15
|
+
* --port preferred port; when busy or absent the system picks a free one
|
|
16
|
+
* --csp sends Content-Security-Policy on every response: `strict` (no inline script or
|
|
17
|
+
* style; blob: media) or the production policy as written. Check a page for a site
|
|
18
|
+
* with a CSP under that same policy: a plain server hides CSP errors.
|
|
19
|
+
* --state state file (default: <dir>.server.json beside the folder, never inside it)
|
|
20
|
+
* --stop ends the server the state file records, only when it answers with that PID
|
|
21
|
+
* A start while the recorded server still answers with the same settings only reports its
|
|
22
|
+
* address; with other settings (another folder sharing the --state file, --csp, PREVIEW_HOST, a
|
|
23
|
+
* --port other than the one it listens on or its own start asked for) it exits 1: --stop first.
|
|
24
|
+
* A request whose Host is a DNS name other than localhost is refused (DNS rebinding).
|
|
25
|
+
* On listen it writes the state file and prints PREVIEW http://localhost:<port>/
|
|
26
|
+
* PREVIEW_HOST=0.0.0.0 binds every interface (on-device phone checks) and prints a warning.
|
|
27
|
+
*
|
|
28
|
+
* Runtime: Node >=18 builtins only. No shell, cross-platform.
|
|
29
|
+
*/
|
|
30
|
+
import http from 'node:http';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import {
|
|
35
|
+
LOOPBACK, answersPid, cspFrom, decodePath, forgetSelf, hostAllowed, hostWarning, isRunning, listen, portMatches, readState,
|
|
36
|
+
resolveInside, send, sendFile, siblingState, stopServer, urlFor, writeState,
|
|
37
|
+
} from './preview-lib.mjs';
|
|
38
|
+
|
|
39
|
+
function handler(dir, headers, host) {
|
|
40
|
+
return (req, res) => {
|
|
41
|
+
if (!hostAllowed(req.headers.host, [host])) return send(res, 403, 'host not allowed', headers);
|
|
42
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, '405', headers);
|
|
43
|
+
const urlPath = decodePath(req.url);
|
|
44
|
+
if (urlPath === null) return send(res, 400, '400', headers);
|
|
45
|
+
if (answersPid(urlPath, res)) return;
|
|
46
|
+
const fp = resolveInside(dir, urlPath === '/' ? '/index.html' : urlPath);
|
|
47
|
+
if (!fp) return send(res, 403, '403', headers);
|
|
48
|
+
sendFile(req, res, fp, headers);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Serves `dir` until close(). Resolves to { server, port, host, url, stateFile, close }.
|
|
54
|
+
* @param {{ port?: number, host?: string, csp?: string, stateFile?: string }} opts
|
|
55
|
+
*/
|
|
56
|
+
export async function startServe(dir, { port, host = process.env.PREVIEW_HOST || LOOPBACK, csp, stateFile } = {}) {
|
|
57
|
+
const root = path.resolve(dir);
|
|
58
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`serve: not a directory: ${root}`);
|
|
59
|
+
const policy = cspFrom(csp);
|
|
60
|
+
const server = http.createServer(handler(root, policy ? { 'Content-Security-Policy': policy } : {}, host));
|
|
61
|
+
const bound = await listen(server, { host, port });
|
|
62
|
+
const url = urlFor(host, bound);
|
|
63
|
+
const file = stateFile ? path.resolve(stateFile) : siblingState(root);
|
|
64
|
+
writeState(file, {
|
|
65
|
+
pid: process.pid, port: bound, requestedPort: port || null, host, url, dir: root, csp: policy, startedAt: new Date().toISOString(),
|
|
66
|
+
});
|
|
67
|
+
const close = () => new Promise((resolve) => { forgetSelf(file); server.close(() => resolve()); });
|
|
68
|
+
return { server, port: bound, host, url, stateFile: file, close };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseArgs(argv) {
|
|
72
|
+
const o = { dir: null, port: null, csp: null, state: null, stop: false };
|
|
73
|
+
for (let i = 0; i < argv.length; i++) {
|
|
74
|
+
const a = argv[i];
|
|
75
|
+
const [key, inline] = a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, null];
|
|
76
|
+
const value = () => (inline !== null ? inline : argv[++i]);
|
|
77
|
+
if (key === '--port') o.port = Number(value());
|
|
78
|
+
else if (key === '--csp') o.csp = value();
|
|
79
|
+
else if (key === '--state') o.state = value();
|
|
80
|
+
else if (key === '--stop') o.stop = true;
|
|
81
|
+
else if (!a.startsWith('--')) o.dir = a;
|
|
82
|
+
}
|
|
83
|
+
return o;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Nothing left running is a success; a port held by another program, or one that stays open, is not.
|
|
87
|
+
async function stopMain(o, dir) {
|
|
88
|
+
const r = await stopServer(o.state ? path.resolve(o.state) : siblingState(dir));
|
|
89
|
+
console.log(`STOP ${r.reason}`);
|
|
90
|
+
process.exit(['stopped', 'not-running', 'no-state'].includes(r.reason) ? 0 : 1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Whether the recorded server answers this start: this folder, the same CSP and host, and the port
|
|
94
|
+
// asked for. A state file shared through --state can record another folder's server.
|
|
95
|
+
const sameSettings = (st, o, dir) => path.resolve(String(st.dir || '')) === dir && (st.csp || null) === cspFrom(o.csp)
|
|
96
|
+
&& (st.host || LOOPBACK) === (process.env.PREVIEW_HOST || LOOPBACK) && portMatches(st, o.port);
|
|
97
|
+
|
|
98
|
+
// A server already running for this state file: the same settings only report its address; other
|
|
99
|
+
// settings (folder, CSP, host, port) never pass for it, so a check meant to run under a CSP cannot
|
|
100
|
+
// run without one, and a page meant for one folder or port is not checked on another.
|
|
101
|
+
async function reportRunning(recorded, o, dir) {
|
|
102
|
+
if (!(await isRunning(recorded))) return false;
|
|
103
|
+
const st = readState(recorded);
|
|
104
|
+
if (sameSettings(st, o, dir)) {
|
|
105
|
+
console.log(`PREVIEW ${st.url} (already running, pid ${st.pid})`);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
console.error(`serve: the state file records a server running with different settings (folder: ${st.dir}, `
|
|
109
|
+
+ `csp: ${st.csp ? 'on' : 'off'}, host: ${st.host}, port: ${st.port}); stop it with --stop and start again`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function main() {
|
|
114
|
+
const o = parseArgs(process.argv.slice(2));
|
|
115
|
+
const dir = path.resolve(o.dir || process.cwd());
|
|
116
|
+
if (o.stop) return stopMain(o, dir);
|
|
117
|
+
if (await reportRunning(o.state ? path.resolve(o.state) : siblingState(dir), o, dir)) return;
|
|
118
|
+
const s = await startServe(dir, { port: o.port || undefined, csp: o.csp || undefined, stateFile: o.state || undefined });
|
|
119
|
+
const warn = hostWarning(s.host);
|
|
120
|
+
if (warn) console.warn(warn);
|
|
121
|
+
console.log(`PREVIEW ${s.url}`);
|
|
122
|
+
console.log(`serving ${dir}${o.csp ? ' (with Content-Security-Policy)' : ''} state: ${s.stateFile}`);
|
|
123
|
+
const quit = () => { forgetSelf(s.stateFile); s.server.close(); process.exit(0); };
|
|
124
|
+
process.on('SIGINT', quit);
|
|
125
|
+
process.on('SIGTERM', quit);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Node resolves links for import.meta.url but not for process.argv[1], so both go through realpath:
|
|
129
|
+
// compared as given, a run through a linked folder would exit 0 without doing anything.
|
|
130
|
+
const invokedDirectly = () => {
|
|
131
|
+
try {
|
|
132
|
+
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
if (invokedDirectly()) {
|
|
138
|
+
main().catch((e) => { console.error(String(e?.message || e)); process.exit(1); });
|
|
139
|
+
}
|