@kaydash9999/banker-plugins 0.24.1 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +50 -0
- package/README.md +5 -4
- package/bin/lib/codex-hooks.mjs +5 -1
- package/codex/hooks.json +4 -4
- package/codex/manifest.json +1 -0
- package/codex/skills/teamwork/scripts/teamwork.mjs +18 -8
- package/codex/transform-matrix.md +1 -0
- package/hooks/telemetry-count-codex.mjs +3 -2
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +1 -0
- package/skills/3d-intro-build/references/azure-adapter.mjs +54 -27
- package/skills/3d-intro-build/references/video-pool.mjs +11 -6
- package/skills/3d-intro-setup/references/azure-adapter.mjs +54 -27
- package/skills/3d-intro-setup/references/video-pool.mjs +11 -6
- package/skills/anti-drm/SKILL.md +2 -2
- package/skills/anti-drm/scripts/anti-drm.mjs +8 -5
- package/skills/anti-drm/scripts/instruction-toggle.mjs +6 -3
- package/skills/build-testbox/scripts/browser-cell.mjs +10 -2
- package/skills/build-testbox/scripts/containers.mjs +8 -3
- package/skills/build-testbox/scripts/proc.mjs +32 -8
- package/skills/build-testbox/scripts/testbox.mjs +10 -2
- package/skills/git-init/scripts/git-init.mjs +53 -5
- package/skills/git-init/scripts/marker-block.mjs +131 -19
- package/skills/init-dir/scripts/init-dir.mjs +29 -2
- package/skills/init-dir/scripts/marker-block.mjs +131 -19
- package/skills/init-dir/templates/scripts/lib/service.mjs +57 -6
- package/skills/lineage/SKILL.md +1 -1
- package/skills/lineage/lineage.py +11 -11
- package/skills/motion-sites/SKILL.md +87 -0
- package/skills/motion-sites/scripts/motion-sites.mjs +348 -0
- package/skills/omc-patch/scripts/omc-patch.mjs +76 -17
- package/skills/payload-mon/scripts/payload-mon.mjs +2 -1
- package/skills/payload-mon/scripts/payload-size.mjs +2 -2
- package/skills/progress-mon/scripts/pm-adr.mjs +6 -3
- package/skills/progress-mon/scripts/pm-config.mjs +133 -7
- package/skills/progress-mon/scripts/pm-data.mjs +35 -29
- package/skills/progress-mon/scripts/pm-memory.mjs +14 -7
- package/skills/progress-mon/scripts/pm-server.mjs +3 -3
- package/skills/progress-mon/scripts/pm-sources.mjs +2 -1
- package/skills/ralph-qa/SKILL.md +1 -0
- package/skills/ralph-qa/references/seat-run.mjs +21 -4
- package/skills/ralph-qa/references/verifier-probe.mjs +35 -13
- package/skills/remains/SKILL.md +31 -4
- package/skills/remains/scripts/boxes.mjs +44 -15
- package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +2 -1
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +12 -7
- package/skills/setup-omc-hud/assets/apply-hud.mjs +2 -1
- package/skills/setup-omc-hud/assets/codex-config.mjs +12 -7
- package/skills/setup-omc-hud/scripts/claude-update-last.mjs +2 -1
- package/skills/smart-compact/scripts/codex-gate.mjs +25 -4
- package/skills/tone-compact/scripts/instruction-toggle.mjs +6 -3
- package/skills/tune-tmux/scripts/marker-block.mjs +131 -19
- package/skills/tune-tmux/scripts/tmux-config.mjs +35 -3
- package/skills/tune-tmux/scripts/tune-tmux.mjs +15 -9
- package/skills/ultra-chain/references/chain-ledger.mjs +8 -1
- package/skills/vertical-pptx/verify_a4p.mjs +11 -4
|
@@ -188,6 +188,8 @@ export const DECISION_REASONS = [
|
|
|
188
188
|
|
|
189
189
|
export const CLI_DEFAULT = "@cli-default";
|
|
190
190
|
|
|
191
|
+
// How a CLI call ended, for a note.
|
|
192
|
+
const callWhy = (res) => (res?.why ? res.why : res?.status === 0 ? "exit 0 with output the probe could not use" : `exit ${res?.status ?? "?"}`);
|
|
191
193
|
const firstLine = (res) => (res?.status === 0 ? String(res.stdout).trim().split("\n")[0] || null : null);
|
|
192
194
|
const absent = (cli) => ({ cli, present: false, decision: "unseated", reason: "cli-absent" });
|
|
193
195
|
const adopt = (seat, model, reason) => ({ ...seat, decision: "adopt", model, reason });
|
|
@@ -382,6 +384,8 @@ const rawMcpNames = (lines) => lines.filter((l) => !/^\s*#/.test(l))
|
|
|
382
384
|
// or the line that opened what is still open), and the strings of each top-level array
|
|
383
385
|
// (`arrays`). Enough for those keys; not a TOML parser.
|
|
384
386
|
export function readToml(text) {
|
|
387
|
+
// a file there that could not be read (permissions, a folder): nothing it says is known, so it reads as broken
|
|
388
|
+
if (text?.unreadable) return { values: {}, roots: [], arrays: {}, mcpServers: [], mcpUnread: true, modelKeys: false, broken: true, brokenAt: 0, unreadable: text.unreadable };
|
|
385
389
|
const state = tomlState();
|
|
386
390
|
const lines = String(text ?? "").split(/\r?\n/);
|
|
387
391
|
const isOpen = () => state.open !== null || state.depth > 0;
|
|
@@ -457,6 +461,7 @@ export function stripJsonc(text) {
|
|
|
457
461
|
// { value } for a readable JSONC text, { unreadable: true } for a broken one, {} when absent.
|
|
458
462
|
function readJsonc(text) {
|
|
459
463
|
if (text == null) return {};
|
|
464
|
+
if (text.unreadable) return { unreadable: true };
|
|
460
465
|
try {
|
|
461
466
|
const value = JSON.parse(stripJsonc(String(text)));
|
|
462
467
|
return value && typeof value === "object" && !Array.isArray(value) ? { value } : { unreadable: true };
|
|
@@ -530,7 +535,7 @@ function codexConfig(world) {
|
|
|
530
535
|
mcpUnread: layers.some((l) => l.mcpUnread),
|
|
531
536
|
modelKeys: layers.some((l) => l.modelKeys),
|
|
532
537
|
broken: layers.some((l) => l.broken),
|
|
533
|
-
brokenAt: layers.map((l, k) => (l.broken ? `${files[k]}
|
|
538
|
+
brokenAt: layers.map((l, k) => (!l.broken ? null : l.unreadable ? `${files[k]} (not readable: ${l.unreadable})` : `${files[k]} line ${l.brokenAt}`)).filter(Boolean),
|
|
534
539
|
};
|
|
535
540
|
}
|
|
536
541
|
|
|
@@ -555,6 +560,8 @@ function mcpBlockedSeat(seat, conf, notes) {
|
|
|
555
560
|
const SEAT_KEYS = ["notify", "otel", "mcp_servers", "web_search", "tools", "analytics", "features", "hooks",
|
|
556
561
|
"sandbox_mode", "sandbox_workspace_write", "approval_policy"];
|
|
557
562
|
|
|
563
|
+
const unreadAt = (read) => (read.unreadable ? `could not be read (${read.unreadable})` : `could not be read to its end (line ${read.brokenAt})`);
|
|
564
|
+
|
|
558
565
|
// The unseated seat when the managed file sets one of those keys, or could not be read to its
|
|
559
566
|
// end (a line read short may set one); null otherwise.
|
|
560
567
|
function managedBlockedSeat(seat, world, notes) {
|
|
@@ -562,7 +569,7 @@ function managedBlockedSeat(seat, world, notes) {
|
|
|
562
569
|
const read = readToml(world.readFile(CODEX_MANAGED));
|
|
563
570
|
const keys = read.roots.filter((k) => SEAT_KEYS.includes(k));
|
|
564
571
|
if (!keys.length && !read.broken) return null;
|
|
565
|
-
notes.push(`${CODEX_MANAGED} ${keys.length ? `sets ${keys.join(", ")}` :
|
|
572
|
+
notes.push(`${CODEX_MANAGED} ${keys.length ? `sets ${keys.join(", ")}` : unreadAt(read)}, which codex applies over the seat's -c flags: the seat cannot hold its guards`);
|
|
566
573
|
return { ...seat, decision: "unseated", reason: "cli-call-failed" };
|
|
567
574
|
}
|
|
568
575
|
|
|
@@ -583,7 +590,7 @@ function requirementsBlockedSeat(seat, world, notes) {
|
|
|
583
590
|
const allows = (k) => SEAT_ALLOWS[k] !== undefined && (read.arrays[k] ?? []).includes(SEAT_ALLOWS[k]);
|
|
584
591
|
const keys = read.roots.filter((k) => !GUARD_FREE.includes(k) && !allows(k));
|
|
585
592
|
if (!keys.length && !read.broken) return null;
|
|
586
|
-
notes.push(`${CODEX_REQUIREMENTS} ${keys.length ? `sets ${keys.join(", ")}` :
|
|
593
|
+
notes.push(`${CODEX_REQUIREMENTS} ${keys.length ? `sets ${keys.join(", ")}` : unreadAt(read)}, which codex holds over the seat's -c, --disable and -s flags: the seat cannot hold its guards`);
|
|
587
594
|
return { ...seat, decision: "unseated", reason: "cli-call-failed" };
|
|
588
595
|
}
|
|
589
596
|
|
|
@@ -653,10 +660,18 @@ function codexSeat(flag, { world, runtime, authorFamilies }) {
|
|
|
653
660
|
const notes = [];
|
|
654
661
|
const conf = codexConfig(world);
|
|
655
662
|
const configured = vetConfigured(safeOnly([conf.cfg.model], "config.toml", notes)[0], authorFamilies, notes);
|
|
656
|
-
const
|
|
663
|
+
const listed = world.run("codex", ["debug", "models", "--bundled"]);
|
|
664
|
+
const read = readCatalog(listed, notes);
|
|
665
|
+
const catalog = read ?? [];
|
|
657
666
|
const sorted = sortModels([...catalog, configured], authorFamilies);
|
|
658
|
-
const
|
|
667
|
+
const ran = world.run("codex", ["--version"]);
|
|
668
|
+
// a version line has a version number in it; an error text on stdout with exit 0 is not one
|
|
669
|
+
const version = /\d+\.\d+/.test(firstLine(ran) ?? "") ? firstLine(ran) : null;
|
|
670
|
+
if (!read) notes.push(`codex debug models --bundled gave no catalog (${callWhy(listed)})`);
|
|
671
|
+
if (!version) notes.push(`codex --version gave no version (${callWhy(ran)})`);
|
|
659
672
|
const seat = { cli: "codex", present: true, version, configured, candidates: sorted.independent, unclassified: sorted.unclassified, mcpServers: conf.mcpServers };
|
|
673
|
+
// both calls failed: this codex does not run here (not logged in, broken install, killed), so it is not a seat
|
|
674
|
+
if (!read && !version) return withNotes({ ...seat, decision: "unseated", reason: "cli-call-failed" }, notes);
|
|
660
675
|
const refused = legacyProfileSeat(seat, conf.cfg, notes) ?? managedBlockedSeat(seat, world, notes)
|
|
661
676
|
?? requirementsBlockedSeat(seat, world, notes) ?? mcpBlockedSeat(seat, conf, notes);
|
|
662
677
|
if (refused) return withNotes(refused, notes);
|
|
@@ -710,8 +725,10 @@ function geminiPoliciesDir(world) {
|
|
|
710
725
|
// seat's read-only tools rest on that flag: such a seat is not seated.
|
|
711
726
|
function geminiPolicySeat(seat, world, notes) {
|
|
712
727
|
const dir = geminiPoliciesDir(world);
|
|
713
|
-
|
|
714
|
-
|
|
728
|
+
const list = world.listDir(dir);
|
|
729
|
+
if (list && !list.some((f) => f.endsWith(".toml"))) return null;
|
|
730
|
+
notes.push(list ? `gemini ignores --admin-policy while ${dir} holds policies: the seat cannot be held to its read-only tools`
|
|
731
|
+
: `the probe could not list ${dir}: gemini ignores --admin-policy while it holds policies, so the seat cannot be shown to keep to its read-only tools`);
|
|
715
732
|
return { ...seat, decision: "unseated", reason: "cli-call-failed" };
|
|
716
733
|
}
|
|
717
734
|
|
|
@@ -835,7 +852,8 @@ const opencodeCache = (world) => join(world.env.XDG_CACHE_HOME || join(world.hom
|
|
|
835
852
|
function catalogProviders(world) {
|
|
836
853
|
const file = join(opencodeCache(world), "models.json");
|
|
837
854
|
const read = readJsonc(world.readFile(file));
|
|
838
|
-
|
|
855
|
+
// an emptied copy ({}) lists no provider: it says nothing about which ones models.dev knows
|
|
856
|
+
return read.value && Object.keys(read.value).length ? new Set(Object.keys(read.value)) : null;
|
|
839
857
|
}
|
|
840
858
|
|
|
841
859
|
// The models the config lists are all opencode can run only when enabled_providers names
|
|
@@ -953,18 +971,20 @@ function realWorld() {
|
|
|
953
971
|
platform: process.platform,
|
|
954
972
|
env: process.env,
|
|
955
973
|
which: onPath,
|
|
974
|
+
// null when the file is not there; { unreadable } when it is but cannot be read, which is not the same as absent
|
|
956
975
|
readFile: (path) => {
|
|
957
976
|
try {
|
|
958
977
|
return readFileSync(path, "utf8");
|
|
959
|
-
} catch {
|
|
960
|
-
return null;
|
|
978
|
+
} catch (e) {
|
|
979
|
+
return e.code === "ENOENT" || e.code === "ENOTDIR" ? null : { unreadable: e.code || "error" };
|
|
961
980
|
}
|
|
962
981
|
},
|
|
982
|
+
// [] when the folder is not there; null when it is but cannot be listed
|
|
963
983
|
listDir: (path) => {
|
|
964
984
|
try {
|
|
965
985
|
return readdirSync(path);
|
|
966
|
-
} catch {
|
|
967
|
-
return [];
|
|
986
|
+
} catch (e) {
|
|
987
|
+
return e.code === "ENOENT" || e.code === "ENOTDIR" ? [] : null;
|
|
968
988
|
}
|
|
969
989
|
},
|
|
970
990
|
run: (cmd, args, env = {}) => {
|
|
@@ -978,11 +998,13 @@ function realWorld() {
|
|
|
978
998
|
const r = spawnSync(viaCmd ? `"${exe}"` : exe, args, {
|
|
979
999
|
encoding: "utf8",
|
|
980
1000
|
timeout: 15000,
|
|
1001
|
+
// a CLI that ignores SIGTERM would hold spawnSync past its time limit
|
|
1002
|
+
killSignal: "SIGKILL",
|
|
981
1003
|
maxBuffer: 64 * 1024 * 1024,
|
|
982
1004
|
shell,
|
|
983
1005
|
env: { ...process.env, ...(viaCmd ? { NoDefaultCurrentDirectoryInExePath: "1" } : {}), ...env },
|
|
984
1006
|
});
|
|
985
|
-
return { status: r.status, stdout: r.stdout ?? "" };
|
|
1007
|
+
return { status: r.status, stdout: r.stdout ?? "", why: r.error ? (r.error.code === "ETIMEDOUT" ? "timed out" : r.error.code) : r.signal ? `killed by ${r.signal}` : null };
|
|
986
1008
|
},
|
|
987
1009
|
};
|
|
988
1010
|
}
|
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) 순으로 본다. 찾은 명령과 그 근거를 확인 범위에 적는다.
|
|
@@ -68,7 +92,7 @@ argument-hint: "[기준 커밋] [--no-boxes]"
|
|
|
68
92
|
- `noTests` 는 시험이 하나도 돌지 않았다는 뜻이라 통과가 아니라 미검증이다.
|
|
69
93
|
- 한 OS 에서만 실패하면, 제품의 결함은 버그로, 시험이 실행 환경을 잘못 가정한 것은 하자로 적는다.
|
|
70
94
|
|
|
71
|
-
박스에 둔 것은 `run` 이 끝날 때 지운다. 시간 초과, 연결 끊김, 중단(SIGINT, SIGTERM) 때는 박스에서 시작한 프로세스를 먼저 끝낸다. Linux, macOS 는 `mktemp -d` 로 만든 개인 폴더를 쓴다. 지우지 못하면 결과의 `cleaned` 가 `false` 이고, 그 박스를 하자로 적는다.
|
|
95
|
+
박스에 둔 것은 `run` 이 끝날 때 지운다. 시간 초과, 연결 끊김, 중단(SIGINT, SIGTERM) 때는 박스에서 시작한 프로세스를 먼저 끝낸다. Linux, macOS 는 `mktemp -d` 로 만든 개인 폴더를 쓴다. 보통은 `/tmp` 아래이고, `runAs` 박스는 `/var/lib` 아래다. `runAs` 계정의 HOME 도 그 안에 있어서, 임시 폴더를 거부하는 도구(teamwork 등)의 시험도 그대로 돈다. 지우지 못하면 결과의 `cleaned` 가 `false` 이고, 그 박스를 하자로 적는다.
|
|
72
96
|
알려진 한계: 이중 fork 로 프로세스 트리를 벗어난 프로세스는 끝나지 않는다. `pgrep` 이 없는 박스에서는 기록된 프로세스 하나만 끝난다.
|
|
73
97
|
|
|
74
98
|
### 등록 파일
|
|
@@ -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,15 +15,23 @@ 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;
|
|
24
28
|
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
25
29
|
const TARGET = /^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*$/;
|
|
26
|
-
|
|
30
|
+
// Where a box's private folder goes. A runAs box gets one outside the temporary folders, so that the copy and the
|
|
31
|
+
// run account's HOME inside it are not under /tmp: tools that refuse temporary folders (teamwork's Codex home check)
|
|
32
|
+
// would refuse the tests' fixtures there. /var/lib is root's, so only a root login (runAs) makes one there.
|
|
33
|
+
const dirBase = (b) => (b.runAs ? "/var/lib" : "/tmp");
|
|
34
|
+
const remoteDir = (b) => new RegExp(`^${dirBase(b).replace(/\//g, "\\/")}\\/banker-remains\\.[A-Za-z0-9]+$`);
|
|
27
35
|
// What ssh and scp print about the connection itself, as opposed to what the remote command prints.
|
|
28
36
|
const SSH_ERR = /^(ssh|scp): |Connection (closed|refused|reset|timed out)|Host key verification failed|Permission denied \(|Could not resolve hostname|No route to host|Broken pipe/m;
|
|
29
37
|
const UPLOAD_MS = 10 * 60 * 1000;
|
|
@@ -102,7 +110,10 @@ export function loadBoxes(file = configFile()) {
|
|
|
102
110
|
|
|
103
111
|
// ── commands for a box ───────────────────────────────────────────────────────
|
|
104
112
|
|
|
105
|
-
|
|
113
|
+
// ssh and scp start in the home folder, so a relative key path is made absolute against this run's folder first,
|
|
114
|
+
// where it was meant. A path from ~ stays as it is: OpenSSH expands it itself.
|
|
115
|
+
const keyPath = (p) => (/^~([\\/]|$)/.test(p) || isAbsolute(p) ? p : resolve(p));
|
|
116
|
+
const extras = (b, portFlag) => [...(b.port ? [portFlag, String(b.port)] : []), ...(b.identity ? ["-i", keyPath(b.identity)] : [])];
|
|
106
117
|
export const sshBase = (b) => [...BASE, ...extras(b, "-p")];
|
|
107
118
|
export const scpBase = (b) => [...BASE, ...extras(b, "-P")];
|
|
108
119
|
|
|
@@ -146,8 +157,8 @@ function winSteps(b, name) {
|
|
|
146
157
|
};
|
|
147
158
|
}
|
|
148
159
|
|
|
149
|
-
// Linux and macOS work in the private folder `mktemp -d` made
|
|
150
|
-
// HOME of its own. Ending a run takes the process tree under the recorded id first, then the folder.
|
|
160
|
+
// Linux and macOS work in the private folder `mktemp -d` made (under /tmp, or /var/lib for runAs). A root login
|
|
161
|
+
// hands the run to runAs, with a HOME of its own inside that folder. Ending a run takes the process tree under the recorded id first, then the folder.
|
|
151
162
|
function posixSteps(b, name, dir) {
|
|
152
163
|
const go = b.runAs
|
|
153
164
|
? `mkdir -p "$D/home" && chown -R ${b.runAs} "$D" && runuser -u ${b.runAs} -- env HOME="$D/home" sh "$D/run.sh"`
|
|
@@ -220,10 +231,10 @@ const sshFailed = (r) => (r.status === null && !r.timedOut) || (r.status === 255
|
|
|
220
231
|
|
|
221
232
|
async function makeDir(b, exec) {
|
|
222
233
|
if (b.os === "win32") return { dir: null };
|
|
223
|
-
const r = await exec("ssh", [...sshBase(b), b.ssh,
|
|
234
|
+
const r = await exec("ssh", [...sshBase(b), b.ssh, `mktemp -d ${dirBase(b)}/banker-remains.XXXXXXXX`], { timeoutMs: PROBE_MS });
|
|
224
235
|
const dir = String(r.stdout || "").trim();
|
|
225
236
|
if (r.status !== 0) return { error: lastLine(r.stderr) || "ssh 실패" };
|
|
226
|
-
return
|
|
237
|
+
return remoteDir(b).test(dir) ? { dir } : { error: `mktemp -d 가 예상과 다른 경로를 냈습니다: ${dir.slice(0, 80)}` };
|
|
227
238
|
}
|
|
228
239
|
|
|
229
240
|
async function attempt(b, plan, exec, files, steps) {
|
|
@@ -256,22 +267,39 @@ export async function runOnBox(b, plan, exec) {
|
|
|
256
267
|
return { ...rest, ms: Date.now() - t0 };
|
|
257
268
|
}
|
|
258
269
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
270
|
+
// Started from the home folder with git -C: Windows looks for a program in the working folder first, so a git.exe
|
|
271
|
+
// inside the folder worked on never runs.
|
|
272
|
+
function git(cwd, args, env = process.env) {
|
|
273
|
+
const r = spawnSync("git", ["-C", resolve(cwd), ...args], { cwd: runDir(), env, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
274
|
+
if (r.status !== 0) throw new Error(`git ${gitWords(args)} 실패: ${lastLine(r.stderr) || r.error?.message || ""}`);
|
|
262
275
|
return r.stdout;
|
|
263
276
|
}
|
|
264
277
|
|
|
278
|
+
// The command words for a message: global options (-c key=value, --no-optional-locks) are left out.
|
|
279
|
+
export const gitWords = (args) => args.filter((a, i) => a !== "-c" && args[i - 1] !== "-c" && !a.startsWith("--no-optional-locks")).join(" ");
|
|
280
|
+
|
|
265
281
|
// The commit to send. `worktree` is a commit object made from the tracked files as they are now
|
|
266
282
|
// (git stash create: no stash entry, no change to the work tree). Untracked files are never sent.
|
|
267
|
-
|
|
268
|
-
|
|
283
|
+
// Neither step writes the repository's index: status takes no optional lock, and stash create works on a copy of
|
|
284
|
+
// the index (GIT_INDEX_FILE), so two runs at once, or a git command of the user's, do not meet on index.lock.
|
|
285
|
+
export function resolveTree(repo, ref) {
|
|
286
|
+
const status = git(repo, ["--no-optional-locks", "status", "--porcelain", "--untracked-files=all"]).split("\n").filter(Boolean);
|
|
269
287
|
const untracked = status.filter((l) => l.startsWith("??")).length;
|
|
270
288
|
if (ref !== "worktree") {
|
|
271
289
|
return { sha: git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]).trim(), untracked, modified: status.length - untracked };
|
|
272
290
|
}
|
|
273
|
-
const
|
|
274
|
-
|
|
291
|
+
const tmp = join(tmpdir(), `banker-remains-index-${randomBytes(8).toString("hex")}`);
|
|
292
|
+
mkdirSync(tmp, { mode: 0o700 });
|
|
293
|
+
try {
|
|
294
|
+
const index = resolve(repo, git(repo, ["rev-parse", "--git-path", "index"]).trim());
|
|
295
|
+
const copy = join(tmp, "index");
|
|
296
|
+
if (existsSync(index)) copyFileSync(index, copy);
|
|
297
|
+
const env = { ...process.env, GIT_INDEX_FILE: copy };
|
|
298
|
+
const made = git(repo, ["-c", "user.name=banker-remains", "-c", "user.email=banker-remains@localhost", "stash", "create"], env).trim();
|
|
299
|
+
return { sha: made || git(repo, ["rev-parse", "HEAD"]).trim(), untracked, modified: 0 };
|
|
300
|
+
} finally {
|
|
301
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
302
|
+
}
|
|
275
303
|
}
|
|
276
304
|
|
|
277
305
|
function testFiles(repo, sha) {
|
|
@@ -423,7 +451,8 @@ export async function main(argv, deps = {}) {
|
|
|
423
451
|
// even if a grandchild still holds its pipes. Output past MAX_OUTPUT is dropped.
|
|
424
452
|
export function spawnExec(file, args, { timeoutMs = UPLOAD_MS } = {}) {
|
|
425
453
|
return new Promise((resolve) => {
|
|
426
|
-
|
|
454
|
+
// from the home folder: Windows looks for ssh.exe and scp.exe in the working folder (the repository) first
|
|
455
|
+
const child = spawn(file, args, { cwd: runDir(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
427
456
|
children.add(child);
|
|
428
457
|
const buf = { stdout: "", stderr: "" };
|
|
429
458
|
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);
|