@kaydash9999/banker-plugins 0.24.1 → 0.25.1
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 +50 -0
- package/README.md +5 -4
- package/bin/lib/codex-hooks.mjs +5 -1
- package/codex/hooks.json +4 -4
- package/codex/manifest.json +1 -0
- package/codex/skills/teamwork/scripts/teamwork.mjs +18 -8
- package/codex/transform-matrix.md +1 -0
- package/hooks/telemetry-count-codex.mjs +3 -2
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +1 -0
- package/skills/3d-intro-build/references/azure-adapter.mjs +54 -27
- package/skills/3d-intro-build/references/video-pool.mjs +11 -6
- package/skills/3d-intro-setup/references/azure-adapter.mjs +54 -27
- package/skills/3d-intro-setup/references/video-pool.mjs +11 -6
- package/skills/anti-drm/SKILL.md +2 -2
- package/skills/anti-drm/scripts/anti-drm.mjs +8 -5
- package/skills/anti-drm/scripts/instruction-toggle.mjs +6 -3
- package/skills/build-testbox/scripts/browser-cell.mjs +10 -2
- package/skills/build-testbox/scripts/containers.mjs +8 -3
- package/skills/build-testbox/scripts/proc.mjs +32 -8
- package/skills/build-testbox/scripts/testbox.mjs +10 -2
- package/skills/git-init/scripts/git-init.mjs +53 -5
- package/skills/git-init/scripts/marker-block.mjs +131 -19
- package/skills/init-dir/scripts/init-dir.mjs +29 -2
- package/skills/init-dir/scripts/marker-block.mjs +131 -19
- package/skills/init-dir/templates/scripts/lib/service.mjs +57 -6
- package/skills/lineage/SKILL.md +1 -1
- package/skills/lineage/lineage.py +11 -11
- package/skills/motion-sites/SKILL.md +87 -0
- package/skills/motion-sites/scripts/motion-sites.mjs +348 -0
- package/skills/omc-patch/scripts/omc-patch.mjs +76 -17
- package/skills/payload-mon/scripts/payload-mon.mjs +2 -1
- package/skills/payload-mon/scripts/payload-size.mjs +2 -2
- package/skills/progress-mon/scripts/pm-adr.mjs +6 -3
- package/skills/progress-mon/scripts/pm-config.mjs +133 -7
- package/skills/progress-mon/scripts/pm-data.mjs +35 -29
- package/skills/progress-mon/scripts/pm-memory.mjs +14 -7
- package/skills/progress-mon/scripts/pm-server.mjs +3 -3
- package/skills/progress-mon/scripts/pm-sources.mjs +2 -1
- package/skills/ralph-qa/SKILL.md +1 -0
- package/skills/ralph-qa/references/seat-run.mjs +21 -4
- package/skills/ralph-qa/references/verifier-probe.mjs +35 -13
- package/skills/remains/SKILL.md +31 -4
- package/skills/remains/scripts/boxes.mjs +44 -15
- package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +2 -1
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +12 -7
- package/skills/setup-omc-hud/assets/apply-hud.mjs +2 -1
- package/skills/setup-omc-hud/assets/codex-config.mjs +12 -7
- package/skills/setup-omc-hud/scripts/claude-update-last.mjs +2 -1
- package/skills/smart-compact/scripts/codex-gate.mjs +25 -4
- package/skills/tone-compact/scripts/instruction-toggle.mjs +6 -3
- package/skills/tune-tmux/scripts/marker-block.mjs +131 -19
- package/skills/tune-tmux/scripts/tmux-config.mjs +35 -3
- package/skills/tune-tmux/scripts/tune-tmux.mjs +15 -9
- package/skills/ultra-chain/references/chain-ledger.mjs +8 -1
- package/skills/vertical-pptx/verify_a4p.mjs +11 -4
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* file. Windows has no owner or permission bits to check.
|
|
21
21
|
* - Writes go to a temp file in the same folder and a rename. A failed write removes the temp file.
|
|
22
22
|
* - When the bytes change, the first original is kept in `<file>.<skill>.bak`; an existing backup is never
|
|
23
|
-
* overwritten.
|
|
23
|
+
* overwritten. Something else at that name (a link, a folder) refuses the change (backup), so no original is lost.
|
|
24
24
|
*
|
|
25
25
|
* Results are { ok, changed, refused?, message, path, file }. Callers map refused to exit status 1.
|
|
26
26
|
*/
|
|
@@ -220,9 +220,25 @@ const options = (opts) => ({
|
|
|
220
220
|
wrap: opts.wrap ?? null,
|
|
221
221
|
skill: opts.skill,
|
|
222
222
|
follow: opts.followSymlink === true,
|
|
223
|
+
readOnly: opts.readOnly === true, // a plan only read (never committed): no backup spot to check
|
|
223
224
|
uid: "uid" in opts ? opts.uid : process.getuid?.(),
|
|
224
225
|
});
|
|
225
226
|
|
|
227
|
+
// A file not there yet is named by its folder as the file system spells it (one key for a linked parent, an 8.3 name
|
|
228
|
+
// or the folder in another case) and its own name as given. Two names differing only in case still get two keys:
|
|
229
|
+
// where the file system takes them for one file, applyBlocks meets that when the second create finds the first.
|
|
230
|
+
const newFileKey = (file) => `path:${join(nativeReal(dirname(resolve(file))), basename(file))}`;
|
|
231
|
+
|
|
232
|
+
// The key of a file that is there now, as planBlock gives it, or null when it cannot be read.
|
|
233
|
+
function keyNow(file) {
|
|
234
|
+
try {
|
|
235
|
+
const id = statSync(file, { bigint: true });
|
|
236
|
+
return id.ino === 0n ? `path:${nativeReal(file)}` : `ino:${id.dev}:${id.ino}`;
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
226
242
|
/**
|
|
227
243
|
* Works out what applyBlock would write, without writing: { ok, changed, refused?, message, path, file, key,
|
|
228
244
|
* before, after, mode }. key names the file (device and inode, or the path of a file not there yet).
|
|
@@ -234,13 +250,17 @@ export function planBlock(path, opts) {
|
|
|
234
250
|
if (at.refuse) return at.refuse;
|
|
235
251
|
if (!at.exists) {
|
|
236
252
|
const after = o.body === null ? null : Buffer.from(blockText(o.mk, o.body, o.wrap, "\n"), "utf8");
|
|
237
|
-
return { ok: true, changed: after !== null, path, file: at.file, key:
|
|
253
|
+
return { ok: true, changed: after !== null, path, file: at.file, key: newFileKey(at.file), before: null, after, mode: NEW_FILE_MODE };
|
|
238
254
|
}
|
|
239
255
|
const read = readChecked(at.file, o.uid);
|
|
240
256
|
if (read.refuse) return read.refuse;
|
|
241
257
|
const next = nextBytes(read.bytes, o, at.file);
|
|
242
258
|
if (next.refuse) return next.refuse;
|
|
243
259
|
const changed = !next.bytes.equals(read.bytes);
|
|
260
|
+
// A change keeps the first original in <file>.<skill>.bak: something else there (a link, a folder) is refused now,
|
|
261
|
+
// in the plan, so a dry run, a status and a check say so before anything is written.
|
|
262
|
+
const bakWhy = changed && !o.readOnly ? backupSpotWhy(`${at.file}.${o.skill}.bak`) : null;
|
|
263
|
+
if (bakWhy) return refusal("backup", `${bakWhy}: ${at.file}.${o.skill}.bak`, path);
|
|
244
264
|
// ino 0 (a file system without file ids) tells nothing apart: then the path as the file system spells it is the
|
|
245
265
|
// key (realpathSync.native gives one spelling for names that differ only in case where case is ignored).
|
|
246
266
|
const key = read.id.ino === 0n ? `path:${nativeReal(at.file)}` : `ino:${read.id.dev}:${read.id.ino}`;
|
|
@@ -264,36 +284,111 @@ function writeAtomic(file, data, mode) {
|
|
|
264
284
|
}
|
|
265
285
|
}
|
|
266
286
|
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
287
|
+
// Opens a file not there yet, as "wx" does, and makes sure the name is the file made. Anything at the name, a link to
|
|
288
|
+
// nothing among them, is EEXIST: Windows opens "wx" through a link to nothing and makes the file it leads to (macOS
|
|
289
|
+
// and Linux answer EEXIST), so the name is looked at first, and a link planted between that look and the open is
|
|
290
|
+
// caught after it by comparing the opened file with the name; the empty file made through it is taken back.
|
|
291
|
+
function openFresh(path, mode) {
|
|
292
|
+
if (lstatSync(path, { throwIfNoEntry: false })) throw eexist(path);
|
|
293
|
+
const fd = openSync(path, "wx", mode);
|
|
294
|
+
let made;
|
|
295
|
+
let st;
|
|
296
|
+
try {
|
|
297
|
+
made = fstatSync(fd, { bigint: true });
|
|
298
|
+
st = lstatSync(path, { bigint: true, throwIfNoEntry: false });
|
|
299
|
+
} catch {
|
|
300
|
+
return fd; // cannot look (ESTALE, EPERM): the file is the caller's, as a plain "wx" leaves it
|
|
301
|
+
}
|
|
302
|
+
if (st && !st.isSymbolicLink() && (made.ino === 0n || (st.dev === made.dev && st.ino === made.ino))) return fd;
|
|
303
|
+
closeSync(fd);
|
|
304
|
+
if (st?.isSymbolicLink()) takeBack(path, made);
|
|
305
|
+
throw eexist(path);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const eexist = (path) => Object.assign(new Error(`EEXIST: file already exists, open '${path}'`), { code: "EEXIST", path });
|
|
309
|
+
|
|
310
|
+
// Removes the file an open made through the link at path, while it is still that empty file.
|
|
311
|
+
function takeBack(path, made) {
|
|
312
|
+
try {
|
|
313
|
+
const target = realpathSync(path);
|
|
314
|
+
const st = statSync(target, { bigint: true });
|
|
315
|
+
if (st.dev === made.dev && st.ino === made.ino && st.size === 0n) rmSync(target, { force: true });
|
|
316
|
+
} catch {
|
|
317
|
+
// the link changed again: nothing of ours to find
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Writes bytes to a file not there yet (openFresh) and closes it. A write that fails takes the empty file away again
|
|
322
|
+
// while the name is still that file, so no empty file stands for a first original later.
|
|
323
|
+
function writeFresh(path, bytes, mode) {
|
|
324
|
+
const fd = openFresh(path, mode);
|
|
272
325
|
try {
|
|
273
326
|
writeFileSync(fd, bytes);
|
|
327
|
+
} catch (e) {
|
|
328
|
+
try {
|
|
329
|
+
const made = fstatSync(fd, { bigint: true });
|
|
330
|
+
const st = lstatSync(path, { bigint: true, throwIfNoEntry: false });
|
|
331
|
+
if (st && st.isFile() && st.dev === made.dev && st.ino === made.ino) rmSync(path, { force: true });
|
|
332
|
+
} catch {
|
|
333
|
+
// cannot tell the name is still that file: it stays, and the write's own error is what is reported
|
|
334
|
+
}
|
|
335
|
+
throw e;
|
|
274
336
|
} finally {
|
|
275
337
|
closeSync(fd);
|
|
276
338
|
}
|
|
277
|
-
return bak;
|
|
278
339
|
}
|
|
279
340
|
|
|
280
|
-
|
|
341
|
+
// Why the backup spot cannot take the first original, or null: something that is not a regular file there, or a look
|
|
342
|
+
// that fails (EPERM on Windows for a .bak being deleted, and the like).
|
|
343
|
+
function backupSpotWhy(bak) {
|
|
344
|
+
try {
|
|
345
|
+
const st = lstatSync(bak, { throwIfNoEntry: false });
|
|
346
|
+
return st && !st.isFile() ? "백업 자리에 일반 파일이 아닌 것이 있어 그대로 둠" : null;
|
|
347
|
+
} catch (e) {
|
|
348
|
+
return `백업 자리를 확인하지 못해 그대로 둠(${e.code || e.message})`;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Keeps the first original: writes <file>.<skill>.bak only when nothing is at that name yet. { bak } (null when a
|
|
353
|
+
// regular file there is the first original already), or { refuse } when something else is there (a link, a folder):
|
|
354
|
+
// then the file is not changed, as its original would have nowhere to go.
|
|
355
|
+
function backup(file, skill, bytes) {
|
|
356
|
+
const bak = `${file}.${skill}.bak`;
|
|
357
|
+
const there = (st) => (st.isFile() ? { bak: null } : { refuse: refusal("backup", `백업 자리에 일반 파일이 아닌 것이 있어 그대로 둠: ${bak}`, file) });
|
|
358
|
+
const st = lstatSync(bak, { throwIfNoEntry: false });
|
|
359
|
+
if (st) return there(st);
|
|
360
|
+
try {
|
|
361
|
+
writeFresh(bak, bytes, BACKUP_MODE);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
if (e?.code !== "EEXIST") throw e;
|
|
364
|
+
const now = lstatSync(bak, { throwIfNoEntry: false }); // made since the look
|
|
365
|
+
return now ? there(now) : { refuse: refusal("backup", `백업을 만들지 못해 그대로 둠: ${bak}`, file) };
|
|
366
|
+
}
|
|
367
|
+
return { bak };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Writes what planBlock worked out. A plan that changes nothing or was refused is returned as a result. A file planned
|
|
372
|
+
* as new that is there by the write (made or linked since the plan) is not touched: the result is a refusal with
|
|
373
|
+
* raced: true, which applyBlocks answers by planning that file again.
|
|
374
|
+
*/
|
|
281
375
|
export function commitBlock(plan, skill) {
|
|
282
376
|
if (!plan.ok) return plan;
|
|
283
377
|
const result = { ok: true, changed: plan.changed, path: plan.path, file: plan.file };
|
|
284
378
|
if (!plan.changed) return { ...result, message: `변경 없음: ${plan.path}` };
|
|
285
379
|
if (plan.before === null && plan.after !== null) {
|
|
286
|
-
const fd = openSync(plan.file, "wx", plan.mode);
|
|
287
380
|
try {
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
381
|
+
writeFresh(plan.file, plan.after, plan.mode);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
if (e?.code !== "EEXIST") throw e;
|
|
384
|
+
return { ...refusal("raced", `계획한 뒤 그 이름에 파일이나 링크가 생겨 그대로 둠: ${plan.path}`, plan.path), raced: true };
|
|
291
385
|
}
|
|
292
386
|
return { ...result, created: true, message: `새로 만듦: ${plan.path}` };
|
|
293
387
|
}
|
|
294
|
-
const
|
|
388
|
+
const kept = backup(plan.file, skill, plan.before);
|
|
389
|
+
if (kept.refuse) return kept.refuse;
|
|
295
390
|
writeAtomic(plan.file, plan.after, plan.mode);
|
|
296
|
-
return { ...result, backup: bak, message: `바꿈: ${plan.path}` };
|
|
391
|
+
return { ...result, backup: kept.bak, message: `바꿈: ${plan.path}` };
|
|
297
392
|
}
|
|
298
393
|
|
|
299
394
|
/** Puts in, replaces or (body null) takes out the block in one file. dryRun plans only. */
|
|
@@ -306,16 +401,33 @@ export function applyBlock(path, opts) {
|
|
|
306
401
|
/**
|
|
307
402
|
* Applies blocks to several files, each entry { path, ...opts }. Every file is planned first: one refusal and
|
|
308
403
|
* none is written. Two entries naming one file (after links are followed) write it once, by the first entry.
|
|
404
|
+
* A file that changes between the plans and the writes (raced) is planned again; a refusal then comes back as
|
|
405
|
+
* { ok: false, partial: true }, the entries before it being written already.
|
|
309
406
|
*/
|
|
310
407
|
export function applyBlocks(entries, { dryRun = false } = {}) {
|
|
311
408
|
const plans = entries.map((e) => ({ entry: e, plan: planBlock(e.path, e) }));
|
|
312
409
|
const refused = plans.filter((p) => !p.plan.ok).map((p) => p.plan);
|
|
313
410
|
if (refused.length > 0) return { ok: false, refused, results: plans.map((p) => p.plan) };
|
|
314
411
|
const seen = new Map();
|
|
315
|
-
const
|
|
316
|
-
|
|
412
|
+
const once = (plan) => ({ ok: true, changed: false, path: plan.path, file: plan.file, sameAs: seen.get(plan.key), message: `같은 파일이라 한 번만 씀: ${plan.path}` });
|
|
413
|
+
const write = (entry, plan) => {
|
|
414
|
+
const done = dryRun ? plan : commitBlock(plan, entry.skill);
|
|
415
|
+
if (!done.ok) return done;
|
|
317
416
|
seen.set(plan.key, plan.path);
|
|
318
|
-
|
|
417
|
+
const now = done.created ? keyNow(plan.file) : null; // a file made now is that file to later spellings too
|
|
418
|
+
if (now && !seen.has(now)) seen.set(now, plan.path);
|
|
419
|
+
return done;
|
|
420
|
+
};
|
|
421
|
+
const results = plans.map(({ entry, plan }) => {
|
|
422
|
+
if (seen.has(plan.key)) return once(plan);
|
|
423
|
+
const done = write(entry, plan);
|
|
424
|
+
if (!done.raced) return done;
|
|
425
|
+
// Made since the plan: by an earlier entry under another spelling (a case-insensitive file system), or by another
|
|
426
|
+
// program. Plan it again as what is there now.
|
|
427
|
+
const again = planBlock(entry.path, entry);
|
|
428
|
+
if (!again.ok) return again;
|
|
429
|
+
return seen.has(again.key) ? once(again) : write(entry, again);
|
|
319
430
|
});
|
|
320
|
-
|
|
431
|
+
const late = results.filter((r) => r.ok === false);
|
|
432
|
+
return late.length > 0 ? { ok: false, partial: true, refused: late, results } : { ok: true, refused: [], results };
|
|
321
433
|
}
|
|
@@ -93,12 +93,35 @@ const real = (p) => {
|
|
|
93
93
|
}
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
+
// Whether a and b are one folder: device and inode (bigint, as NTFS file ids pass 2^53), or where the file system has
|
|
97
|
+
// no inode numbers, the path as the file system spells it (realpathSync.native gives one spelling for names that differ
|
|
98
|
+
// only in case, an 8.3 short name or a linked parent), case folded on Windows and macOS. A string compare of the JS
|
|
99
|
+
// realpath let `--root c:\users\me` (or /users/me on macOS) pass the HOME refusal.
|
|
100
|
+
function sameDir(a, b) {
|
|
101
|
+
try {
|
|
102
|
+
const x = statSync(a, { bigint: true });
|
|
103
|
+
const y = statSync(b, { bigint: true });
|
|
104
|
+
if (x.ino !== 0n && y.ino !== 0n) return x.dev === y.dev && x.ino === y.ino;
|
|
105
|
+
} catch {
|
|
106
|
+
// one is gone: the spellings below decide
|
|
107
|
+
}
|
|
108
|
+
const spell = (p) => {
|
|
109
|
+
try {
|
|
110
|
+
return realpathSync.native(p);
|
|
111
|
+
} catch {
|
|
112
|
+
return real(p);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const fold = (p) => (process.platform === "win32" || process.platform === "darwin" ? p.toLowerCase() : p);
|
|
116
|
+
return fold(spell(a)) === fold(spell(b));
|
|
117
|
+
}
|
|
118
|
+
|
|
96
119
|
// The root folder, or a failure: missing (2), HOME or a file system root (3).
|
|
97
120
|
function checkRoot(rootArg) {
|
|
98
121
|
const root = resolve(rootArg);
|
|
99
122
|
if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) return fail(2, `루트 폴더가 없음: ${root}`);
|
|
100
123
|
const r = real(root);
|
|
101
|
-
if (
|
|
124
|
+
if (sameDir(root, homedir())) return fail(3, `루트가 HOME 이라 거부: ${root}. 프로젝트 폴더에서 실행하거나 --root 로 지정`);
|
|
102
125
|
if (parse(r).root === r) return fail(3, `루트가 파일 시스템 루트라 거부: ${root}`);
|
|
103
126
|
return { root };
|
|
104
127
|
}
|
|
@@ -165,6 +188,7 @@ const blockEntries = (root, files, present, follow) =>
|
|
|
165
188
|
}));
|
|
166
189
|
|
|
167
190
|
function instructionStatus(r) {
|
|
191
|
+
if (r.ok === false) return `refused:${r.refused}`;
|
|
168
192
|
if (r.sameAs) return "same-file";
|
|
169
193
|
if (!r.changed) return "unchanged";
|
|
170
194
|
return r.created || r.before === null ? "created" : "updated";
|
|
@@ -227,7 +251,10 @@ function apply(o) {
|
|
|
227
251
|
if (p.error) return p;
|
|
228
252
|
const folders = o.dryRun ? plannedFolders(p.folders) : makeFolders(p.root, p.folders);
|
|
229
253
|
const done = o.dryRun ? p.plan : applyBlocks(p.entries);
|
|
230
|
-
if (!done.ok)
|
|
254
|
+
if (!done.ok) {
|
|
255
|
+
const wrote = done.partial ? done.results.filter((r) => r.ok && r.changed).map((r) => r.path) : [];
|
|
256
|
+
return fail(1, `지시 파일 거부: ${done.refused.map((r) => r.message).join("; ")}. 폴더는 만들었음${wrote.length ? `. 이미 쓴 파일: ${wrote.join(", ")}` : ""}`);
|
|
257
|
+
}
|
|
231
258
|
const instructions = done.results.map((r, i) => ({ file: p.entries[i].name, status: instructionStatus(r) }));
|
|
232
259
|
const warnings = [...p.warnings, ...leftoverWarnings(p.root)];
|
|
233
260
|
return { code: 0, report: { ok: true, command: "apply", root: p.root, runtime: o.runtime, dryRun: o.dryRun, folders, instructions, warnings, present: p.present } };
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* file. Windows has no owner or permission bits to check.
|
|
21
21
|
* - Writes go to a temp file in the same folder and a rename. A failed write removes the temp file.
|
|
22
22
|
* - When the bytes change, the first original is kept in `<file>.<skill>.bak`; an existing backup is never
|
|
23
|
-
* overwritten.
|
|
23
|
+
* overwritten. Something else at that name (a link, a folder) refuses the change (backup), so no original is lost.
|
|
24
24
|
*
|
|
25
25
|
* Results are { ok, changed, refused?, message, path, file }. Callers map refused to exit status 1.
|
|
26
26
|
*/
|
|
@@ -220,9 +220,25 @@ const options = (opts) => ({
|
|
|
220
220
|
wrap: opts.wrap ?? null,
|
|
221
221
|
skill: opts.skill,
|
|
222
222
|
follow: opts.followSymlink === true,
|
|
223
|
+
readOnly: opts.readOnly === true, // a plan only read (never committed): no backup spot to check
|
|
223
224
|
uid: "uid" in opts ? opts.uid : process.getuid?.(),
|
|
224
225
|
});
|
|
225
226
|
|
|
227
|
+
// A file not there yet is named by its folder as the file system spells it (one key for a linked parent, an 8.3 name
|
|
228
|
+
// or the folder in another case) and its own name as given. Two names differing only in case still get two keys:
|
|
229
|
+
// where the file system takes them for one file, applyBlocks meets that when the second create finds the first.
|
|
230
|
+
const newFileKey = (file) => `path:${join(nativeReal(dirname(resolve(file))), basename(file))}`;
|
|
231
|
+
|
|
232
|
+
// The key of a file that is there now, as planBlock gives it, or null when it cannot be read.
|
|
233
|
+
function keyNow(file) {
|
|
234
|
+
try {
|
|
235
|
+
const id = statSync(file, { bigint: true });
|
|
236
|
+
return id.ino === 0n ? `path:${nativeReal(file)}` : `ino:${id.dev}:${id.ino}`;
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
226
242
|
/**
|
|
227
243
|
* Works out what applyBlock would write, without writing: { ok, changed, refused?, message, path, file, key,
|
|
228
244
|
* before, after, mode }. key names the file (device and inode, or the path of a file not there yet).
|
|
@@ -234,13 +250,17 @@ export function planBlock(path, opts) {
|
|
|
234
250
|
if (at.refuse) return at.refuse;
|
|
235
251
|
if (!at.exists) {
|
|
236
252
|
const after = o.body === null ? null : Buffer.from(blockText(o.mk, o.body, o.wrap, "\n"), "utf8");
|
|
237
|
-
return { ok: true, changed: after !== null, path, file: at.file, key:
|
|
253
|
+
return { ok: true, changed: after !== null, path, file: at.file, key: newFileKey(at.file), before: null, after, mode: NEW_FILE_MODE };
|
|
238
254
|
}
|
|
239
255
|
const read = readChecked(at.file, o.uid);
|
|
240
256
|
if (read.refuse) return read.refuse;
|
|
241
257
|
const next = nextBytes(read.bytes, o, at.file);
|
|
242
258
|
if (next.refuse) return next.refuse;
|
|
243
259
|
const changed = !next.bytes.equals(read.bytes);
|
|
260
|
+
// A change keeps the first original in <file>.<skill>.bak: something else there (a link, a folder) is refused now,
|
|
261
|
+
// in the plan, so a dry run, a status and a check say so before anything is written.
|
|
262
|
+
const bakWhy = changed && !o.readOnly ? backupSpotWhy(`${at.file}.${o.skill}.bak`) : null;
|
|
263
|
+
if (bakWhy) return refusal("backup", `${bakWhy}: ${at.file}.${o.skill}.bak`, path);
|
|
244
264
|
// ino 0 (a file system without file ids) tells nothing apart: then the path as the file system spells it is the
|
|
245
265
|
// key (realpathSync.native gives one spelling for names that differ only in case where case is ignored).
|
|
246
266
|
const key = read.id.ino === 0n ? `path:${nativeReal(at.file)}` : `ino:${read.id.dev}:${read.id.ino}`;
|
|
@@ -264,36 +284,111 @@ function writeAtomic(file, data, mode) {
|
|
|
264
284
|
}
|
|
265
285
|
}
|
|
266
286
|
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
287
|
+
// Opens a file not there yet, as "wx" does, and makes sure the name is the file made. Anything at the name, a link to
|
|
288
|
+
// nothing among them, is EEXIST: Windows opens "wx" through a link to nothing and makes the file it leads to (macOS
|
|
289
|
+
// and Linux answer EEXIST), so the name is looked at first, and a link planted between that look and the open is
|
|
290
|
+
// caught after it by comparing the opened file with the name; the empty file made through it is taken back.
|
|
291
|
+
function openFresh(path, mode) {
|
|
292
|
+
if (lstatSync(path, { throwIfNoEntry: false })) throw eexist(path);
|
|
293
|
+
const fd = openSync(path, "wx", mode);
|
|
294
|
+
let made;
|
|
295
|
+
let st;
|
|
296
|
+
try {
|
|
297
|
+
made = fstatSync(fd, { bigint: true });
|
|
298
|
+
st = lstatSync(path, { bigint: true, throwIfNoEntry: false });
|
|
299
|
+
} catch {
|
|
300
|
+
return fd; // cannot look (ESTALE, EPERM): the file is the caller's, as a plain "wx" leaves it
|
|
301
|
+
}
|
|
302
|
+
if (st && !st.isSymbolicLink() && (made.ino === 0n || (st.dev === made.dev && st.ino === made.ino))) return fd;
|
|
303
|
+
closeSync(fd);
|
|
304
|
+
if (st?.isSymbolicLink()) takeBack(path, made);
|
|
305
|
+
throw eexist(path);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const eexist = (path) => Object.assign(new Error(`EEXIST: file already exists, open '${path}'`), { code: "EEXIST", path });
|
|
309
|
+
|
|
310
|
+
// Removes the file an open made through the link at path, while it is still that empty file.
|
|
311
|
+
function takeBack(path, made) {
|
|
312
|
+
try {
|
|
313
|
+
const target = realpathSync(path);
|
|
314
|
+
const st = statSync(target, { bigint: true });
|
|
315
|
+
if (st.dev === made.dev && st.ino === made.ino && st.size === 0n) rmSync(target, { force: true });
|
|
316
|
+
} catch {
|
|
317
|
+
// the link changed again: nothing of ours to find
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Writes bytes to a file not there yet (openFresh) and closes it. A write that fails takes the empty file away again
|
|
322
|
+
// while the name is still that file, so no empty file stands for a first original later.
|
|
323
|
+
function writeFresh(path, bytes, mode) {
|
|
324
|
+
const fd = openFresh(path, mode);
|
|
272
325
|
try {
|
|
273
326
|
writeFileSync(fd, bytes);
|
|
327
|
+
} catch (e) {
|
|
328
|
+
try {
|
|
329
|
+
const made = fstatSync(fd, { bigint: true });
|
|
330
|
+
const st = lstatSync(path, { bigint: true, throwIfNoEntry: false });
|
|
331
|
+
if (st && st.isFile() && st.dev === made.dev && st.ino === made.ino) rmSync(path, { force: true });
|
|
332
|
+
} catch {
|
|
333
|
+
// cannot tell the name is still that file: it stays, and the write's own error is what is reported
|
|
334
|
+
}
|
|
335
|
+
throw e;
|
|
274
336
|
} finally {
|
|
275
337
|
closeSync(fd);
|
|
276
338
|
}
|
|
277
|
-
return bak;
|
|
278
339
|
}
|
|
279
340
|
|
|
280
|
-
|
|
341
|
+
// Why the backup spot cannot take the first original, or null: something that is not a regular file there, or a look
|
|
342
|
+
// that fails (EPERM on Windows for a .bak being deleted, and the like).
|
|
343
|
+
function backupSpotWhy(bak) {
|
|
344
|
+
try {
|
|
345
|
+
const st = lstatSync(bak, { throwIfNoEntry: false });
|
|
346
|
+
return st && !st.isFile() ? "백업 자리에 일반 파일이 아닌 것이 있어 그대로 둠" : null;
|
|
347
|
+
} catch (e) {
|
|
348
|
+
return `백업 자리를 확인하지 못해 그대로 둠(${e.code || e.message})`;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Keeps the first original: writes <file>.<skill>.bak only when nothing is at that name yet. { bak } (null when a
|
|
353
|
+
// regular file there is the first original already), or { refuse } when something else is there (a link, a folder):
|
|
354
|
+
// then the file is not changed, as its original would have nowhere to go.
|
|
355
|
+
function backup(file, skill, bytes) {
|
|
356
|
+
const bak = `${file}.${skill}.bak`;
|
|
357
|
+
const there = (st) => (st.isFile() ? { bak: null } : { refuse: refusal("backup", `백업 자리에 일반 파일이 아닌 것이 있어 그대로 둠: ${bak}`, file) });
|
|
358
|
+
const st = lstatSync(bak, { throwIfNoEntry: false });
|
|
359
|
+
if (st) return there(st);
|
|
360
|
+
try {
|
|
361
|
+
writeFresh(bak, bytes, BACKUP_MODE);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
if (e?.code !== "EEXIST") throw e;
|
|
364
|
+
const now = lstatSync(bak, { throwIfNoEntry: false }); // made since the look
|
|
365
|
+
return now ? there(now) : { refuse: refusal("backup", `백업을 만들지 못해 그대로 둠: ${bak}`, file) };
|
|
366
|
+
}
|
|
367
|
+
return { bak };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Writes what planBlock worked out. A plan that changes nothing or was refused is returned as a result. A file planned
|
|
372
|
+
* as new that is there by the write (made or linked since the plan) is not touched: the result is a refusal with
|
|
373
|
+
* raced: true, which applyBlocks answers by planning that file again.
|
|
374
|
+
*/
|
|
281
375
|
export function commitBlock(plan, skill) {
|
|
282
376
|
if (!plan.ok) return plan;
|
|
283
377
|
const result = { ok: true, changed: plan.changed, path: plan.path, file: plan.file };
|
|
284
378
|
if (!plan.changed) return { ...result, message: `변경 없음: ${plan.path}` };
|
|
285
379
|
if (plan.before === null && plan.after !== null) {
|
|
286
|
-
const fd = openSync(plan.file, "wx", plan.mode);
|
|
287
380
|
try {
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
381
|
+
writeFresh(plan.file, plan.after, plan.mode);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
if (e?.code !== "EEXIST") throw e;
|
|
384
|
+
return { ...refusal("raced", `계획한 뒤 그 이름에 파일이나 링크가 생겨 그대로 둠: ${plan.path}`, plan.path), raced: true };
|
|
291
385
|
}
|
|
292
386
|
return { ...result, created: true, message: `새로 만듦: ${plan.path}` };
|
|
293
387
|
}
|
|
294
|
-
const
|
|
388
|
+
const kept = backup(plan.file, skill, plan.before);
|
|
389
|
+
if (kept.refuse) return kept.refuse;
|
|
295
390
|
writeAtomic(plan.file, plan.after, plan.mode);
|
|
296
|
-
return { ...result, backup: bak, message: `바꿈: ${plan.path}` };
|
|
391
|
+
return { ...result, backup: kept.bak, message: `바꿈: ${plan.path}` };
|
|
297
392
|
}
|
|
298
393
|
|
|
299
394
|
/** Puts in, replaces or (body null) takes out the block in one file. dryRun plans only. */
|
|
@@ -306,16 +401,33 @@ export function applyBlock(path, opts) {
|
|
|
306
401
|
/**
|
|
307
402
|
* Applies blocks to several files, each entry { path, ...opts }. Every file is planned first: one refusal and
|
|
308
403
|
* none is written. Two entries naming one file (after links are followed) write it once, by the first entry.
|
|
404
|
+
* A file that changes between the plans and the writes (raced) is planned again; a refusal then comes back as
|
|
405
|
+
* { ok: false, partial: true }, the entries before it being written already.
|
|
309
406
|
*/
|
|
310
407
|
export function applyBlocks(entries, { dryRun = false } = {}) {
|
|
311
408
|
const plans = entries.map((e) => ({ entry: e, plan: planBlock(e.path, e) }));
|
|
312
409
|
const refused = plans.filter((p) => !p.plan.ok).map((p) => p.plan);
|
|
313
410
|
if (refused.length > 0) return { ok: false, refused, results: plans.map((p) => p.plan) };
|
|
314
411
|
const seen = new Map();
|
|
315
|
-
const
|
|
316
|
-
|
|
412
|
+
const once = (plan) => ({ ok: true, changed: false, path: plan.path, file: plan.file, sameAs: seen.get(plan.key), message: `같은 파일이라 한 번만 씀: ${plan.path}` });
|
|
413
|
+
const write = (entry, plan) => {
|
|
414
|
+
const done = dryRun ? plan : commitBlock(plan, entry.skill);
|
|
415
|
+
if (!done.ok) return done;
|
|
317
416
|
seen.set(plan.key, plan.path);
|
|
318
|
-
|
|
417
|
+
const now = done.created ? keyNow(plan.file) : null; // a file made now is that file to later spellings too
|
|
418
|
+
if (now && !seen.has(now)) seen.set(now, plan.path);
|
|
419
|
+
return done;
|
|
420
|
+
};
|
|
421
|
+
const results = plans.map(({ entry, plan }) => {
|
|
422
|
+
if (seen.has(plan.key)) return once(plan);
|
|
423
|
+
const done = write(entry, plan);
|
|
424
|
+
if (!done.raced) return done;
|
|
425
|
+
// Made since the plan: by an earlier entry under another spelling (a case-insensitive file system), or by another
|
|
426
|
+
// program. Plan it again as what is there now.
|
|
427
|
+
const again = planBlock(entry.path, entry);
|
|
428
|
+
if (!again.ok) return again;
|
|
429
|
+
return seen.has(again.key) ? once(again) : write(entry, again);
|
|
319
430
|
});
|
|
320
|
-
|
|
431
|
+
const late = results.filter((r) => r.ok === false);
|
|
432
|
+
return late.length > 0 ? { ok: false, partial: true, refused: late, results } : { ok: true, refused: [], results };
|
|
321
433
|
}
|
|
@@ -34,13 +34,15 @@
|
|
|
34
34
|
* pid and time, removed when it ends). A later start waits for it, then sees the service running. A lock whose pid
|
|
35
35
|
* is dead, is another program, or is older than 30 seconds plus the start check is removed, by one start at a time:
|
|
36
36
|
* the one holding logs/<name>.start.lock.break (O_EXCL), which reads the lock again and removes only the lock it
|
|
37
|
-
* judged stale. A link or a folder at either name is refused (exit 1)
|
|
37
|
+
* judged stale. A link or a folder at either name is refused (exit 1), a link to nothing among them: Windows opens
|
|
38
|
+
* O_EXCL through such a link and makes the file it leads to, so the name is looked at before the open and again after.
|
|
38
39
|
*
|
|
39
40
|
* Environment (tuning): SERVICE_LOG_ROTATE_BYTES (default 104857600), SERVICE_STOP_GRACE_MS (10000),
|
|
40
41
|
* SERVICE_START_CHECK_MS (2000).
|
|
41
42
|
*/
|
|
42
43
|
import { spawn, spawnSync } from "node:child_process";
|
|
43
|
-
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
44
|
+
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
45
|
+
import { randomBytes } from "node:crypto";
|
|
44
46
|
import { uptime } from "node:os";
|
|
45
47
|
import { dirname, join } from "node:path";
|
|
46
48
|
import { fileURLToPath } from "node:url";
|
|
@@ -105,10 +107,16 @@ function readPid(file) {
|
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
// A fresh temp file with a random name (no one can plant a link at it first), renamed over the pid file.
|
|
108
111
|
function writePid(file, value) {
|
|
109
|
-
const tmp = `${file}.${
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(tmp, `${JSON.stringify(value)}\n`, { flag: "wx" });
|
|
115
|
+
renameSync(tmp, file);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
rmSync(tmp, { force: true });
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
// Removes the pid file when it still names this supervisor.
|
|
@@ -356,12 +364,30 @@ function lockBusy(text, st) {
|
|
|
356
364
|
}
|
|
357
365
|
|
|
358
366
|
// One file made with O_EXCL holding this start's pid and time: { text } made, { code } not (EEXIST: there is one).
|
|
367
|
+
// Anything at the name, a link to nothing among them, is EEXIST: Windows opens O_EXCL through a link to nothing and
|
|
368
|
+
// makes the file it leads to (macOS and Linux answer EEXIST), so the name is looked at first, and after the open,
|
|
369
|
+
// before anything is written, the opened file is compared with the name: a name gone, a link or another file is EEXIST
|
|
370
|
+
// (an empty file made through a link is taken back). A look that cannot be made (ESTALE, EPERM) writes as a plain
|
|
371
|
+
// O_EXCL open would. A write that fails takes the empty lock away again.
|
|
359
372
|
function makeLockFile(file) {
|
|
360
373
|
const text = `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`;
|
|
361
374
|
try {
|
|
375
|
+
if (lstatSync(file, { throwIfNoEntry: false }) !== undefined) return { code: "EEXIST" };
|
|
362
376
|
const fd = openSync(file, "wx");
|
|
363
377
|
try {
|
|
364
|
-
|
|
378
|
+
let made = null;
|
|
379
|
+
try {
|
|
380
|
+
made = fstatSync(fd, { bigint: true });
|
|
381
|
+
} catch {
|
|
382
|
+
// cannot look: the name is not judged
|
|
383
|
+
}
|
|
384
|
+
if (namesOpened(file, made) === false) return { code: "EEXIST" };
|
|
385
|
+
try {
|
|
386
|
+
writeSync(fd, text);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
if (namesOpened(file, made) === true) rmSync(file, { force: true });
|
|
389
|
+
throw e;
|
|
390
|
+
}
|
|
365
391
|
} finally {
|
|
366
392
|
closeSync(fd);
|
|
367
393
|
}
|
|
@@ -371,6 +397,31 @@ function makeLockFile(file) {
|
|
|
371
397
|
}
|
|
372
398
|
}
|
|
373
399
|
|
|
400
|
+
// Whether the name is the opened file (made): true, false (gone, a link, another file), or null when it cannot be
|
|
401
|
+
// told (a look that fails, no file ids). A link: the empty file made through it is taken back, and false.
|
|
402
|
+
function namesOpened(file, made) {
|
|
403
|
+
let st;
|
|
404
|
+
try {
|
|
405
|
+
st = lstatSync(file, { bigint: true, throwIfNoEntry: false });
|
|
406
|
+
} catch {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
if (!st) return false;
|
|
410
|
+
if (st.isSymbolicLink()) {
|
|
411
|
+
if (!made) return false;
|
|
412
|
+
try {
|
|
413
|
+
const target = realpathSync(file);
|
|
414
|
+
const at = statSync(target, { bigint: true });
|
|
415
|
+
if (at.dev === made.dev && at.ino === made.ino && at.size === 0n) rmSync(target, { force: true });
|
|
416
|
+
} catch {
|
|
417
|
+
// the link changed again: nothing of ours to find
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
if (!made || made.ino === 0n) return null;
|
|
422
|
+
return st.dev === made.dev && st.ino === made.ino;
|
|
423
|
+
}
|
|
424
|
+
|
|
374
425
|
// lstat that answers undefined for a missing file and null for one busy being deleted (the next try sees).
|
|
375
426
|
function lstatNow(file) {
|
|
376
427
|
try {
|
package/skills/lineage/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: lineage
|
|
3
3
|
description: "현재 세션 대화를 카카오톡 스타일의 단일 HTML 로 내보냄. Claude 답변은 한 줄 요약 아래에 원문을 접어 두고, 펼치면 마크다운으로 렌더해 보여 줌. 기본은 세션 모델이 요약을 쓰고 노이즈 턴을 걸러 내며, `--rulebase` 는 모델 없이 규칙만 씀. 여러 세션을 합칠 수 있음. 'lineage'/'대화 export'/'카톡 스타일 html' 시 사용."
|
|
4
4
|
invocation: /lineage
|
|
5
|
-
version: 3.1.
|
|
5
|
+
version: 3.1.2
|
|
6
6
|
schema_version: 1
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -1590,24 +1590,24 @@ def _stdin_id_key():
|
|
|
1590
1590
|
if key is not None and len(key) == 32:
|
|
1591
1591
|
return key
|
|
1592
1592
|
key = os.urandom(32)
|
|
1593
|
-
tmp =
|
|
1593
|
+
tmp = None
|
|
1594
1594
|
try:
|
|
1595
1595
|
CACHE_BASE.mkdir(parents=True, exist_ok=True)
|
|
1596
1596
|
os.chmod(str(CACHE_BASE), 0o700)
|
|
1597
|
-
#
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
0o600,
|
|
1597
|
+
# mkstemp: a fresh random name made exclusively (0600), so no link can be planted at it
|
|
1598
|
+
# first (Windows opens O_EXCL through a link to nothing and makes the file it leads to)
|
|
1599
|
+
fd, tmp = tempfile.mkstemp(
|
|
1600
|
+
dir=str(CACHE_BASE), prefix=f".{path.name}.", suffix=".tmp"
|
|
1602
1601
|
)
|
|
1603
1602
|
with os.fdopen(fd, "wb") as f:
|
|
1604
1603
|
f.write(key)
|
|
1605
|
-
os.replace(
|
|
1604
|
+
os.replace(tmp, str(path)) # whole or not at all
|
|
1606
1605
|
except OSError:
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1606
|
+
if tmp is not None:
|
|
1607
|
+
try:
|
|
1608
|
+
os.unlink(tmp)
|
|
1609
|
+
except OSError:
|
|
1610
|
+
pass
|
|
1611
1611
|
return key
|
|
1612
1612
|
|
|
1613
1613
|
|