@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +49 -0
- package/README.md +5 -4
- package/bin/lib/codex-hooks.mjs +5 -1
- package/codex/hooks.json +4 -4
- package/codex/manifest.json +2 -1
- package/codex/skills/teamwork/scripts/teamwork.mjs +21 -11
- package/codex/transform-matrix.md +1 -0
- package/hooks/telemetry-count-codex.mjs +3 -2
- package/package.json +1 -1
- 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 +144 -22
- package/skills/init-dir/SKILL.md +1 -1
- package/skills/init-dir/scripts/init-dir.mjs +29 -2
- package/skills/init-dir/scripts/marker-block.mjs +144 -22
- package/skills/init-dir/templates/scripts/README.md +4 -1
- package/skills/init-dir/templates/scripts/lib/service.mjs +140 -33
- package/skills/lineage/SKILL.md +1 -1
- package/skills/lineage/lineage.py +11 -11
- package/skills/motion-sites/SKILL.md +79 -0
- package/skills/motion-sites/scripts/motion-sites.mjs +331 -0
- package/skills/omc-patch/scripts/omc-patch.mjs +2 -1
- package/skills/payload-mon/scripts/payload-mon.mjs +2 -1
- package/skills/payload-mon/scripts/payload-size.mjs +2 -2
- package/skills/progress-mon/README.md +4 -3
- package/skills/progress-mon/SKILL.md +1 -1
- package/skills/progress-mon/references/adr.md +7 -0
- package/skills/progress-mon/scripts/pm-adr-view.mjs +101 -0
- package/skills/progress-mon/scripts/pm-adr.mjs +38 -105
- package/skills/progress-mon/scripts/pm-check.mjs +16 -7
- 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-render.mjs +1 -1
- package/skills/progress-mon/scripts/pm-server.mjs +3 -3
- package/skills/progress-mon/scripts/pm-sources.mjs +12 -2
- package/skills/remains/SKILL.md +30 -3
- package/skills/remains/scripts/boxes.mjs +14 -4
- 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 +144 -22
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
509
|
+
adr: readAdrFile(path.join(ctx.dataDir, 'adr.json')),
|
|
510
|
+
adrCheck: tab('adr-check.json'),
|
|
501
511
|
};
|
|
502
512
|
}
|
package/skills/remains/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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,
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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,
|
|
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
|
|
64
|
-
//
|
|
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}.${
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
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
|
|
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
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|