@kaydash9999/banker-plugins 0.20.2 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +179 -1
- package/PRIVACY.md +13 -3
- package/README.md +25 -2
- package/bin/banker.js +84 -40
- package/bin/lib/telemetry-config.mjs +31 -16
- package/codex/manifest.json +1 -1
- package/codex/transform-matrix.md +1 -1
- package/commands/setup.md +23 -0
- package/hooks/progress.mjs +143 -38
- package/hooks/register.mjs +13 -3
- package/hooks/update-checkin.mjs +15 -7
- package/hooks/update-fetch.mjs +15 -9
- package/hooks/update-notify.mjs +2 -9
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +11 -5
- package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
- package/skills/3d-intro-build/references/curate.mjs +48 -14
- package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
- package/skills/3d-intro-build/references/prompts.md +4 -4
- package/skills/3d-intro-build/references/serve.mjs +6 -1
- package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
- package/skills/3d-intro-build/references/video-pool.mjs +250 -135
- package/skills/3d-intro-setup/SKILL.md +41 -10
- package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
- package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
- package/skills/anti-drm/SKILL.md +6 -3
- package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
- package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
- package/skills/audit-mock/SKILL.md +1 -1
- package/skills/audit-security/SKILL.md +64 -2
- package/skills/deep-research/SKILL.md +5 -0
- package/skills/docs-setup/SKILL.md +37 -7
- package/skills/lineage/SKILL.md +12 -12
- package/skills/lineage/lineage.py +399 -300
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/motion-graphic-make/references/curate.mjs +48 -14
- package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
- package/skills/motion-graphic-make/references/serve.mjs +6 -1
- package/skills/motion-graphic-setup/SKILL.md +58 -13
- package/skills/obsidizer/SKILL.md +1 -1
- package/skills/obsidizer/obsidize.mjs +8 -1
- package/skills/omc-patch/SKILL.md +80 -23
- package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
- package/skills/payload-mon/SKILL.md +4 -2
- package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
- package/skills/payload-mon/scripts/payload-size.mjs +4 -1
- package/skills/pdf-vision-extract/SKILL.md +11 -6
- package/skills/ralph-qa/SKILL.md +125 -151
- package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
- package/skills/ralph-qa/references/payload-scan.mjs +22 -7
- package/skills/ralph-qa/references/seat-payload.mjs +646 -0
- package/skills/ralph-qa/references/seat-run.mjs +956 -0
- package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
- package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
- package/skills/setup-harness-factory/SKILL.md +17 -1
- package/skills/setup-insane-search/SKILL.md +16 -0
- package/skills/setup-java/SKILL.md +20 -1
- package/skills/setup-lsp/SKILL.md +56 -13
- package/skills/setup-mcp/SKILL.md +73 -4
- package/skills/setup-node/SKILL.md +31 -6
- package/skills/setup-omc/SKILL.md +24 -0
- package/skills/setup-omc-hud/SKILL.md +9 -6
- package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
- package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
- package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
- package/skills/setup-playwright/SKILL.md +45 -2
- package/skills/setup-pwsh/SKILL.md +19 -0
- package/skills/setup-python/SKILL.md +45 -7
- package/skills/setup-sandbox/SKILL.md +30 -5
- package/skills/setup-stitch/SKILL.md +22 -0
- package/skills/setup-tmux/SKILL.md +24 -4
- package/skills/tone-compact/SKILL.md +2 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
- package/skills/ultra-chain/SKILL.md +4 -3
- package/skills/ultra-chain/references/preview-lib.mjs +51 -15
- package/skills/ultra-chain/references/serve.mjs +6 -1
- package/skills/ultra-interview/SKILL.md +1 -0
- package/skills/update-banker/SKILL.md +19 -3
- package/skills/vertical-pptx/SKILL.md +6 -3
- 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
|
-
|
|
108
|
-
fs.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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) &&
|
|
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 (!
|
|
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
|
-
|
|
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` 가 존재 **이고**
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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순위(권한
|
|
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 의 `
|
|
155
|
+
- hyperframes 가 이 바이너리를 인식하는지는 §3 의 `doctor` 로 확인한다.
|
|
114
156
|
- 재강조: 이 폴백은 사용자 프로젝트에 설치되는 npm 의존성일 뿐, banker 플러그인 저장소(`skills/`)나 배포 아티팩트에는 어떤 ffmpeg 바이너리도 포함되지 않는다.
|
|
115
157
|
|
|
116
158
|
## 3. hyperframes 설치 · 최신화
|
|
117
159
|
```bash
|
|
118
|
-
npx hyperframes
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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 필요,
|
|
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
|
-
|
|
430
|
+
syncIfAble(fd);
|
|
424
431
|
} catch (err) {
|
|
425
432
|
fs.closeSync(fd);
|
|
426
433
|
discardTemp(tmp);
|