@kaydash9999/banker-plugins 0.20.5 → 0.22.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 +52 -0
- package/PRIVACY.md +25 -1
- package/README.md +63 -18
- package/bin/banker.js +173 -14
- package/bin/lib/codex-hooks.mjs +254 -0
- package/bin/lib/codex-routes.mjs +112 -0
- package/bin/lib/command-skill.cjs +19 -0
- package/bin/lib/telemetry-config.mjs +4 -1
- package/codex/command-skills/front-qa/SKILL.md +56 -0
- package/codex/command-skills/setup/SKILL.md +87 -0
- package/codex/hooks.json +29 -0
- package/codex/manifest.json +13 -10
- package/codex/skills/graceful-pause/SKILL.md +30 -0
- package/codex/skills/progress/SKILL.md +42 -0
- package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
- package/codex/skills/progress/scripts/progress.mjs +295 -0
- package/codex/transform-matrix.md +18 -13
- package/commands/setup.md +2 -2
- package/hooks/obsidize-hook.mjs +116 -4
- package/hooks/progress-plan.mjs +1 -1
- package/hooks/telemetry-count-codex.mjs +309 -0
- package/hooks/update-notify.mjs +105 -14
- package/package.json +1 -1
- package/skills/3d-intro-build/references/verify-intro.mjs +15 -1
- package/skills/anti-drm/SKILL.md +1 -1
- package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
- package/skills/append_wiki/SKILL.md +34 -8
- package/skills/arch-diagram/build_pptx_template.py +47 -28
- package/skills/audit-web-page/SKILL.md +1 -1
- package/skills/audit-web-page/harness.py +0 -1
- package/skills/compact-wiki/SKILL.md +37 -6
- package/skills/deep-research/SKILL.md +6 -0
- package/skills/lfm/SKILL.md +148 -0
- package/skills/lineage/SKILL.md +14 -4
- package/skills/lineage/lineage.py +1582 -516
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/obsidizer/SKILL.md +44 -10
- package/skills/obsidizer/obsidize.mjs +42 -11
- package/skills/omc-patch/SKILL.md +6 -0
- package/skills/payload-mon/SKILL.md +3 -1
- package/skills/play-qa/SKILL.md +1 -1
- package/skills/ralph-qa/SKILL.md +1 -1
- package/skills/remains/SKILL.md +1 -1
- package/skills/rfp-author/SKILL.md +5 -3
- package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
- package/skills/rfp-author/scripts/md2docx.py +63 -34
- package/skills/setup-bypass-permissions/SKILL.md +28 -10
- package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
- package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
- package/skills/setup-omc-hud/SKILL.md +33 -3
- package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
- package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
- package/skills/smart-compact/SKILL.md +59 -13
- package/skills/smart-compact/scripts/codex-gate.mjs +385 -0
- package/skills/theme-selection/SKILL.md +1 -1
- package/skills/tone-compact/SKILL.md +1 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
- package/skills/ultra-chain/SKILL.md +10 -6
- package/skills/ultra-interview/SKILL.md +13 -2
- package/skills/ultra-ui-qa/SKILL.md +1 -1
- package/skills/update-banker/SKILL.md +31 -6
- package/skills/vertical-pptx/SKILL.md +1 -1
- package/skills/vertical-pptx/build_a4p.js +20 -2
- package/skills/vertical-pptx/build_a4p.py +560 -359
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* banker 텔레메트리 count 훅 (Codex Stop) - Codex 세션이 banker 스킬을 쓴 턴이 끝날 때 로컬 usage-log 에
|
|
4
|
+
* `banker:<name>\t<hour>` 한 줄을 남긴다. Claude Code 의 telemetry-count(.mjs, -skill.mjs)에 해당한다.
|
|
5
|
+
*
|
|
6
|
+
* Codex 0.144.5 실측으로 스킬이 쓰이는 길은 둘이다.
|
|
7
|
+
* - 모델이 고른 스킬: 도구 호출로 SKILL.md 를 읽는다(Bash `cat <CODEX_HOME>/skills/banker-<name>/SKILL.md`).
|
|
8
|
+
* - 사용자가 $banker-<name> 으로 부른 스킬: Codex 가 도구 호출 없이 `<skill>\n<name>..</name>\n<path>..</path>` 로
|
|
9
|
+
* 시작하는 사용자 역할 메시지로 본문을 넣는다. 이때는 머리의 <path> 줄만 보고 본문은 보지 않는다.
|
|
10
|
+
* Codex 플러그인으로 설치한 banker(codex plugin add, $banker:<name>)는 SKILL.md 가
|
|
11
|
+
* `<CODEX_HOME>/plugins/cache/<마켓플레이스>/banker/<판>/{skills,codex/skills,codex/command-skills}/<name>/` 에 있다.
|
|
12
|
+
* 두 길의 경로를 모두 센다.
|
|
13
|
+
* 도구 호출마다 도는 PreToolUse 대신 턴마다 한 번 도는 Stop 에서, 훅 입력의 transcript_path(rollout) 중
|
|
14
|
+
* 이번 턴(task_started 의 turn_id 뒤)의 두 기록에 `skills/banker-<name>/SKILL.md` 가 있으면 센다. 나눠 읽어도 한 번이고, Stop 이 한 턴에 두 번 와도
|
|
15
|
+
* 최근 턴 키(configDir 의 codex-count-turns)로 한 번만 센다. 기록은 끝에서부터 32 MB 까지만 읽는다.
|
|
16
|
+
*
|
|
17
|
+
* fail-closed 규약은 Claude 쪽과 같다(telemetry-count-codex.test.mjs 가 검증):
|
|
18
|
+
* - countingActive() 가 아니면 로컬 쓰기조차 하지 않는다.
|
|
19
|
+
* - 이름은 SKILL_NAME_RE 전체 일치이고 이 Codex 에 설치된 banker 스킬(<CODEX_HOME>/skills/banker-<name>/SKILL.md, 또는
|
|
20
|
+
* 기록의 경로 그대로 <CODEX_HOME>/plugins/cache/ 아래에 있는 플러그인의 SKILL.md)만.
|
|
21
|
+
* 모델이 `echo skills/banker-<아무 글>/SKILL.md` 를 돌려 체크인의 이름 칸으로 글을 내보내지 못하게 한다. 턴 키 파일은 일반 파일만 읽고, 링크 자리에 쓰지 않는다.
|
|
22
|
+
* - 어떤 오류도 삼키고 항상 exit 0. 도구 호출을 절대 막지 않는다. 표준 출력은 비운다.
|
|
23
|
+
*/
|
|
24
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { codexHomeOf, pluginRoutes } from '../bin/lib/codex-routes.mjs';
|
|
28
|
+
import { appendUsageLog, CODEX_SHAPE_MISS_PATH, configDir, countingActive, randomHex, readRegular, recordUsedSkill, SKILL_NAME_RE, writeByRename } from '../bin/lib/telemetry-config.mjs';
|
|
29
|
+
|
|
30
|
+
export { codexHomeOf };
|
|
31
|
+
|
|
32
|
+
const SKILL_PATH_RE = /skills[\\/]+banker-([a-z0-9_-]{1,64})[\\/]+SKILL\.md/g;
|
|
33
|
+
// 플러그인 길: plugins/cache/<마켓플레이스>/banker/<판>/<스킬 폴더>/<name>/SKILL.md. 1 마켓플레이스, 2 판, 3 스킬 폴더, 4 이름.
|
|
34
|
+
// 대소문자를 가리지 않는 파일 시스템에서 `FRONT-QA` 같은 변형이 다른 이름으로 세어지지 않게 i 를 두지 않는다.
|
|
35
|
+
const PLUGIN_PATH_RE = /plugins[\\/]+cache[\\/]+([A-Za-z0-9._-]{1,80})[\\/]+banker[\\/]+([A-Za-z0-9._-]{1,80})[\\/]+(skills|codex[\\/]+skills|codex[\\/]+command-skills)[\\/]+([a-z0-9_-]{1,64})[\\/]+SKILL\.md/g;
|
|
36
|
+
const KEEP_TURNS = 100;
|
|
37
|
+
export const TURNS_FILE = () => join(configDir(), 'codex-count-turns');
|
|
38
|
+
|
|
39
|
+
function readStdin() {
|
|
40
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
41
|
+
let data = '';
|
|
42
|
+
process.stdin.setEncoding('utf8');
|
|
43
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
44
|
+
process.stdin.on('end', () => resolvePromise(data));
|
|
45
|
+
process.stdin.on('error', rejectPromise);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MAX_READ = 32 << 20;
|
|
50
|
+
const FIRST_READ = 1 << 20;
|
|
51
|
+
|
|
52
|
+
// 글에 나오는 banker 스킬, 중복 없이. npm 길은 'banker:<name>', 플러그인 길은 'banker:<name>\t<홈 기준 SKILL.md 상대 경로>'
|
|
53
|
+
// (설치 확인에 쓰고, 셀 때는 이름만 남긴다).
|
|
54
|
+
export function namesIn(text, names = new Set()) {
|
|
55
|
+
for (const m of text.matchAll(SKILL_PATH_RE)) {
|
|
56
|
+
const name = `banker:${m[1]}`;
|
|
57
|
+
if (SKILL_NAME_RE.test(name)) names.add(name);
|
|
58
|
+
}
|
|
59
|
+
for (const m of text.matchAll(PLUGIN_PATH_RE)) {
|
|
60
|
+
const name = `banker:${m[4]}`;
|
|
61
|
+
if (m[1] === '.' || m[1] === '..' || m[2] === '.' || m[2] === '..' || !SKILL_NAME_RE.test(name)) continue;
|
|
62
|
+
names.add(`${name}\t${['plugins', 'cache', m[1], 'banker', m[2], ...m[3].split(/[\\/]+/), m[4], 'SKILL.md'].join('/')}`);
|
|
63
|
+
}
|
|
64
|
+
return names;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// rollout 끝에서 max 바이트의 줄과, 파일 전체를 읽었는지. 일반 파일만: 기다리지 않게 O_NONBLOCK 으로 열고 연 것을 fstat 으로 본다. 앞에서 잘린 첫 줄은 버린다.
|
|
68
|
+
function tailLines(file, max) {
|
|
69
|
+
const fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
70
|
+
try {
|
|
71
|
+
if (!fstatSync(fd).isFile()) return { lines: [], whole: true };
|
|
72
|
+
const size = fstatSync(fd).size;
|
|
73
|
+
const start = Math.max(0, size - max);
|
|
74
|
+
const buf = Buffer.alloc(size - start);
|
|
75
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
76
|
+
const lines = buf.toString('utf8').split('\n');
|
|
77
|
+
if (start > 0) lines.shift();
|
|
78
|
+
return { lines, whole: start === 0 };
|
|
79
|
+
} finally {
|
|
80
|
+
closeSync(fd);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isCall = (rec) => rec?.type === 'response_item' && /_call$/.test(String(rec?.payload?.type ?? ''));
|
|
85
|
+
const SKILL_HEAD_RE = /^<skill>\n<name>[^\n<]{1,80}<\/name>\n<path>([^\n<]{1,4096})<\/path>\n/;
|
|
86
|
+
|
|
87
|
+
// $banker-<name> 으로 부른 스킬을 Codex 가 넣은 메시지의 <path>. 그 밖의 기록은 ''.
|
|
88
|
+
function injectedPath(rec) {
|
|
89
|
+
const p = rec?.payload;
|
|
90
|
+
if (rec?.type !== 'response_item' || p?.type !== 'message' || p.role !== 'user' || !Array.isArray(p.content)) return '';
|
|
91
|
+
const text = p.content[0]?.text;
|
|
92
|
+
return typeof text === 'string' ? (SKILL_HEAD_RE.exec(text)?.[1] ?? '') : '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 이번 턴(turn_id 의 task_started 뒤)의 도구 호출과 넣은 스킬 머리에서 읽은 banker 스킬 이름. 사용자 글과 모델 답은 세지 않는다.
|
|
96
|
+
// 턴 시작은 대개 기록 끝 가까이에 있으므로 끝 1 MB 부터 읽고, 못 찾으면 두 배씩 max 까지 넓힌다.
|
|
97
|
+
// 기록 전체를 읽고도 task_started 가 하나도 없으면 null: Codex 는 턴마다 task_started 를 쓰므로 기록 형식이 바뀐 것이다.
|
|
98
|
+
// 이 턴의 것만 없을 때(시작이 기록되지 않는 턴)는 형식 변경으로 보지 않는다.
|
|
99
|
+
export function turnSkills(file, turnId, max = MAX_READ) {
|
|
100
|
+
for (let size = Math.min(FIRST_READ, max); ; size = Math.min(size * 2, max)) {
|
|
101
|
+
const { lines, whole } = tailLines(file, size);
|
|
102
|
+
const start = turnStart(lines, turnId);
|
|
103
|
+
if (start >= 0) return skillsAfter(lines, start);
|
|
104
|
+
if (whole) return lines.some((l) => l.trim()) && !lines.some((l) => l.includes('"task_started"')) ? null : [];
|
|
105
|
+
if (size >= max) return [];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// rollout 첫 기록(session_meta)의 Codex 판. Codex 기록이 아니면 ''.
|
|
110
|
+
export function codexVersionOf(file) {
|
|
111
|
+
try {
|
|
112
|
+
const fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
113
|
+
try {
|
|
114
|
+
if (!fstatSync(fd).isFile()) return '';
|
|
115
|
+
const buf = Buffer.alloc(Math.min(fstatSync(fd).size, 8 << 20)); // session_meta 는 지침을 품어 클 수 있다
|
|
116
|
+
readSync(fd, buf, 0, buf.length, 0);
|
|
117
|
+
const rec = JSON.parse(buf.toString('utf8').split('\n')[0]);
|
|
118
|
+
return rec?.type === 'session_meta' ? String(rec.payload?.cli_version ?? '?').slice(0, 40) : '';
|
|
119
|
+
} finally {
|
|
120
|
+
closeSync(fd);
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 이 Codex 판의 기록을 읽지 못했음을 남긴다(같은 판은 한 번만). 다음 세션 시작 때 update-notify --codex 가 알린다.
|
|
128
|
+
function noteShapeMiss(file) {
|
|
129
|
+
const version = codexVersionOf(file);
|
|
130
|
+
if (!version) return;
|
|
131
|
+
let prev = {};
|
|
132
|
+
try { prev = JSON.parse(readRegular(CODEX_SHAPE_MISS_PATH)); } catch { /* 없음 */ }
|
|
133
|
+
if (prev?.version !== version) writeByRename(CODEX_SHAPE_MISS_PATH, JSON.stringify({ version, notified: false }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function turnStart(lines, turnId) {
|
|
137
|
+
let start = lines.length - 1;
|
|
138
|
+
while (start >= 0 && !(lines[start].includes('"task_started"') && lines[start].includes(turnId))) start--;
|
|
139
|
+
return start;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function skillsAfter(lines, start) {
|
|
143
|
+
const names = new Set();
|
|
144
|
+
for (const line of lines.slice(start + 1)) {
|
|
145
|
+
if (!line.includes('SKILL.md')) continue;
|
|
146
|
+
let rec;
|
|
147
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
148
|
+
if (isCall(rec)) namesIn(JSON.stringify(rec.payload), names);
|
|
149
|
+
else namesIn(injectedPath(rec), names);
|
|
150
|
+
}
|
|
151
|
+
return [...names];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ID_RE = /^[A-Za-z0-9._-]{1,80}$/;
|
|
155
|
+
|
|
156
|
+
// 이 턴의 키. session_id 와 turn_id 가 모양이 맞을 때만(파일 한 줄 형식을 깨지 않게).
|
|
157
|
+
export function turnKey(payload) {
|
|
158
|
+
const s = payload?.session_id;
|
|
159
|
+
const t = payload?.turn_id;
|
|
160
|
+
return typeof s === 'string' && typeof t === 'string' && ID_RE.test(s) && ID_RE.test(t) ? `${s}:${t}` : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function readTurns() {
|
|
164
|
+
try {
|
|
165
|
+
return readRegular(TURNS_FILE()).split('\n').filter(Boolean);
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 링크나 일반 파일이 아닌 자리에는 쓰지 않는다. 새 파일을 만들어 바꿔 넣는다.
|
|
172
|
+
function writeTurns(lines) {
|
|
173
|
+
const file = TURNS_FILE();
|
|
174
|
+
try {
|
|
175
|
+
if (!lstatSync(file).isFile()) return;
|
|
176
|
+
} catch (e) {
|
|
177
|
+
if (e?.code !== 'ENOENT') return;
|
|
178
|
+
}
|
|
179
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
180
|
+
try {
|
|
181
|
+
writeFileSync(tmp, `${lines.slice(-KEEP_TURNS).join('\n')}\n`, { flag: 'wx', mode: 0o600 });
|
|
182
|
+
renameSync(tmp, file);
|
|
183
|
+
} catch {
|
|
184
|
+
rmSync(tmp, { force: true });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 이 Codex 에 설치된 banker 스킬인지. 플러그인 길은 그 홈의 config.toml 에 켜져 있는 banker@<마켓플레이스> 의 것이고,
|
|
189
|
+
// 기록의 경로 그대로 plugins/cache 아래에 있어야 한다(지운 플러그인이 남긴 폴더나 다른 마켓플레이스의 banker 는 세지 않음).
|
|
190
|
+
export function installed(entry, home, routes = pluginRoutes(home)) {
|
|
191
|
+
const [name, rel] = entry.split('\t');
|
|
192
|
+
if (rel) return routes.includes(`banker@${rel.split('/')[2]}`) && existsSync(join(home, ...rel.split('/')));
|
|
193
|
+
return existsSync(join(home, 'skills', `banker-${name.slice('banker:'.length)}`, 'SKILL.md'));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 이 턴에 아직 세지 않은 이름만 센다.
|
|
197
|
+
export function countOnce(payload) {
|
|
198
|
+
const key = turnKey(payload);
|
|
199
|
+
const file = typeof payload?.transcript_path === 'string' ? payload.transcript_path : '';
|
|
200
|
+
const home = codexHomeOf(file);
|
|
201
|
+
const got = key && file ? turnSkills(file, payload.turn_id) : [];
|
|
202
|
+
if (got === null) {
|
|
203
|
+
noteShapeMiss(file);
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
const routes = got.some((n) => n.includes('\t')) ? pluginRoutes(home) : []; // config.toml 은 한 번만 읽는다
|
|
207
|
+
const names = [...new Set(got.filter((n) => installed(n, home, routes)).map((n) => n.split('\t')[0]))];
|
|
208
|
+
if (!names.length) return [];
|
|
209
|
+
return withTurnsLock(() => {
|
|
210
|
+
const seen = readTurns();
|
|
211
|
+
const fresh = names.filter((n) => !seen.includes(`${key}\t${n}`));
|
|
212
|
+
if (!fresh.length) return [];
|
|
213
|
+
for (const n of fresh) {
|
|
214
|
+
appendUsageLog(n);
|
|
215
|
+
recordUsedSkill(n);
|
|
216
|
+
}
|
|
217
|
+
writeTurns([...seen, ...fresh.map((n) => `${key}\t${n}`)]);
|
|
218
|
+
return fresh;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// npm 길과 플러그인 길의 Stop 훅이 같은 턴에 함께 돌면 둘 다 턴 키를 읽고 셀 수 있다. 턴 키를 읽고 쓰는 동안
|
|
223
|
+
// codex-count-turns.lock 폴더를 잡는다(mkdir 은 이미 있으면 실패하고 링크를 따라가지 않아 둘 중 하나만 잡음).
|
|
224
|
+
// 2초 안에 못 잡으면 잡은 쪽이 이 턴을 세므로 세지 않는다. 잠금은 몇 ms 만 잡으므로 훅 시간 제한(5초)보다 조금 긴
|
|
225
|
+
// 6초가 지난 잠금은 죽은 훅의 것이다: 고유 이름으로 옮겨(rename 은 하나만 성공) 지운 뒤 다시 잡는다. 옮긴 것이
|
|
226
|
+
// 새것이면(그사이 다른 훅이 넘겨받아 잡은 것) 그 훅에 이 턴을 맡기고 물러난다.
|
|
227
|
+
// configDir 을 만들 수 없거나 잠금 폴더를 만들 수 없는 다른 오류면 잠금 없이 전처럼 센다.
|
|
228
|
+
const LOCK_WAIT_MS = 2000;
|
|
229
|
+
const LOCK_STALE_MS = 6000;
|
|
230
|
+
export function withTurnsLock(fn, { wait = LOCK_WAIT_MS } = {}) {
|
|
231
|
+
const lock = `${TURNS_FILE()}.lock`;
|
|
232
|
+
try {
|
|
233
|
+
mkdirSync(configDir(), { recursive: true });
|
|
234
|
+
} catch {
|
|
235
|
+
return fn();
|
|
236
|
+
}
|
|
237
|
+
const end = Date.now() + wait;
|
|
238
|
+
const token = `${process.pid}-${randomHex()}`;
|
|
239
|
+
for (;;) {
|
|
240
|
+
try {
|
|
241
|
+
mkdirSync(lock);
|
|
242
|
+
try { writeFileSync(join(lock, 'owner'), token, { flag: 'wx' }); } catch { /* 표시 없이도 잠금은 잡았다 */ }
|
|
243
|
+
break;
|
|
244
|
+
} catch (e) {
|
|
245
|
+
if (e?.code !== 'EEXIST') return fn();
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
249
|
+
const old = `${lock}.stale-${process.pid}-${randomHex()}`;
|
|
250
|
+
renameSync(lock, old);
|
|
251
|
+
// 본 것과 옮긴 것 사이에 다른 훅이 낡은 잠금을 치우고 새로 잡았을 수 있다. 옮긴 것이 새것이면 그 훅이 이
|
|
252
|
+
// 턴을 세는 중이므로 세지 않고 물러난다. 돌려놓지 않는다(그사이 세 번째 훅이 잡은 잠금을 덮을 수 있다).
|
|
253
|
+
// 옮긴 폴더는 다음에 잠금을 잡은 훅이 치운다.
|
|
254
|
+
if (Date.now() - statSync(old).mtimeMs <= LOCK_STALE_MS) return [];
|
|
255
|
+
rmSync(old, { recursive: true, force: true });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
} catch { /* 방금 풀렸거나 다른 훅이 먼저 치움: 다시 잡아 본다 */ }
|
|
259
|
+
if (Date.now() >= end) return [];
|
|
260
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
dropMovedLocks(lock);
|
|
264
|
+
return fn();
|
|
265
|
+
} finally {
|
|
266
|
+
releaseLock(lock, token);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 잠금 폴더 안의 주인 표시(owner)가 이 훅의 것일 때만 놓는다. 넘겨받던 훅이 이 잠금을 옮기고 다른 훅이 그 이름을
|
|
271
|
+
// 새로 잡았으면, 그 훅의 잠금은 지우지 않는다.
|
|
272
|
+
function releaseLock(lock, token) {
|
|
273
|
+
try {
|
|
274
|
+
if (readFileSync(join(lock, 'owner'), 'utf8') !== token) return;
|
|
275
|
+
rmSync(join(lock, 'owner'), { force: true });
|
|
276
|
+
rmdirSync(lock);
|
|
277
|
+
} catch { /* 없거나 남의 것 */ }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 넘겨받다 물러난 훅이 남긴 옮긴 잠금(<잠금>.stale-*) 가운데 오래된 것을 지운다.
|
|
281
|
+
function dropMovedLocks(lock) {
|
|
282
|
+
const dir = configDir();
|
|
283
|
+
const prefix = `${lock.slice(dir.length + 1)}.stale-`;
|
|
284
|
+
try {
|
|
285
|
+
for (const name of readdirSync(dir)) {
|
|
286
|
+
if (!name.startsWith(prefix)) continue;
|
|
287
|
+
try {
|
|
288
|
+
if (Date.now() - statSync(join(dir, name)).mtimeMs > LOCK_STALE_MS) rmSync(join(dir, name), { recursive: true, force: true });
|
|
289
|
+
} catch { /* 다른 훅이 먼저 지움 */ }
|
|
290
|
+
}
|
|
291
|
+
} catch { /* 다음에 */ }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function main() {
|
|
295
|
+
let payload;
|
|
296
|
+
try {
|
|
297
|
+
payload = JSON.parse(await readStdin());
|
|
298
|
+
} catch {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!countingActive()) return;
|
|
302
|
+
countOnce(payload);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (process.argv[1] && /telemetry-count-codex\.mjs$/.test(process.argv[1])) {
|
|
306
|
+
main()
|
|
307
|
+
.catch(() => {})
|
|
308
|
+
.finally(() => process.exit(0));
|
|
309
|
+
}
|
package/hooks/update-notify.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - 어떤 오류도 삼키고 항상 exit 0. 세션을 절대 막지 않는다. 비TTY 안전.
|
|
15
15
|
*/
|
|
16
16
|
import { spawn } from 'node:child_process';
|
|
17
|
-
import { realpathSync } from 'node:fs';
|
|
17
|
+
import { mkdirSync, readdirSync, realpathSync, renameSync, rmdirSync, rmSync, statSync } from 'node:fs';
|
|
18
18
|
import { dirname, join } from 'node:path';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
|
|
@@ -22,11 +22,19 @@ import {
|
|
|
22
22
|
UPDATE_CHECK_PATH, updateCheckDue,
|
|
23
23
|
noUpdateCheck, countingActive, endpoint,
|
|
24
24
|
installedVersion, compareVersions, writeUpdateCache, readUsedSkills, readRegular,
|
|
25
|
+
CODEX_SHAPE_MISS_PATH, writeByRename, configDir, randomHex,
|
|
25
26
|
} from '../bin/lib/telemetry-config.mjs';
|
|
27
|
+
import { codexHomeOf, DEFAULT_MARKETPLACE, dualInstallNote, pluginRootInfo } from '../bin/lib/codex-routes.mjs';
|
|
26
28
|
|
|
27
29
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
28
30
|
// 업데이트 안내에 쓰는 npm 패키지 이름(고지 문구·이모지/em-dash 없음).
|
|
29
31
|
const NPM_PKG = '@kaydash9999/banker-plugins';
|
|
32
|
+
// Codex 의 훅은 --codex 를 받는다. 그중 Codex 플러그인(codex/hooks.json)의 훅은 --plugin 도 받는다. npm 길의 훅은
|
|
33
|
+
// banker setup --codex 가 <CODEX_HOME>/hooks.json 에 넣는다.
|
|
34
|
+
const CODEX = process.argv.includes('--codex');
|
|
35
|
+
const PLUGIN = CODEX && process.argv.includes('--plugin');
|
|
36
|
+
// Codex 에서 banker 스킬을 부르는 이름: npm 길은 $banker-<이름>, 플러그인은 $banker:<이름>.
|
|
37
|
+
const skillCall = (name) => (PLUGIN ? `$banker:${name}` : `$banker-${name}`);
|
|
30
38
|
|
|
31
39
|
function readStdin() {
|
|
32
40
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
@@ -65,7 +73,14 @@ function personalizedSkills(cached) {
|
|
|
65
73
|
// (기존 동작). banker: 접두는 떼서 짧게. 이모지/em-dash 없음(사용자 가시 채널).
|
|
66
74
|
function buildMessage(latest, installed, cached) {
|
|
67
75
|
const head = `banker ${latest} 사용 가능 (현재 ${installed}).`;
|
|
68
|
-
|
|
76
|
+
// Codex 에는 /banker: 명령이 없어 스킬 이름과 그 설치 길의 갱신 명령을 안내한다. 플러그인은 마켓플레이스를 올리면
|
|
77
|
+
// 설치된 플러그인도 새 판으로 바뀌고, Codex 는 세션을 시작할 때 git 마켓플레이스를 스스로 올린다(0.144.5 실측).
|
|
78
|
+
const mkt = pluginRootInfo(process.env.PLUGIN_ROOT)?.marketplace ?? DEFAULT_MARKETPLACE;
|
|
79
|
+
const tail = PLUGIN
|
|
80
|
+
? `업데이트: Codex 가 세션을 시작할 때 마켓플레이스와 함께 올립니다. 바로 올리려면 codex plugin marketplace upgrade ${mkt} 뒤 Codex 를 다시 여십시오`
|
|
81
|
+
: CODEX
|
|
82
|
+
? `업데이트: ${skillCall('update-banker')} 또는 npm i -g ${NPM_PKG} 뒤 banker setup --codex`
|
|
83
|
+
: `업데이트: /banker:update-banker 또는 npm i -g ${NPM_PKG}`;
|
|
69
84
|
const hits = personalizedSkills(cached);
|
|
70
85
|
if (hits.length) {
|
|
71
86
|
const names = hits.slice(0, 5).map((s) => s.replace(/^banker:/, '')).join(', ');
|
|
@@ -79,14 +94,26 @@ function buildMessage(latest, installed, cached) {
|
|
|
79
94
|
// compareVersions null(미확정·malformed)·구버전·동일 → 무고지. 고지 후 notified 를 기록해 재고지를 막는다.
|
|
80
95
|
// 고지 문구는 buildMessage 가 만든다(로컬 changedSkills∩써본스킬이 있으면 개인화, 없으면 일반).
|
|
81
96
|
function maybeNotify(cached) {
|
|
82
|
-
if (!cached) return;
|
|
97
|
+
if (!cached) return '';
|
|
83
98
|
const installed = installedVersion();
|
|
84
99
|
const cmp = compareVersions(cached.latest, installed);
|
|
85
|
-
if (cmp === null || cmp <= 0) return; // 미확정/구/동일 → 무고지
|
|
86
|
-
if (cached.notified === cached.latest) return; // 이미 고지한 버전 → dedupe
|
|
87
|
-
const msg = buildMessage(cached.latest, installed, cached);
|
|
88
|
-
process.stdout.write(JSON.stringify({ systemMessage: msg }));
|
|
100
|
+
if (cmp === null || cmp <= 0) return ''; // 미확정/구/동일 → 무고지
|
|
101
|
+
if (cached.notified === cached.latest) return ''; // 이미 고지한 버전 → dedupe
|
|
89
102
|
writeUpdateCache({ notified: cached.latest }); // 형제 필드(latest/checkedAt/changedSkills) 보존하며 notified 만 병합.
|
|
103
|
+
return buildMessage(cached.latest, installed, cached);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Codex: 사용량 카운팅 훅이 이 Codex 판의 기록 형식을 읽지 못했으면(codex-shape-miss.json) 한 번 알린다.
|
|
107
|
+
// 카운트가 조용히 0 으로 남지 않게 하려는 것이다. 업데이트 확인을 끈 사용자에게도 알린다.
|
|
108
|
+
export function shapeMissNotice() {
|
|
109
|
+
try {
|
|
110
|
+
const miss = JSON.parse(readRegular(CODEX_SHAPE_MISS_PATH));
|
|
111
|
+
if (!miss || typeof miss.version !== 'string' || miss.notified) return '';
|
|
112
|
+
writeByRename(CODEX_SHAPE_MISS_PATH, JSON.stringify({ version: miss.version, notified: true }));
|
|
113
|
+
return `banker 사용량 카운팅이 Codex ${miss.version.slice(0, 40)} 의 기록 형식을 읽지 못함. ${skillCall('update-banker')} 로 banker 를 업데이트하고, 그래도 같으면 알려 주십시오`;
|
|
114
|
+
} catch {
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
90
117
|
}
|
|
91
118
|
|
|
92
119
|
// 갱신 fetcher 절대경로 결정: count-default-on 활성(countingActive) + 엔드포인트 설정 시
|
|
@@ -106,15 +133,79 @@ function spawnFetcher() {
|
|
|
106
133
|
}
|
|
107
134
|
}
|
|
108
135
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
try {
|
|
136
|
+
// Codex: npm 길과 플러그인 길이 함께 있으면 알린다. 세션마다 한 번만 알리는 일은 claimSession 이 맡는다.
|
|
137
|
+
export function dualNotice(payload, env = process.env) {
|
|
138
|
+
try {
|
|
139
|
+
const cwd = typeof payload?.cwd === 'string' ? payload.cwd : '';
|
|
140
|
+
return dualInstallNote(codexHomeOf(payload?.transcript_path, env), cwd);
|
|
141
|
+
} catch {
|
|
142
|
+
return '';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
112
145
|
|
|
113
|
-
|
|
146
|
+
// Codex: 두 길의 SessionStart 훅이 모두 신뢰돼 같은 세션에서 함께 돌면 같은 알림이 두 번 나간다. 세션 id 이름의
|
|
147
|
+
// 폴더를 configDir 의 codex-sessions 에 만든 훅만 알린다. mkdir 은 이미 있으면 실패하고 링크를 따라가지 않아 둘이
|
|
148
|
+
// 동시에 와도 하나만 만든다. 함께 도는 훅은 훅 시간 제한(5초) 안에 오므로, 30초 넘게 지난 폴더는 이어 연 세션(resume)
|
|
149
|
+
// 의 것이다: 고유 이름으로 옮겨(rename 은 하나만 성공) 다시 만든 훅이 알린다. 옮긴 것이 새것이면(그사이 짝 훅이
|
|
150
|
+
// 넘겨받아 만든 것) 알리지 않는다. 세션 id 가 없거나 폴더를 만들 수 없으면
|
|
151
|
+
// 전처럼 알린다. 이틀 지난 폴더와 예전 판의 codex-dual-notified 는 지운다.
|
|
152
|
+
const SESSION_RE = /^[A-Za-z0-9._-]{1,80}$/;
|
|
153
|
+
const CLAIM_KEEP_MS = 2 * 24 * 60 * 60 * 1000;
|
|
154
|
+
const CLAIM_TWIN_MS = 30 * 1000;
|
|
155
|
+
export function claimSession(payload, now = Date.now()) {
|
|
156
|
+
const sid = typeof payload?.session_id === 'string' && SESSION_RE.test(payload.session_id) && !/^\.+$/.test(payload.session_id) ? payload.session_id : '';
|
|
157
|
+
if (!sid) return true;
|
|
158
|
+
const dir = join(configDir(), 'codex-sessions');
|
|
159
|
+
try {
|
|
160
|
+
mkdirSync(dir, { recursive: true });
|
|
161
|
+
} catch {
|
|
162
|
+
return true; // 폴더 자리에 다른 것이 있음: 중복을 막지 못해도 알림은 낸다
|
|
163
|
+
}
|
|
164
|
+
const mine = join(dir, sid);
|
|
165
|
+
try {
|
|
166
|
+
mkdirSync(mine);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (e?.code !== 'EEXIST') return true;
|
|
169
|
+
try {
|
|
170
|
+
if (now - statSync(mine).mtimeMs < CLAIM_TWIN_MS) return false; // 같은 세션의 다른 길 훅이 방금 알렸다
|
|
171
|
+
const old = `${mine}.old-${process.pid}-${randomHex()}`;
|
|
172
|
+
renameSync(mine, old);
|
|
173
|
+
if (now - statSync(old).mtimeMs < CLAIM_TWIN_MS) {
|
|
174
|
+
// 본 것과 옮긴 것 사이에 짝 훅이 넘겨받아 새로 만든 폴더였다: 그 훅이 알리므로 알리지 않는다. 돌려놓지 않고
|
|
175
|
+
// (그사이 다른 훅이 만든 폴더를 덮을 수 있다) 옮긴 폴더는 이틀 뒤 정리에서 지운다.
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
rmdirSync(old);
|
|
179
|
+
mkdirSync(mine);
|
|
180
|
+
} catch {
|
|
181
|
+
return false; // 함께 온 다른 훅이 먼저 옮기거나 만들었다
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
rmSync(join(configDir(), 'codex-dual-notified'), { force: true });
|
|
186
|
+
for (const d of readdirSync(dir)) {
|
|
187
|
+
try {
|
|
188
|
+
if (d !== sid && now - statSync(join(dir, d)).mtimeMs > CLAIM_KEEP_MS) rmdirSync(join(dir, d));
|
|
189
|
+
} catch { /* 다른 훅이 먼저 지움 */ }
|
|
190
|
+
}
|
|
191
|
+
} catch { /* 정리는 다음 세션에 */ }
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
114
194
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
195
|
+
async function main() {
|
|
196
|
+
// SessionStart payload. Codex 는 두 길 알림에 transcript_path, session_id 를 쓴다. 파싱 실패해도 무해.
|
|
197
|
+
let payload = null;
|
|
198
|
+
try { payload = JSON.parse(await readStdin()); } catch { /* stdin 없음/오류 무해 */ }
|
|
199
|
+
|
|
200
|
+
if (CODEX && !claimSession(payload)) return; // 같은 세션의 다른 길 훅이 이미 알렸다
|
|
201
|
+
const notes = CODEX ? [shapeMissNotice(), dualNotice(payload)] : [];
|
|
202
|
+
if (!noUpdateCheck()) { // opt-out 이면 업데이트 확인만 무동작.
|
|
203
|
+
const cached = readCache(); // 포그라운드는 캐시 읽기만(네트워크 없음).
|
|
204
|
+
notes.unshift(maybeNotify(cached));
|
|
205
|
+
if (updateCheckDue(cached)) spawnFetcher(); // 갱신은 백그라운드로 위임. 실패한 시도(triedAt)에서도 하루를 쉰다.
|
|
206
|
+
}
|
|
207
|
+
const msg = notes.filter(Boolean).join('\n');
|
|
208
|
+
if (msg) process.stdout.write(JSON.stringify({ systemMessage: msg }));
|
|
118
209
|
}
|
|
119
210
|
|
|
120
211
|
// 직접 실행(훅)일 때만 main() 을 돌린다. import(테스트)시엔 chooseFetcher 만 노출하고 main 은 미실행.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaydash9999/banker-plugins",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"banker": "bin/banker.js"
|
|
@@ -304,11 +304,25 @@ async function pixels(context, png) {
|
|
|
304
304
|
return { width: img.width, height: img.height, data: Uint8ClampedArray.from(img.data) };
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
// Chromium now and then answers a screenshot with "Unable to capture screenshot" while it is busy (seen once in three
|
|
308
|
+
// full runs on a loaded Linux box). That error alone is tried again, up to three times with a short wait; any other
|
|
309
|
+
// error is the page's and is thrown at once.
|
|
310
|
+
export async function screenshot(page, opts, { tries = 3, waitMs = 150 } = {}) {
|
|
311
|
+
for (let n = 1; ; n++) {
|
|
312
|
+
try {
|
|
313
|
+
return await page.screenshot(opts);
|
|
314
|
+
} catch (e) {
|
|
315
|
+
if (n >= tries || !/Unable to capture screenshot/i.test(String(e?.message))) throw e;
|
|
316
|
+
await new Promise((r) => setTimeout(r, waitMs * n));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
307
321
|
async function lineContrast(page, i, panel) {
|
|
308
322
|
const clip = { x: Math.max(0, Math.floor(panel.x)), y: Math.max(0, Math.floor(panel.y)), width: Math.ceil(panel.width), height: Math.ceil(panel.height) };
|
|
309
323
|
const lines = await page.evaluate(hideTextLines, { i, parts: TEXT_PARTS });
|
|
310
324
|
await page.waitForTimeout(80);
|
|
311
|
-
const png = await
|
|
325
|
+
const png = await screenshot(page, { type: 'png', clip });
|
|
312
326
|
await page.evaluate(showText, { i, parts: TEXT_PARTS });
|
|
313
327
|
const img = await pixels(page.context(), png);
|
|
314
328
|
return lines.map((l) => {
|
package/skills/anti-drm/SKILL.md
CHANGED
|
@@ -30,7 +30,7 @@ argument-hint: "on | off | status | config"
|
|
|
30
30
|
| 런타임 | `--runtime` | 스킬 디렉터리 |
|
|
31
31
|
|---|---|---|
|
|
32
32
|
| Claude Code | `claude` | 스킬 맨 위 "Base directory for this skill:" 경로 |
|
|
33
|
-
| Codex | `codex` | `~/.codex/skills/banker-anti-drm/`
|
|
33
|
+
| Codex | `codex` | Codex 스킬 목록이 보이는 이 SKILL.md 경로(file)의 폴더. npm 설치는 `~/.codex/skills/banker-anti-drm/`(프로젝트 범위 설치면 `./.codex/skills/banker-anti-drm/`), 플러그인 설치(`codex plugin add`)는 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/anti-drm/`. 절대경로로 씀 |
|
|
34
34
|
|
|
35
35
|
3. 아래 인자별 절차를 따름
|
|
36
36
|
|
|
@@ -186,7 +186,7 @@ export function writeFresh(file, data, mode = 0o644) {
|
|
|
186
186
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
187
187
|
rmSync(tmp, { force: true });
|
|
188
188
|
try {
|
|
189
|
-
|
|
189
|
+
writeNew(tmp, data, mode);
|
|
190
190
|
renameOver(tmp, file);
|
|
191
191
|
} catch (e) {
|
|
192
192
|
rmSync(tmp, { force: true });
|
|
@@ -194,6 +194,48 @@ export function writeFresh(file, data, mode = 0o644) {
|
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
// Opens a file that is not there yet, as "wx" does, and makes sure the name is the file made. macOS and Linux refuse
|
|
198
|
+
// any name that is there, a link to nothing among them (EEXIST). Windows follows a link to nothing and makes the file
|
|
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
|
+
// 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.
|
|
202
|
+
export function openNew(path, mode) {
|
|
203
|
+
const fd = openSync(path, "wx", mode);
|
|
204
|
+
let made = null;
|
|
205
|
+
let st;
|
|
206
|
+
try {
|
|
207
|
+
made = fstatSync(fd, { bigint: true });
|
|
208
|
+
st = lstatSync(path, { bigint: true });
|
|
209
|
+
} catch {
|
|
210
|
+
return fd;
|
|
211
|
+
}
|
|
212
|
+
if (!st.isSymbolicLink() && (made.ino === 0n || sameInode(made, st))) return fd;
|
|
213
|
+
closeSync(fd);
|
|
214
|
+
if (st.isSymbolicLink()) dropFollowed(path, made);
|
|
215
|
+
throw Object.assign(new Error(`EEXIST: file already exists, open '${path}'`), { code: "EEXIST", syscall: "open", path });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Removes the file a "wx" open made through the link at path, when it is still the empty file that open made.
|
|
219
|
+
function dropFollowed(path, made) {
|
|
220
|
+
try {
|
|
221
|
+
const target = realpathSync(path);
|
|
222
|
+
const st = statSync(target, { bigint: true });
|
|
223
|
+
if (sameInode(made, st) && st.size === 0n) rmSync(target, { force: true });
|
|
224
|
+
} catch {
|
|
225
|
+
// gone already, or the link changed again: nothing of ours to take back
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Writes data to a file that is not there yet (openNew) and closes it.
|
|
230
|
+
function writeNew(path, data, mode) {
|
|
231
|
+
const fd = openNew(path, mode);
|
|
232
|
+
try {
|
|
233
|
+
writeFileSync(fd, data);
|
|
234
|
+
} finally {
|
|
235
|
+
closeSync(fd);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
197
239
|
// Windows does not rename a file over a link to a folder: there the link goes first, and the folder it
|
|
198
240
|
// leads to stays.
|
|
199
241
|
function renameOver(tmp, file) {
|
|
@@ -510,13 +552,14 @@ function writeBlocked(changes) {
|
|
|
510
552
|
// whatever the umask allows more. A file left empty is removed, unless it is a
|
|
511
553
|
// link or has other names (a hard link), whose other names would keep the old
|
|
512
554
|
// text: it is emptied instead. Codex skips an empty file anyway.
|
|
513
|
-
// A file that was not there when it was read is created only if it still is not (
|
|
555
|
+
// A file that was not there when it was read is created only if it still is not (openNew): a name that appeared in
|
|
514
556
|
// between, a link planted there among them, is not written through.
|
|
515
557
|
function apply(t, changes) {
|
|
516
558
|
for (const { file, after } of changes) {
|
|
517
559
|
if (file.bytes !== null) writeFresh(`${file.path}.${t.slug}.bak`, file.bytes, 0o600);
|
|
518
560
|
if (after === "" && !isLink(file.path) && !hasOtherNames(file.path)) rmSync(file.path, { force: true });
|
|
519
|
-
else
|
|
561
|
+
else if (file.bytes === null) writeNew(file.path, after, 0o644);
|
|
562
|
+
else writeFileSync(file.path, after, { mode: 0o644, flag: "w" });
|
|
520
563
|
}
|
|
521
564
|
}
|
|
522
565
|
|
|
@@ -568,9 +611,19 @@ const SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
|
568
611
|
// when it may still be there (no stats or no inode number to prove it is that file, or the share could not say).
|
|
569
612
|
// Any other error (permissions, EIO) throws, after the same taking away.
|
|
570
613
|
function tryLock(path, token) {
|
|
614
|
+
// A name that is there in any form is held. Windows opens "wx" through a link to nothing and makes the file it leads
|
|
615
|
+
// to; macOS and Linux answer EEXIST for it below, and openNew takes back a file Windows made through a link planted
|
|
616
|
+
// after this look. A look that fails leaves the answer to the open, as before.
|
|
617
|
+
let there = false;
|
|
618
|
+
try {
|
|
619
|
+
there = lstatSync(path, { throwIfNoEntry: false }) !== undefined;
|
|
620
|
+
} catch {
|
|
621
|
+
// ESTALE and the like: the open below answers
|
|
622
|
+
}
|
|
623
|
+
if (there) return "held";
|
|
571
624
|
let fd;
|
|
572
625
|
try {
|
|
573
|
-
fd =
|
|
626
|
+
fd = openNew(path, 0o600);
|
|
574
627
|
} catch (e) {
|
|
575
628
|
if (e.code === "EEXIST") return "held";
|
|
576
629
|
if (e.code === "ESTALE") return "stale";
|