@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.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +50 -0
  4. package/README.md +5 -4
  5. package/bin/lib/codex-hooks.mjs +5 -1
  6. package/codex/hooks.json +4 -4
  7. package/codex/manifest.json +1 -0
  8. package/codex/skills/teamwork/scripts/teamwork.mjs +18 -8
  9. package/codex/transform-matrix.md +1 -0
  10. package/hooks/telemetry-count-codex.mjs +3 -2
  11. package/package.json +1 -1
  12. package/skills/3d-intro-build/SKILL.md +1 -0
  13. package/skills/3d-intro-build/references/azure-adapter.mjs +54 -27
  14. package/skills/3d-intro-build/references/video-pool.mjs +11 -6
  15. package/skills/3d-intro-setup/references/azure-adapter.mjs +54 -27
  16. package/skills/3d-intro-setup/references/video-pool.mjs +11 -6
  17. package/skills/anti-drm/SKILL.md +2 -2
  18. package/skills/anti-drm/scripts/anti-drm.mjs +8 -5
  19. package/skills/anti-drm/scripts/instruction-toggle.mjs +6 -3
  20. package/skills/build-testbox/scripts/browser-cell.mjs +10 -2
  21. package/skills/build-testbox/scripts/containers.mjs +8 -3
  22. package/skills/build-testbox/scripts/proc.mjs +32 -8
  23. package/skills/build-testbox/scripts/testbox.mjs +10 -2
  24. package/skills/git-init/scripts/git-init.mjs +53 -5
  25. package/skills/git-init/scripts/marker-block.mjs +131 -19
  26. package/skills/init-dir/scripts/init-dir.mjs +29 -2
  27. package/skills/init-dir/scripts/marker-block.mjs +131 -19
  28. package/skills/init-dir/templates/scripts/lib/service.mjs +57 -6
  29. package/skills/lineage/SKILL.md +1 -1
  30. package/skills/lineage/lineage.py +11 -11
  31. package/skills/motion-sites/SKILL.md +87 -0
  32. package/skills/motion-sites/scripts/motion-sites.mjs +348 -0
  33. package/skills/omc-patch/scripts/omc-patch.mjs +76 -17
  34. package/skills/payload-mon/scripts/payload-mon.mjs +2 -1
  35. package/skills/payload-mon/scripts/payload-size.mjs +2 -2
  36. package/skills/progress-mon/scripts/pm-adr.mjs +6 -3
  37. package/skills/progress-mon/scripts/pm-config.mjs +133 -7
  38. package/skills/progress-mon/scripts/pm-data.mjs +35 -29
  39. package/skills/progress-mon/scripts/pm-memory.mjs +14 -7
  40. package/skills/progress-mon/scripts/pm-server.mjs +3 -3
  41. package/skills/progress-mon/scripts/pm-sources.mjs +2 -1
  42. package/skills/ralph-qa/SKILL.md +1 -0
  43. package/skills/ralph-qa/references/seat-run.mjs +21 -4
  44. package/skills/ralph-qa/references/verifier-probe.mjs +35 -13
  45. package/skills/remains/SKILL.md +31 -4
  46. package/skills/remains/scripts/boxes.mjs +44 -15
  47. package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +2 -1
  48. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +12 -7
  49. package/skills/setup-omc-hud/assets/apply-hud.mjs +2 -1
  50. package/skills/setup-omc-hud/assets/codex-config.mjs +12 -7
  51. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +2 -1
  52. package/skills/smart-compact/scripts/codex-gate.mjs +25 -4
  53. package/skills/tone-compact/scripts/instruction-toggle.mjs +6 -3
  54. package/skills/tune-tmux/scripts/marker-block.mjs +131 -19
  55. package/skills/tune-tmux/scripts/tmux-config.mjs +35 -3
  56. package/skills/tune-tmux/scripts/tune-tmux.mjs +15 -9
  57. package/skills/ultra-chain/references/chain-ledger.mjs +8 -1
  58. package/skills/vertical-pptx/verify_a4p.mjs +11 -4
@@ -642,12 +642,26 @@ export async function generateImage({
642
642
  const classicWorthTrying = (r) => !(r.ok && dataB64(r)) && r.status !== 429 && r.status !== 0;
643
643
  const dataB64 = (r) => r.body?.data?.[0]?.b64_json;
644
644
 
645
- // The PNG an image call answered with, or an error that starts with `failed`.
645
+ // The PNG an image call answered with, or an error that starts with `failed`. Bytes that are not an image (a URL or an
646
+ // error text in the image field, base64 that decodes to nothing) are a failure, not a picture.
646
647
  function pngFrom(r, b64, failed) {
647
648
  if (!(r.ok && b64)) throw new Error(`${failed}: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
648
- return Buffer.from(b64, 'base64');
649
+ const buf = Buffer.from(String(b64), 'base64');
650
+ if (!isImage(buf)) throw new Error(`${failed}: the answer is not a PNG, JPEG or WebP image (${buf.length} bytes)`);
651
+ return buf;
649
652
  }
650
653
 
654
+ /** Whether bytes start as a PNG, JPEG or WebP image. */
655
+ export const isImage = (b) => Buffer.isBuffer(b) && b.length >= 12 && (b.readUInt32BE(0) === 0x89504e47
656
+ || (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) || (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP'));
657
+
658
+ /**
659
+ * Whether bytes start as an ISO media file (mp4, mov): the first box is one such a file starts with (ftyp, or styp,
660
+ * free, skip, wide, moov, mdat in fragmented and QuickTime files). An HTML or JSON error page is not.
661
+ */
662
+ const MEDIA_BOXES = new Set(['ftyp', 'styp', 'free', 'skip', 'wide', 'moov', 'mdat']);
663
+ export const isMp4 = (b) => Buffer.isBuffer(b) && b.length >= 12 && MEDIA_BOXES.has(b.toString('latin1', 4, 8));
664
+
651
665
  /** Edit an image (optional mask) via the v1 images/edits multipart path. Returns a PNG Buffer. */
652
666
  export async function editImage({
653
667
  endpoint, key, deployment, apiVersion = 'preview', prompt, imageBuf, maskBuf, size,
@@ -781,6 +795,7 @@ export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview'
781
795
  key, method: 'GET', binary: true,
782
796
  });
783
797
  if (!(r.status === 200 && r.body?.length)) throw Object.assign(new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`), { status: r.status });
798
+ if (!isMp4(r.body)) throw Object.assign(new Error(`downloadVideo failed: HTTP 200 but the body is not an mp4 (${r.ct || 'no content type'}, ${r.body.length} bytes)`), { status: r.status });
784
799
  return r.body;
785
800
  }
786
801
 
@@ -919,7 +934,7 @@ function twoImageForm({ model, prompt, firstPng, lastPng, size, seconds }) {
919
934
  * 3) the optional ffmpeg-static package (dynamic import — may be absent)
920
935
  * A project's .env.3d-intro.local never names the program, and "." or another relative PATH entry is
921
936
  * skipped: either would let the files of a project, a cloned repository say, choose what runs.
922
- * Throws an actionable error if none is found.
937
+ * Throws an actionable error if none is found, or if $FFMPEG_PATH is set but not usable.
923
938
  */
924
939
  export async function resolveFfmpeg() {
925
940
  const found = namedFfmpeg() || ffmpegOnPath() || await staticFfmpeg();
@@ -928,10 +943,17 @@ export async function resolveFfmpeg() {
928
943
  }
929
944
 
930
945
  // The ffmpeg this machine names. Absolute only: a relative value would be looked up in the current
931
- // folder, the project. The files are read only when the shell names nothing usable.
946
+ // folder, the project. A $FFMPEG_PATH from the shell is the user's choice for this run: when it is
947
+ // relative or names no file, this stops with why instead of quietly taking another ffmpeg. The files
948
+ // are read only when the shell names nothing.
932
949
  function namedFfmpeg() {
933
950
  const usable = (p) => !!p && path.isAbsolute(p) && fs.existsSync(p);
934
- if (usable(process.env.FFMPEG_PATH)) return process.env.FFMPEG_PATH;
951
+ const shell = process.env.FFMPEG_PATH;
952
+ if (shell) {
953
+ if (usable(shell)) return shell;
954
+ const why = path.isAbsolute(shell) ? 'no such file' : 'not an absolute path';
955
+ throw new Error(`FFMPEG_PATH from the shell is not usable (${why}): ${shell}; fix it or unset it`);
956
+ }
935
957
  return [readSettings(), readEnvQuietly(homeCredsPath)].map((s) => s.FFMPEG_PATH).find(usable) || null;
936
958
  }
937
959
 
@@ -953,17 +975,30 @@ async function staticFfmpeg() {
953
975
  }
954
976
 
955
977
  // Run ffmpeg with an ARGUMENT ARRAY (never a shell string). Returns {code, stdout, stderr, args}.
978
+ // ffmpeg has a time limit: a stuck one (a FIFO input, a blocked disk) would hold the whole run. SIGKILL, as a stuck
979
+ // ffmpeg may not end on SIGTERM.
980
+ export const FF_TIMEOUT_MS = 10 * 60 * 1000;
956
981
  function runFf(bin, args, cwd) {
957
- const r = cp.spawnSync(bin, args, { cwd, encoding: 'utf8', maxBuffer: 1 << 28 });
958
- return { code: r.status, stdout: r.stdout || '', stderr: r.stderr || '', args };
982
+ const r = cp.spawnSync(bin, args, { cwd, encoding: 'utf8', maxBuffer: 1 << 28, timeout: FF_TIMEOUT_MS, killSignal: 'SIGKILL' });
983
+ return { code: r.status, signal: r.signal || null, error: r.error || null, stdout: r.stdout || '', stderr: r.stderr || '', args };
984
+ }
985
+
986
+ // One ffmpeg run that must make `out`: an earlier file there is removed first, and the run counts only when ffmpeg
987
+ // ended by itself with 0 and `out` is a file with something in it. A killed or failed ffmpeg's partial file is not a result.
988
+ function ffOut(name, bin, args, out) {
989
+ fs.rmSync(out, { force: true });
990
+ const r = runFf(bin, args);
991
+ const size = (() => { try { return fs.statSync(out).size; } catch { return 0; } })();
992
+ if (r.code === 0 && size > 0) return out;
993
+ const how = r.error ? (r.error.code === 'ETIMEDOUT' ? `timed out after ${FF_TIMEOUT_MS / 1000} s` : r.error.code || r.error.message)
994
+ : r.signal ? `killed by ${r.signal}` : r.code !== 0 ? `exit ${r.code}: ${tail(r.stderr)}` : 'ffmpeg wrote no output';
995
+ throw new Error(`${name} failed: ${how}`);
959
996
  }
960
997
 
961
998
  /** Extract the exact LAST frame of an mp4 to a PNG (reverse + first-frame trick). Returns out. */
962
999
  export async function extractLastFrame(mp4, out, { ffmpeg } = {}) {
963
1000
  const bin = ffmpeg || await resolveFfmpeg();
964
- const r = runFf(bin, ['-y', '-i', mp4, '-vf', 'reverse', '-frames:v', '1', out]);
965
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`extractLastFrame failed: ${tail(r.stderr)}`);
966
- return out;
1001
+ return ffOut('extractLastFrame', bin, ['-y', '-i', mp4, '-vf', 'reverse', '-frames:v', '1', out], out);
967
1002
  }
968
1003
 
969
1004
  /**
@@ -974,20 +1009,15 @@ export async function extractLastFrame(mp4, out, { ffmpeg } = {}) {
974
1009
  */
975
1010
  export async function stillToClip(png, out, { ffmpeg, seconds = 1, fps = 24, size } = {}) {
976
1011
  const bin = ffmpeg || await resolveFfmpeg();
977
- fs.rmSync(out, { force: true }); // a file from an earlier run must not pass for this one
978
1012
  const vf = `${size ? `scale=${String(size).replace('x', ':')},` : ''}format=yuv420p`;
979
- const r = runFf(bin, ['-y', '-loop', '1', '-i', png, '-t', String(seconds), '-r', String(fps), '-vf', vf,
980
- '-c:v', 'libx264', '-crf', '20', '-g', '8', '-movflags', '+faststart', '-an', out]);
981
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`stillToClip failed: ${tail(r.stderr)}`);
982
- return out;
1013
+ return ffOut('stillToClip', bin, ['-y', '-loop', '1', '-i', png, '-t', String(seconds), '-r', String(fps), '-vf', vf,
1014
+ '-c:v', 'libx264', '-crf', '20', '-g', '8', '-movflags', '+faststart', '-an', out], out);
983
1015
  }
984
1016
 
985
1017
  /** Extract the FIRST frame of an mp4 to a PNG. Returns out. */
986
1018
  export async function extractFirstFrame(mp4, out, { ffmpeg } = {}) {
987
1019
  const bin = ffmpeg || await resolveFfmpeg();
988
- const r = runFf(bin, ['-y', '-i', mp4, '-frames:v', '1', out]);
989
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`extractFirstFrame failed: ${tail(r.stderr)}`);
990
- return out;
1020
+ return ffOut('extractFirstFrame', bin, ['-y', '-i', mp4, '-frames:v', '1', out], out);
991
1021
  }
992
1022
 
993
1023
  /**
@@ -1000,13 +1030,12 @@ export async function crossfade(aMp4, bMp4, out, sec = 0.5, { ffmpeg, offset } =
1000
1030
  let off = offset;
1001
1031
  if (off == null) {
1002
1032
  const dur = probeDurationSec(bin, aMp4);
1003
- off = dur != null ? Math.max(0, dur - sec) : 0;
1033
+ // with A's length unknown, an offset of 0 would cut A to the fade: a wrong clip, not a result
1034
+ if (dur == null) throw new Error(`crossfade failed: could not read the duration of ${path.basename(aMp4)}; pass { offset }`);
1035
+ off = Math.max(0, dur - sec);
1004
1036
  }
1005
1037
  const filter = `[0:v][1:v]xfade=transition=fade:duration=${sec}:offset=${off}`;
1006
- const args = ['-y', '-i', aMp4, '-i', bMp4, '-filter_complex', filter, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', out];
1007
- const r = runFf(bin, args);
1008
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`crossfade failed: ${tail(r.stderr)}`);
1009
- return out;
1038
+ return ffOut('crossfade', bin, ['-y', '-i', aMp4, '-i', bMp4, '-filter_complex', filter, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', out], out);
1010
1039
  }
1011
1040
 
1012
1041
  /**
@@ -1015,9 +1044,7 @@ export async function crossfade(aMp4, bMp4, out, sec = 0.5, { ffmpeg, offset } =
1015
1044
  */
1016
1045
  export async function concatClips(mp4List, out, { ffmpeg, fps = 30, width = 720, height = 1280 } = {}) {
1017
1046
  const bin = ffmpeg || await resolveFfmpeg();
1018
- const r = runFf(bin, concatArgs(clipList(mp4List), out, { fps, width, height }));
1019
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`concatClips failed: ${tail(r.stderr)}`);
1020
- return out;
1047
+ return ffOut('concatClips', bin, concatArgs(clipList(mp4List), out, { fps, width, height }), out);
1021
1048
  }
1022
1049
 
1023
1050
  function clipList(mp4List) {
@@ -1038,7 +1065,7 @@ function concatArgs(list, out, { fps, width, height }) {
1038
1065
 
1039
1066
  // Probe an mp4's duration (seconds) by parsing ffmpeg's stderr banner. Returns null if absent.
1040
1067
  function probeDurationSec(bin, mp4) {
1041
- const r = cp.spawnSync(bin, ['-i', mp4], { encoding: 'utf8' });
1068
+ const r = cp.spawnSync(bin, ['-i', mp4], { encoding: 'utf8', timeout: FF_TIMEOUT_MS, killSignal: 'SIGKILL' });
1042
1069
  const text = `${r.stderr || ''}${r.stdout || ''}`;
1043
1070
  const m = text.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
1044
1071
  if (!m) return null;
@@ -43,7 +43,7 @@ import crypto from 'node:crypto';
43
43
  import cp from 'node:child_process';
44
44
  import {
45
45
  createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey, keyHeldBack, probeSoraDeployment,
46
- API_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, HELD_KEY, errnoOf, timeLeft,
46
+ API_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, HELD_KEY, errnoOf, timeLeft, isMp4, isImage, FF_TIMEOUT_MS,
47
47
  } from './azure-adapter.mjs';
48
48
 
49
49
  export const WAN_DEFAULT_MODEL = 'wan3.0-video-prime';
@@ -399,9 +399,11 @@ async function stripAlpha(buf) {
399
399
  const src = path.join(dir, 'in.png');
400
400
  const out = path.join(dir, 'out.png');
401
401
  fs.writeFileSync(src, buf);
402
- const r = cp.spawnSync(bin, ['-y', '-i', src, '-vf', 'format=rgb24', '-frames:v', '1', out], { encoding: 'utf8' });
403
- if (!fs.existsSync(out)) throw new Error(`stripAlpha failed: ${String(r.stderr || '').slice(-400)}`);
404
- return fs.readFileSync(out);
402
+ const r = cp.spawnSync(bin, ['-y', '-i', src, '-vf', 'format=rgb24', '-frames:v', '1', out], { encoding: 'utf8', timeout: FF_TIMEOUT_MS, killSignal: 'SIGKILL' });
403
+ // only a run that ended by itself with 0: a killed ffmpeg's partial PNG would go to WAN as the frame
404
+ const png = r.status === 0 && fs.existsSync(out) ? fs.readFileSync(out) : null;
405
+ if (!png || !isImage(png)) throw new Error(`stripAlpha failed: ${r.error ? r.error.code : r.signal ? `killed by ${r.signal}` : String(r.stderr || '').slice(-400)}`);
406
+ return png;
405
407
  } finally {
406
408
  fs.rmSync(dir, { recursive: true, force: true });
407
409
  }
@@ -410,6 +412,8 @@ async function stripAlpha(buf) {
410
412
  /** Encode a PNG/JPEG frame as the data URI WAN accepts, dropping any PNG alpha channel first. */
411
413
  export async function frameDataUri(buf) {
412
414
  const b = Buffer.isBuffer(buf) ? buf : fs.readFileSync(buf);
415
+ // an empty or non-image seed would be sent with a paid task and answered with a clip that does not start from it
416
+ if (!isImage(b)) throw new Error(`frameDataUri: the frame is not a PNG, JPEG or WebP image (${b.length} bytes)`);
413
417
  if (b[0] === 0xff && b[1] === 0xd8) return `data:image/jpeg;base64,${b.toString('base64')}`;
414
418
  const png = pngHasAlpha(b) ? await stripAlpha(b) : b;
415
419
  return `data:image/png;base64,${png.toString('base64')}`;
@@ -514,10 +518,11 @@ export async function downloadWanVideo(url, { sleep = realSleep, tries = 3 } = {
514
518
  let last;
515
519
  for (let i = 0; i < tries; i++) {
516
520
  last = await wanFetch(url, { binary: true, timeoutMs: DOWNLOAD_TIMEOUT_MS });
517
- if (last.status === 200 && last.body?.length) return last.body;
521
+ // a 200 that is an HTML or JSON page (an expired or refused signed URL) is not the video: try again, then fail
522
+ if (last.status === 200 && isMp4(last.body)) return last.body;
518
523
  if (i < tries - 1) await sleep(2000 * (i + 1));
519
524
  }
520
- throw new Error(`WAN video download failed: HTTP ${last?.status}`);
525
+ throw new Error(`WAN video download failed: HTTP ${last?.status}${last?.status === 200 ? ` but the body is not an mp4 (${last.body?.length ?? 0} bytes)` : ''}`);
521
526
  }
522
527
 
523
528
  /**
@@ -217,7 +217,7 @@ argument-hint: "on | off | status | config"
217
217
  - 설정 자리가 링크면 `status` 는 설정 줄에 링크가 가리키는 곳을 함께 보이고, 그 파일의 틀린 값은 글 대신 형식과 길이만 보임(`(string, 12자)`)
218
218
  - 폴더, FIFO, 64KiB 를 넘는 파일, 읽을 수 없는 파일은 덮어쓰지 않음(링크와 하드 링크는 다음 줄)
219
219
  - 저장할 때 링크와 하드 링크(이름이 둘 이상인 일반 파일)는 그 이름만 새 파일로 바꿈. 가리키던 파일은 읽지 않고 그대로 둠
220
- - anti-drm 이 쓰지 않은 파일이 그 자리에 있으면 `anti-drm.json.bak` 에 한 번 남김 (이 계정만 읽기 가능). 링크와 하드 링크는 남기지 않음
220
+ - anti-drm 이 쓰지 않은 파일이 그 자리에 있으면 `anti-drm.json.bak` 에 한 번 남김 (이 계정만 읽기 가능). 링크와 하드 링크는 남기지 않음. `.bak` 자리에 일반 파일이 아닌 것(폴더, 링크)이 있으면 설정을 저장하지 않고 거부
221
221
  - 설정 폴더
222
222
  - Claude Code: `$CLAUDE_CONFIG_DIR`, 없으면 `~/.claude`
223
223
  - Codex: `$CODEX_HOME`, 없으면 `~/.codex`
@@ -243,7 +243,7 @@ argument-hint: "on | off | status | config"
243
243
  | 설정이 없습니다 | "질문" 으로 설정을 정해 `on` 에 넘김 |
244
244
  | 표준 입력에 설정 줄이 없습니다 | heredoc 이 비었음. "설정 넘기기" 대로 줄을 넣어 다시 실행 |
245
245
  | 표준 입력이 64KiB 보다 큽니다 | "답 처리" 대로 정규화한 값만 넘김 |
246
- | 설정 파일이 올바르지 않습니다 (뒤에 `--stdin 이나 --deny, --alt 로 함께 주세요` 가 붙음) | 이유를 전하고 설정을 다시 정할지 물은 뒤 진행. 정하면 "질문" 뒤 켜는 중이면 `on --runtime <런타임> --stdin`, 설정만 바꿀 때는 `config --stdin` 실행. 그 파일은 새 파일로 바뀌고(그 자리가 폴더나 특수 파일이거나, 64KiB 보다 크거나, 읽을 수 없는 파일이면 바꾸지 않고 거부함. 링크와 하드 링크(이름이 둘 이상인 일반 파일)는 거부하지 않고 그 이름만 일반 파일로 바꾸며, 가리키던 파일은 그대로 두고 `.bak` 에 남기지 않음), anti-drm 형식이 아니면 `anti-drm.json.bak` 에 한 번 남음(먼저 남긴 파일이나 일반 파일이 아닌 것이 그 자리에 있으면 남기지 않고 결과에 알림). 에이전트가 그 파일을 직접 지우거나 옮기지 않음 |
246
+ | 설정 파일이 올바르지 않습니다 (뒤에 `--stdin 이나 --deny, --alt 로 함께 주세요` 가 붙음) | 이유를 전하고 설정을 다시 정할지 물은 뒤 진행. 정하면 "질문" 뒤 켜는 중이면 `on --runtime <런타임> --stdin`, 설정만 바꿀 때는 `config --stdin` 실행. 그 파일은 새 파일로 바뀌고(그 자리가 폴더나 특수 파일이거나, 64KiB 보다 크거나, 읽을 수 없는 파일이면 바꾸지 않고 거부함. 링크와 하드 링크(이름이 둘 이상인 일반 파일)는 거부하지 않고 그 이름만 일반 파일로 바꾸며, 가리키던 파일은 그대로 두고 `.bak` 에 남기지 않음), anti-drm 형식이 아니면 `anti-drm.json.bak` 에 한 번 남음(먼저 남긴 파일이 있으면 남기지 않고 결과에 알림. 그 자리에 일반 파일이 아닌 것이 있으면 원래 파일을 잃지 않게 설정을 저장하지 않고 거부). 에이전트가 그 파일을 직접 지우거나 옮기지 않음 |
247
247
  | 설정 파일이 올바르지 않습니다 (뒤에 `anti-drm 은 이 자리를 덮어쓰지 않습니다` 가 붙음) | 설정 자리가 폴더, 특수 파일, 64KiB 보다 큰 파일, 읽을 수 없는 파일임(링크면 이 말이 붙지 않고, 설정을 다시 주면 그 이름이 새 파일로 바뀜). 지우지 않음. 무엇인지 보이고, 옮길지 권한을 고칠지(다른 경로는 `BANKER_ANTI_DRM_CONFIG`) 물은 뒤 다시 실행 |
248
248
  | 일반 파일이 아니라 설정을 저장하지 않았습니다 | 설정 경로의 폴더나 특수 파일은 지우지 않음. 무엇인지 보이고, 옮길지 다른 경로(`BANKER_ANTI_DRM_CONFIG`)를 쓸지 물은 뒤 다시 실행 |
249
249
  | 64KiB 보다 큰 파일이라, 읽을 수 없는 파일이라 설정을 저장하지 않았습니다 | 그 파일이 무엇인지 보이고, 권한을 고칠지 옮길지 물은 뒤 다시 실행 |
@@ -358,11 +358,13 @@ function priorSettings(file) {
358
358
  * rename. keep, when given, is a file anti-drm did not write; its bytes go to
359
359
  * <file>.bak, readable by this account only, once: a .bak already there is the
360
360
  * first such file and stays. Gives back what became of keep: null when none was
361
- * given, else "kept", "earlier" or "other" (see keepOnce).
361
+ * given, else "kept", "earlier" or "other" (see keepOnce). With "other" the settings are not written: the
362
+ * original would be lost.
362
363
  */
363
364
  export function writeConfig(file, config, keep = null) {
364
365
  mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
365
366
  const kept = keep === null ? null : keepOnce(`${file}.bak`, keep);
367
+ if (kept === "other") return kept; // the original has nowhere to go: the settings are not written over it
366
368
  writeFresh(file, `${JSON.stringify(config, null, 2)}\n`, 0o644);
367
369
  return kept;
368
370
  }
@@ -472,12 +474,11 @@ export function status(opts = {}) {
472
474
 
473
475
  // What a save does beyond writing the settings, as lines to report: a link or a hard link at the settings path becomes
474
476
  // a regular file (what it led to stays, and nothing of it goes to .bak), and a file anti-drm did not write goes to
475
- // .bak, or is not kept when an earlier one, or something that is not a regular file, is there already. kept is what
476
- // writeConfig gave back.
477
+ // .bak, or is not kept when an earlier one is there already (something that is not a regular file there refuses the
478
+ // save before this). kept is what writeConfig gave back.
477
479
  const KEPT_NOTES = {
478
480
  kept: (bak) => `참고: anti-drm 이 쓰지 않은 원래 파일은 ${bak} 에 남겼습니다.`,
479
481
  earlier: (bak) => `참고: anti-drm 이 쓰지 않은 원래 파일은 남기지 않았습니다. 먼저 남긴 파일이 있습니다: ${bak}`,
480
- other: (bak) => `참고: anti-drm 이 쓰지 않은 원래 파일은 남기지 않았습니다. 그 자리에 일반 파일이 아닌 것이 있습니다: ${bak}`,
481
482
  };
482
483
  const REPLACED_NOTES = {
483
484
  link: LINK_REPLACED,
@@ -496,7 +497,9 @@ function save(o, config) {
496
497
  const prior = priorSettings(o.configFile);
497
498
  if (prior.refusal) return refuse(`${prior.refusal}: ${where(o.configFile)}`);
498
499
  const replaced = pathKind(o.configFile);
499
- notes = saveNotes(o.configFile, replaced, writeConfig(o.configFile, config, prior.keep));
500
+ const kept = writeConfig(o.configFile, config, prior.keep);
501
+ if (kept === "other") return refuse(`원래 파일을 남길 자리에 일반 파일이 아닌 것이 있어 설정을 저장하지 않았습니다: ${where(`${o.configFile}.bak`)}`);
502
+ notes = saveNotes(o.configFile, replaced, kept);
500
503
  } catch (e) {
501
504
  if (denied(e)) return refuse(`쓸 권한이 없어 설정을 저장하지 못했습니다 (${e.code}): ${where(o.configFile)}`);
502
505
  throw e;
@@ -198,7 +198,8 @@ export function writeFresh(file, data, mode = 0o644) {
198
198
  // any name that is there, a link to nothing among them (EEXIST). Windows follows a link to nothing and makes the file
199
199
  // it leads to: then that file is removed again when it is still the empty one made, and the open is refused with
200
200
  // EEXIST as elsewhere. A look before the open catches such a link; this catches one planted between the look and the
201
- // open. A look that fails after the open leaves the file to the caller, as a plain "wx" would.
201
+ // open. A name gone after the open is EEXIST too. A look that cannot be made (ESTALE, EPERM) leaves the file to the
202
+ // caller, as a plain "wx" would.
202
203
  export function openNew(path, mode) {
203
204
  const fd = openSync(path, "wx", mode);
204
205
  let made = null;
@@ -206,8 +207,10 @@ export function openNew(path, mode) {
206
207
  try {
207
208
  made = fstatSync(fd, { bigint: true });
208
209
  st = lstatSync(path, { bigint: true });
209
- } catch {
210
- return fd;
210
+ } catch (e) {
211
+ if (e?.code !== "ENOENT") return fd; // cannot look (ESTALE, EPERM): the file is the caller's, as a plain "wx" leaves it
212
+ closeSync(fd); // the name is gone: nothing is written through a name not proved
213
+ throw Object.assign(new Error(`EEXIST: file already exists, open '${path}'`), { code: "EEXIST", syscall: "open", path });
211
214
  }
212
215
  if (!st.isSymbolicLink() && (made.ino === 0n || sameInode(made, st))) return fd;
213
216
  closeSync(fd);
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // spec: { url, jobs, timeoutMs, settleMs, outDir, cells: [{ id, device, browser, png }] }
9
9
  // Prints one line "BANKER_TESTBOX_RESULT <json>": { cells: [...] } or { fatal, detail }.
10
- import { mkdirSync, readFileSync } from "node:fs";
10
+ import { mkdirSync, readFileSync, realpathSync } from "node:fs";
11
11
  import { join, resolve } from "node:path";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
13
 
@@ -173,7 +173,15 @@ async function main(argv) {
173
173
  return { cells: await runGroup(pw, spec) };
174
174
  }
175
175
 
176
- const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
176
+ // Run directly, also through a linked path or another spelling: both sides as real paths.
177
+ const realOr = (p) => {
178
+ try {
179
+ return realpathSync(p);
180
+ } catch {
181
+ return resolve(p);
182
+ }
183
+ };
184
+ const isMain = process.argv[1] && realOr(process.argv[1]) === realOr(fileURLToPath(import.meta.url));
177
185
  if (isMain) {
178
186
  main(process.argv.slice(2))
179
187
  .then((r) => { process.stdout.write(`${RESULT_TAG}${JSON.stringify(r)}\n`); process.exit(0); })
@@ -3,14 +3,18 @@
3
3
  // No bind mount (SELinux relabel, Windows paths). Containers are named banker-testbox-<random>-<os> and removed on
4
4
  // success, failure, time-out and signal.
5
5
  import { spawnSync } from "node:child_process";
6
- import { copyFileSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
7
- import { tmpdir } from "node:os";
6
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
7
+ import { homedir, tmpdir } from "node:os";
8
8
  import { dirname, isAbsolute, join, relative, sep } from "node:path";
9
9
  import { imageFor } from "./args.mjs";
10
10
  import { pool } from "./browser-cell.mjs";
11
11
  import { firstLine, runProc, which } from "./proc.mjs";
12
12
  import { cellOf } from "./report.mjs";
13
13
 
14
+ // The folder programs start in: the home folder (Windows looks for a program in the working folder first, so a
15
+ // program planted in the folder worked on never runs), or the temp folder when there is no home folder.
16
+ const runDir = () => (existsSync(homedir()) ? homedir() : tmpdir());
17
+
14
18
  export const NAME_PREFIX = "banker-testbox";
15
19
  const iso = () => new Date().toISOString();
16
20
  export const randomTag = () => Math.random().toString(16).slice(2, 10).padEnd(8, "0");
@@ -66,7 +70,8 @@ export function removeAllSync(engine, names) {
66
70
 
67
71
  function gitFiles(src) {
68
72
  const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_/i.test(k)));
69
- const r = spawnSync("git", ["-C", src, "ls-files", "-z", "-co", "--exclude-standard"], { encoding: "utf8", env, maxBuffer: 256 * 1024 * 1024 });
73
+ // from the home folder: Windows looks for git.exe in the working folder first
74
+ const r = spawnSync("git", ["-C", src, "ls-files", "-z", "-co", "--exclude-standard"], { cwd: runDir(), encoding: "utf8", env, maxBuffer: 256 * 1024 * 1024 });
70
75
  return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : null;
71
76
  }
72
77
 
@@ -1,9 +1,9 @@
1
1
  // Child processes for build-testbox: PATH lookup (PATHEXT on Windows), a promise-returning runner with a time limit
2
2
  // and a set of live children, so a signal handler can stop them all. No shell: arguments go to the program as given.
3
3
  // On Windows a .cmd or .bat goes through cmd.exe with each argument quoted (Node refuses to spawn them directly).
4
- import { spawn } from "node:child_process";
4
+ import { spawn, spawnSync } from "node:child_process";
5
5
  import { accessSync, constants, statSync } from "node:fs";
6
- import { delimiter, extname, isAbsolute, join } from "node:path";
6
+ import { delimiter, extname, isAbsolute, join, win32 } from "node:path";
7
7
 
8
8
  const LIVE = new Set();
9
9
  const MAX_OUT = 8 * 1024 * 1024;
@@ -51,6 +51,21 @@ function collector() {
51
51
  return { parts, add };
52
52
  }
53
53
 
54
+ // Ends a child and everything it started: on POSIX its process group (the child leads one, spawned detached), on
55
+ // Windows the tree through System32's taskkill /T /F (not a taskkill found in the working folder).
56
+ function killTree(child) {
57
+ if (process.platform === "win32") {
58
+ const taskkill = win32.join(process.env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe");
59
+ spawnSync(taskkill, ["/T", "/F", "/PID", String(child.pid)], { stdio: "ignore", windowsHide: true });
60
+ return;
61
+ }
62
+ try {
63
+ process.kill(-child.pid, "SIGKILL");
64
+ } catch {
65
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
66
+ }
67
+ }
68
+
54
69
  // Runs a program and resolves { code, signal, stdout, stderr, out, timedOut, error }. Never rejects: a missing
55
70
  // program is { code: null, error: "ENOENT" }. out holds stdout and stderr in arrival order.
56
71
  export function runProc(cmd, args = [], opts = {}) {
@@ -61,13 +76,19 @@ export function runProc(cmd, args = [], opts = {}) {
61
76
  let timedOut = false;
62
77
  let child;
63
78
  try {
64
- child = spawn(bin, argv, { cwd: opts.cwd, env: opts.env || process.env, stdio: ["ignore", "pipe", "pipe"], ...extra });
79
+ const detached = process.platform !== "win32"; // its own process group, so a time-out ends what it started too
80
+ child = spawn(bin, argv, { cwd: opts.cwd, env: opts.env || process.env, stdio: ["ignore", "pipe", "pipe"], detached, ...extra });
65
81
  } catch (e) {
66
82
  resolveRun({ code: null, signal: null, ...parts, timedOut, error: e.code || String(e.message) });
67
83
  return;
68
84
  }
69
85
  LIVE.add(child);
70
- const timer = opts.timeoutMs ? setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, opts.timeoutMs) : null;
86
+ const timer = opts.timeoutMs ? setTimeout(() => { timedOut = true; killTree(child); }, opts.timeoutMs) : null;
87
+ // After a time-out, a process that left the tree may still hold the pipes: they are closed a second after the exit,
88
+ // so the run ends instead of waiting for it.
89
+ child.on("exit", () => {
90
+ if (timedOut) setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); }, 1000).unref();
91
+ });
71
92
  child.stdout.on("data", add("stdout"));
72
93
  child.stderr.on("data", add("stderr"));
73
94
  let error = null;
@@ -80,12 +101,15 @@ export function runProc(cmd, args = [], opts = {}) {
80
101
  });
81
102
  }
82
103
 
83
- // Stops every child still running (used by the SIGINT and SIGTERM handlers).
104
+ // Stops every child still running (used by the SIGINT and SIGTERM handlers, and when this process exits). On POSIX a
105
+ // child leads its own process group, so it no longer gets the terminal's signals: an entry point that uses runProc
106
+ // keeps signal handlers that call killAll (testbox.mjs does).
84
107
  export function killAll() {
85
- for (const child of LIVE) {
86
- try { child.kill("SIGKILL"); } catch { /* already gone */ }
87
- }
108
+ for (const child of LIVE) killTree(child);
88
109
  LIVE.clear();
89
110
  }
90
111
 
91
112
  export const firstLine = (s) => String(s || "").split(/\r?\n/).map((l) => l.trim()).find(Boolean) || "";
113
+
114
+ // A child group left when this process ends some other way (an uncaught error) is ended too.
115
+ process.on("exit", killAll);
@@ -2,7 +2,7 @@
2
2
  // build-testbox: a test bed each PC builds for itself. Browser matrix (Playwright Chromium and WebKit, desktop and
3
3
  // mobile emulation) and container OS (Rocky Linux; macOS only as a Tart VM on Apple Silicon) run in parallel.
4
4
  // run downloads nothing; setup downloads only after --yes. Pure Node 18+, no dependency.
5
- import { existsSync, mkdirSync, rmSync } from "node:fs";
5
+ import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
6
6
  import { join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { UsageError, parseArgs } from "./args.mjs";
@@ -144,5 +144,13 @@ export async function main(argv) {
144
144
  }
145
145
  }
146
146
 
147
- const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
147
+ // Run directly, also through a linked path or another spelling: both sides as real paths.
148
+ const realOr = (p) => {
149
+ try {
150
+ return realpathSync(p);
151
+ } catch {
152
+ return resolve(p);
153
+ }
154
+ };
155
+ const isMain = process.argv[1] && realOr(process.argv[1]) === realOr(fileURLToPath(import.meta.url));
148
156
  if (isMain) main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
@@ -24,13 +24,17 @@
24
24
  * repository, a .git below the root, no commit identity). No network is used.
25
25
  */
26
26
  import { spawnSync } from "node:child_process";
27
- import { lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
27
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
28
28
  import { homedir, tmpdir } from "node:os";
29
29
  import { join, parse, resolve, sep } from "node:path";
30
30
  import { pathToFileURL } from "node:url";
31
31
  import { HASH, commitBlock, findBlock, markers, planBlock } from "./marker-block.mjs";
32
32
  import { scan } from "./payload-scan.mjs";
33
33
 
34
+ // The folder programs start in: the home folder (Windows looks for a program in the working folder first, so a
35
+ // program planted in the folder worked on never runs), or the temp folder when there is no home folder.
36
+ const runDir = () => (existsSync(homedir()) ? homedir() : tmpdir());
37
+
34
38
  const USAGE = [
35
39
  "git-init: 민감 정보를 빼는 .gitignore 를 쓰고 비밀값을 스캔한 뒤 git init, git add ., git commit -m \"init\" 으로 첫 커밋을 만듦(check 는 계획만)",
36
40
  "사용법: node git-init.mjs [check|run] [--root <dir>] [--branch <이름>] [--local-identity \"<이름> <<메일>>\"] [--nested]",
@@ -120,9 +124,29 @@ function gitEnv(extra) {
120
124
  return env;
121
125
  }
122
126
 
123
- // Runs git; never throws. ok is false when git could not start or exited non-zero.
127
+ // On Windows git is a wrapper (cmd\git.exe) that starts the real git: a time limit on the wrapper alone leaves the real
128
+ // git running, which can still make the commit after the failure was reported. With a time limit, git runs under this
129
+ // small Node supervisor instead, which ends the whole tree with System32's taskkill /T /F and exits 124.
130
+ const TREE_SUPERVISOR = [
131
+ "const {spawn,spawnSync}=require('child_process');const p=require('path');",
132
+ "const [ms,...a]=process.argv.slice(1);",
133
+ "const c=spawn('git',a,{stdio:'inherit',windowsHide:true});",
134
+ "const t=setTimeout(()=>{spawnSync(p.win32.join(process.env.SystemRoot||'C:\\\\Windows','System32','taskkill.exe'),['/T','/F','/PID',String(c.pid)],{stdio:'ignore',windowsHide:true});process.exit(124)},Number(ms));",
135
+ "c.on('error',()=>process.exit(127));c.on('exit',(code)=>{clearTimeout(t);process.exit(code==null?1:code)});",
136
+ ].join("");
137
+
138
+ // Runs git on the folder cwd (git -C), started from the home folder: Windows looks for a program in the working folder
139
+ // first, so a git.exe inside the folder being initialized never runs. Never throws. ok is false when git could not
140
+ // start or exited non-zero; a time-out is error.code ETIMEDOUT.
124
141
  function git(args, cwd, { env, timeout } = {}) {
125
- const r = spawnSync("git", args, { cwd, encoding: "utf8", env: gitEnv(env), timeout, killSignal: "SIGKILL", maxBuffer: 256 * 1024 * 1024, windowsHide: true });
142
+ const opts = { cwd: runDir(), encoding: "utf8", env: gitEnv(env), killSignal: "SIGKILL", maxBuffer: 256 * 1024 * 1024, windowsHide: true };
143
+ const argv = ["-C", cwd, ...args];
144
+ if (timeout && process.platform === "win32") {
145
+ const r = spawnSync(process.execPath, ["-e", TREE_SUPERVISOR, String(timeout), ...argv], { ...opts, timeout: timeout + 30000 });
146
+ const error = r.status === 124 ? Object.assign(new Error("git timed out"), { code: "ETIMEDOUT" }) : r.error;
147
+ return { ok: !error && r.status === 0, out: r.stdout ?? "", err: (r.stderr ?? "").trim(), error };
148
+ }
149
+ const r = spawnSync("git", argv, { ...opts, timeout });
126
150
  return { ok: !r.error && r.status === 0, out: r.stdout ?? "", err: (r.stderr ?? "").trim(), error: r.error };
127
151
  }
128
152
 
@@ -136,12 +160,35 @@ const real = (p) => {
136
160
  }
137
161
  };
138
162
 
163
+ // Whether a and b are one folder: device and inode (bigint, as NTFS file ids pass 2^53), or where the file system has
164
+ // no inode numbers, the path as the file system spells it (realpathSync.native gives one spelling for names that differ
165
+ // only in case, an 8.3 short name or a linked parent), case folded on Windows and macOS. A string compare of the JS
166
+ // realpath let `--root c:\users\me` (or /users/me on macOS) pass the HOME refusal.
167
+ function sameDir(a, b) {
168
+ try {
169
+ const x = statSync(a, { bigint: true });
170
+ const y = statSync(b, { bigint: true });
171
+ if (x.ino !== 0n && y.ino !== 0n) return x.dev === y.dev && x.ino === y.ino;
172
+ } catch {
173
+ // one is gone: the spellings below decide
174
+ }
175
+ const spell = (p) => {
176
+ try {
177
+ return realpathSync.native(p);
178
+ } catch {
179
+ return real(p);
180
+ }
181
+ };
182
+ const fold = (p) => (process.platform === "win32" || process.platform === "darwin" ? p.toLowerCase() : p);
183
+ return fold(spell(a)) === fold(spell(b));
184
+ }
185
+
139
186
  // The root folder, or a failure: missing (2), HOME or a file system root (3).
140
187
  function checkRoot(rootArg) {
141
188
  const root = resolve(rootArg);
142
189
  if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) return fail(2, `루트 폴더가 없음: ${root}`);
143
190
  const r = real(root);
144
- if (r === real(homedir())) return fail(3, `루트가 HOME 이라 거부: ${root}. 프로젝트 폴더에서 실행하거나 --root 로 지정`);
191
+ if (sameDir(root, homedir())) return fail(3, `루트가 HOME 이라 거부: ${root}. 프로젝트 폴더에서 실행하거나 --root 로 지정`);
145
192
  if (parse(r).root === r) return fail(3, `루트가 파일 시스템 루트라 거부: ${root}`);
146
193
  return { root };
147
194
  }
@@ -212,7 +259,7 @@ export function missingPatterns(text) {
212
259
  function gitignorePlan(root, follow) {
213
260
  const path = join(root, ".gitignore");
214
261
  const opts = { comment: HASH, id: ID, skill: SKILL, followSymlink: follow };
215
- const probe = planBlock(path, { ...opts, body: null });
262
+ const probe = planBlock(path, { ...opts, body: null, readOnly: true }); // read only: never written
216
263
  if (!probe.ok) return { refuse: probe };
217
264
  const missing = missingPatterns(probe.before === null ? "" : probe.before.toString("utf8"));
218
265
  const plan = planBlock(path, { ...opts, body: missing.length > 0 ? [BLOCK_HEAD, ...missing].join("\n") : null });
@@ -325,6 +372,7 @@ function commitInit(root) {
325
372
 
326
373
  function runSteps(o, ctx) {
327
374
  const done = commitBlock(ctx.gi.plan, SKILL);
375
+ if (!done.ok) return fail(1, `.gitignore 거부: ${done.message}. git init 하지 않음`, { ...ctx.report, ok: false });
328
376
  const report = { ...ctx.report, gitignore: { ...ctx.report.gitignore, status: done.changed ? (done.created ? "created" : "updated") : "unchanged", backup: done.backup ?? null } };
329
377
  const list = listCandidates(ctx.tmp, ctx.root, []);
330
378
  const nested = list.filter((p) => p.endsWith("/"));