@kaydash9999/banker-plugins 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +32 -0
  4. package/README.md +5 -4
  5. package/bin/lib/codex-hooks.mjs +5 -1
  6. package/codex/hooks.json +4 -4
  7. package/codex/manifest.json +1 -0
  8. package/codex/skills/teamwork/scripts/teamwork.mjs +18 -8
  9. package/codex/transform-matrix.md +1 -0
  10. package/hooks/telemetry-count-codex.mjs +3 -2
  11. package/package.json +1 -1
  12. package/skills/anti-drm/SKILL.md +2 -2
  13. package/skills/anti-drm/scripts/anti-drm.mjs +8 -5
  14. package/skills/anti-drm/scripts/instruction-toggle.mjs +6 -3
  15. package/skills/build-testbox/scripts/browser-cell.mjs +10 -2
  16. package/skills/build-testbox/scripts/containers.mjs +8 -3
  17. package/skills/build-testbox/scripts/proc.mjs +32 -8
  18. package/skills/build-testbox/scripts/testbox.mjs +10 -2
  19. package/skills/git-init/scripts/git-init.mjs +53 -5
  20. package/skills/git-init/scripts/marker-block.mjs +131 -19
  21. package/skills/init-dir/scripts/init-dir.mjs +29 -2
  22. package/skills/init-dir/scripts/marker-block.mjs +131 -19
  23. package/skills/init-dir/templates/scripts/lib/service.mjs +57 -6
  24. package/skills/lineage/SKILL.md +1 -1
  25. package/skills/lineage/lineage.py +11 -11
  26. package/skills/motion-sites/SKILL.md +79 -0
  27. package/skills/motion-sites/scripts/motion-sites.mjs +331 -0
  28. package/skills/omc-patch/scripts/omc-patch.mjs +2 -1
  29. package/skills/payload-mon/scripts/payload-mon.mjs +2 -1
  30. package/skills/payload-mon/scripts/payload-size.mjs +2 -2
  31. package/skills/progress-mon/scripts/pm-adr.mjs +6 -3
  32. package/skills/progress-mon/scripts/pm-config.mjs +133 -7
  33. package/skills/progress-mon/scripts/pm-data.mjs +35 -29
  34. package/skills/progress-mon/scripts/pm-memory.mjs +14 -7
  35. package/skills/progress-mon/scripts/pm-server.mjs +3 -3
  36. package/skills/progress-mon/scripts/pm-sources.mjs +2 -1
  37. package/skills/remains/SKILL.md +30 -3
  38. package/skills/remains/scripts/boxes.mjs +14 -4
  39. package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +2 -1
  40. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +12 -7
  41. package/skills/setup-omc-hud/assets/apply-hud.mjs +2 -1
  42. package/skills/setup-omc-hud/assets/codex-config.mjs +12 -7
  43. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +2 -1
  44. package/skills/smart-compact/scripts/codex-gate.mjs +25 -4
  45. package/skills/tone-compact/scripts/instruction-toggle.mjs +6 -3
  46. package/skills/tune-tmux/scripts/marker-block.mjs +131 -19
  47. package/skills/tune-tmux/scripts/tmux-config.mjs +35 -3
  48. package/skills/tune-tmux/scripts/tune-tmux.mjs +15 -9
  49. package/skills/ultra-chain/references/chain-ledger.mjs +8 -1
  50. package/skills/vertical-pptx/verify_a4p.mjs +11 -4
@@ -24,13 +24,17 @@
24
24
  * repository, a .git below the root, no commit identity). No network is used.
25
25
  */
26
26
  import { spawnSync } from "node:child_process";
27
- import { lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
27
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
28
28
  import { homedir, tmpdir } from "node:os";
29
29
  import { join, parse, resolve, sep } from "node:path";
30
30
  import { pathToFileURL } from "node:url";
31
31
  import { HASH, commitBlock, findBlock, markers, planBlock } from "./marker-block.mjs";
32
32
  import { scan } from "./payload-scan.mjs";
33
33
 
34
+ // The folder programs start in: the home folder (Windows looks for a program in the working folder first, so a
35
+ // program planted in the folder worked on never runs), or the temp folder when there is no home folder.
36
+ const runDir = () => (existsSync(homedir()) ? homedir() : tmpdir());
37
+
34
38
  const USAGE = [
35
39
  "git-init: 민감 정보를 빼는 .gitignore 를 쓰고 비밀값을 스캔한 뒤 git init, git add ., git commit -m \"init\" 으로 첫 커밋을 만듦(check 는 계획만)",
36
40
  "사용법: node git-init.mjs [check|run] [--root <dir>] [--branch <이름>] [--local-identity \"<이름> <<메일>>\"] [--nested]",
@@ -120,9 +124,29 @@ function gitEnv(extra) {
120
124
  return env;
121
125
  }
122
126
 
123
- // Runs git; never throws. ok is false when git could not start or exited non-zero.
127
+ // On Windows git is a wrapper (cmd\git.exe) that starts the real git: a time limit on the wrapper alone leaves the real
128
+ // git running, which can still make the commit after the failure was reported. With a time limit, git runs under this
129
+ // small Node supervisor instead, which ends the whole tree with System32's taskkill /T /F and exits 124.
130
+ const TREE_SUPERVISOR = [
131
+ "const {spawn,spawnSync}=require('child_process');const p=require('path');",
132
+ "const [ms,...a]=process.argv.slice(1);",
133
+ "const c=spawn('git',a,{stdio:'inherit',windowsHide:true});",
134
+ "const t=setTimeout(()=>{spawnSync(p.win32.join(process.env.SystemRoot||'C:\\\\Windows','System32','taskkill.exe'),['/T','/F','/PID',String(c.pid)],{stdio:'ignore',windowsHide:true});process.exit(124)},Number(ms));",
135
+ "c.on('error',()=>process.exit(127));c.on('exit',(code)=>{clearTimeout(t);process.exit(code==null?1:code)});",
136
+ ].join("");
137
+
138
+ // Runs git on the folder cwd (git -C), started from the home folder: Windows looks for a program in the working folder
139
+ // first, so a git.exe inside the folder being initialized never runs. Never throws. ok is false when git could not
140
+ // start or exited non-zero; a time-out is error.code ETIMEDOUT.
124
141
  function git(args, cwd, { env, timeout } = {}) {
125
- const r = spawnSync("git", args, { cwd, encoding: "utf8", env: gitEnv(env), timeout, killSignal: "SIGKILL", maxBuffer: 256 * 1024 * 1024, windowsHide: true });
142
+ const opts = { cwd: runDir(), encoding: "utf8", env: gitEnv(env), killSignal: "SIGKILL", maxBuffer: 256 * 1024 * 1024, windowsHide: true };
143
+ const argv = ["-C", cwd, ...args];
144
+ if (timeout && process.platform === "win32") {
145
+ const r = spawnSync(process.execPath, ["-e", TREE_SUPERVISOR, String(timeout), ...argv], { ...opts, timeout: timeout + 30000 });
146
+ const error = r.status === 124 ? Object.assign(new Error("git timed out"), { code: "ETIMEDOUT" }) : r.error;
147
+ return { ok: !error && r.status === 0, out: r.stdout ?? "", err: (r.stderr ?? "").trim(), error };
148
+ }
149
+ const r = spawnSync("git", argv, { ...opts, timeout });
126
150
  return { ok: !r.error && r.status === 0, out: r.stdout ?? "", err: (r.stderr ?? "").trim(), error: r.error };
127
151
  }
128
152
 
@@ -136,12 +160,35 @@ const real = (p) => {
136
160
  }
137
161
  };
138
162
 
163
+ // Whether a and b are one folder: device and inode (bigint, as NTFS file ids pass 2^53), or where the file system has
164
+ // no inode numbers, the path as the file system spells it (realpathSync.native gives one spelling for names that differ
165
+ // only in case, an 8.3 short name or a linked parent), case folded on Windows and macOS. A string compare of the JS
166
+ // realpath let `--root c:\users\me` (or /users/me on macOS) pass the HOME refusal.
167
+ function sameDir(a, b) {
168
+ try {
169
+ const x = statSync(a, { bigint: true });
170
+ const y = statSync(b, { bigint: true });
171
+ if (x.ino !== 0n && y.ino !== 0n) return x.dev === y.dev && x.ino === y.ino;
172
+ } catch {
173
+ // one is gone: the spellings below decide
174
+ }
175
+ const spell = (p) => {
176
+ try {
177
+ return realpathSync.native(p);
178
+ } catch {
179
+ return real(p);
180
+ }
181
+ };
182
+ const fold = (p) => (process.platform === "win32" || process.platform === "darwin" ? p.toLowerCase() : p);
183
+ return fold(spell(a)) === fold(spell(b));
184
+ }
185
+
139
186
  // The root folder, or a failure: missing (2), HOME or a file system root (3).
140
187
  function checkRoot(rootArg) {
141
188
  const root = resolve(rootArg);
142
189
  if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) return fail(2, `루트 폴더가 없음: ${root}`);
143
190
  const r = real(root);
144
- if (r === real(homedir())) return fail(3, `루트가 HOME 이라 거부: ${root}. 프로젝트 폴더에서 실행하거나 --root 로 지정`);
191
+ if (sameDir(root, homedir())) return fail(3, `루트가 HOME 이라 거부: ${root}. 프로젝트 폴더에서 실행하거나 --root 로 지정`);
145
192
  if (parse(r).root === r) return fail(3, `루트가 파일 시스템 루트라 거부: ${root}`);
146
193
  return { root };
147
194
  }
@@ -212,7 +259,7 @@ export function missingPatterns(text) {
212
259
  function gitignorePlan(root, follow) {
213
260
  const path = join(root, ".gitignore");
214
261
  const opts = { comment: HASH, id: ID, skill: SKILL, followSymlink: follow };
215
- const probe = planBlock(path, { ...opts, body: null });
262
+ const probe = planBlock(path, { ...opts, body: null, readOnly: true }); // read only: never written
216
263
  if (!probe.ok) return { refuse: probe };
217
264
  const missing = missingPatterns(probe.before === null ? "" : probe.before.toString("utf8"));
218
265
  const plan = planBlock(path, { ...opts, body: missing.length > 0 ? [BLOCK_HEAD, ...missing].join("\n") : null });
@@ -325,6 +372,7 @@ function commitInit(root) {
325
372
 
326
373
  function runSteps(o, ctx) {
327
374
  const done = commitBlock(ctx.gi.plan, SKILL);
375
+ if (!done.ok) return fail(1, `.gitignore 거부: ${done.message}. git init 하지 않음`, { ...ctx.report, ok: false });
328
376
  const report = { ...ctx.report, gitignore: { ...ctx.report.gitignore, status: done.changed ? (done.created ? "created" : "updated") : "unchanged", backup: done.backup ?? null } };
329
377
  const list = listCandidates(ctx.tmp, ctx.root, []);
330
378
  const nested = list.filter((p) => p.endsWith("/"));
@@ -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: `path:${resolve(at.file)}`, before: null, after, mode: NEW_FILE_MODE };
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
- // Keeps the first original: writes <file>.<skill>.bak only when nothing is at that name yet.
268
- function backup(file, skill, bytes) {
269
- const bak = `${file}.${skill}.bak`;
270
- if (lstatSync(bak, { throwIfNoEntry: false })) return null;
271
- const fd = openSync(bak, "wx", BACKUP_MODE);
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
- /** Writes what planBlock worked out. A plan that changes nothing or was refused is returned as a result. */
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
- writeFileSync(fd, plan.after);
289
- } finally {
290
- closeSync(fd);
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 bak = backup(plan.file, skill, plan.before);
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 results = plans.map(({ entry, plan }) => {
316
- if (seen.has(plan.key)) return { ok: true, changed: false, path: plan.path, file: plan.file, sameAs: seen.get(plan.key), message: `같은 파일이라 한 번만 씀: ${plan.path}` };
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
- return dryRun ? plan : commitBlock(plan, entry.skill);
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
- return { ok: true, refused: [], results };
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 (r === real(homedir())) return fail(3, `루트가 HOME 이라 거부: ${root}. 프로젝트 폴더에서 실행하거나 --root 로 지정`);
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) return fail(1, `지시 파일 거부: ${done.refused.map((r) => r.message).join("; ")}. 폴더는 만들었음`);
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: `path:${resolve(at.file)}`, before: null, after, mode: NEW_FILE_MODE };
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
- // Keeps the first original: writes <file>.<skill>.bak only when nothing is at that name yet.
268
- function backup(file, skill, bytes) {
269
- const bak = `${file}.${skill}.bak`;
270
- if (lstatSync(bak, { throwIfNoEntry: false })) return null;
271
- const fd = openSync(bak, "wx", BACKUP_MODE);
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
- /** Writes what planBlock worked out. A plan that changes nothing or was refused is returned as a result. */
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
- writeFileSync(fd, plan.after);
289
- } finally {
290
- closeSync(fd);
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 bak = backup(plan.file, skill, plan.before);
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 results = plans.map(({ entry, plan }) => {
316
- if (seen.has(plan.key)) return { ok: true, changed: false, path: plan.path, file: plan.file, sameAs: seen.get(plan.key), message: `같은 파일이라 한 번만 씀: ${plan.path}` };
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
- return dryRun ? plan : commitBlock(plan, entry.skill);
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
- return { ok: true, refused: [], results };
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
  }