@kaydash9999/banker-plugins 0.25.0 → 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 +18 -0
- 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/motion-sites/SKILL.md +9 -1
- package/skills/motion-sites/scripts/motion-sites.mjs +20 -3
- package/skills/omc-patch/scripts/omc-patch.mjs +74 -16
- 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 +1 -1
- package/skills/remains/scripts/boxes.mjs +31 -12
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "banker",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.1",
|
|
4
4
|
"description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "smallOpenSource",
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.1] - 2026-11-03
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- `motion-sites`: Python 을 찾지 못해 첫 실행 구성이 멈출 때, 실패 이유와 `SKILL.md` 가 `MOTION_SITES_PYTHON` 으로 Python 을 지정하는 방법을 알린다. 전에는 PATH 밖의 Python(Windows 의 Miniconda, `py` 없이 Store 연결 파일만 있는 PC)을 쓸 방법이 안내되지 않았다. Windows 박스 실제 설치 시험에서 찾음.
|
|
7
|
+
- `motion-sites`: `MOTION_SITES_PYTHON` 으로 지정한 Python 이 실행되지 않으면 그 사실을 알리고 멈춘다. 전에는 PATH 의 다른 Python 이 있으면 알리지 않고 그쪽에 Playwright 를 설치했다. 지정한 Python 으로 만든 캐시 폴더 가상 환경은 그대로 찾는다.
|
|
8
|
+
- `3d-intro-build`, `3d-intro-setup`: 셸의 `FFMPEG_PATH` 가 상대 경로이거나 없는 파일이면 `resolveFfmpeg()` 가 그 까닭으로 멈춘다. 전에는 알리지 않고 설정 파일, PATH, `ffmpeg-static` 의 다른 ffmpeg 를 썼다. 설정 파일의 값은 예전처럼 쓸 수 없으면 다음 후보로 넘어간다.
|
|
9
|
+
- `remains`: 박스 시험 실행기(`boxes.mjs`)가 저장소의 index 를 쓰지 않는다. `--ref worktree` 의 `git stash create` 는 index 사본으로, `git status` 는 선택 잠금 없이 돈다. 두 실행을 동시에 하거나 사용자의 git 명령이 index 를 잠그고 있으면 "could not write index" 로 멈추던 것을 고친다. 실패 문구가 `git -c -c stash create` 처럼 깨져 보이던 것도 고친다.
|
|
10
|
+
- `remains`: root 로 접속해 `runAs` 계정으로 돌리는 Linux 박스는 개인 폴더를 `/tmp` 대신 `/var/lib` 아래에 만든다. 저장소 사본과 그 계정의 HOME 이 임시 폴더 아래에 있어, 임시 폴더를 거부하는 teamwork 의 설치 흐름 시험을 확인할 수 없던 것을 고친다.
|
|
11
|
+
- 오류 주입 시험으로 찾은 "실패를 성공으로 보고" 를 고친다(omc-patch, ralph-qa 의 seat-run, verifier-probe, 3d-intro 의 azure-adapter, video-pool).
|
|
12
|
+
- `omc-patch`: 풀다 멈춘 판 폴더로 전환하던 것(옆 폴더에 다 푼 뒤에만 판 이름으로 옮기고, hooks.json 이나 scripts/ 가 없는 폴더는 설치된 판으로 보지 않음), hooks.json 이 없거나 훅을 하나도 부르지 않거나 부르는 훅 파일이 없을 때 "실패 0건" 이던 것, 빈 백업이나 잘린 백업으로 되돌리고 "되돌리기 완료" 이던 것, 깨진 hooks.json 을 "이 도구가 지운 적 없음" 으로 넘기던 것, 잘린 패치 파일과 패치 뒤 새로 생긴 감싸지 않은 import 를 `--check` 가 "모두 적용" 으로 보던 것, 오류 문구가 있는 git 종료 1 을 "origin 없음" 으로 보던 것
|
|
13
|
+
- `verifier-probe`: 두 호출이 모두 실패한 codex 를 앉힐 수 있는 좌석으로 보고하던 것(이제 `cli-call-failed` 와 호출이 끝난 사정), 읽지 못한 설정 파일(권한, 폴더)을 없는 파일로 보고 모델을 채택하던 것, 빈 models.dev 사본(`{}`)으로 채택하던 것, SIGTERM 을 무시하는 CLI 에 끝없이 멈추던 것(SIGKILL), 오류 글을 판으로 적던 것
|
|
14
|
+
- `seat-run`: 코드펜스 안의 `VERDICT:` 줄을 좌석의 판정으로 세던 것, 실패한 좌석의 `facts` 에 판정이 남던 것. send 출력에 `answered`, `failed` 를 더한다
|
|
15
|
+
- `3d-intro`: ffmpeg 가 실패하거나 끝나지 않거나 아무것도 쓰지 않아도 앞서 있던 파일이나 반쯤 쓴 파일을 결과로 내던 것(이제 출력부터 지우고 종료 0 과 빈 파일 아님을 요구, 10분 시간 제한), 길이를 읽지 못하면 0초에서 겹치던 crossfade, 200 으로 온 HTML, JSON 오류 페이지를 영상으로 받던 것, 이미지가 아닌 답을 PNG 로 내던 것, 빈 시드 프레임으로 유료 작업을 보내던 것
|
|
16
|
+
|
|
17
|
+
### Notes
|
|
18
|
+
- teamwork 시험은 저장소 사본이 임시 폴더 아래에 있으면(remains 박스 실행기 등) 설치 흐름 고정물을 홈 아래 난수 폴더에 두고 끝나면 지운다. 홈도 임시 폴더 아래면 설치 흐름 시험만 까닭을 대고 건너뛴다. 전에는 macOS, Rocky8 박스에서 74, 75건이 거짓 실패했다.
|
|
19
|
+
- `scripts/class-guards.test.mjs` 는 git 저장소가 아닌 사본(박스에 보내는 추적 파일 묶음)에서도 파일을 걸어 돈다.
|
|
20
|
+
|
|
3
21
|
## [0.25.0] - 2026-11-02
|
|
4
22
|
|
|
5
23
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaydash9999/banker-plugins",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.1",
|
|
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"
|
|
@@ -63,6 +63,7 @@ if (creds._tracked) throw new Error(`프로젝트 키 파일을 git 이 추적
|
|
|
63
63
|
- `creds._ignored` 가 있으면 프로젝트 키 파일의 `FFMPEG_PATH` 를 쓰지 않은 것이다. 저장소와 함께 온 파일이 실행할 프로그램을 정하지 못하게 막은 것이다. 값은 출력하지 않으니 사람이 파일을 열어 확인하고, 그 경로가 필요하면 `3d-intro-setup` §2 로 홈 설정 파일에 남긴다.
|
|
64
64
|
Claude Code: `/banker:setup` 에서 3d-intro-setup / Codex: `banker-3d-intro-setup`.
|
|
65
65
|
- `resolveFfmpeg()` 가 throw 하면 ffmpeg 가 없다. 같은 `3d-intro-setup` 으로 보낸다.
|
|
66
|
+
- 오류가 `FFMPEG_PATH from the shell is not usable` 이면 셸에서 지정한 `FFMPEG_PATH` 가 상대 경로이거나 없는 파일이다. 다른 ffmpeg 로 넘어가지 않는다. 그 값을 고치거나 지우게 한다.
|
|
66
67
|
- 둘 다 통과해야 다음 단계로 간다.
|
|
67
68
|
- 영상 provider 상태를 무료로 확인해 둔다.
|
|
68
69
|
`poolSummary({ creds })` 는 WAN 키별 상태(`ok`, `cooldown`, `exhausted`, `invalid`, `malformed`)와 마지막 줄의 Sora 상태(`label: 'AZURE_SORA'`, `ok`, `retired`, `unset`, `malformed`)를 키 원문 없이 돌려준다. WAN 줄은 `provider: 'wan'`, Sora 줄은 `provider: 'sora'` 다.
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -29,7 +29,7 @@ banker 는 원본을 복사하지 않는다. 원본의 예시(`examples/`)와
|
|
|
29
29
|
|---|---|
|
|
30
30
|
| `skillFile` | 설치된 원본 `SKILL.md` 경로. 없거나 꺼져 있으면 `null` |
|
|
31
31
|
| `disabled` | 설치는 됐지만 꺼져 있음 |
|
|
32
|
-
| `python.bin` | Playwright 를 쓸 Python |
|
|
32
|
+
| `python.bin` | Playwright 를 쓸 Python. 찾는 차례는 `MOTION_SITES_PYTHON`, 캐시 폴더의 가상 환경, PATH 의 `python3`, `python`(Windows 는 `py -3`, `python`). `MOTION_SITES_PYTHON` 이 있으면 그것과 캐시 폴더의 가상 환경만 보고, 지정한 것이 실행되지 않으면 다른 Python 으로 넘어가지 않는다 |
|
|
33
33
|
| `python.playwright`, `python.chromium` | Playwright 와 Chromium 이 있는지 |
|
|
34
34
|
| `ready` | 셋 다 있으면 `true`(종료 0) |
|
|
35
35
|
|
|
@@ -49,6 +49,14 @@ banker 는 원본을 복사하지 않는다. 원본의 예시(`examples/`)와
|
|
|
49
49
|
3. Codex 에서는 이 명령이 네트워크와 `~/.codex` 쓰기를 하므로 `sandbox_permissions: "require_escalated"` 로 승인을 요청한다.
|
|
50
50
|
4. 출력의 `steps` 에 실패한 단계가 있으면 그 단계와 `why` 를 보여 주고 멈춘다. 설치되지 않은 것을 설치된 것처럼 말하지 않는다.
|
|
51
51
|
- 네트워크가 없으면 여기서 멈춘다. 오프라인에서는 이 스킬을 쓸 수 없다.
|
|
52
|
+
- `python` 단계가 "Python 3 을 찾지 못함" 으로 실패하면, 설치한 Python 이 PATH 밖에 있는지 사용자에게 묻는다. Windows 에서 `py` 가 없고 PATH 의 `python` 이 Microsoft Store 연결 파일뿐이거나, Miniconda 를 PATH 에 넣지 않고 설치한 경우다.
|
|
53
|
+
- 있으면 그 python 실행 파일의 절대 경로를 환경 변수 `MOTION_SITES_PYTHON` 에 영구로 지정하라고 안내한다. `%USERPROFILE%`, `~` 같은 줄임은 쓰지 않는다. 셸에 따라 글자 그대로 저장되어 엔진이 찾지 못한다.
|
|
54
|
+
- Windows(cmd 나 PowerShell): `setx MOTION_SITES_PYTHON "C:\Users\<이름>\miniconda3\python.exe"`
|
|
55
|
+
- macOS, Linux: 셸 설정 파일(`~/.zshrc`, `~/.bashrc`)에 `export MOTION_SITES_PYTHON=/절대/경로/python3`
|
|
56
|
+
- 한 번만 주는 것으로는 안 된다. 다음 실행의 상태 확인도 그 Python 을 봐야 한다. 이미 떠 있는 프로그램에는 새 값이 반영되지 않으므로, Claude Code 나 Codex 를 완전히 끝내고 다시 시작한 뒤 2단계를 다시 한다.
|
|
57
|
+
- Playwright 는 지정한 Python 에 설치된다. 그 Python 을 바꾸고 싶지 않으면 `<그 python> -m venv <폴더>` 로 가상 환경을 만들고 그 안의 python 을 지정한다(Windows `<폴더>\Scripts\python.exe`, macOS 와 Linux `<폴더>/bin/python`). pip 이 시스템 Python 을 거부하는(externally managed) 경우는 엔진이 캐시 폴더에 가상 환경을 스스로 만든다.
|
|
58
|
+
- 실패 이유가 "MOTION_SITES_PYTHON 에 지정한 ... 가 실행되지 않음" 이면 지정한 경로가 틀린 것이다. 경로를 고치게 한다.
|
|
59
|
+
- Python 이 없으면 Python 3 설치를 안내한다. 직접 설치하지 않는다.
|
|
52
60
|
- Linux 에서 Chromium 이 시스템 라이브러리 부족으로 뜨지 않으면 `<python.bin> -m playwright install-deps chromium` 을 안내한다. 관리자 권한이 필요하므로 직접 실행하지 않는다.
|
|
53
61
|
5. 끝나면 `ready` 가 `true` 인지 확인하고 3단계로 간다.
|
|
54
62
|
|
|
@@ -215,9 +215,16 @@ export function lookPython(py, runner = run) {
|
|
|
215
215
|
return { bin, cmd: py, playwright: pw, chromium: exe.ok && chrome !== "" && existsSync(chrome) };
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
/**
|
|
218
|
+
/**
|
|
219
|
+
* The first Python with Playwright (and Chromium, when one has both), else the first that runs. A MOTION_SITES_PYTHON
|
|
220
|
+
* is the user's choice: only it and the cache's virtual environment (made from it when pip refuses it) are looked at,
|
|
221
|
+
* and when it does not run there is none, so Playwright never goes into a Python the user did not name.
|
|
222
|
+
*/
|
|
219
223
|
export function pickPython(env = process.env, runner = run) {
|
|
220
|
-
const
|
|
224
|
+
const all = pythons(env);
|
|
225
|
+
const named = env.MOTION_SITES_PYTHON ? lookPython(all[0], runner) : null;
|
|
226
|
+
if (env.MOTION_SITES_PYTHON && !named) return null;
|
|
227
|
+
const seen = (named ? [named, lookPython(all[1], runner)] : all.map((p) => lookPython(p, runner))).filter(Boolean);
|
|
221
228
|
return seen.find((p) => p.playwright && p.chromium) ?? seen.find((p) => p.playwright) ?? seen[0] ?? null;
|
|
222
229
|
}
|
|
223
230
|
|
|
@@ -265,10 +272,20 @@ function enablePlugin(runtime, runner) {
|
|
|
265
272
|
return { step: "plugin enable", ok: r.ok, why: r.why };
|
|
266
273
|
}
|
|
267
274
|
|
|
275
|
+
// No Python found: says where it looked and how to name one that is installed but not on PATH (Windows with only
|
|
276
|
+
// Miniconda, or only the Microsoft Store link, has no py and no working python).
|
|
277
|
+
export const NO_PYTHON = "Python 3 을 찾지 못함(python3, python, Windows 는 py). 설치한 Python 이 PATH 밖에 있으면 환경 변수 MOTION_SITES_PYTHON 에 그 python 실행 파일의 절대 경로를 영구로 지정한 뒤 다시 실행";
|
|
278
|
+
|
|
279
|
+
/** Why no Python was found; a MOTION_SITES_PYTHON that is set but does not run is named, so a typo is not hidden. */
|
|
280
|
+
export function noPython(env = process.env) {
|
|
281
|
+
if (!env.MOTION_SITES_PYTHON) return NO_PYTHON;
|
|
282
|
+
return `MOTION_SITES_PYTHON 에 지정한 ${pythons(env)[0][0]} 가 실행되지 않음. 경로를 고친 뒤 다시 실행. ${NO_PYTHON}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
268
285
|
/** The Python steps: Playwright, then Chromium. */
|
|
269
286
|
function installBrowser(env, runner) {
|
|
270
287
|
let py = pickPython(env, runner);
|
|
271
|
-
if (!py) return [{ step: "python", ok: false, why:
|
|
288
|
+
if (!py) return [{ step: "python", ok: false, why: noPython(env) }];
|
|
272
289
|
const steps = [];
|
|
273
290
|
let cmd = py.cmd;
|
|
274
291
|
if (!py.playwright) {
|
|
@@ -252,8 +252,10 @@ function gitFetch(args) {
|
|
|
252
252
|
// ── 1. 업데이트 ──────────────────────────────────────────────────────────────
|
|
253
253
|
function ensureVersion() {
|
|
254
254
|
const have = installedVersions();
|
|
255
|
-
if (have.includes(TARGET_VERSION)) return useCached();
|
|
256
|
-
|
|
255
|
+
if (have.includes(TARGET_VERSION) && completeVersion(TARGET_VERSION)) return useCached();
|
|
256
|
+
// 풀다 멈춘 폴더(hooks/hooks.json 이나 scripts/ 가 없음)는 설치된 것으로 보지 않는다. 그대로 전환하면 빈 플러그인으로 바뀐다.
|
|
257
|
+
if (have.includes(TARGET_VERSION)) bad(`${TARGET_VERSION} 캐시 폴더가 불완전함 (hooks/hooks.json 이나 scripts/ 가 없음)${MODE === "check" ? "" : ". 다시 풉니다"}`);
|
|
258
|
+
if (MODE === "check") { if (!have.includes(TARGET_VERSION)) bad(`${TARGET_VERSION} 미설치 (설치된 버전: ${have.join(", ") || "없음"})`); return false; }
|
|
257
259
|
if (!canInstallTarget()) return false;
|
|
258
260
|
|
|
259
261
|
log(` ${TARGET_VERSION} 설치 중...`);
|
|
@@ -265,6 +267,9 @@ function ensureVersion() {
|
|
|
265
267
|
return switchActive(sha);
|
|
266
268
|
}
|
|
267
269
|
|
|
270
|
+
// 판 폴더에 훅 목록과 스크립트 폴더가 있는가.
|
|
271
|
+
const completeVersion = (v) => existsSync(join(CACHE, v, "hooks", "hooks.json")) && existsSync(join(CACHE, v, "scripts"));
|
|
272
|
+
|
|
268
273
|
// TARGET_VERSION 이 캐시에 있을 때. 이미 활성이면 끝, 아니면 마켓플레이스 태그의 커밋으로 전환한다.
|
|
269
274
|
// 전환도 마켓플레이스와 git 이 있어야 하므로 먼저 확인한다(풀지 않으므로 tar 는 없어도 된다).
|
|
270
275
|
function useCached() {
|
|
@@ -356,16 +361,27 @@ function targetSha() {
|
|
|
356
361
|
}
|
|
357
362
|
|
|
358
363
|
// git archive | tar 로 소스를 편다. 저장소에 dist/ 가 포함되어 빌드는 불필요하다.
|
|
364
|
+
// 옆의 난수 이름 폴더에 다 푼 뒤에만 판 이름으로 옮긴다. 도중에 멈추면(tar 실패, 신호, 디스크 부족) 판 이름의 폴더는 생기지 않아,
|
|
365
|
+
// 다음 실행이 반쯤 찬 폴더를 설치된 판으로 보고 전환하지 않는다. 옆 폴더는 판 이름 꼴이 아니라 installedVersions 가 세지 않는다.
|
|
359
366
|
function unpackTarget(dest) {
|
|
360
|
-
|
|
361
|
-
mkdirSync(
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
367
|
+
const part = `${dest}.part-${randomBytes(6).toString("hex")}`;
|
|
368
|
+
mkdirSync(part, { recursive: true });
|
|
369
|
+
try {
|
|
370
|
+
git(["archive", `v${TARGET_VERSION}`, "-o", join(part, "_src.tar")]);
|
|
371
|
+
execFileSync("tar", ["-xf", join(part, "_src.tar"), "-C", part], { cwd: homedir(), stdio: "ignore" });
|
|
372
|
+
unlinkSync(join(part, "_src.tar"));
|
|
373
|
+
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
|
|
374
|
+
renameSync(part, dest);
|
|
375
|
+
} catch (e) {
|
|
376
|
+
rmSync(part, { recursive: true, force: true });
|
|
377
|
+
throw e;
|
|
378
|
+
}
|
|
365
379
|
}
|
|
366
380
|
|
|
367
381
|
// node_modules 는 복사하지 않고 직전 버전 것을 링크한다. Windows 는 junction
|
|
368
382
|
// (관리자 권한 불필요), POSIX 는 type 인자가 무시되고 디렉터리 symlink 가 된다.
|
|
383
|
+
// 없어도 전환을 막지 않는다. Claude Code 가 직접 설치한 OMC 판에는 node_modules 가 없고, 실행에 쓰는 것은 dist/ 에
|
|
384
|
+
// 묶여 있다(2026-11-03 실측: 5.7.21~5.7.30 모두 없음, OMC 정상 동작). 막으면 보통 PC 에서 전환이 늘 실패한다.
|
|
369
385
|
function linkNodeModules(dest, have) {
|
|
370
386
|
const prev = have.find((v) => v !== TARGET_VERSION);
|
|
371
387
|
const nm = join(dest, "node_modules");
|
|
@@ -699,16 +715,22 @@ function openParenOf(src, j) {
|
|
|
699
715
|
// 그 상태를 «패치됨» 으로 넘기면 --check 가 결함 트리에 «모두 적용» 을 낸다(2026-10-12 실측).
|
|
700
716
|
function siteInfo(src) {
|
|
701
717
|
if (src.includes("__omcRaceImport")) {
|
|
702
|
-
|
|
718
|
+
// 헬퍼가 있어도 적용이 감쌌어야 할 자리가 남았으면(패치 뒤 상류가 줄을 더함) 적용된 것이 아니다.
|
|
719
|
+
// 헬퍼 본문의 Promise.race 는 손으로 두른 것이 아니므로, 같은 길이의 공백으로 지운 글에서 찾는다(자리는 그대로).
|
|
720
|
+
const left = openSites(src.split(HELPER).join(" ".repeat(HELPER.length)));
|
|
721
|
+
const why = helperDefect(scanFacts(src)) ?? (left.length ? `감싸지 않은 최상위 await import 가 남음 :${left.map((p) => src.slice(0, p).split("\n").length).join(", :")}` : null);
|
|
703
722
|
return why ? { state: "misplaced", sites: [], why } : { state: "patched", sites: [] };
|
|
704
723
|
}
|
|
705
724
|
const all = scanTopLevelAwaitImports(src);
|
|
706
725
|
if (!all.length) return { state: "n/a", sites: [] };
|
|
707
|
-
const open =
|
|
726
|
+
const open = openSites(src);
|
|
708
727
|
if (!open.length) return { state: "guarded", sites: [] };
|
|
709
728
|
return { state: "needs", sites: open };
|
|
710
729
|
}
|
|
711
730
|
|
|
731
|
+
// 적용이 감싸는 자리: 최상위 await import 가운데 바로 앞(300자)에 손으로 두른 Promise.race 가 없는 것.
|
|
732
|
+
const openSites = (src) => scanTopLevelAwaitImports(src).filter((p) => !/Promise\.race/.test(src.slice(Math.max(0, p - 300), p)));
|
|
733
|
+
|
|
712
734
|
// 헬퍼가 쓰일 수 없는 자리면 그 이유를, 제자리면 null 을 낸다.
|
|
713
735
|
// - 선언이 블록 안(깊이 1 이상): 다른 블록의 자리에서 ReferenceError(옛 킷이 남긴 자리)
|
|
714
736
|
// - 선언을 못 찾음: 이름만으로는 «패치됨» 이라 할 근거가 없다. 앞쪽 글을 토크나이저가 잘못
|
|
@@ -834,15 +856,33 @@ function patchHooks(root) {
|
|
|
834
856
|
// 4.15.7 이 둘 다 겪었다 — PostToolUse 를 지우면 그 스크립트가 목록에서 빠지고(그래서
|
|
835
857
|
// .omcbak 이 있어도 안 돌아온다), 손으로 고친 파일은 .omcbak 조차 없어 아예 안 보인다.
|
|
836
858
|
const names = MODE === "revert" ? scriptNames(root, r) : hookNames(root);
|
|
859
|
+
// 고칠 훅을 하나도 찾지 못했으면 «실패 0건» 이 아니다. 목록이 빈 hooks.json(`{}`, `[]`)은 고친 것도 확인한 것도 없다.
|
|
860
|
+
// hooks.json 이 없거나 읽히지 않으면 [3/4] 가 이름과 이유를 대고 센다. 여기서는 읽혔는데 훅이 없을 때만 센다.
|
|
861
|
+
if (MODE !== "revert" && !names.length) {
|
|
862
|
+
if (readableFile(join(root, "hooks", "hooks.json"))) notChecked(r, join(root, "hooks", "hooks.json"), "hooks.json 이 부르는 훅(scripts/*.mjs)을 하나도 찾지 못함");
|
|
863
|
+
return r;
|
|
864
|
+
}
|
|
837
865
|
for (const n of names) {
|
|
838
866
|
const f = join(root, "scripts", `${n}.mjs`);
|
|
839
|
-
if (
|
|
840
|
-
|
|
841
|
-
|
|
867
|
+
if (MODE === "revert") { if (existsSync(f)) revertScript(root, n, f, r); continue; }
|
|
868
|
+
// hooks.json 이 부르는데 파일이 없으면 이름을 대고 센다. 그 밖에 볼 수 없으면(권한) readHook 이 이유를 대고 센다.
|
|
869
|
+
if (missingFile(f)) { notChecked(r, f, `hooks.json 이 부르는 훅 파일이 없음: ${shown(n)}`); continue; }
|
|
870
|
+
checkOrPatchScript(root, n, f, r);
|
|
842
871
|
}
|
|
843
872
|
return r;
|
|
844
873
|
}
|
|
845
874
|
|
|
875
|
+
// 확인하지 못한 것: 이름과 이유를 대고 실패로 센다. check 는 «모두 적용» 이라 하지 않게 읽지 못한 것으로도 센다.
|
|
876
|
+
function notChecked(r, f, why) {
|
|
877
|
+
bad(why);
|
|
878
|
+
r.fail++;
|
|
879
|
+
r.failed.push(f);
|
|
880
|
+
if (MODE === "check") r.unread++;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const readableFile = (f) => { try { readRegular(f); return true; } catch { return false; } };
|
|
884
|
+
const missingFile = (f) => { try { lstatSync(f); return false; } catch (e) { return e.code === "ENOENT"; } };
|
|
885
|
+
|
|
846
886
|
// revert 가 훑는 이름: scripts/ 의 .mjs 전부. scripts/ 가 없는 판 폴더는 되돌릴 훅이 없어 한 줄로 알리고 넘어간다.
|
|
847
887
|
// 그 밖에 목록을 읽지 못하면(파일, 권한) 이름과 이유를 대고 실패로 센다 — 다음 판과 [3/4], [4/4] 는 그대로 간다.
|
|
848
888
|
function scriptNames(root, r) {
|
|
@@ -927,12 +967,20 @@ function handRestoreHint(bak, f) {
|
|
|
927
967
|
|
|
928
968
|
// 백업의 내용으로 파일을 바꾼다. 쓰지 못하면 그 이유, 백업이 읽으려는 순간 사라졌으면 GONE, 아니면 null.
|
|
929
969
|
const GONE = "백업이 먼저 사라짐";
|
|
970
|
+
// 빈 백업이나 문법 검사를 통과하지 못하는 백업(잘린 복사)으로는 되돌리지 않는다. 쓴 뒤 검사에 걸리면 쓰기 전 글로 되돌린다.
|
|
930
971
|
function restoreFrom(f, bak) {
|
|
931
972
|
let data;
|
|
932
973
|
try { data = readRegular(bak); }
|
|
933
974
|
catch (e) { return e.code === "ENOENT" ? GONE : `읽지 못함: ${failWhy(e)}`; }
|
|
934
|
-
|
|
975
|
+
if (!data.length) return "백업이 비어 있음";
|
|
976
|
+
let before;
|
|
977
|
+
try { before = readRegular(f); writeAtomic(f, data); }
|
|
935
978
|
catch (e) { return `쓰지 못함: ${failWhy(e)}`; }
|
|
979
|
+
const err = syntaxError(f);
|
|
980
|
+
if (!err) return null;
|
|
981
|
+
try { writeAtomic(f, before); }
|
|
982
|
+
catch (e) { return `백업이 문법 검사를 통과하지 못했고 원래 글로도 되돌리지 못함 (${failWhy(e)}): ${oneLine(err)}`; }
|
|
983
|
+
return `백업이 문법 검사를 통과하지 못함: ${oneLine(err)}`;
|
|
936
984
|
}
|
|
937
985
|
|
|
938
986
|
// 되돌린 뒤 백업을 지운다. 다른 실행이 먼저 지웠으면(ENOENT) 된 것이다. 못 지우면 그 이유, 아니면 null.
|
|
@@ -948,6 +996,9 @@ function checkOrPatchScript(root, n, f, r) {
|
|
|
948
996
|
// 헬퍼 자리 결함은 check 와 apply 모두 이름을 댄다. 이미 감싼 파일이라 transform 으로는
|
|
949
997
|
// 고칠 수 없으므로 apply 도 건드리지 않고, 되돌린 뒤 다시 적용하라고 안내한다(misplacedHint).
|
|
950
998
|
if (info.state === "misplaced") { bad(`헬퍼 자리 결함: ${n} (${info.why})`); r.misplaced++; return; }
|
|
999
|
+
// 헬퍼 이름이 있어도 잘린 파일(복사가 끊김)은 돌지 않는다. 문법 검사를 통과해야 패치된 것으로 센다.
|
|
1000
|
+
const broken = info.state === "patched" ? syntaxError(f) : null;
|
|
1001
|
+
if (broken) { bad(`헬퍼 자리 결함: ${n} (패치된 파일이 문법 검사를 통과하지 못함: ${oneLine(broken)})`); r.misplaced++; return; }
|
|
951
1002
|
if (info.state !== "needs") { r.skip++; return; }
|
|
952
1003
|
const bak = `${f}.omcbak`;
|
|
953
1004
|
const why = whyNotWrite(root, f, bak);
|
|
@@ -1079,6 +1130,7 @@ function revertHooksJson(root, hj) {
|
|
|
1079
1130
|
const bak = `${hj}.omcbak`;
|
|
1080
1131
|
const saved = readBackup(bak);
|
|
1081
1132
|
if (saved !== null) {
|
|
1133
|
+
if (!isJson(saved)) { hjBad("hooks.json 을 되돌리지 않음 (백업이 비었거나 JSON 이 아님)"); return "unsafe"; }
|
|
1082
1134
|
if (staleHooksJson(hj, saved)) { hjBad(`hooks.json 을 되돌리지 않음 (지금 파일에 PostToolUse 가 있고 백업과 글이 다름. 백업이 옛 원본일 수 있음)`); return "unsafe"; }
|
|
1083
1135
|
writeAtomic(hj, saved);
|
|
1084
1136
|
log(` 되돌림 ${hjTag}hooks.json`);
|
|
@@ -1102,9 +1154,14 @@ function revertHooksJson(root, hj) {
|
|
|
1102
1154
|
return "reverted";
|
|
1103
1155
|
}
|
|
1104
1156
|
if (usable.length > 1) { hjBad(`hooks.json 수동 백업이 여러 개라 자동 복구하지 않음: ${usable.map(shown).join(", ")}`); return "ambiguous"; }
|
|
1105
|
-
|
|
1157
|
+
if (unsafe.length) return "linked";
|
|
1158
|
+
// 깨진 hooks.json 에 되돌릴 백업도 없으면 «이 도구가 지운 적 없음» 이라 할 근거가 없다. 그대로 두고 실패로 센다.
|
|
1159
|
+
if (!isJson(readHooksJson(hj))) { hjBad("hooks.json 이 JSON 이 아니고 되돌릴 백업도 없음"); return "unsafe"; }
|
|
1160
|
+
return "no-backup";
|
|
1106
1161
|
}
|
|
1107
1162
|
|
|
1163
|
+
const isJson = (text) => { try { JSON.parse(String(text)); return true; } catch { return false; } };
|
|
1164
|
+
|
|
1108
1165
|
// 파일에서 읽은 값(파일 이름, PINNED 의 host)과 출력하는 경로를 출력에 넣을 때 쓴다. 줄바꿈과 제어 문자를 ? 로 바꾸고 길이를 자른다.
|
|
1109
1166
|
// SKILL 은 [!] 줄을 그대로 보고하라고 하므로, 값이 제 줄을 새로 시작하면 도구가 낸 줄처럼 읽힌다.
|
|
1110
1167
|
// 바꾸는 문자: 제어 문자(Cc), 서식 문자(Cf: 폭 없는 공백, 방향 표시, 단어 결합자, 태그 문자), 줄과 문단 구분자,
|
|
@@ -1270,7 +1327,7 @@ function removePostToolUse(hj, why) {
|
|
|
1270
1327
|
// 고정과 되돌리기가 하지 않은 일을 했다고 끝난다.
|
|
1271
1328
|
function originUrl() {
|
|
1272
1329
|
try { return git(["config", "--get", "remote.origin.url"]); }
|
|
1273
|
-
catch (e) { return e.status === 1 ? null : { unreadable: gitLine(e) }; }
|
|
1330
|
+
catch (e) { return e.status === 1 && !String(e.stderr || "").trim() ? null : { unreadable: gitLine(e) }; }
|
|
1274
1331
|
}
|
|
1275
1332
|
|
|
1276
1333
|
// git 오류의 원인 줄: `fatal:` 이나 `error:` 로 시작하는 첫 줄, 없으면 첫 줄(`warning:` 이 먼저 올 수 있다), 정화하고
|
|
@@ -1688,7 +1745,8 @@ for (const v of targets) {
|
|
|
1688
1745
|
else if (hj === "needs") hjNeedBy[v] = true;
|
|
1689
1746
|
else if (hj === "reverted" || hj === "ambiguous" || hj === "linked") { /* patchHooksJson 이 출력함 */ }
|
|
1690
1747
|
else if (hj === "no-backup") ok(`${tag}되돌릴 백업 없음 (이 도구가 지운 적 없음)`);
|
|
1691
|
-
|
|
1748
|
+
// 판 폴더에 hooks.json 이 없으면 고친 것도 확인한 것도 없다. 실패로 센다.
|
|
1749
|
+
else if (hj === "no-file") { bad(`${tag}hooks.json 파일 자체가 없음`); r.fail++; hjFailBy[v] = true; failed.add(fileKey(join(root, "hooks", "hooks.json"))); }
|
|
1692
1750
|
// "failed" 는 고친 hooks.json 이 JSON 으로 읽히지 않아 되돌린 경우다. 고치지 못했으니 실패로 센다.
|
|
1693
1751
|
else if (hj === "unsafe" || hj === "failed") { r.fail++; hjFailBy[v] = true; failed.add(fileKey(join(root, "hooks", "hooks.json"))); }
|
|
1694
1752
|
if (hj === "ambiguous" || hj === "linked") left.add(fileKey(join(root, "hooks", "hooks.json")));
|
package/skills/ralph-qa/SKILL.md
CHANGED
|
@@ -408,6 +408,7 @@ node '<R>/gate-evidence.mjs' cleanup --dir '<g>'
|
|
|
408
408
|
|
|
409
409
|
### 3. 판정 수신
|
|
410
410
|
각 좌석에서 4값 중 하나를 받는다. 외부 좌석은 **단독 줄 `VERDICT: <값>`** 이 필수이며, 부재·중복·절단·파싱실패·거부는 `ERROR` 다.
|
|
411
|
+
코드펜스(```, ~~~) 안의 `VERDICT:` 줄은 인용한 글이라 세지 않는다. 판정 줄을 코드펜스로 감싼 답은 판정 줄이 없는 답이라 `ERROR` 다. send 출력의 `answered`, `failed` 는 답한 좌석과 실패한 좌석의 수다. `ok: true` 는 send 가 돌았다는 뜻일 뿐이고, 실패한 좌석의 `facts` 에는 `verdict` 가 없다.
|
|
411
412
|
|
|
412
413
|
**나눠 보낸 페이로드.** 한 좌석에 나눠 보낸 부분들은 그 좌석의 한 판정이다. 모든 부분이 `APPROVE` 일 때만 `APPROVE`, 하나라도 `ERROR` 면 `ERROR`, 그 밖은 가장 나쁜 값(`REJECT` 가 `ITERATE` 보다 나쁘다)이다. 부분끼리는 서로의 내용을 모르므로 좌석 줄에 `분할 N` 을 적는다.
|
|
413
414
|
|
|
@@ -601,11 +601,24 @@ const readText = (file) => {
|
|
|
601
601
|
}
|
|
602
602
|
};
|
|
603
603
|
|
|
604
|
+
// The standalone verdict lines of an answer. A line inside a code fence (``` or ~~~) is quoted text, not the seat's verdict.
|
|
605
|
+
export function verdictLines(out) {
|
|
606
|
+
let fence = null;
|
|
607
|
+
const found = [];
|
|
608
|
+
for (const line of out.split(/\r?\n/)) {
|
|
609
|
+
const mark = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
|
|
610
|
+
if (mark && (!fence || (mark[1][0] === fence[0] && mark[1].length >= fence.length))) { fence = fence ? null : mark[1]; continue; }
|
|
611
|
+
const m = !fence && VERDICT_LINE.exec(line);
|
|
612
|
+
if (m) found.push(m[1]);
|
|
613
|
+
}
|
|
614
|
+
return found;
|
|
615
|
+
}
|
|
616
|
+
|
|
604
617
|
// What a seat's answer and errors say.
|
|
605
618
|
function factsOf(cli, plan) {
|
|
606
619
|
const out = readText(plan.answer);
|
|
607
620
|
const err = readText(plan.stderr);
|
|
608
|
-
const facts = { verdicts: out
|
|
621
|
+
const facts = { verdicts: verdictLines(out), empty: !out.trim() };
|
|
609
622
|
if (cli === "codex") facts.ranModel = (/^model: (\S+)/m.exec(err.split("\n").slice(0, 40).join("\n")) || [])[1] || null;
|
|
610
623
|
if (cli === "gemini") Object.assign(facts, { policyError: err.includes("Policy file error"), apiError: err.includes("Full report") });
|
|
611
624
|
if (cli === "opencode") facts.fallback = err.includes("Falling back to default agent");
|
|
@@ -665,10 +678,12 @@ async function runParts(ctx, run, seat, g, parts, base) {
|
|
|
665
678
|
}
|
|
666
679
|
const last = done[done.length - 1];
|
|
667
680
|
const secs = Math.round(done.reduce((t, x) => t + (x.r.secs || 0), 0) * 10) / 10;
|
|
681
|
+
// a failed seat has no verdict to count, so its facts carry none (a reader that skips `status` would count it)
|
|
682
|
+
const shownFacts = (x) => { const f = factsShown(x.f); if (x.why) delete f.verdict; return f; };
|
|
668
683
|
const result = { ...base, status: last.why ? "failed" : "answered", why: last.why, exit: last.r.exit, signal: last.r.signal, secs,
|
|
669
|
-
answer: last.plan.answer, errors: last.plan.stderr, facts:
|
|
684
|
+
answer: last.plan.answer, errors: last.plan.stderr, facts: shownFacts(last) };
|
|
670
685
|
if (!last.why && parts.length > 1) result.facts.verdict = gathered(done.map((x) => x.f.verdicts[0]));
|
|
671
|
-
if (parts.length > 1) result.parts = done.map((x) => ({ answer: x.plan.answer, errors: x.plan.stderr, why: x.why, facts:
|
|
686
|
+
if (parts.length > 1) result.parts = done.map((x) => ({ answer: x.plan.answer, errors: x.plan.stderr, why: x.why, facts: shownFacts(x) }));
|
|
672
687
|
return result;
|
|
673
688
|
}
|
|
674
689
|
|
|
@@ -838,7 +853,9 @@ async function send(o, ctx) {
|
|
|
838
853
|
if (thrown) throw thrown.reason;
|
|
839
854
|
const seats = settled.map((x) => x.value);
|
|
840
855
|
if (!a.dryRun) appendFileSync(join(d, "run.md"), seats.filter((x) => x.cliVersion).map((x) => `cli-${x.seat} ${x.cliVersion}\n`).join(""), { mode: 0o600 });
|
|
841
|
-
|
|
856
|
+
// ok says the send ran; whether seats answered is in `answered`, `failed` and each seat's status
|
|
857
|
+
const answered = seats.filter((x) => x.status === "answered").length;
|
|
858
|
+
const result = { ok: true, ...(a.dryRun ? { dryRun: true } : {}), dir: d, out, bytes: payload.length, scan: 0, unsent: unsentCount(d), answered, failed: seats.filter((x) => x.status === "failed").length, seats, declared: declared(run, payload.length, seats, d) };
|
|
842
859
|
if (!a.dryRun) writeResult(d, "send", resultJson("send", result));
|
|
843
860
|
return [result, signals.code(), a.dryRun ? null : d];
|
|
844
861
|
} finally {
|
|
@@ -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
|
@@ -92,7 +92,7 @@ argument-hint: "[기준 커밋] [--no-boxes]"
|
|
|
92
92
|
- `noTests` 는 시험이 하나도 돌지 않았다는 뜻이라 통과가 아니라 미검증이다.
|
|
93
93
|
- 한 OS 에서만 실패하면, 제품의 결함은 버그로, 시험이 실행 환경을 잘못 가정한 것은 하자로 적는다.
|
|
94
94
|
|
|
95
|
-
박스에 둔 것은 `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` 이고, 그 박스를 하자로 적는다.
|
|
96
96
|
알려진 한계: 이중 fork 로 프로세스 트리를 벗어난 프로세스는 끝나지 않는다. `pgrep` 이 없는 박스에서는 기록된 프로세스 하나만 끝난다.
|
|
97
97
|
|
|
98
98
|
### 등록 파일
|
|
@@ -27,7 +27,11 @@ const FIELDS = new Set(["name", "os", "ssh", "port", "identity", "path", "runAs"
|
|
|
27
27
|
const SECRETISH = /pass|secret|token|credential/i;
|
|
28
28
|
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
29
29
|
const TARGET = /^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*$/;
|
|
30
|
-
|
|
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]+$`);
|
|
31
35
|
// What ssh and scp print about the connection itself, as opposed to what the remote command prints.
|
|
32
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;
|
|
33
37
|
const UPLOAD_MS = 10 * 60 * 1000;
|
|
@@ -153,8 +157,8 @@ function winSteps(b, name) {
|
|
|
153
157
|
};
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
// Linux and macOS work in the private folder `mktemp -d` made
|
|
157
|
-
// 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.
|
|
158
162
|
function posixSteps(b, name, dir) {
|
|
159
163
|
const go = b.runAs
|
|
160
164
|
? `mkdir -p "$D/home" && chown -R ${b.runAs} "$D" && runuser -u ${b.runAs} -- env HOME="$D/home" sh "$D/run.sh"`
|
|
@@ -227,10 +231,10 @@ const sshFailed = (r) => (r.status === null && !r.timedOut) || (r.status === 255
|
|
|
227
231
|
|
|
228
232
|
async function makeDir(b, exec) {
|
|
229
233
|
if (b.os === "win32") return { dir: null };
|
|
230
|
-
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 });
|
|
231
235
|
const dir = String(r.stdout || "").trim();
|
|
232
236
|
if (r.status !== 0) return { error: lastLine(r.stderr) || "ssh 실패" };
|
|
233
|
-
return
|
|
237
|
+
return remoteDir(b).test(dir) ? { dir } : { error: `mktemp -d 가 예상과 다른 경로를 냈습니다: ${dir.slice(0, 80)}` };
|
|
234
238
|
}
|
|
235
239
|
|
|
236
240
|
async function attempt(b, plan, exec, files, steps) {
|
|
@@ -265,22 +269,37 @@ export async function runOnBox(b, plan, exec) {
|
|
|
265
269
|
|
|
266
270
|
// Started from the home folder with git -C: Windows looks for a program in the working folder first, so a git.exe
|
|
267
271
|
// inside the folder worked on never runs.
|
|
268
|
-
function git(cwd, args) {
|
|
269
|
-
const r = spawnSync("git", ["-C", resolve(cwd), ...args], { cwd: runDir(), encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
270
|
-
if (r.status !== 0) throw new Error(`git ${args
|
|
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 || ""}`);
|
|
271
275
|
return r.stdout;
|
|
272
276
|
}
|
|
273
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
|
+
|
|
274
281
|
// The commit to send. `worktree` is a commit object made from the tracked files as they are now
|
|
275
282
|
// (git stash create: no stash entry, no change to the work tree). Untracked files are never sent.
|
|
276
|
-
|
|
277
|
-
|
|
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);
|
|
278
287
|
const untracked = status.filter((l) => l.startsWith("??")).length;
|
|
279
288
|
if (ref !== "worktree") {
|
|
280
289
|
return { sha: git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]).trim(), untracked, modified: status.length - untracked };
|
|
281
290
|
}
|
|
282
|
-
const
|
|
283
|
-
|
|
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
|
+
}
|
|
284
303
|
}
|
|
285
304
|
|
|
286
305
|
function testFiles(repo, sha) {
|