@kaydash9999/banker-plugins 0.20.1 → 0.20.5

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 (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +195 -1
  4. package/PRIVACY.md +13 -3
  5. package/README.md +27 -4
  6. package/bin/banker.js +84 -40
  7. package/bin/lib/telemetry-config.mjs +31 -16
  8. package/codex/manifest.json +1 -1
  9. package/codex/transform-matrix.md +1 -1
  10. package/commands/setup.md +23 -0
  11. package/hooks/progress.mjs +143 -38
  12. package/hooks/register.mjs +13 -3
  13. package/hooks/update-checkin.mjs +15 -7
  14. package/hooks/update-fetch.mjs +15 -9
  15. package/hooks/update-notify.mjs +2 -9
  16. package/package.json +1 -1
  17. package/skills/3d-intro-build/SKILL.md +11 -5
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  19. package/skills/3d-intro-build/references/curate.mjs +48 -14
  20. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  21. package/skills/3d-intro-build/references/prompts.md +4 -4
  22. package/skills/3d-intro-build/references/serve.mjs +6 -1
  23. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  24. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  25. package/skills/3d-intro-setup/SKILL.md +41 -10
  26. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  28. package/skills/anti-drm/SKILL.md +6 -3
  29. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  30. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  31. package/skills/audit-mock/SKILL.md +1 -1
  32. package/skills/audit-security/SKILL.md +64 -2
  33. package/skills/deep-research/SKILL.md +5 -0
  34. package/skills/docs-setup/SKILL.md +37 -7
  35. package/skills/lineage/SKILL.md +12 -12
  36. package/skills/lineage/lineage.py +399 -300
  37. package/skills/motion-graphic-make/SKILL.md +1 -1
  38. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  39. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  40. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  41. package/skills/motion-graphic-setup/SKILL.md +58 -13
  42. package/skills/obsidizer/SKILL.md +1 -1
  43. package/skills/obsidizer/obsidize.mjs +8 -1
  44. package/skills/omc-patch/SKILL.md +80 -23
  45. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  46. package/skills/payload-mon/SKILL.md +5 -2
  47. package/skills/payload-mon/scripts/payload-mon.mjs +137 -30
  48. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  49. package/skills/pdf-vision-extract/SKILL.md +11 -6
  50. package/skills/ralph-qa/SKILL.md +125 -151
  51. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  52. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  53. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  54. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  55. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  56. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  57. package/skills/setup-harness-factory/SKILL.md +17 -1
  58. package/skills/setup-insane-search/SKILL.md +16 -0
  59. package/skills/setup-java/SKILL.md +20 -1
  60. package/skills/setup-lsp/SKILL.md +56 -13
  61. package/skills/setup-mcp/SKILL.md +73 -4
  62. package/skills/setup-node/SKILL.md +31 -6
  63. package/skills/setup-omc/SKILL.md +24 -0
  64. package/skills/setup-omc-hud/SKILL.md +13 -5
  65. package/skills/setup-omc-hud/assets/apply-hud.mjs +381 -27
  66. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  67. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  68. package/skills/setup-playwright/SKILL.md +45 -2
  69. package/skills/setup-pwsh/SKILL.md +19 -0
  70. package/skills/setup-python/SKILL.md +45 -7
  71. package/skills/setup-sandbox/SKILL.md +30 -5
  72. package/skills/setup-stitch/SKILL.md +22 -0
  73. package/skills/setup-tmux/SKILL.md +24 -4
  74. package/skills/tone-compact/SKILL.md +2 -1
  75. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  76. package/skills/ultra-chain/SKILL.md +4 -3
  77. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  78. package/skills/ultra-chain/references/serve.mjs +6 -1
  79. package/skills/ultra-interview/SKILL.md +1 -0
  80. package/skills/update-banker/SKILL.md +19 -3
  81. package/skills/vertical-pptx/SKILL.md +6 -3
  82. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -13,7 +13,7 @@ description: "10초 안팎의 내레이션 없는 모션 그래픽(키네틱 타
13
13
  ## 0. 전제조건: hyperframes (없으면 설치부터)
14
14
  이 스킬은 hyperframes CLI 가 설치, 정상 동작해야 한다.
15
15
  **진행 전 가용성을 먼저 확인**하고, 문제가 있으면 **설치부터 안내**한다(추정, 임의 재설치 금지):
16
- - 확인: `npx hyperframes doctor`
16
+ - 확인: `npx hyperframes doctor`(오프라인에서는 전역 설치한 `hyperframes doctor`. 깔린 것이 없으면 `npx` 가 패키지를 받으려 해 네트워크가 없으면 약 70초 뒤 실패한다)
17
17
  - 실패/미설치면 먼저 `motion-graphic-setup` 스킬로 설치(Claude Code: `/banker:setup` 에서 motion-graphic-setup / Codex: `banker-motion-graphic-setup`). 설치, 검증 후 이 스킬을 이어서 진행한다.
18
18
  - 독립 검증에서 배치한 페이지를 잴 때는 Node 용 playwright 패키지와 Chromium 이 필요하다(`npm i -g playwright && npx playwright install chromium`).
19
19
 
@@ -64,10 +64,28 @@ const MEDIA = /\.(png|jpe?g|webp|gif|mp4|webm|mov)$/i;
64
64
  function linkRefusal(dir, fp) {
65
65
  let real;
66
66
  try { real = fs.realpathSync(fp); } catch { return null; }
67
+ return realRefusal(dir, real);
68
+ }
69
+
70
+ function realRefusal(dir, real) {
67
71
  if (!real.startsWith(fs.realpathSync(dir) + path.sep)) return '링크가 프로젝트 폴더 밖을 가리켜 보이지 않습니다.';
68
72
  return MEDIA.test(real) ? null : '링크가 이미지나 영상이 아닌 파일을 가리켜 보이지 않습니다.';
69
73
  }
70
74
 
75
+ // What a listed path may be served as: { dev, ino } (BigInt) of the file its links lead to, taken without
76
+ // following a link, so a link put in its place after this check is not that file. null when there is
77
+ // no such file, or its links leave the project or end at a file that is not an image or a video.
78
+ function servedFile(dir, fp) {
79
+ try {
80
+ const real = fs.realpathSync(fp);
81
+ if (realRefusal(dir, real)) return null;
82
+ const st = fs.lstatSync(real, { bigint: true });
83
+ return { dev: st.dev, ino: st.ino };
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
71
89
  // Why a listed file is neither shown nor served nor hashed, or null when it may be: it must be an
72
90
  // image or a video inside the project, by its path and by where its links lead, so a key file or
73
91
  // a record listed by mistake never leaves the folder.
@@ -103,9 +121,11 @@ export function readDecisions(dir) {
103
121
  }
104
122
 
105
123
  // Written whole or not at all: a crash mid-write leaves the previous file.
124
+ // The temp name is random and opened exclusively: a link a repository put at a guessable name is never written through.
106
125
  function writeJson(file, value) {
107
- fs.writeFileSync(`${file}.tmp`, JSON.stringify(value, null, 2) + '\n');
108
- fs.renameSync(`${file}.tmp`, file);
126
+ const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
127
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { flag: 'wx' });
128
+ try { fs.renameSync(tmp, file); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
109
129
  }
110
130
 
111
131
  const writeDecisions = (dir, all) => writeJson(path.join(dir, DECISIONS), all);
@@ -247,20 +267,26 @@ export function archiveRejected(dir, { now = new Date() } = {}) {
247
267
  // path turns into one after the first check).
248
268
  const hashes = new Map();
249
269
  const notRegular = () => new Error('일반 파일 아님');
270
+ // A file changed within this long of a hash may change again in the same timestamp step (2 s on FAT):
271
+ // its hash is not kept, so the next view reads it again.
272
+ const RACY_MS = 3000n;
273
+ const keyOf = (fp, st) => `${fp}:${st.dev}:${st.ino}:${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
274
+ const racy = (st, startedNs) => startedNs - (st.ctimeNs > st.mtimeNs ? st.ctimeNs : st.mtimeNs) < RACY_MS * 1_000_000n;
250
275
  export async function hashFile(fp) {
251
276
  if (!(await fs.promises.stat(fp)).isFile()) throw notRegular();
252
277
  const fh = await fs.promises.open(fp, READ_FLAGS);
253
278
  let done;
254
279
  try {
255
- const st = await fh.stat();
280
+ const startedNs = BigInt(Date.now()) * 1_000_000n;
281
+ const st = await fh.stat({ bigint: true });
256
282
  if (!st.isFile()) throw notRegular();
257
- const key = `${fp}:${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`;
283
+ const key = keyOf(fp, st);
258
284
  done = hashes.get(key);
259
285
  if (done) return done;
260
- done = sha256Of(fh, st.size);
261
- done.catch(() => hashes.delete(key));
286
+ done = sha256Of(fh, Number(st.size));
262
287
  hashes.set(key, done);
263
288
  await done.catch(() => {});
289
+ if (racy(st, startedNs) || !(await done.then(() => true, () => false))) hashes.delete(key);
264
290
  } finally {
265
291
  await fh.close();
266
292
  }
@@ -287,7 +313,10 @@ async function hashText(dir, file) {
287
313
 
288
314
  // The hash text of every listed take by file, four files at a time per call (a long list must not
289
315
  // run out of file handles), before the page is drawn.
290
- async function takeHashes(input, dir) {
316
+ // A take not hashed within waitMs shows that its read goes on; the next view shows its hash.
317
+ const HASH_WAIT_MS = 5000;
318
+ const STILL_READING = '읽는 중(새로 고치면 보임)';
319
+ async function takeHashes(input, dir, waitMs = HASH_WAIT_MS) {
291
320
  const files = [...new Set(input.scenes.flatMap((s) => s.takes.map((t) => t.file)))];
292
321
  const out = new Map();
293
322
  let next = 0;
@@ -297,8 +326,12 @@ async function takeHashes(input, dir) {
297
326
  out.set(f, await hashText(dir, f));
298
327
  }
299
328
  };
300
- await Promise.all(Array.from({ length: Math.min(4, files.length) }, worker));
301
- return out;
329
+ const all = Promise.all(Array.from({ length: Math.min(4, files.length) }, worker));
330
+ let timer;
331
+ const late = new Promise((resolve) => { timer = setTimeout(resolve, waitMs); });
332
+ await Promise.race([all, late]);
333
+ clearTimeout(timer);
334
+ return new Map(files.map((f) => [f, out.has(f) ? out.get(f) : STILL_READING]));
302
335
  }
303
336
 
304
337
  function media(t, dir) {
@@ -366,11 +399,11 @@ function summary(input, entries) {
366
399
  }
367
400
 
368
401
  /** The review page for `input`. Resolves once every listed take is hashed. */
369
- export async function renderPage(input, decisions, { dir }) {
402
+ export async function renderPage(input, decisions, { dir, hashWaitMs }) {
370
403
  const stage = decisions[input.stage] || { scenes: [] };
371
404
  const entries = new Map(stage.scenes.map((e) => [e.id, { ...e, rejected: e.rejected || [] }]));
372
405
  const promptLabel = input.promptLabel || (INSTRUCTION_STAGES.has(input.stage) ? '구성 지시문' : '프롬프트');
373
- const ctx = { dir, promptLabel, entries, hashes: await takeHashes(input, dir) };
406
+ const ctx = { dir, promptLabel, entries, hashes: await takeHashes(input, dir, hashWaitMs) };
374
407
  const body = input.scenes.map((s, i) => sceneBlock(s, i, ctx)).join('\n') || '<p class="empty">curate-input.json 에 장면이 없습니다.</p>';
375
408
  return `<!doctype html>
376
409
  <html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
@@ -505,8 +538,9 @@ async function page(dir, res) {
505
538
  function serveListed(dir, req, res, urlPath) {
506
539
  const fp = resolveInside(dir, urlPath);
507
540
  if (!fp) return send(res, 403, '403');
508
- if (!listedFiles(dir).has(fp) || linkRefusal(dir, fp)) return send(res, 404, '404');
509
- sendFile(req, res, fp);
541
+ const expect = listedFiles(dir).has(fp) ? servedFile(dir, fp) : null;
542
+ if (!expect) return send(res, 404, '404');
543
+ sendFile(req, res, fp, {}, { expect });
510
544
  }
511
545
 
512
546
  function route(dir, req, res, urlPath) {
@@ -546,7 +580,7 @@ export async function startCurate(dir, { port, host = process.env.PREVIEW_HOST |
546
580
  // Starts hashing the listed takes, so the first view of the page does not wait for a large render.
547
581
  // An input that cannot be read hashes nothing here; the page reports it.
548
582
  function warmHashes(dir) {
549
- try { return takeHashes(readInput(dir), dir).catch(() => new Map()); } catch { return Promise.resolve(new Map()); }
583
+ try { return takeHashes(readInput(dir), dir, 2 ** 31 - 1).catch(() => new Map()); } catch { return Promise.resolve(new Map()); }
550
584
  }
551
585
 
552
586
  // ---- CLI ------------------------------------------------------------------------
@@ -16,11 +16,13 @@
16
16
  *
17
17
  * Runtime: Node >=18 builtins only (node:fs / node:http / node:net / node:path). No shell.
18
18
  */
19
+ import crypto from 'node:crypto';
19
20
  import fs from 'node:fs';
20
21
  import http from 'node:http';
21
22
  import net from 'node:net';
23
+ import os from 'node:os';
22
24
  import path from 'node:path';
23
- import { pipeline } from 'node:stream';
25
+ import { Transform, pipeline } from 'node:stream';
24
26
 
25
27
  export const LOOPBACK = '127.0.0.1';
26
28
  // Answers { pid } so a stop can tell this server from another process on the same port.
@@ -80,39 +82,57 @@ export function send(res, status, body, headers = {}) {
80
82
  export const READ_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0);
81
83
 
82
84
  // An open regular file: { fd, size }, or { status } when there is none (404) or it cannot be read (403).
83
- function openRegular(fp) {
85
+ // With `expect` ({ dev, ino } of the file a check passed, as BigInt), another file at the path is none:
86
+ // the path changed between that check and this open. BigInt, since an NTFS file id is 64 bits and a
87
+ // Number keeps only 53: two files could read as one.
88
+ function openRegular(fp, expect) {
84
89
  let fd;
85
90
  try { fd = fs.openSync(fp, READ_FLAGS); } catch (e) { return { status: ['ENOENT', 'ENOTDIR', 'EISDIR'].includes(e.code) ? 404 : 403 }; }
86
91
  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 };
92
+ try { st = fs.fstatSync(fd, { bigint: true }); } catch { /* a status the system cannot give (EIO on a network drive) */ }
93
+ if (st && st.isFile() && sameFile(st, expect)) return { fd, size: Number(st.size) };
89
94
  fs.closeSync(fd);
90
95
  return { status: st ? 404 : 403 };
91
96
  }
92
97
 
98
+ const sameFile = (st, expect) => !expect || (st.dev === expect.dev && st.ino === expect.ino);
99
+
93
100
  // Sends the bytes `start`..`end` of an open file (what the headers declared, even if the file grows
94
101
  // meanwhile) and closes it, also when the browser drops the download midway (a pipe would keep the
95
102
  // file open). Nothing to send closes the file and ends the response.
96
- function stream(res, fp, { fd, start, end }) {
97
- if (end < start) {
103
+ // A HEAD request gets the headers only: the file is closed unread.
104
+ function stream(res, fp, { fd, start, end, head }) {
105
+ if (head || end < start) {
98
106
  fs.closeSync(fd);
99
107
  return void res.end();
100
108
  }
101
- pipeline(fs.createReadStream(fp, { fd, start, end }), res, () => {});
109
+ pipeline(fs.createReadStream(fp, { fd, start, end }), declaredLength(end - start + 1), res, () => {});
110
+ }
111
+
112
+ // Passes the bytes on and fails at the end when they were fewer than the headers declared (the file
113
+ // shrank since): the pipeline then destroys the response, which closes the connection. Ended as
114
+ // usual, it would leave the browser waiting on a kept-alive connection for bytes that never come.
115
+ function declaredLength(want) {
116
+ let sent = 0;
117
+ return new Transform({
118
+ transform(chunk, _enc, done) { sent += chunk.length; done(null, chunk); },
119
+ flush(done) { done(sent < want ? new Error('the file is shorter than its declared length') : null); },
120
+ });
102
121
  }
103
122
 
104
123
  // A file, with byte ranges for video (smoother scrubbing). Never cached: a rebuilt page is fresh.
105
124
  // 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);
125
+ // ending the server. `expect` ({ dev, ino }) names the only file that may go out (404 otherwise).
126
+ export function sendFile(req, res, fp, headers = {}, { expect } = {}) {
127
+ const f = openRegular(fp, expect);
109
128
  if (f.status) return send(res, f.status, String(f.status), headers);
129
+ f.head = req.method === 'HEAD';
110
130
  const type = MIME[path.extname(fp).toLowerCase()] || 'application/octet-stream';
111
131
  const base = { ...headers, 'Content-Type': type, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' };
112
132
  const range = type.startsWith('video/') ? /bytes=(\d+)-(\d*)/.exec(req.headers.range || '') : null;
113
133
  if (range) return sendRange(res, fp, f, range, base, headers);
114
134
  res.writeHead(200, { ...base, 'Content-Length': f.size });
115
- stream(res, fp, { fd: f.fd, start: 0, end: f.size - 1 });
135
+ stream(res, fp, { fd: f.fd, start: 0, end: f.size - 1, head: f.head });
116
136
  }
117
137
 
118
138
  function sendRange(res, fp, f, range, base, headers) {
@@ -124,7 +144,7 @@ function sendRange(res, fp, f, range, base, headers) {
124
144
  return void res.end();
125
145
  }
126
146
  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 });
147
+ stream(res, fp, { fd: f.fd, start, end, head: f.head });
128
148
  }
129
149
 
130
150
  // True when the request was the PID question, which this answers.
@@ -169,8 +189,12 @@ export const portMatches = (st, port) => !port || st.port === port || st.request
169
189
  // The state file of a server for `dir`: beside the folder, so a deployed folder never carries it.
170
190
  export const siblingState = (dir) => path.join(path.dirname(dir), `${path.basename(dir)}.server.json`);
171
191
 
192
+ // Through a fresh random name opened exclusively, then renamed over the old file: a link at the state
193
+ // file's name (one a repository can bring) is replaced, never written through.
172
194
  export function writeState(file, info) {
173
- fs.writeFileSync(file, JSON.stringify(info, null, 2) + '\n');
195
+ const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
196
+ fs.writeFileSync(tmp, JSON.stringify(info, null, 2) + '\n', { flag: 'wx' });
197
+ try { fs.renameSync(tmp, file); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
174
198
  }
175
199
 
176
200
  export function readState(file) {
@@ -220,10 +244,22 @@ async function waitClosed(port, timeoutMs, host) {
220
244
  // A PID a state file may name: a whole number above 1 (never a process group, never init).
221
245
  const validPid = (pid) => Number.isInteger(pid) && pid > 1;
222
246
 
247
+ // A host a state file may name: this machine (loopback, a wildcard, an address of its own). A state file
248
+ // can come with a repository: its host must not send a request elsewhere, nor pick the PID a signal goes to.
249
+ // A record a stop or a start may act on: a PID that is a process id, on this machine.
250
+ const recordedHere = (st) => validPid(st.pid) && thisMachine(st.host);
251
+
252
+ // No host is the default loopback one. A loopback address counts only as an IPv4 address of 127/8: `127.evil.test`
253
+ // or `127.256.0.1` is a DNS name, resolved anywhere (a resolver's search domain may answer it).
254
+ export function thisMachine(host) {
255
+ if (!host || ['0.0.0.0', '::', '::1', 'localhost'].includes(host) || (net.isIPv4(String(host)) && String(host).startsWith('127.'))) return true;
256
+ return Object.values(os.networkInterfaces()).flat().some((a) => a && a.address === host);
257
+ }
258
+
223
259
  /** True when the server a state file records still answers on its port with its PID. */
224
260
  export async function isRunning(stateFile) {
225
261
  const st = readState(stateFile);
226
- return Boolean(st && st.port) && validPid(st.pid) && (await askPid(st.port, 1500, st.host)) === st.pid;
262
+ return Boolean(st && st.port) && recordedHere(st) && (await askPid(st.port, 1500, st.host)) === st.pid;
227
263
  }
228
264
 
229
265
  function signal(pid, sig) {
@@ -236,7 +272,7 @@ function signal(pid, sig) {
236
272
  export async function stopServer(stateFile, { timeoutMs = 3000 } = {}) {
237
273
  const st = readState(stateFile);
238
274
  if (!st || !st.port) return { stopped: false, reason: 'no-state' };
239
- if (!validPid(st.pid)) return { stopped: false, reason: 'invalid-state' };
275
+ if (!recordedHere(st)) return { stopped: false, reason: 'invalid-state' };
240
276
  const who = await askPid(st.port, Math.min(timeoutMs, 1500), st.host);
241
277
  if (who === NONE) {
242
278
  dropState(stateFile);
@@ -95,6 +95,9 @@ async function stopMain(o, dir) {
95
95
  const sameSettings = (st, o, dir) => path.resolve(String(st.dir || '')) === dir && (st.csp || null) === cspFrom(o.csp)
96
96
  && (st.host || LOOPBACK) === (process.env.PREVIEW_HOST || LOOPBACK) && portMatches(st, o.port);
97
97
 
98
+ // A value in one quoted piece, with "?" for what a reader takes for a line end or what changes how text shows.
99
+ const oneLine = (v) => JSON.stringify(String(v)).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '?');
100
+
98
101
  // A server already running for this state file: the same settings only report its address; other
99
102
  // settings (folder, CSP, host, port) never pass for it, so a check meant to run under a CSP cannot
100
103
  // run without one, and a page meant for one folder or port is not checked on another.
@@ -105,7 +108,9 @@ async function reportRunning(recorded, o, dir) {
105
108
  console.log(`PREVIEW ${st.url} (already running, pid ${st.pid})`);
106
109
  return true;
107
110
  }
108
- console.error(`serve: the state file records a server running with different settings (folder: ${st.dir}, `
111
+ // the folder in one quoted piece, and as "?" what a reader takes for a line end or what changes how text shows (a
112
+ // line or paragraph separator, NEL, a bidi override, a tag character): none of it adds a line to what an agent reads
113
+ console.error(`serve: the state file records a server running with different settings (folder: ${oneLine(st.dir)}, `
109
114
  + `csp: ${st.csp ? 'on' : 'off'}, host: ${st.host}, port: ${st.port}); stop it with --stop and start again`);
110
115
  process.exit(1);
111
116
  }
@@ -28,7 +28,38 @@ winget --version
28
28
  if (Get-Command node -ErrorAction SilentlyContinue) { node -v } else { "node: 미설치" }
29
29
  if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { (ffmpeg -version)[0] } else { "ffmpeg: 미설치" }
30
30
  ```
31
- **이미 설치 확인**: `node -v` 가 22 이상 **이고** `ffmpeg` 가 존재 **이고** `npx hyperframes doctor` 가 통과하면, 아래 §1~§3 은 건너뛰고 §4 검증만 수행한다.
31
+ **이미 설치 확인**: `node -v` 가 22 이상 **이고** `ffmpeg` 가 존재 **이고** hyperframes 진단이 통과하면, 아래 §1~§3 은 건너뛰고 §4 검증만 수행한다.
32
+ - 진단은 전역 설치가 있으면 `hyperframes doctor` 다. 없으면 아래 네트워크 확인이 통과한 뒤에만 `npx hyperframes doctor` 를 부른다. 깔린 것이 없는 `npx` 는 패키지를 받으려 해, 오프라인이면 약 70초 뒤 실패하고 그 실패가 "미설치" 로 읽힌다.
33
+ - 전역 설치가 없고 네트워크 확인이 실패하면 진단하지 않는다. hyperframes 는 "오프라인이라 확인하지 못함" 으로 둔다.
34
+
35
+ ### 네트워크 확인 (내려받기 전)
36
+
37
+ 이 스킬이 내려받는 원천에 닿는지 먼저 본다(curl 은 10초, npm 은 15초 안). 닿지 않으면 그 원천에서 받는 단계는 하지 않고, 이미 설치된 것의 확인만 한 뒤 "오프라인이라 설치하지 않음(필요한 것: <못 깐 것>)" 으로 보고한다.
38
+
39
+ ```bash
40
+ [ "${CODEX_SANDBOX_NETWORK_DISABLED:-}" = 1 ] && echo "Codex sandbox 가 네트워크를 막음(아래 실패는 오프라인이 아님)"
41
+ in15() { "$@" & p=$!; ( sleep 15 && kill "$p" ) > /dev/null 2>&1 & k=$!; wait "$p" 2>/dev/null; r=$?; kill "$k" 2>/dev/null; wait "$k" 2>/dev/null; [ "$r" = 143 ] && echo "15초 안에 끝나지 않아 멈춤(패킷을 버리는 망일 수 있음)"; return "$r"; }
42
+ in15 sh -c 'cd ~ && exec npm ping --fetch-retries=0 --fetch-timeout=10000 --no-update-notifier' # hyperframes(npx), ffmpeg-static
43
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://deb.nodesource.com # Ubuntu, Debian 의 NodeSource 저장소
44
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://rpm.nodesource.com # RHEL, Rocky 의 NodeSource 저장소
45
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://download1.rpmfusion.org # RHEL, Rocky 의 ffmpeg(RPM Fusion 저장소 rpm)
46
+ ```
47
+
48
+ - `npm ping` 은 홈 폴더에서 부른다. 지금 폴더에 있는 프로젝트의 `.npmrc` 가 정한 레지스트리와 토큰을 따르지 않게 하려는 것이다.
49
+ - `npm ping` 은 15초 감시(`in15`) 안에서 부른다. 패킷을 버리는 망에서 npm 은 연결에 시간 제한을 두지 않아(`--fetch-timeout` 은 받는 중의 쉼만 잰다) OS 의 연결 재시도가 끝날 때까지 기다린다(Rocky 8.10 실측 130초). 15초 안에 끝나지 않으면 `15초 안에 끝나지 않아 멈춤(패킷을 버리는 망일 수 있음)` 과 종료 143 이다. `--no-update-notifier` 는 npm 이 제 판을 묻는 요청을 하나 더 내지 않게 한다.
50
+ - `npm ping` 이 실패하면 ping 에 답하지 않는 사내 미러일 수 있다. `(cd ~ && T=$(mktemp -d) && { npm view hyperframes version --fetch-retries=0 --fetch-timeout=10000 --cache "$T" --no-update-notifier; r=$?; rm -rf "$T"; exit $r; })` 도 실패해야 오프라인으로 본다. 이 스킬이 설치할 패키지(`hyperframes`)를 빈 캐시로 물으므로 옛 값이 나올 수 없다. ping 이 15초 감시에 걸려 멈췄으면(종료 143) 이 확인은 하지 않는다. 같은 레지스트리라 같은 시간을 또 기다린다.
51
+ - `npm` 이 없으면(명령 없음) 오프라인이 아니라 확인하지 못한 것이다. Node 를 먼저 갖춘다(`setup-node`).
52
+ - `npm ping` 은 npm 의 프록시 설정(`proxy`, `https-proxy`)을 따른다. 사내 미러만 열린 망이면 그 미러가 쓰이는지 npm 설정을 본다.
53
+ - `curl -sSI` 는 머리만 받는다(본문을 받지 않음). 닿지 못하면 종료 코드가 0 이 아니다(HTTP 상태와 관계없음). `curl` 은 `HTTPS_PROXY` 를 따른다. 확인 줄은 `--ssl-no-revoke` 를 준다. Windows 의 Git Bash curl 도 Schannel 이라, 폐지 목록 서버에 닿지 못하는 망에서 폐지 확인이 실패하면 종료 60 이다(Windows 박스 실측). 다른 curl 은 이 깃발을 받고 무시한다(macOS curl 8.7.1, Rocky 8.10 curl 7.61.1 실측).
54
+ - Windows PowerShell 에서는 같은 주소를 `curl.exe --ssl-no-revoke -sSI -o NUL --connect-timeout 5 --max-time 10 <주소>` 로 보고 `$LASTEXITCODE` 가 0 인지 본다(PowerShell 5.1 의 `curl` 은 `Invoke-WebRequest` 별칭이다). `curl.exe` 는 Windows 시스템 프록시를 따르지 않고 `HTTPS_PROXY` 만 따른다. `--ssl-no-revoke` 는 폐지 목록 서버에 닿지 못하는 망에서 Schannel 의 폐지 확인 실패를 오프라인으로 읽지 않게 한다. 닿는지만 보고 받은 내용은 쓰지 않으므로 이 확인에만 준다. `curl.exe` 가 없으면 `$LASTEXITCODE` 에 앞 명령의 값이 남으므로 `Get-Command curl.exe -ErrorAction SilentlyContinue` 로 먼저 보고, 없으면 확인하지 못한 것으로 적는다. `npm ping` 은 `Push-Location ~; npm ping --fetch-retries=0 --fetch-timeout=10000 --no-update-notifier; Pop-Location` 처럼 홈에서 부르고 돌아온다(돌아오지 않으면 뒤 단계가 프로젝트가 아니라 홈에서 돈다). PowerShell 에는 15초 감시를 두지 않는다(Windows 의 연결 시간 제한은 약 21초로 알려져 있다. 미실측).
55
+ - 사내 미러만 열린 망이면 확인 주소를 그 미러로 바꿔 본다.
56
+ - OS 패키지 관리자(dnf, apt, brew, winget)의 단계는 자기 설정의 미러를 쓰므로 이 확인과 관계없이 시도하고, 실패하면 그 단계를 실패로 보고한다.
57
+ - 패킷을 버리거나 답하지 않는 망에서 dnf 는 저장소마다 약 2분(SYN 을 버리는 망 실측 121초), apt 는 원천마다 약 4분(답하지 않는 원천 실측 247초) 기다린 뒤 실패로 끝난다. 시간 제한은 관리자 설정을 따르므로 이 스킬이 바꾸지 않는다.
58
+ - `apt-get update` 는 받지 못해도 종료 0 이다. 출력의 `W: Failed to fetch` 가 있으면 그 단계를 실패로 보고한다.
59
+ - Codex sandbox 가 네트워크를 막는 모드(기본 `workspace-write`, `read-only`)에서는 `CODEX_SANDBOX_NETWORK_DISABLED=1` 이 서고 확인이 모두 실패한다(curl 종료 7, npm `EPERM`). 블록의 첫 줄이 그렇다고 알린다. 그때는 '오프라인' 이 아니라 'sandbox 가 네트워크를 막음' 으로 보고하고, 승인을 받아 sandbox 밖에서 확인부터 다시 한다. Claude Code sandbox 안(`SANDBOX_RUNTIME=1`)에서는 허용 목록 밖 주소를 sandbox 프록시가 막는다. Windows PowerShell 에서는 `$env:CODEX_SANDBOX_NETWORK_DISABLED -eq '1'` 로 본다.
60
+ - 확인이 실패하면 보고에 그 명령, 종료 코드, stderr 첫 줄을 붙인다(예: `npm error code ETIMEDOUT`, `curl: (28) ...`). 원인을 '오프라인' 하나로 짐작하지 않는다.
61
+ - curl 의 종료 60(인증서 검증 실패)도 오프라인이 아니다. 망은 열려 있고, TLS 를 가로채는 사내 프록시의 인증서를 OS 가 믿지 않는 것이다. '인증서 문제로 설치하지 않음' 으로 보고한다.
62
+ - curl 은 닿는데 npm 만 인증서 오류(`UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `SELF_SIGNED_CERT_IN_CHAIN`, `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`)로 실패하면 망은 열려 있고 npm 이 사내 인증서를 믿지 않는 것이다(npm 은 OS 인증서 저장소를 쓰지 않는다). '인증서 문제로 설치하지 않음' 으로 보고하고 `NODE_EXTRA_CA_CERTS=<사내 CA 파일>` 을 안내한다. Node 24 는 `NODE_OPTIONS=--use-system-ca` 도 된다.
32
63
 
33
64
  ## 1. Node ≥22 확보
34
65
  목적은 Node>=22 확보이지 특정 설치 경로 강제가 아니다.
@@ -43,21 +74,31 @@ Apple Silicon(`/opt/homebrew`)·Intel(`/usr/local`) 차이는 `brew` 가 자동
43
74
 
44
75
  ### Linux — Ubuntu / Debian
45
76
  ```bash
46
- curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
47
- sudo apt-get install -y nodejs
77
+ T=$(mktemp -d)
78
+ curl -fsSL --connect-timeout 10 --max-time 120 -o "$T/nodesource.sh" https://deb.nodesource.com/setup_22.x \
79
+ && sudo -E bash "$T/nodesource.sh" && sudo apt-get install -y nodejs
80
+ rm -rf "$T"
48
81
  node -v
49
82
  ```
83
+ 설치 스크립트는 파일로 받고, 받았을 때만 실행한다. 받기에 실패하면 그 뒤 단계는 돌지 않는다(셸에 바로 넘기면 받기가 실패해도 종료 0 이 된다).
50
84
  배포 기본 apt 저장소의 `nodejs` 는 버전이 낮은 경우가 많아 NodeSource 22.x 저장소를 사용한다.
51
85
 
52
86
  ### Linux — RHEL8 / Rocky8
53
87
  ⚠️ dnf AppStream 모듈의 `nodejs` 스트림은 미러 구성에 따라 22 미만일 수 있다 — 사전 확인: `dnf module list nodejs`.
54
88
  ```bash
55
89
  sudo dnf module reset -y nodejs
56
- sudo dnf module enable -y nodejs:22 2>/dev/null || \
57
- curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo -E bash -
58
- sudo dnf install -y nodejs
90
+ ok=
91
+ if sudo dnf module enable -y nodejs:22 2>/dev/null; then ok=1
92
+ else
93
+ T=$(mktemp -d)
94
+ curl -fsSL --connect-timeout 10 --max-time 120 -o "$T/nodesource.sh" https://rpm.nodesource.com/setup_22.x \
95
+ && sudo -E bash "$T/nodesource.sh" && ok=1
96
+ rm -rf "$T"
97
+ fi
98
+ if [ "$ok" = 1 ]; then sudo dnf install -y nodejs; else echo "Node 22 저장소(AppStream 22 스트림, NodeSource)를 갖추지 못해 nodejs 를 설치하지 않음. setup-node 로 위임"; fi
59
99
  node -v
60
100
  ```
101
+ 두 원천이 다 실패하면 배포판 기본 스트림(RHEL8 은 22 미만)을 깔지 않는다.
61
102
  22 스트림이 없거나 sudo 가 불가하면 **`setup-node` 스킬로 위임**(nvm 경로, RHEL8 glibc 2.28 사전확인 포함) — 이쪽이 더 안전하다.
62
103
 
63
104
  ### Windows
@@ -72,7 +113,8 @@ node -v
72
113
 
73
114
  ## 2. ffmpeg 확보
74
115
  hyperframes 렌더러(headless Chrome 캡처 + ffmpeg 인코딩)가 요구하는 `ffmpeg` 실행 파일을 확보한다.
75
- 1순위는 시스템 패키지, 2순위(권한 없음/오프라인/저장소 미제공)는 프로젝트-로컬 `ffmpeg-static` npm 폴백이다.
116
+ 1순위는 시스템 패키지, 2순위(권한 없음, 저장소 미제공)는 프로젝트-로컬 `ffmpeg-static` npm 폴백이다.
117
+ 오프라인이면 `ffmpeg-static` 도 받을 수 없다(npm 설치에 네트워크가 필요하다). 미리 받아 둔 ffmpeg 실행 파일의 절대 경로를 `export FFMPEG_PATH=<절대 경로>` 로 준다.
76
118
 
77
119
  **★ banker 플러그인 자체(스킬 파일·npm 패키지 배포물)에는 ffmpeg 바이너리를 절대 번들하지 않는다.** 아래 폴백은 사용자 프로젝트의 `node_modules` 에만 설치된다.
78
120
 
@@ -110,30 +152,33 @@ export FFMPEG_PATH="$(node -e "console.log(require('ffmpeg-static'))")"
110
152
  "$FFMPEG_PATH" -version | head -1
111
153
  ```
112
154
  - `--no-save`: 전역이 아니라 **현재 프로젝트의 `node_modules`** 에만 받는다 — 사용자 워크스페이스 소관이며 banker 플러그인 소관이 아니다.
113
- - hyperframes 가 이 바이너리를 인식하는지는 §3 의 `npx hyperframes doctor` 로 확인한다.
155
+ - hyperframes 가 이 바이너리를 인식하는지는 §3 의 `doctor` 로 확인한다.
114
156
  - 재강조: 이 폴백은 사용자 프로젝트에 설치되는 npm 의존성일 뿐, banker 플러그인 저장소(`skills/`)나 배포 아티팩트에는 어떤 ffmpeg 바이너리도 포함되지 않는다.
115
157
 
116
158
  ## 3. hyperframes 설치 · 최신화
117
159
  ```bash
118
- npx hyperframes init
119
- npx hyperframes skills update
120
- npx hyperframes doctor
160
+ hf() { if command -v hyperframes >/dev/null; then hyperframes "$@"; else npx hyperframes "$@"; fi; } # 전역 설치가 있으면 그것을 부른다
161
+ hf init
162
+ hf skills update
163
+ hf doctor
121
164
  ```
122
165
  - `init`: 최초 실행 시 스캐폴딩과 함께 전역 스킬셋(라우터 `/hyperframes` + `hyperframes-*` 도메인 스킬 + `media-use`)을 설치하고, 이미 있으면 최신 여부만 GitHub 기준으로 확인한다.
123
166
  - `skills update`: 코어 스킬만 골라 최신화한다(비대화형 환경 권장 경로) — 이미 최신이면 no-op.
124
167
  - `doctor`: Node·ffmpeg·설치된 스킬 등 실행 환경을 종합 진단한다.
168
+ - 오프라인에서 쓰려면: 전역이나 프로젝트에 깔린 것이 없으면 `npx hyperframes` 는 패키지를 받으려 해, 네트워크가 없으면 약 70초 뒤 실패한다. 네트워크가 되는 곳에서 `npm i -g hyperframes@<판>` 으로 깔아 두고 `hyperframes <명령>` 을 직접 부른다. 0단계 확인이 오프라인이면 `npx` 를 쓰지 않고, 네트워크가 필요한 `init` 의 최신 확인과 `skills update` 는 "오프라인이라 설치하지 않음" 으로 보고한다.
125
169
 
126
170
  ## 4. 검증 (보고 의무)
127
171
  ```bash
128
172
  node -v # v22.x.x 이상 기대
129
173
  command -v ffmpeg >/dev/null && ffmpeg -version | head -1 || echo "시스템 ffmpeg 없음 — ffmpeg-static 폴백 확인"
130
- npx hyperframes doctor # 종합 진단(node/ffmpeg/스킬셋)
174
+ if command -v hyperframes >/dev/null; then hyperframes doctor; else npx hyperframes doctor; fi # 종합 진단(node/ffmpeg/스킬셋)
131
175
  ```
176
+ - 전역 설치가 없고 0단계 확인이 오프라인이면 진단을 부르지 않는다. Unknown 에 "오프라인이라 hyperframes 진단을 하지 못함" 으로 적는다.
132
177
  4-field 보고: 변경(설치 경로·ffmpeg 확보 방식) / Evidence(위 출력) / 검증(`node -v`≥22 + `hyperframes doctor` 통과) / Unknown(권한·네트워크 제약으로 시스템 ffmpeg 불가 등, 가짜 성공 금지).
133
178
 
134
179
  ## 함정
135
180
  - **RHEL8/Rocky8 dnf 모듈 nodejs 버전**: AppStream 스트림이 22 미만일 수 있음(사전 `dnf module list nodejs` 확인) → NodeSource 저장소 또는 `setup-node`(nvm) 로 위임.
136
- - **RHEL8/Rocky8 ffmpeg 미제공**: BaseOS/AppStream 에 없음(라이선스) → RPM Fusion 필요, 접근 막히면 `ffmpeg-static` 폴백.
181
+ - **RHEL8/Rocky8 ffmpeg 미제공**: BaseOS/AppStream 에 없음(라이선스) → RPM Fusion 필요, 저장소에만 닿지 못하면 `ffmpeg-static` 폴백(오프라인이면 미리 받은 실행 파일을 `FFMPEG_PATH` 로).
137
182
  - **ffmpeg 바이너리 미번들 원칙**: `ffmpeg-static` 폴백은 사용자 프로젝트 `node_modules` 한정 — banker 플러그인 배포물에는 절대 포함하지 않는다(라이선스·배포 크기·플랫폼별 바이너리 불일치 회피).
138
183
  - **GEMINI_API_KEY 는 선택**: AI 이미지 생성이 필요한 카테고리에서만 쓰이고, 무료 폼 카테고리(kinetic-type·stat·charts·logo-reveal·lower-thirds·maps)는 키 없이 전부 동작한다 — 이 스킬은 어떤 API 키도 요구하지 않는다.
139
184
  - **Windows PATH 충돌**: winget Node 와 nvm-windows 동시 설치 금지(상세는 `setup-node` 함정 참조).
@@ -156,7 +156,7 @@ vault에서도 문서 안에 출처가 없는 값이라 쓰면 **날조**가 된
156
156
  - **기본 = dry-run.** `--apply` 전까지 아무것도 쓰지 않는다. 미리보기 없는 자동 변형은 금지(위
157
157
  한계 섹션에서 hook 경로를 거부하는 이유이기도 하다).
158
158
  - **`--apply` 전 필수 스냅샷**: `cp -r <vault> <vault>.bak-<ts>` (compact-wiki와 동일 선례).
159
- - **모든 쓰기는 원자적**: 유니크 temp(`randomUUID`) + `wx`(배타 생성) + `fsync` + `rename`. 절대
159
+ - **모든 쓰기는 원자적**: 유니크 temp(`randomUUID`) + `wx`(배타 생성) + `fsync`(파일 시스템이 받으면. `EINVAL`, `ENOTSUP` 이면 건너뜀) + `rename`. 절대
160
160
  in-place bare write가 아니다 — OMC 자신의 `atomicWriteFileSync`와 같은 형태를, 빌려 쓰지 않고
161
161
  독립 구현한다. **read-back CAS는 hook이 아니라 캐노니컬라이저에 있고, 두 진입점(bare 실행·armed
162
162
  hook)이 함께 상속**한다: temp를 먼저 다 쓰고 → 재읽어 바뀌었으면 skip → `rename`.
@@ -403,6 +403,13 @@ function discardTemp(tmp) {
403
403
  try { fs.unlinkSync(tmp); } catch { /* renamed away, or never created */ }
404
404
  }
405
405
 
406
+ // fsync where the file system does it. One that does not (EINVAL, ENOTSUP) still gets the whole page by the rename;
407
+ // only its safety across a crash is less. The same codes as the 3d-intro and motion-graphic key files' syncIfAble.
408
+ const NO_FSYNC = new Set(['EINVAL', 'ENOTSUP']);
409
+ function syncIfAble(fd) {
410
+ try { fs.fsyncSync(fd); } catch (e) { if (!NO_FSYNC.has(e.code)) throw e; }
411
+ }
412
+
406
413
  /**
407
414
  * Write `text` into a fresh temp file beside `file` and fsync it; the caller renames it
408
415
  * into place. Never a partial in-place write (PM-7).
@@ -420,7 +427,7 @@ function writeTemp(file, text) {
420
427
  const fd = fs.openSync(tmp, 'wx', 0o600);
421
428
  try {
422
429
  fs.writeFileSync(fd, text, 'utf8');
423
- fs.fsyncSync(fd);
430
+ syncIfAble(fd);
424
431
  } catch (err) {
425
432
  fs.closeSync(fd);
426
433
  discardTemp(tmp);