@kaydash9999/banker-plugins 0.24.0 → 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 (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +49 -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 +2 -1
  8. package/codex/skills/teamwork/scripts/teamwork.mjs +21 -11
  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 +144 -22
  21. package/skills/init-dir/SKILL.md +1 -1
  22. package/skills/init-dir/scripts/init-dir.mjs +29 -2
  23. package/skills/init-dir/scripts/marker-block.mjs +144 -22
  24. package/skills/init-dir/templates/scripts/README.md +4 -1
  25. package/skills/init-dir/templates/scripts/lib/service.mjs +140 -33
  26. package/skills/lineage/SKILL.md +1 -1
  27. package/skills/lineage/lineage.py +11 -11
  28. package/skills/motion-sites/SKILL.md +79 -0
  29. package/skills/motion-sites/scripts/motion-sites.mjs +331 -0
  30. package/skills/omc-patch/scripts/omc-patch.mjs +2 -1
  31. package/skills/payload-mon/scripts/payload-mon.mjs +2 -1
  32. package/skills/payload-mon/scripts/payload-size.mjs +2 -2
  33. package/skills/progress-mon/README.md +4 -3
  34. package/skills/progress-mon/SKILL.md +1 -1
  35. package/skills/progress-mon/references/adr.md +7 -0
  36. package/skills/progress-mon/scripts/pm-adr-view.mjs +101 -0
  37. package/skills/progress-mon/scripts/pm-adr.mjs +38 -105
  38. package/skills/progress-mon/scripts/pm-check.mjs +16 -7
  39. package/skills/progress-mon/scripts/pm-config.mjs +133 -7
  40. package/skills/progress-mon/scripts/pm-data.mjs +35 -29
  41. package/skills/progress-mon/scripts/pm-memory.mjs +14 -7
  42. package/skills/progress-mon/scripts/pm-render.mjs +1 -1
  43. package/skills/progress-mon/scripts/pm-server.mjs +3 -3
  44. package/skills/progress-mon/scripts/pm-sources.mjs +12 -2
  45. package/skills/remains/SKILL.md +30 -3
  46. package/skills/remains/scripts/boxes.mjs +14 -4
  47. package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +2 -1
  48. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +12 -7
  49. package/skills/setup-omc-hud/assets/apply-hud.mjs +2 -1
  50. package/skills/setup-omc-hud/assets/codex-config.mjs +12 -7
  51. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +2 -1
  52. package/skills/smart-compact/scripts/codex-gate.mjs +25 -4
  53. package/skills/tone-compact/scripts/instruction-toggle.mjs +6 -3
  54. package/skills/tune-tmux/scripts/marker-block.mjs +144 -22
  55. package/skills/tune-tmux/scripts/tmux-config.mjs +35 -3
  56. package/skills/tune-tmux/scripts/tune-tmux.mjs +15 -9
  57. package/skills/ultra-chain/references/chain-ledger.mjs +8 -1
  58. package/skills/vertical-pptx/verify_a4p.mjs +11 -4
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import http from 'node:http';
5
5
  import path from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
- import { privateIPv4, dataLabel, loadConfig, writeAtomic, readRegular, UsageError } from './pm-config.mjs';
7
+ import { privateIPv4, dataLabel, loadConfig, writeAtomic, writeNew, readRegular, UsageError } from './pm-config.mjs';
8
8
  import { workflowRoots } from './pm-sources.mjs';
9
9
  import { memoryWarnings } from './pm-memory.mjs';
10
10
 
@@ -521,7 +521,7 @@ export async function saveHost(ctx, ip, io, fsx = fs) {
521
521
 
522
522
  // ---------- start 잠금 ----------
523
523
  // 동시에 부른 start 가 서버를 둘 띄우지 않게 start 전체를 server.json.lock 으로 감싼다(wx: 둘 가운데 하나만 만듦,
524
- // 그 자리의 링크는 따라가지 않고 EEXIST). 못 잡은 쪽은 기다렸다가 잡고, 그 안에서 떠 있는 서버를 다시 보아 재사용한다.
524
+ // 그 자리의 링크는 따라가지 않고 EEXIST. Windows 의 wx 는 끊어진 링크를 따라가므로 writeNew 가 먼저 lstat 로 본다). 못 잡은 쪽은 기다렸다가 잡고, 그 안에서 떠 있는 서버를 다시 보아 재사용한다.
525
525
  // 낡은 잠금(쥔 프로세스가 죽음, 또는 START_LOCK_STALE_MS 넘게 남음, 링크나 일반 파일 아님)은 넘겨받기 표지
526
526
  // server.json.lock.taking 을 wx 로 잡은 쪽만, 그 안에서 다시 판정한 뒤 지우고 새로 만든다.
527
527
  const START_LOCK = 'server.json.lock';
@@ -535,7 +535,7 @@ const LOCK_BUSY = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
535
535
  // 돌려줌: 만들었으면 null, 아니면 그 까닭 코드. 그 밖 오류는 UsageError.
536
536
  function makeLockFile(file) {
537
537
  try {
538
- fs.writeFileSync(file, `${process.pid} ${iso(Date.now())}\n`, { flag: 'wx', mode: 0o600 });
538
+ writeNew(file, `${process.pid} ${iso(Date.now())}\n`, { mode: 0o600 });
539
539
  return null;
540
540
  } catch (e) {
541
541
  if (LOCK_BUSY.has(e.code)) return e.code;
@@ -7,6 +7,7 @@ import path from 'node:path';
7
7
  import vm from 'node:vm';
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { eventsPath, stagesFor, isDir, noBom, readRegular } from './pm-config.mjs';
10
+ import { adrSha } from './pm-adr.mjs';
10
11
 
11
12
  // ---------- 시각 ----------
12
13
 
@@ -67,6 +68,13 @@ const parseLines = (t) =>
67
68
 
68
69
  // JSON 파일. 돌려줌: { value } | { missing: true } | { error }
69
70
  export const readJsonFile = (file) => cached(file, parseJson);
71
+
72
+ // adr.json: 값과 글의 sha256(check 대조 기록 adr-check.json 과 견줌). 돌려줌: { value, sha } | { missing: true } | { error }
73
+ const parseAdr = (t) => ({ json: JSON.parse(t), sha: adrSha(t) });
74
+ export function readAdrFile(file) {
75
+ const r = cached(file, parseAdr);
76
+ return r.value ? { value: r.value.json, sha: r.value.sha } : r;
77
+ }
70
78
  // JSONL 파일의 객체 줄 목록. 없거나 못 읽으면 빈 목록.
71
79
  export const readLines = (file) => cached(file, parseLines).value || [];
72
80
 
@@ -178,7 +186,8 @@ export function stageStates(records) {
178
186
  // ---------- git ----------
179
187
 
180
188
  const gitCache = new Map();
181
- const git = (cwd, args) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
189
+ // 홈 폴더에서 부른다(Windows 는 일하는 폴더의 git.exe 를 먼저 찾는다)
190
+ const git = (cwd, args) => execFileSync('git', ['-C', path.resolve(cwd), ...args], { cwd: fs.existsSync(os.homedir()) ? os.homedir() : os.tmpdir(), encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
182
191
 
183
192
  const readText = (p) => {
184
193
  try {
@@ -497,6 +506,7 @@ export function collect(ctx, config, now, inject = {}) {
497
506
  reqs: tab('reqs.json'),
498
507
  flow: tab('flow.json'),
499
508
  arch: tab('arch.json'),
500
- adr: tab('adr.json'),
509
+ adr: readAdrFile(path.join(ctx.dataDir, 'adr.json')),
510
+ adrCheck: tab('adr-check.json'),
501
511
  };
502
512
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: remains
3
- description: "남은 버그, 하자, 미검증 항목을 찾아 표로 보여 줌. 테스트박스가 등록돼 있으면 각 OS 에서 시험을 돌려 확인함. 읽기와 시험 실행만 하고 고치지 않음. 'remains'/'남은 버그'/'남은 하자'/'미검증 항목'/'뭐가 남았어' 시 사용."
3
+ description: "남은 버그, 하자, 미검증 항목을 찾아 표로 보여 줌. 찾은 결함과 원인이 같은 자리는 저장소 전체에서 찾음. 테스트박스가 등록돼 있으면 각 OS 에서 시험을 돌려 확인함. 읽기와 시험 실행만 하고 고치지 않음. 'remains'/'남은 버그'/'남은 하자'/'미검증 항목'/'뭐가 남았어' 시 사용."
4
4
  argument-hint: "[기준 커밋] [--no-boxes]"
5
5
  ---
6
6
 
@@ -28,10 +28,34 @@ argument-hint: "[기준 커밋] [--no-boxes]"
28
28
  |---|---|
29
29
  | 이번 대화 | 미검증이나 보류로 남긴 것, 검토의 비차단 지적, 사용자가 미룬 결정, 보고했지만 확인하지 않은 주장 |
30
30
  | 저장소 기록 | 인계 문서(예: `.omc/handoffs/`), CHANGELOG `[Unreleased]` 의 `UNVALIDATED` 나 미검증 표기, 문서의 미검증 절 |
31
- | 코드 표시 | 범위 안에서 바뀐 파일의 `TODO`, `FIXME`, `XXX`, `HACK`, 시험의 `skip`, `only`, `todo` |
31
+ | 코드 표시 | 범위 안에서 바뀐 파일의 `TODO`, `FIXME`, `XXX`, `HACK`, 시험의 `skip`, `only`, `todo`. 같은 종류는 2-1 에서 저장소 전체로 넓힌다 |
32
32
  | git 상태 | 커밋하지 않은 변경, push 하지 않은 커밋, 추적하지 않는 새 파일 |
33
33
  | 메모리와 노트 | 있으면 그 안의 남은 지적 |
34
34
 
35
+ ## 2-1. 같은 종류 전체 찾기 (읽기만)
36
+
37
+ 지적을 그 자리에만 적으면 같은 원인의 결함이 다른 파일에 남는다. 그러면 고칠 때마다 다음 검토에서 같은 종류가 또 나온다. 그래서 모은 결함 항목(버그와 하자)마다 같은 종류를 저장소 전체에서 찾는다.
38
+
39
+ 1. 항목마다 결함 종류를 원인으로 한 문장에 쓴다. 자리 이름으로 쓰지 않는다.
40
+ - 예: "Windows 의 wx 는 끊어진 링크를 따라가므로, 추측 가능한 이름에 새 파일을 만들면 링크 너머에 파일이 생긴다"
41
+ - 예: "경로 문자열로 같은 파일인지 판정하면 대소문자, 8.3 짧은 이름, 링크인 부모 폴더만 다른 같은 파일을 놓친다"
42
+ 2. 같은 원인을 내는 코드 꼴을 모두 적는다. API 하나만 보지 않는다.
43
+ - 예: `wx` 만이 아니라 `O_EXCL`, `COPYFILE_EXCL`, `wx` 없이 추측 가능한 임시 이름에 쓰는 것까지
44
+ - 예: `===` 비교만이 아니라 `Map`, `Set` 키, `startsWith`, `path.relative` 의 `..` 판정, `includes` 까지
45
+ 3. 범위는 바뀐 파일이 아니라 배포되는 파일 전부다.
46
+ - 같은 파일의 사본(다른 스킬 폴더, 런타임별 폴더)도 각각 센다.
47
+ - 시험과 개발 도구에서 찾은 것은 따로 표시한다.
48
+ 4. 찾은 명령과 건수를 확인 범위에 적는다.
49
+ - 찾기가 0건이면 패턴을 의심한다. 더 단순한 패턴이나 다른 도구(`rg`, `git grep`)로 다시 찾는다. 셸의 `grep` 이 다른 도구로 바뀌어 있을 수 있다.
50
+ 5. 찾은 자리마다 판정한다.
51
+ - 고칠 것: 결과(거짓 성공, 예외, 경계 우회, 밖 파일 생성 등)를 적는다.
52
+ - 해당 없음: 까닭(방어가 이미 있음, 닿는 길이 없음, 이름을 미리 알 수 없음 등)을 적는다.
53
+ 6. 프로젝트에 기록된, 이미 고친 결함도 종류로 삼는다.
54
+ - 출처: CHANGELOG 의 고친 항목, 위키와 메모리의 함정 기록
55
+ - 비슷한 항목은 종류 하나로 묶어 중복을 줄인다.
56
+ - 예전에 한 곳만 고친 종류가 다른 곳에 남았는지 1~5 와 같은 방법으로 본다.
57
+ 7. 자리가 많으면 런타임이 지원하는 읽기 전용 하위 에이전트에게 종류별로 나눠 맡겨도 된다. 맡길 때는 결함 종류, 찾을 꼴, 이미 고친 본보기를 함께 준다. 하위 에이전트도 고치지 않는다.
58
+
35
59
  ## 3. 이 머신에서 시험
36
60
 
37
61
  - 프로젝트의 시험 명령을 찾는다. `package.json` 의 `scripts.test`, CI 워크플로의 시험 단계, `Makefile`, pytest 설정, `*.test.mjs`(node:test) 순으로 본다. 찾은 명령과 그 근거를 확인 범위에 적는다.
@@ -90,7 +114,7 @@ argument-hint: "[기준 커밋] [--no-boxes]"
90
114
  | `name` | 박스 이름(영문이나 숫자로 시작, 영문, 숫자, `.`, `_`, `-`) |
91
115
  | `os` | `linux`, `darwin`, `win32` 중 하나 |
92
116
  | `ssh` | `user@host`. 암호 없이 SSH 키로 접속돼야 한다 |
93
- | `port`, `identity` | 선택. SSH 포트와 키 파일 경로 |
117
+ | `port`, `identity` | 선택. SSH 포트와 키 파일 경로(상대 경로는 boxes.mjs 를 실행한 폴더 기준) |
94
118
  | `path` | 선택. Linux, macOS 박스에서 PATH 앞에 붙일 절대 경로(비대화형 SSH 의 PATH 에 node 가 없을 때) |
95
119
  | `runAs` | 선택. root 로 접속하는 Linux 박스에서 시험을 대신 돌릴 계정(예: `nobody`) |
96
120
 
@@ -123,5 +147,8 @@ argument-hint: "[기준 커밋] [--no-boxes]"
123
147
 
124
148
  - 근거는 다시 확인할 수 있게 쓴다(파일과 줄, 명령, 시험 이름, 로그 경로).
125
149
  - 심각도가 높은 것부터 놓는다.
150
+ - 2-1 의 결함 종류는 종류마다 한 행으로 쓴다. 항목 칸에 종류를, 근거 칸에 고칠 자리 수와 해당 없음 수를 적는다.
151
+ - 고칠 자리가 20곳 이하면 표 아래에 `파일:줄 | 판정 | 까닭` 목록을 붙인다.
152
+ - 20곳을 넘으면 자리 목록을 시스템 임시 폴더의 파일에 쓰고 그 경로를 근거에 적는다. 저장소 안에는 쓰지 않는다.
126
153
  - 남은 항목이 없으면 표 대신 "남은 항목 없음" 과 확인 범위를 쓴다.
127
154
  - 마지막 줄에는 사용자가 고를 다음 행동을 쓴다(예: 고칠 항목 고르기). 고치는 일은 요청을 받은 뒤에 한다.
@@ -15,9 +15,13 @@ import { spawn, spawnSync } from "node:child_process";
15
15
  import { randomBytes } from "node:crypto";
16
16
  import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
17
17
  import { homedir, tmpdir } from "node:os";
18
- import { join } from "node:path";
18
+ import { isAbsolute, join, resolve } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
 
21
+ // The folder programs start in: the home folder (Windows looks for a program in the working folder first, so a
22
+ // program planted in the folder worked on never runs), or the temp folder when there is no home folder.
23
+ const runDir = () => (existsSync(homedir()) ? homedir() : tmpdir());
24
+
21
25
  const OSES = new Set(["linux", "darwin", "win32"]);
22
26
  const FIELDS = new Set(["name", "os", "ssh", "port", "identity", "path", "runAs"]);
23
27
  const SECRETISH = /pass|secret|token|credential/i;
@@ -102,7 +106,10 @@ export function loadBoxes(file = configFile()) {
102
106
 
103
107
  // ── commands for a box ───────────────────────────────────────────────────────
104
108
 
105
- const extras = (b, portFlag) => [...(b.port ? [portFlag, String(b.port)] : []), ...(b.identity ? ["-i", b.identity] : [])];
109
+ // ssh and scp start in the home folder, so a relative key path is made absolute against this run's folder first,
110
+ // where it was meant. A path from ~ stays as it is: OpenSSH expands it itself.
111
+ const keyPath = (p) => (/^~([\\/]|$)/.test(p) || isAbsolute(p) ? p : resolve(p));
112
+ const extras = (b, portFlag) => [...(b.port ? [portFlag, String(b.port)] : []), ...(b.identity ? ["-i", keyPath(b.identity)] : [])];
106
113
  export const sshBase = (b) => [...BASE, ...extras(b, "-p")];
107
114
  export const scpBase = (b) => [...BASE, ...extras(b, "-P")];
108
115
 
@@ -256,8 +263,10 @@ export async function runOnBox(b, plan, exec) {
256
263
  return { ...rest, ms: Date.now() - t0 };
257
264
  }
258
265
 
266
+ // Started from the home folder with git -C: Windows looks for a program in the working folder first, so a git.exe
267
+ // inside the folder worked on never runs.
259
268
  function git(cwd, args) {
260
- const r = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
269
+ const r = spawnSync("git", ["-C", resolve(cwd), ...args], { cwd: runDir(), encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
261
270
  if (r.status !== 0) throw new Error(`git ${args.filter((a) => !a.includes("=")).join(" ")} 실패: ${lastLine(r.stderr) || r.error?.message || ""}`);
262
271
  return r.stdout;
263
272
  }
@@ -423,7 +432,8 @@ export async function main(argv, deps = {}) {
423
432
  // even if a grandchild still holds its pipes. Output past MAX_OUTPUT is dropped.
424
433
  export function spawnExec(file, args, { timeoutMs = UPLOAD_MS } = {}) {
425
434
  return new Promise((resolve) => {
426
- const child = spawn(file, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
435
+ // from the home folder: Windows looks for ssh.exe and scp.exe in the working folder (the repository) first
436
+ const child = spawn(file, args, { cwd: runDir(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
427
437
  children.add(child);
428
438
  const buf = { stdout: "", stderr: "" };
429
439
  for (const k of ["stdout", "stderr"]) {
@@ -38,6 +38,7 @@
38
38
  * folder (a sandbox, a read-only mount, a file another account owns): the settings are unchanged.
39
39
  * Every output ends with the line `설정 파일: <path>`.
40
40
  */
41
+ import { randomBytes } from "node:crypto";
41
42
  import { accessSync, chmodSync, constants, existsSync, lstatSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
42
43
  import { homedir } from "node:os";
43
44
  import { dirname, join } from "node:path";
@@ -187,7 +188,7 @@ function writeFresh(file, data, mode) {
187
188
  if (lstatSync(file, { throwIfNoEntry: false })?.isDirectory()) {
188
189
  throw Object.assign(new Error(`쓸 자리에 폴더가 있습니다: ${file}`), { code: "EISDIR" });
189
190
  }
190
- const tmp = `${file}.${process.pid}.tmp`;
191
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
191
192
  rmSync(tmp, { force: true });
192
193
  try {
193
194
  writeFileSync(tmp, data, { flag: "wx", mode });
@@ -9,6 +9,7 @@
9
9
  * - 파일이 링크이거나 일반 파일이 아니면 고치지 않는다. 쓰기 전 원본을 백업(이 계정만 읽음)하고, 새 파일에 쓴 뒤
10
10
  * 바꿔 넣으며 파일 모드를 지킨다.
11
11
  */
12
+ import crypto from 'node:crypto';
12
13
  import fs from 'node:fs';
13
14
  import os from 'node:os';
14
15
  import path from 'node:path';
@@ -34,15 +35,19 @@ export function readToml(file) {
34
35
  return { text: fs.readFileSync(file, 'utf8'), mode: st.mode & 0o7777, exists: true };
35
36
  }
36
37
 
37
- // 원본을 backup 에 복사한 뒤(원본이 있을 때) 새 파일에 써서 바꿔 넣는다.
38
+ // 원본을 backup 에 복사한 뒤(원본이 있을 때) 새 파일에 써서 바꿔 넣는다. 백업도 같은 방식이라 백업 자리의 링크는
39
+ // 따라가지 않고 바뀐다.
38
40
  export function writeToml(file, text, { mode = null, backup = null } = {}) {
39
- if (backup && fs.existsSync(file)) {
40
- fs.copyFileSync(file, backup);
41
- fs.chmodSync(backup, 0o600); // 원본에 비밀 값이 있을 수 있다
42
- }
43
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
41
+ if (backup && fs.existsSync(file)) replaceFile(backup, fs.readFileSync(file), 0o600); // 원본에 비밀 값이 있을 수 있다
42
+ replaceFile(file, text, mode);
43
+ }
44
+
45
+ // 난수 이름의 새 파일에 쓴 뒤 file 자리로 바꿔 넣는다. 이름을 미리 맞힐 수 없어, 그 자리에 심어 둔 링크를 wx 가
46
+ // 따라가는 일(Windows 는 끊어진 링크를 따라가 대상 파일을 만든다)이 없다. 실패하면 새 파일을 지운다.
47
+ function replaceFile(file, data, mode) {
48
+ const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
44
49
  try {
45
- fs.writeFileSync(tmp, text, { flag: 'wx', mode: mode ?? 0o600 });
50
+ fs.writeFileSync(tmp, data, { flag: 'wx', mode: mode ?? 0o600 });
46
51
  if (mode !== null) fs.chmodSync(tmp, mode);
47
52
  fs.renameSync(tmp, file);
48
53
  } catch (e) {
@@ -34,6 +34,7 @@
34
34
  * changes the exit code. APPLY_HUD_CHECK_TIMEOUT_MS (tests) shortens its 25s limit.
35
35
  * Exit 1 means nothing changed; exit 2 means an unexpected error stopped it partway.
36
36
  */
37
+ import { randomBytes } from "node:crypto";
37
38
  import { spawnSync } from "node:child_process";
38
39
  import {
39
40
  accessSync,
@@ -405,7 +406,7 @@ function writeSettings(file, text) {
405
406
  } catch {
406
407
  if (isLink(file)) return writeFileSync(file, text);
407
408
  }
408
- const tmp = `${target}.${process.pid}.tmp`;
409
+ const tmp = `${target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
409
410
  try {
410
411
  rmSync(tmp, { force: true });
411
412
  writeFileSync(tmp, text, { flag: "wx", mode });
@@ -9,6 +9,7 @@
9
9
  * - 파일이 링크이거나 일반 파일이 아니면 고치지 않는다. 쓰기 전 원본을 백업(이 계정만 읽음)하고, 새 파일에 쓴 뒤
10
10
  * 바꿔 넣으며 파일 모드를 지킨다.
11
11
  */
12
+ import crypto from 'node:crypto';
12
13
  import fs from 'node:fs';
13
14
  import os from 'node:os';
14
15
  import path from 'node:path';
@@ -34,15 +35,19 @@ export function readToml(file) {
34
35
  return { text: fs.readFileSync(file, 'utf8'), mode: st.mode & 0o7777, exists: true };
35
36
  }
36
37
 
37
- // 원본을 backup 에 복사한 뒤(원본이 있을 때) 새 파일에 써서 바꿔 넣는다.
38
+ // 원본을 backup 에 복사한 뒤(원본이 있을 때) 새 파일에 써서 바꿔 넣는다. 백업도 같은 방식이라 백업 자리의 링크는
39
+ // 따라가지 않고 바뀐다.
38
40
  export function writeToml(file, text, { mode = null, backup = null } = {}) {
39
- if (backup && fs.existsSync(file)) {
40
- fs.copyFileSync(file, backup);
41
- fs.chmodSync(backup, 0o600); // 원본에 비밀 값이 있을 수 있다
42
- }
43
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
41
+ if (backup && fs.existsSync(file)) replaceFile(backup, fs.readFileSync(file), 0o600); // 원본에 비밀 값이 있을 수 있다
42
+ replaceFile(file, text, mode);
43
+ }
44
+
45
+ // 난수 이름의 새 파일에 쓴 뒤 file 자리로 바꿔 넣는다. 이름을 미리 맞힐 수 없어, 그 자리에 심어 둔 링크를 wx 가
46
+ // 따라가는 일(Windows 는 끊어진 링크를 따라가 대상 파일을 만든다)이 없다. 실패하면 새 파일을 지운다.
47
+ function replaceFile(file, data, mode) {
48
+ const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
44
49
  try {
45
- fs.writeFileSync(tmp, text, { flag: 'wx', mode: mode ?? 0o600 });
50
+ fs.writeFileSync(tmp, data, { flag: 'wx', mode: mode ?? 0o600 });
46
51
  if (mode !== null) fs.chmodSync(tmp, mode);
47
52
  fs.renameSync(tmp, file);
48
53
  } catch (e) {
@@ -25,6 +25,7 @@
25
25
  * `node --check`. Exit status 1 means nothing was changed; 2 means an unexpected
26
26
  * error stopped the command partway, so check `status`.
27
27
  */
28
+ import { randomBytes } from "node:crypto";
28
29
  import { spawnSync } from "node:child_process";
29
30
  import { accessSync, constants, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
30
31
  import { homedir, tmpdir } from "node:os";
@@ -139,7 +140,7 @@ function insertBlock(base) {
139
140
  // either name is replaced rather than written through, and a write that fails
140
141
  // leaves the old file as it was.
141
142
  function writeFresh(file, data) {
142
- const tmp = `${file}.${process.pid}.tmp`;
143
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
143
144
  rmSync(tmp, { force: true });
144
145
  try {
145
146
  writeFileSync(tmp, data, { flag: "wx" });
@@ -27,6 +27,7 @@ import {
27
27
  chmodSync, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync,
28
28
  renameSync, rmSync, statSync, writeFileSync,
29
29
  } from "node:fs";
30
+ import { randomBytes } from "node:crypto";
30
31
  import { homedir, tmpdir } from "node:os";
31
32
  import { join, posix, win32 } from "node:path";
32
33
  import { fileURLToPath } from "node:url";
@@ -60,10 +61,11 @@ function readJson(file) {
60
61
  }
61
62
  }
62
63
 
63
- // A new file beside the target, given the target's mode when there is one, then renamed over it. The temp name is
64
- // created exclusively and a link in the target's place is replaced, not followed. The temp file goes on failure.
64
+ // A new file beside the target, given the target's mode when there is one, then renamed over it. The temp name ends
65
+ // in random bytes, so no one can plant a link at it first (Windows opens "wx" through a link to nothing and makes the
66
+ // file it leads to), and a link in the target's place is replaced, not followed. The temp file goes on failure.
65
67
  function writeAtomic(file, data) {
66
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
68
+ const tmp = `${file}.${randomBytes(8).toString("hex")}.tmp`;
67
69
  try {
68
70
  writeFileSync(tmp, data, { flag: "wx" });
69
71
  const mode = modeOf(file);
@@ -214,10 +216,29 @@ export function hookCommand(node, script, opts = {}) {
214
216
  const r = P.relative(dir, f);
215
217
  return r === "" || (!r.startsWith("..") && !P.isAbsolute(r));
216
218
  };
217
- if (under(node, tmpdir()) || (codex && [node, SELF].some((f) => under(f, cwd)))) return null;
219
+ // Each path as given and as the file system spells it: a link, macOS /var against /private/var, a Windows 8.3 name
220
+ // or another case would otherwise slip a node in the temp folder past the check.
221
+ const both = (p) => [p, realOf(p)];
222
+ const temps = [tmpdir(), ...(win ? [] : ["/tmp"])].flatMap(both);
223
+ const inTemp = both(node).some((f) => temps.some((d) => under(f, d)));
224
+ const inWork = codex && [node, SELF].flatMap(both).some((f) => both(cwd).some((d) => under(f, d)));
225
+ if (inTemp || inWork) return null;
218
226
  return formatCommand(node, script, win);
219
227
  }
220
228
 
229
+ // The path as the file system spells it (native first: it gives the long name and the case on disk), or as given.
230
+ function realOf(p) {
231
+ try {
232
+ return realpathSync.native(p);
233
+ } catch {
234
+ try {
235
+ return realpathSync(p);
236
+ } catch {
237
+ return p;
238
+ }
239
+ }
240
+ }
241
+
221
242
  const COPY_RE = new RegExp(`[/\\\\]${COPY_NAME.replace(/\./g, "\\.")}["']? hook$`);
222
243
  const isOurs = (h) => isObject(h) && typeof h.command === "string" && COPY_RE.test(h.command);
223
244
 
@@ -198,7 +198,8 @@ export function writeFresh(file, data, mode = 0o644) {
198
198
  // any name that is there, a link to nothing among them (EEXIST). Windows follows a link to nothing and makes the file
199
199
  // it leads to: then that file is removed again when it is still the empty one made, and the open is refused with
200
200
  // EEXIST as elsewhere. A look before the open catches such a link; this catches one planted between the look and the
201
- // open. A look that fails after the open leaves the file to the caller, as a plain "wx" would.
201
+ // open. A name gone after the open is EEXIST too. A look that cannot be made (ESTALE, EPERM) leaves the file to the
202
+ // caller, as a plain "wx" would.
202
203
  export function openNew(path, mode) {
203
204
  const fd = openSync(path, "wx", mode);
204
205
  let made = null;
@@ -206,8 +207,10 @@ export function openNew(path, mode) {
206
207
  try {
207
208
  made = fstatSync(fd, { bigint: true });
208
209
  st = lstatSync(path, { bigint: true });
209
- } catch {
210
- return fd;
210
+ } catch (e) {
211
+ if (e?.code !== "ENOENT") return fd; // cannot look (ESTALE, EPERM): the file is the caller's, as a plain "wx" leaves it
212
+ closeSync(fd); // the name is gone: nothing is written through a name not proved
213
+ throw Object.assign(new Error(`EEXIST: file already exists, open '${path}'`), { code: "EEXIST", syscall: "open", path });
211
214
  }
212
215
  if (!st.isSymbolicLink() && (made.ino === 0n || sameInode(made, st))) return fd;
213
216
  closeSync(fd);
@@ -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
  */
@@ -67,6 +67,13 @@ const readlinkOr = (path) => {
67
67
  return "?";
68
68
  }
69
69
  };
70
+ const nativeReal = (path) => {
71
+ try {
72
+ return realpathSync.native(path);
73
+ } catch {
74
+ return resolve(path);
75
+ }
76
+ };
70
77
  const realOrNull = (path) => {
71
78
  try {
72
79
  return realpathSync(path);
@@ -96,7 +103,8 @@ function fileProblem(st, file, uid) {
96
103
  return null;
97
104
  }
98
105
 
99
- // The file's stat and bytes, opened without blocking so a FIFO planted after the look never waits.
106
+ // The file's stat and bytes, opened without blocking so a FIFO planted after the look never waits. id: the same
107
+ // file's bigint stat for telling files apart (an NTFS file id passes 2^53, so as a Number two files can share one).
100
108
  function readChecked(file, uid) {
101
109
  const first = statSync(file);
102
110
  const problem = fileProblem(first, file, uid);
@@ -105,7 +113,7 @@ function readChecked(file, uid) {
105
113
  try {
106
114
  const st = fstatSync(fd);
107
115
  const late = fileProblem(st, file, uid);
108
- return late ? { refuse: late } : { st, bytes: readFileSync(fd) };
116
+ return late ? { refuse: late } : { st, id: fstatSync(fd, { bigint: true }), bytes: readFileSync(fd) };
109
117
  } finally {
110
118
  closeSync(fd);
111
119
  }
@@ -212,9 +220,25 @@ const options = (opts) => ({
212
220
  wrap: opts.wrap ?? null,
213
221
  skill: opts.skill,
214
222
  follow: opts.followSymlink === true,
223
+ readOnly: opts.readOnly === true, // a plan only read (never committed): no backup spot to check
215
224
  uid: "uid" in opts ? opts.uid : process.getuid?.(),
216
225
  });
217
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
+
218
242
  /**
219
243
  * Works out what applyBlock would write, without writing: { ok, changed, refused?, message, path, file, key,
220
244
  * before, after, mode }. key names the file (device and inode, or the path of a file not there yet).
@@ -226,14 +250,20 @@ export function planBlock(path, opts) {
226
250
  if (at.refuse) return at.refuse;
227
251
  if (!at.exists) {
228
252
  const after = o.body === null ? null : Buffer.from(blockText(o.mk, o.body, o.wrap, "\n"), "utf8");
229
- 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 };
230
254
  }
231
255
  const read = readChecked(at.file, o.uid);
232
256
  if (read.refuse) return read.refuse;
233
257
  const next = nextBytes(read.bytes, o, at.file);
234
258
  if (next.refuse) return next.refuse;
235
259
  const changed = !next.bytes.equals(read.bytes);
236
- const key = `ino:${read.st.dev}:${read.st.ino}`;
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);
264
+ // ino 0 (a file system without file ids) tells nothing apart: then the path as the file system spells it is the
265
+ // key (realpathSync.native gives one spelling for names that differ only in case where case is ignored).
266
+ const key = read.id.ino === 0n ? `path:${nativeReal(at.file)}` : `ino:${read.id.dev}:${read.id.ino}`;
237
267
  return { ok: true, changed, path, file: at.file, key, before: read.bytes, after: next.bytes, mode: read.st.mode & 0o7777 };
238
268
  }
239
269
 
@@ -254,36 +284,111 @@ function writeAtomic(file, data, mode) {
254
284
  }
255
285
  }
256
286
 
257
- // Keeps the first original: writes <file>.<skill>.bak only when nothing is at that name yet.
258
- function backup(file, skill, bytes) {
259
- const bak = `${file}.${skill}.bak`;
260
- if (lstatSync(bak, { throwIfNoEntry: false })) return null;
261
- 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);
262
325
  try {
263
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;
264
336
  } finally {
265
337
  closeSync(fd);
266
338
  }
267
- return bak;
268
339
  }
269
340
 
270
- /** 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
+ */
271
375
  export function commitBlock(plan, skill) {
272
376
  if (!plan.ok) return plan;
273
377
  const result = { ok: true, changed: plan.changed, path: plan.path, file: plan.file };
274
378
  if (!plan.changed) return { ...result, message: `변경 없음: ${plan.path}` };
275
379
  if (plan.before === null && plan.after !== null) {
276
- const fd = openSync(plan.file, "wx", plan.mode);
277
380
  try {
278
- writeFileSync(fd, plan.after);
279
- } finally {
280
- 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 };
281
385
  }
282
386
  return { ...result, created: true, message: `새로 만듦: ${plan.path}` };
283
387
  }
284
- const bak = backup(plan.file, skill, plan.before);
388
+ const kept = backup(plan.file, skill, plan.before);
389
+ if (kept.refuse) return kept.refuse;
285
390
  writeAtomic(plan.file, plan.after, plan.mode);
286
- return { ...result, backup: bak, message: `바꿈: ${plan.path}` };
391
+ return { ...result, backup: kept.bak, message: `바꿈: ${plan.path}` };
287
392
  }
288
393
 
289
394
  /** Puts in, replaces or (body null) takes out the block in one file. dryRun plans only. */
@@ -296,16 +401,33 @@ export function applyBlock(path, opts) {
296
401
  /**
297
402
  * Applies blocks to several files, each entry { path, ...opts }. Every file is planned first: one refusal and
298
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.
299
406
  */
300
407
  export function applyBlocks(entries, { dryRun = false } = {}) {
301
408
  const plans = entries.map((e) => ({ entry: e, plan: planBlock(e.path, e) }));
302
409
  const refused = plans.filter((p) => !p.plan.ok).map((p) => p.plan);
303
410
  if (refused.length > 0) return { ok: false, refused, results: plans.map((p) => p.plan) };
304
411
  const seen = new Map();
305
- const results = plans.map(({ entry, plan }) => {
306
- 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;
307
416
  seen.set(plan.key, plan.path);
308
- 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);
309
430
  });
310
- 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 };
311
433
  }