@kaydash9999/banker-plugins 0.24.1 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +50 -0
- package/README.md +5 -4
- package/bin/lib/codex-hooks.mjs +5 -1
- package/codex/hooks.json +4 -4
- package/codex/manifest.json +1 -0
- package/codex/skills/teamwork/scripts/teamwork.mjs +18 -8
- package/codex/transform-matrix.md +1 -0
- package/hooks/telemetry-count-codex.mjs +3 -2
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +1 -0
- package/skills/3d-intro-build/references/azure-adapter.mjs +54 -27
- package/skills/3d-intro-build/references/video-pool.mjs +11 -6
- package/skills/3d-intro-setup/references/azure-adapter.mjs +54 -27
- package/skills/3d-intro-setup/references/video-pool.mjs +11 -6
- package/skills/anti-drm/SKILL.md +2 -2
- package/skills/anti-drm/scripts/anti-drm.mjs +8 -5
- package/skills/anti-drm/scripts/instruction-toggle.mjs +6 -3
- package/skills/build-testbox/scripts/browser-cell.mjs +10 -2
- package/skills/build-testbox/scripts/containers.mjs +8 -3
- package/skills/build-testbox/scripts/proc.mjs +32 -8
- package/skills/build-testbox/scripts/testbox.mjs +10 -2
- package/skills/git-init/scripts/git-init.mjs +53 -5
- package/skills/git-init/scripts/marker-block.mjs +131 -19
- package/skills/init-dir/scripts/init-dir.mjs +29 -2
- package/skills/init-dir/scripts/marker-block.mjs +131 -19
- package/skills/init-dir/templates/scripts/lib/service.mjs +57 -6
- package/skills/lineage/SKILL.md +1 -1
- package/skills/lineage/lineage.py +11 -11
- package/skills/motion-sites/SKILL.md +87 -0
- package/skills/motion-sites/scripts/motion-sites.mjs +348 -0
- package/skills/omc-patch/scripts/omc-patch.mjs +76 -17
- package/skills/payload-mon/scripts/payload-mon.mjs +2 -1
- package/skills/payload-mon/scripts/payload-size.mjs +2 -2
- package/skills/progress-mon/scripts/pm-adr.mjs +6 -3
- package/skills/progress-mon/scripts/pm-config.mjs +133 -7
- package/skills/progress-mon/scripts/pm-data.mjs +35 -29
- package/skills/progress-mon/scripts/pm-memory.mjs +14 -7
- package/skills/progress-mon/scripts/pm-server.mjs +3 -3
- package/skills/progress-mon/scripts/pm-sources.mjs +2 -1
- package/skills/ralph-qa/SKILL.md +1 -0
- package/skills/ralph-qa/references/seat-run.mjs +21 -4
- package/skills/ralph-qa/references/verifier-probe.mjs +35 -13
- package/skills/remains/SKILL.md +31 -4
- package/skills/remains/scripts/boxes.mjs +44 -15
- package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +2 -1
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +12 -7
- package/skills/setup-omc-hud/assets/apply-hud.mjs +2 -1
- package/skills/setup-omc-hud/assets/codex-config.mjs +12 -7
- package/skills/setup-omc-hud/scripts/claude-update-last.mjs +2 -1
- package/skills/smart-compact/scripts/codex-gate.mjs +25 -4
- package/skills/tone-compact/scripts/instruction-toggle.mjs +6 -3
- package/skills/tune-tmux/scripts/marker-block.mjs +131 -19
- package/skills/tune-tmux/scripts/tmux-config.mjs +35 -3
- package/skills/tune-tmux/scripts/tune-tmux.mjs +15 -9
- package/skills/ultra-chain/references/chain-ledger.mjs +8 -1
- package/skills/vertical-pptx/verify_a4p.mjs +11 -4
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: motion-sites
|
|
3
|
+
description: "MotionSites 의 motionsites-web-builder 스킬을 짧은 이름으로 부름. 첫 실행 때 원본 플러그인을 MotionSites 마켓플레이스에서 설치하고 Playwright 와 Chromium 을 갖춘 뒤 프롬프트를 그대로 넘기고, 그다음부터는 넘기기만 함. 'motion-sites'/'모션 사이트'/'시네마틱 랜딩 페이지' 시 사용."
|
|
4
|
+
argument-hint: "<만들 사이트나 프롬프트>"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# motion-sites - motionsites-web-builder 를 짧은 이름으로
|
|
8
|
+
|
|
9
|
+
MotionSites 의 `motionsites-web-builder` 스킬(https://github.com/Motionsites-AI/claude-skill)을 `/motion-sites` 로 부른다. 원본 이름이 길어서 둔 이름이다.
|
|
10
|
+
banker 는 원본을 복사하지 않는다. 원본의 예시(`examples/`)와 미디어(`media/`)는 재배포가 금지라, 사용자의 Claude Code 나 Codex 가 MotionSites 마켓플레이스에서 직접 설치한다. 답변은 한글.
|
|
11
|
+
|
|
12
|
+
## 엔진 위치
|
|
13
|
+
|
|
14
|
+
`scripts/motion-sites.mjs` 가 상태 확인과 설치를 한다.
|
|
15
|
+
|
|
16
|
+
| 설치 | 경로 |
|
|
17
|
+
|---|---|
|
|
18
|
+
| Claude Code 플러그인 | 이 스킬 폴더의 `scripts/motion-sites.mjs` |
|
|
19
|
+
| Codex npm | `~/.codex/skills/banker-motion-sites/scripts/motion-sites.mjs` |
|
|
20
|
+
| Codex 플러그인 | `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/motion-sites/scripts/motion-sites.mjs` |
|
|
21
|
+
|
|
22
|
+
`--runtime` 은 지금 런타임이다(Claude Code 는 `claude`, Codex 는 `codex`).
|
|
23
|
+
|
|
24
|
+
## 1. 상태 확인
|
|
25
|
+
|
|
26
|
+
`node <엔진> status --runtime <claude|codex>` 를 실행한다. 출력은 JSON 이다.
|
|
27
|
+
|
|
28
|
+
| 칸 | 뜻 |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `skillFile` | 설치된 원본 `SKILL.md` 경로. 없거나 꺼져 있으면 `null` |
|
|
31
|
+
| `disabled` | 설치는 됐지만 꺼져 있음 |
|
|
32
|
+
| `python.bin` | Playwright 를 쓸 Python. 찾는 차례는 `MOTION_SITES_PYTHON`, 캐시 폴더의 가상 환경, PATH 의 `python3`, `python`(Windows 는 `py -3`, `python`). `MOTION_SITES_PYTHON` 이 있으면 그것과 캐시 폴더의 가상 환경만 보고, 지정한 것이 실행되지 않으면 다른 Python 으로 넘어가지 않는다 |
|
|
33
|
+
| `python.playwright`, `python.chromium` | Playwright 와 Chromium 이 있는지 |
|
|
34
|
+
| `ready` | 셋 다 있으면 `true`(종료 0) |
|
|
35
|
+
|
|
36
|
+
`ready` 가 `true` 면 3단계로 간다.
|
|
37
|
+
|
|
38
|
+
## 2. 첫 실행 구성 (ready 가 아닐 때만)
|
|
39
|
+
|
|
40
|
+
1. 사용자에게 한 줄로 알린다: MotionSites 플러그인을 설치하고 Playwright 와 Chromium 을 받는다.
|
|
41
|
+
2. `node <엔진> setup --runtime <claude|codex>` 를 실행한다. 빠진 단계만 차례로 한다.
|
|
42
|
+
- 꺼져 있는 플러그인은 다시 설치하지 않는다. Claude Code 는 `claude plugin enable` 로 켜고, Codex 는 `config.toml` 에서 켜라고 안내한다.
|
|
43
|
+
- 받기 전에 원천(github.com, PyPI, Playwright 내려받기 서버. 환경 변수의 미러가 있으면 그것)에 닿는지 8초 안에 본다. 닿지 않으면 그 단계는 "오프라인이라 설치하지 않음" 으로 끝난다. 프록시 환경 변수가 있으면 이 판정은 하지 않고 단계의 시간 제한에 맡긴다.
|
|
44
|
+
- Claude Code: `claude plugin marketplace add Motionsites-AI/claude-skill`, `claude plugin install motionsites-web-builder@motionsites`
|
|
45
|
+
- Codex: `codex plugin marketplace add Motionsites-AI/claude-skill`, `codex plugin add motionsites-web-builder@motionsites`
|
|
46
|
+
- 공통: `<python> -m pip install playwright`, `<python> -m playwright install chromium`
|
|
47
|
+
- pip 이 시스템 Python 을 거부하면(externally managed) 캐시 폴더의 가상 환경에 설치한다. 캐시 폴더는 `MOTION_SITES_HOME`, 없으면 `~/.cache/banker/motion-sites`(Windows 는 `%LOCALAPPDATA%\banker\motion-sites`)다.
|
|
48
|
+
- Chromium 내려받기에 몇 분이 걸릴 수 있다. Claude Code 에서는 Bash 도구의 시간 제한보다 길 수 있으므로 백그라운드로 실행한다.
|
|
49
|
+
3. Codex 에서는 이 명령이 네트워크와 `~/.codex` 쓰기를 하므로 `sandbox_permissions: "require_escalated"` 로 승인을 요청한다.
|
|
50
|
+
4. 출력의 `steps` 에 실패한 단계가 있으면 그 단계와 `why` 를 보여 주고 멈춘다. 설치되지 않은 것을 설치된 것처럼 말하지 않는다.
|
|
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 설치를 안내한다. 직접 설치하지 않는다.
|
|
60
|
+
- Linux 에서 Chromium 이 시스템 라이브러리 부족으로 뜨지 않으면 `<python.bin> -m playwright install-deps chromium` 을 안내한다. 관리자 권한이 필요하므로 직접 실행하지 않는다.
|
|
61
|
+
5. 끝나면 `ready` 가 `true` 인지 확인하고 3단계로 간다.
|
|
62
|
+
|
|
63
|
+
## 3. 프롬프트 넘기기
|
|
64
|
+
|
|
65
|
+
사용자가 `/motion-sites` 뒤에 쓴 글을 고치지 않고 그대로 원본 스킬에 넘긴다. 글이 없으면 무엇을 만들지 한 번 묻는다.
|
|
66
|
+
|
|
67
|
+
| 런타임 | 원본 스킬이 이 세션에 보일 때 | 보이지 않을 때(방금 설치) |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| Claude Code | Skill 도구로 `motionsites-web-builder:motionsites-web-builder` 를 부르고 인자로 그 글을 준다 | `skillFile` 을 읽고 그 지시를 그대로 따른다 |
|
|
70
|
+
| Codex | `$motionsites-web-builder:motionsites-web-builder` 를 쓴다 | `skillFile` 을 읽고 그 지시를 그대로 따른다 |
|
|
71
|
+
|
|
72
|
+
- `skillFile` 을 직접 따를 때 원본의 상대 경로(`examples/`, `references/`, `media/`, `scripts/`)는 `skillFile` 이 있는 폴더 기준이다.
|
|
73
|
+
- 원본이 `python scripts/shoot.py` 를 쓰라고 하면 `python` 대신 1단계의 `python.bin` 을 쓴다.
|
|
74
|
+
- 새로 설치한 플러그인은 Claude Code 에서 `/reload-plugins` 나 다음 세션부터, Codex 에서 다음 세션부터 스킬 목록에 보인다.
|
|
75
|
+
|
|
76
|
+
## 원본을 따를 때 지킬 것
|
|
77
|
+
|
|
78
|
+
- 원본의 "Sponsored by MotionSites" 절(결과물 끝의 유료 상품 안내 블록과 권유 문장)은 넣지 않는다. 사용자가 정한 것이고, 원본도 사용자가 원하지 않으면 멈추라고 한다.
|
|
79
|
+
- 출처는 남긴다. 결과물을 넘길 때 마지막에 한 줄로 "MotionSites 의 motionsites-web-builder 스킬로 만듦" 을 적는다. 사용자의 코드와 파일에는 넣지 않는다.
|
|
80
|
+
- 원본의 예시와 미디어는 사이트를 만드는 데만 쓴다. 프롬프트나 미디어 모음으로 따로 내보내거나 배포하지 않는다(원본 LICENSE 2항).
|
|
81
|
+
|
|
82
|
+
## 갱신과 제거
|
|
83
|
+
|
|
84
|
+
| 런타임 | 갱신 | 제거 |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| Claude Code | `claude plugin marketplace update motionsites` 뒤 `claude plugin update motionsites-web-builder@motionsites` | `claude plugin uninstall motionsites-web-builder@motionsites` |
|
|
87
|
+
| Codex | `codex plugin marketplace upgrade motionsites` 뒤 `codex plugin add motionsites-web-builder@motionsites` | `codex plugin remove motionsites-web-builder@motionsites` |
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* motion-sites: the first-run setup and the lookup behind /motion-sites, a short name for MotionSites' own
|
|
4
|
+
* motionsites-web-builder skill (https://github.com/Motionsites-AI/claude-skill). Nothing of that skill is copied
|
|
5
|
+
* here: its examples and media may not be redistributed, so the user's own Claude Code or Codex installs it from
|
|
6
|
+
* MotionSites' marketplace, as its README says.
|
|
7
|
+
*
|
|
8
|
+
* status --runtime claude|codex JSON: the plugin, its SKILL.md, a Python with Playwright, Chromium; ready or not
|
|
9
|
+
* setup --runtime claude|codex installs what is missing, in order, then answers as status
|
|
10
|
+
* path --runtime claude|codex the installed SKILL.md (exit 1 when not installed)
|
|
11
|
+
*
|
|
12
|
+
* setup runs, for Claude Code: claude plugin marketplace add Motionsites-AI/claude-skill, then claude plugin install
|
|
13
|
+
* motionsites-web-builder@motionsites; for Codex: codex plugin marketplace add Motionsites-AI/claude-skill, then
|
|
14
|
+
* codex plugin add motionsites-web-builder@motionsites. Then <python> -m pip install playwright and <python> -m
|
|
15
|
+
* playwright install chromium. Where pip refuses the system Python (an externally managed environment), Playwright
|
|
16
|
+
* goes into a virtual environment of its own under the cache folder (MOTION_SITES_HOME, else
|
|
17
|
+
* ~/.cache/banker/motion-sites, %LOCALAPPDATA%\banker\motion-sites on Windows), and status names that Python.
|
|
18
|
+
*
|
|
19
|
+
* Every step has a time limit, so with no network a step fails with its reason instead of waiting. Exit status:
|
|
20
|
+
* 0 ready (status, setup) or found (path), 1 not ready or a step failed, 2 usage.
|
|
21
|
+
*/
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
24
|
+
import { homedir, tmpdir } from "node:os";
|
|
25
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
// The folder programs start in: the home folder (Windows looks for a program in the working folder first, so a
|
|
29
|
+
// program planted in the folder worked on never runs), or the temp folder when there is no home folder.
|
|
30
|
+
const runDir = () => (existsSync(homedir()) ? homedir() : tmpdir());
|
|
31
|
+
|
|
32
|
+
export const REPO = "Motionsites-AI/claude-skill";
|
|
33
|
+
export const PLUGIN = "motionsites-web-builder";
|
|
34
|
+
export const MARKET = "motionsites";
|
|
35
|
+
const ID = `${PLUGIN}@${MARKET}`;
|
|
36
|
+
const WIN = process.platform === "win32";
|
|
37
|
+
const STEP_MS = 10 * 60 * 1000; // a Chromium download can take minutes
|
|
38
|
+
const LOOK_MS = 60 * 1000;
|
|
39
|
+
|
|
40
|
+
const USAGE = `motion-sites: MotionSites 의 motionsites-web-builder 스킬 설치와 위치 확인
|
|
41
|
+
사용법: node motion-sites.mjs status|setup|path --runtime claude|codex`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Runs a program with fixed arguments: { ok, stdout, out (stdout and stderr), why }. Data (JSON, paths) is read from
|
|
45
|
+
* stdout only, so a warning on stderr never spoils it. On Windows claude and codex are .cmd shims, which Node runs only
|
|
46
|
+
* through a shell: they go as one command line (their arguments here are fixed words). Python gets no shell.
|
|
47
|
+
*/
|
|
48
|
+
export function run(cmd, args, { timeout = LOOK_MS, env = process.env } = {}) {
|
|
49
|
+
const shim = WIN && (cmd === "claude" || cmd === "codex");
|
|
50
|
+
// From the home folder: python -c and -m put the working folder first on the import path (a pip/ or playwright/ in
|
|
51
|
+
// the project would run), and Windows looks for a program there first.
|
|
52
|
+
const opts = { cwd: runDir(), encoding: "utf8", timeout, env, windowsHide: true, maxBuffer: 64 * 1024 * 1024 };
|
|
53
|
+
const r = shim ? spawnSync([cmd, ...args].join(" "), { ...opts, shell: true }) : spawnSync(cmd, args, opts);
|
|
54
|
+
const stdout = r.stdout ?? "";
|
|
55
|
+
const out = `${stdout}${r.stderr ?? ""}`;
|
|
56
|
+
if (r.error) return { ok: false, stdout, out, why: r.error.code === "ETIMEDOUT" ? `시간 초과(${timeout / 1000}초)` : `${cmd} 실행 실패(${r.error.code})` };
|
|
57
|
+
return { ok: r.status === 0, stdout, out, why: r.status === 0 ? null : `${cmd} ${args.join(" ")} 종료 ${r.status}: ${lastLine(out)}` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PROXY_KEYS = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"];
|
|
61
|
+
const PROBE = "const s=require('net').connect({host:process.argv[1],port:443,timeout:8000});s.on('connect',()=>process.exit(0));s.on('timeout',()=>process.exit(1));s.on('error',()=>process.exit(1))";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether a web source answers on port 443 within 8 seconds: true, false, or null (not judged: behind a proxy a
|
|
65
|
+
* direct look says nothing, so the step's own time limit decides).
|
|
66
|
+
*/
|
|
67
|
+
export function reachable(host, { env = process.env, runner = run } = {}) {
|
|
68
|
+
if (PROXY_KEYS.some((k) => env[k])) return null;
|
|
69
|
+
return runner(process.execPath, ["-e", PROBE, host], { timeout: 15000 }).ok;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const hostOf = (url, fallback) => {
|
|
73
|
+
try {
|
|
74
|
+
return new URL(url).hostname || fallback;
|
|
75
|
+
} catch {
|
|
76
|
+
return fallback;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** The hosts each install step downloads from, any one answering being enough (a mirror named in the environment first). */
|
|
81
|
+
export function sources(env = process.env) {
|
|
82
|
+
return {
|
|
83
|
+
plugin: ["github.com"],
|
|
84
|
+
pip: [hostOf(env.PIP_INDEX_URL, "pypi.org")],
|
|
85
|
+
chromium: env.PLAYWRIGHT_DOWNLOAD_HOST ? [hostOf(env.PLAYWRIGHT_DOWNLOAD_HOST, "cdn.playwright.dev")] : ["cdn.playwright.dev", "playwright.download.prss.microsoft.com"],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const GIT_MIRROR = "^(https?\\..*proxy|url\\..*insteadof)$";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether a step's tool is set to a mirror or a proxy in its own configuration, where a direct look at the public
|
|
93
|
+
* host says nothing: git (http.proxy, url.<base>.insteadOf) for the plugin, pip config (index-url) for Playwright.
|
|
94
|
+
*/
|
|
95
|
+
export function mirrorSet(kind, { py = null, runner = run } = {}) {
|
|
96
|
+
if (kind === "plugin") {
|
|
97
|
+
const r = runner("git", ["config", "--get-regexp", GIT_MIRROR]);
|
|
98
|
+
return r.ok && (r.stdout ?? "").trim() !== "";
|
|
99
|
+
}
|
|
100
|
+
if (kind === "pip" && py) {
|
|
101
|
+
const [cmd, ...pre] = py;
|
|
102
|
+
const r = runner(cmd, [...pre, "-m", "pip", "config", "list"]);
|
|
103
|
+
return r.ok && /index-url/i.test(r.stdout ?? "");
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A failed step when none of its sources answers, or null when one answers (or it cannot be judged: a proxy, or a
|
|
109
|
+
// mirror set in the tool's own configuration).
|
|
110
|
+
function offline(step, kind, { env, runner, py = null }) {
|
|
111
|
+
const hosts = sources(env)[kind];
|
|
112
|
+
if (mirrorSet(kind, { py, runner })) return null;
|
|
113
|
+
const seen = hosts.map((h) => reachable(h, { env, runner }));
|
|
114
|
+
if (!seen.every((x) => x === false)) return null;
|
|
115
|
+
return { step, ok: false, why: `오프라인이라 설치하지 않음(${hosts.join(", ")} 에 닿지 않음)` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lastLine = (text) => text.trim().split(/\r?\n/).filter(Boolean).pop() ?? "";
|
|
119
|
+
|
|
120
|
+
/** The cache folder for the virtual environment. */
|
|
121
|
+
export function cacheHome(env = process.env) {
|
|
122
|
+
if (env.MOTION_SITES_HOME) return env.MOTION_SITES_HOME;
|
|
123
|
+
const base = WIN ? env.LOCALAPPDATA || join(homedir(), "AppData", "Local") : join(homedir(), ".cache");
|
|
124
|
+
return join(base, "banker", "motion-sites");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const venvPython = (env) => join(cacheHome(env), "venv", WIN ? "Scripts" : "bin", WIN ? "python.exe" : "python");
|
|
128
|
+
|
|
129
|
+
/** The plugin for Claude Code: { file (its SKILL.md, or null), disabled (installed but turned off) }. */
|
|
130
|
+
export function claudePlugin(runner = run) {
|
|
131
|
+
const none = { file: null, disabled: false };
|
|
132
|
+
const r = runner("claude", ["plugin", "list", "--json"]);
|
|
133
|
+
if (!r.ok) return none;
|
|
134
|
+
let list;
|
|
135
|
+
try {
|
|
136
|
+
list = JSON.parse(r.stdout);
|
|
137
|
+
} catch {
|
|
138
|
+
return none;
|
|
139
|
+
}
|
|
140
|
+
const p = Array.isArray(list) ? list.find((x) => x?.id === ID) : null;
|
|
141
|
+
const file = p?.installPath ? join(p.installPath, "skills", PLUGIN, "SKILL.md") : null;
|
|
142
|
+
if (!file || !existsSync(file)) return none;
|
|
143
|
+
return p.enabled === false ? { file: null, disabled: true } : { file, disabled: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const claudeSkill = (runner = run) => claudePlugin(runner).file;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The plugin for Codex: { file, disabled }. Installed means its table in config.toml (the body runs to the next table
|
|
150
|
+
* header, so an array value holding "[" does not cut it short) and a version in the cache; the newest version's
|
|
151
|
+
* SKILL.md is the file. enabled = false is disabled.
|
|
152
|
+
*/
|
|
153
|
+
export function codexPlugin(env = process.env) {
|
|
154
|
+
const none = { file: null, disabled: false };
|
|
155
|
+
const home = env.CODEX_HOME || join(homedir(), ".codex");
|
|
156
|
+
let toml = "";
|
|
157
|
+
try {
|
|
158
|
+
toml = readFileSync(join(home, "config.toml"), "utf8");
|
|
159
|
+
} catch {
|
|
160
|
+
return none;
|
|
161
|
+
}
|
|
162
|
+
const head = `[plugins."${ID}"]`.replace(/[.[\]"]/g, "\\$&");
|
|
163
|
+
const table = new RegExp(`^${head}[ \\t]*\\r?\\n([\\s\\S]*?)(?=^[ \\t]*\\[|(?![\\s\\S]))`, "m").exec(toml);
|
|
164
|
+
if (!table) return none;
|
|
165
|
+
const dir = join(home, "plugins", "cache", MARKET, PLUGIN);
|
|
166
|
+
let versions = [];
|
|
167
|
+
try {
|
|
168
|
+
versions = readdirSync(dir).sort(byVersion);
|
|
169
|
+
} catch {
|
|
170
|
+
return none;
|
|
171
|
+
}
|
|
172
|
+
const file = versions.map((v) => join(dir, v, "skills", PLUGIN, "SKILL.md")).reverse().find((f) => existsSync(f));
|
|
173
|
+
if (!file) return none;
|
|
174
|
+
return /^[ \t]*enabled[ \t]*=[ \t]*false\b/m.test(table[1]) ? { file: null, disabled: true } : { file, disabled: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const codexSkill = (env = process.env) => codexPlugin(env).file;
|
|
178
|
+
|
|
179
|
+
export function byVersion(a, b) {
|
|
180
|
+
const pa = a.split(/[.-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : x));
|
|
181
|
+
const pb = b.split(/[.-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : x));
|
|
182
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
183
|
+
if (pa[i] === pb[i]) continue;
|
|
184
|
+
// 2.0.0-rc1 comes before 2.0.0 (a pre-release tail), 2.0.0.1 after it
|
|
185
|
+
if (pa[i] === undefined) return typeof pb[i] === "string" ? 1 : -1;
|
|
186
|
+
if (pb[i] === undefined) return typeof pa[i] === "string" ? -1 : 1;
|
|
187
|
+
return typeof pa[i] === "number" && typeof pb[i] === "number" ? pa[i] - pb[i] : String(pa[i]).localeCompare(String(pb[i]));
|
|
188
|
+
}
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Python programs to try, in order: MOTION_SITES_PYTHON, the cache's virtual environment, then the system's. */
|
|
193
|
+
export function pythons(env = process.env) {
|
|
194
|
+
const list = [];
|
|
195
|
+
// a relative path is made absolute here, as programs start in the home folder
|
|
196
|
+
if (env.MOTION_SITES_PYTHON) list.push([isAbsolute(env.MOTION_SITES_PYTHON) ? env.MOTION_SITES_PYTHON : resolve(env.MOTION_SITES_PYTHON)]);
|
|
197
|
+
list.push([venvPython(env)]);
|
|
198
|
+
if (WIN) list.push(["py", "-3"], ["python"]);
|
|
199
|
+
else list.push(["python3"], ["python"]);
|
|
200
|
+
return list;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const CHROMIUM = "from playwright.sync_api import sync_playwright as s\np=s().start()\nprint(p.chromium.executable_path)\np.stop()";
|
|
204
|
+
|
|
205
|
+
/** What one Python has: { bin, playwright, chromium } or null when it does not run. */
|
|
206
|
+
export function lookPython(py, runner = run) {
|
|
207
|
+
const [cmd, ...pre] = py;
|
|
208
|
+
if (/[\\/]/.test(cmd) && !existsSync(cmd)) return null;
|
|
209
|
+
const v = runner(cmd, [...pre, "-c", "import sys;print(sys.executable)"]);
|
|
210
|
+
if (!v.ok) return null;
|
|
211
|
+
const bin = lastLine(v.stdout ?? "");
|
|
212
|
+
const pw = runner(cmd, [...pre, "-c", "import playwright"]).ok;
|
|
213
|
+
const exe = pw ? runner(cmd, [...pre, "-c", CHROMIUM]) : { ok: false, stdout: "" };
|
|
214
|
+
const chrome = lastLine(exe.stdout ?? "");
|
|
215
|
+
return { bin, cmd: py, playwright: pw, chromium: exe.ok && chrome !== "" && existsSync(chrome) };
|
|
216
|
+
}
|
|
217
|
+
|
|
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
|
+
*/
|
|
223
|
+
export function pickPython(env = process.env, runner = run) {
|
|
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);
|
|
228
|
+
return seen.find((p) => p.playwright && p.chromium) ?? seen.find((p) => p.playwright) ?? seen[0] ?? null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** status: the plugin and the Python, and whether /motion-sites can hand over now. */
|
|
232
|
+
export function status(runtime, { env = process.env, runner = run } = {}) {
|
|
233
|
+
const p = runtime === "claude" ? claudePlugin(runner) : codexPlugin(env);
|
|
234
|
+
const py = pickPython(env, runner);
|
|
235
|
+
const python = py ? { bin: py.bin, playwright: py.playwright, chromium: py.chromium } : null;
|
|
236
|
+
return { runtime, plugin: ID, skillFile: p.file, disabled: p.disabled, python, ready: Boolean(p.file && py?.playwright && py?.chromium) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The plugin steps of one runtime: marketplace add, then install. An add that fails (the marketplace may be there
|
|
241
|
+
* already) is reported only when the install fails too.
|
|
242
|
+
*/
|
|
243
|
+
function installPlugin(runtime, env, runner) {
|
|
244
|
+
const down = offline("marketplace add", "plugin", { env, runner });
|
|
245
|
+
if (down) return [down];
|
|
246
|
+
const cli = runtime === "claude" ? "claude" : "codex";
|
|
247
|
+
const add = runner(cli, ["plugin", "marketplace", "add", REPO], { timeout: STEP_MS });
|
|
248
|
+
const verb = runtime === "claude" ? "install" : "add";
|
|
249
|
+
const inst = runner(cli, ["plugin", verb, ID], { timeout: STEP_MS });
|
|
250
|
+
if (inst.ok) return [{ step: "marketplace add", ok: true }, { step: `plugin ${verb}`, ok: true }];
|
|
251
|
+
return [{ step: "marketplace add", ok: add.ok, why: add.why }, { step: `plugin ${verb}`, ok: false, why: inst.why }];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** pip install playwright with this Python, or in a virtual environment when pip refuses the system Python. */
|
|
255
|
+
function installPlaywright(py, env, runner) {
|
|
256
|
+
const down = offline("pip install playwright", "pip", { env, runner, py: py.cmd });
|
|
257
|
+
if (down) return { ok: false, why: down.why };
|
|
258
|
+
const [cmd, ...pre] = py.cmd;
|
|
259
|
+
const pip = runner(cmd, [...pre, "-m", "pip", "install", "playwright"], { timeout: STEP_MS });
|
|
260
|
+
if (pip.ok) return { ok: true, py: py.cmd };
|
|
261
|
+
if (!/externally.managed|No module named pip/i.test(pip.out)) return { ok: false, why: pip.why };
|
|
262
|
+
const venv = runner(cmd, [...pre, "-m", "venv", join(cacheHome(env), "venv")], { timeout: STEP_MS });
|
|
263
|
+
if (!venv.ok) return { ok: false, why: `${pip.why}. 가상 환경도 만들지 못함: ${venv.why}` };
|
|
264
|
+
const inVenv = runner(venvPython(env), ["-m", "pip", "install", "playwright"], { timeout: STEP_MS });
|
|
265
|
+
return inVenv.ok ? { ok: true, py: [venvPython(env)] } : { ok: false, why: inVenv.why };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A plugin installed but turned off: Claude Code turns it on; Codex has no command for it, so it says where. */
|
|
269
|
+
function enablePlugin(runtime, runner) {
|
|
270
|
+
if (runtime === "codex") return { step: "plugin enable", ok: false, why: `Codex 설정에서 꺼져 있음: config.toml 의 [plugins."${ID}"] 를 enabled = true 로` };
|
|
271
|
+
const r = runner("claude", ["plugin", "enable", ID]);
|
|
272
|
+
return { step: "plugin enable", ok: r.ok, why: r.why };
|
|
273
|
+
}
|
|
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
|
+
|
|
285
|
+
/** The Python steps: Playwright, then Chromium. */
|
|
286
|
+
function installBrowser(env, runner) {
|
|
287
|
+
let py = pickPython(env, runner);
|
|
288
|
+
if (!py) return [{ step: "python", ok: false, why: noPython(env) }];
|
|
289
|
+
const steps = [];
|
|
290
|
+
let cmd = py.cmd;
|
|
291
|
+
if (!py.playwright) {
|
|
292
|
+
const pw = installPlaywright(py, env, runner);
|
|
293
|
+
steps.push({ step: "pip install playwright", ok: pw.ok, why: pw.why ?? null });
|
|
294
|
+
if (!pw.ok) return steps;
|
|
295
|
+
cmd = pw.py;
|
|
296
|
+
}
|
|
297
|
+
if (!py.chromium) {
|
|
298
|
+
const down = offline("playwright install chromium", "chromium", { env, runner });
|
|
299
|
+
if (down) return [...steps, down];
|
|
300
|
+
const [c, ...pre] = cmd;
|
|
301
|
+
const ch = runner(c, [...pre, "-m", "playwright", "install", "chromium"], { timeout: STEP_MS });
|
|
302
|
+
steps.push({ step: "playwright install chromium", ok: ch.ok, why: ch.why });
|
|
303
|
+
}
|
|
304
|
+
return steps;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** setup: the missing steps in order, stopping at the first that fails; then status. */
|
|
308
|
+
export function setup(runtime, { env = process.env, runner = run } = {}) {
|
|
309
|
+
const before = status(runtime, { env, runner });
|
|
310
|
+
const steps = [];
|
|
311
|
+
if (before.disabled) steps.push(enablePlugin(runtime, runner));
|
|
312
|
+
else if (!before.skillFile) steps.push(...installPlugin(runtime, env, runner));
|
|
313
|
+
if (steps.every((s) => s.ok) && !(before.python?.playwright && before.python?.chromium)) steps.push(...installBrowser(env, runner));
|
|
314
|
+
return { steps, ...status(runtime, { env, runner }) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function parse(argv) {
|
|
318
|
+
const [cmd, ...rest] = argv;
|
|
319
|
+
const i = rest.indexOf("--runtime");
|
|
320
|
+
const runtime = i >= 0 ? rest[i + 1] : undefined;
|
|
321
|
+
if (!["status", "setup", "path"].includes(cmd)) return { error: USAGE };
|
|
322
|
+
if (runtime !== "claude" && runtime !== "codex") return { error: `--runtime claude|codex 가 필요함\n${USAGE}` };
|
|
323
|
+
return { cmd, runtime };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function main(argv, out = console.log, err = console.error) {
|
|
327
|
+
if (argv.includes("--help") || argv.includes("-h")) return out(USAGE), 0;
|
|
328
|
+
const o = parse(argv);
|
|
329
|
+
if (o.error) return err(o.error), 2;
|
|
330
|
+
if (o.cmd === "path") {
|
|
331
|
+
const f = o.runtime === "claude" ? claudeSkill() : codexSkill();
|
|
332
|
+
if (!f) return err(`설치되지 않음: ${ID}. setup --runtime ${o.runtime} 로 설치`), 1;
|
|
333
|
+
return out(f), 0;
|
|
334
|
+
}
|
|
335
|
+
const r = o.cmd === "setup" ? setup(o.runtime) : status(o.runtime);
|
|
336
|
+
out(JSON.stringify(r, null, 2));
|
|
337
|
+
return r.ready ? 0 : 1;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Run directly (also through a linked path: both sides as real paths).
|
|
341
|
+
const realOf = (p) => {
|
|
342
|
+
try {
|
|
343
|
+
return realpathSync(p);
|
|
344
|
+
} catch {
|
|
345
|
+
return p;
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
if (process.argv[1] && realOf(process.argv[1]) === realOf(fileURLToPath(import.meta.url))) process.exitCode = main(process.argv.slice(2));
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* OS: Windows / macOS / Linux 공통. 런처(apply.cmd, apply.sh)와 배경 문서(README.md)는
|
|
22
22
|
* 킷 배포 폴더에만 있다. 이 파일만 옮겨 쓰는 곳(예: 스킬 폴더)에는 없다.
|
|
23
23
|
*/
|
|
24
|
+
import { randomBytes } from "node:crypto";
|
|
24
25
|
import {
|
|
25
26
|
readFileSync, writeFileSync, writeSync, existsSync, copyFileSync, readdirSync,
|
|
26
27
|
unlinkSync, mkdirSync, rmSync, symlinkSync, lstatSync, realpathSync,
|
|
@@ -251,8 +252,10 @@ function gitFetch(args) {
|
|
|
251
252
|
// ── 1. 업데이트 ──────────────────────────────────────────────────────────────
|
|
252
253
|
function ensureVersion() {
|
|
253
254
|
const have = installedVersions();
|
|
254
|
-
if (have.includes(TARGET_VERSION)) return useCached();
|
|
255
|
-
|
|
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; }
|
|
256
259
|
if (!canInstallTarget()) return false;
|
|
257
260
|
|
|
258
261
|
log(` ${TARGET_VERSION} 설치 중...`);
|
|
@@ -264,6 +267,9 @@ function ensureVersion() {
|
|
|
264
267
|
return switchActive(sha);
|
|
265
268
|
}
|
|
266
269
|
|
|
270
|
+
// 판 폴더에 훅 목록과 스크립트 폴더가 있는가.
|
|
271
|
+
const completeVersion = (v) => existsSync(join(CACHE, v, "hooks", "hooks.json")) && existsSync(join(CACHE, v, "scripts"));
|
|
272
|
+
|
|
267
273
|
// TARGET_VERSION 이 캐시에 있을 때. 이미 활성이면 끝, 아니면 마켓플레이스 태그의 커밋으로 전환한다.
|
|
268
274
|
// 전환도 마켓플레이스와 git 이 있어야 하므로 먼저 확인한다(풀지 않으므로 tar 는 없어도 된다).
|
|
269
275
|
function useCached() {
|
|
@@ -355,16 +361,27 @@ function targetSha() {
|
|
|
355
361
|
}
|
|
356
362
|
|
|
357
363
|
// git archive | tar 로 소스를 편다. 저장소에 dist/ 가 포함되어 빌드는 불필요하다.
|
|
364
|
+
// 옆의 난수 이름 폴더에 다 푼 뒤에만 판 이름으로 옮긴다. 도중에 멈추면(tar 실패, 신호, 디스크 부족) 판 이름의 폴더는 생기지 않아,
|
|
365
|
+
// 다음 실행이 반쯤 찬 폴더를 설치된 판으로 보고 전환하지 않는다. 옆 폴더는 판 이름 꼴이 아니라 installedVersions 가 세지 않는다.
|
|
358
366
|
function unpackTarget(dest) {
|
|
359
|
-
|
|
360
|
-
mkdirSync(
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
+
}
|
|
364
379
|
}
|
|
365
380
|
|
|
366
381
|
// node_modules 는 복사하지 않고 직전 버전 것을 링크한다. Windows 는 junction
|
|
367
382
|
// (관리자 권한 불필요), POSIX 는 type 인자가 무시되고 디렉터리 symlink 가 된다.
|
|
383
|
+
// 없어도 전환을 막지 않는다. Claude Code 가 직접 설치한 OMC 판에는 node_modules 가 없고, 실행에 쓰는 것은 dist/ 에
|
|
384
|
+
// 묶여 있다(2026-11-03 실측: 5.7.21~5.7.30 모두 없음, OMC 정상 동작). 막으면 보통 PC 에서 전환이 늘 실패한다.
|
|
368
385
|
function linkNodeModules(dest, have) {
|
|
369
386
|
const prev = have.find((v) => v !== TARGET_VERSION);
|
|
370
387
|
const nm = join(dest, "node_modules");
|
|
@@ -422,7 +439,7 @@ function switchActive(sha) {
|
|
|
422
439
|
// 줄인 비트는 chmod 가 맞춘다). 만든 뒤에 맞추면 그 사이 기본 권한(0664 등)으로 남이 읽을 수 있다.
|
|
423
440
|
function writeAtomic(file, data, mode, { follow = true } = {}) {
|
|
424
441
|
const real = follow ? realOr(file) : file;
|
|
425
|
-
const tmp = join(dirname(real), `.${basename(real)}.omc-tmp-${process.pid}`);
|
|
442
|
+
const tmp = join(dirname(real), `.${basename(real)}.omc-tmp-${process.pid}-${randomBytes(6).toString("hex")}`);
|
|
426
443
|
const m = mode ?? modeOf(real);
|
|
427
444
|
rmSync(tmp, { force: true });
|
|
428
445
|
try {
|
|
@@ -698,16 +715,22 @@ function openParenOf(src, j) {
|
|
|
698
715
|
// 그 상태를 «패치됨» 으로 넘기면 --check 가 결함 트리에 «모두 적용» 을 낸다(2026-10-12 실측).
|
|
699
716
|
function siteInfo(src) {
|
|
700
717
|
if (src.includes("__omcRaceImport")) {
|
|
701
|
-
|
|
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);
|
|
702
722
|
return why ? { state: "misplaced", sites: [], why } : { state: "patched", sites: [] };
|
|
703
723
|
}
|
|
704
724
|
const all = scanTopLevelAwaitImports(src);
|
|
705
725
|
if (!all.length) return { state: "n/a", sites: [] };
|
|
706
|
-
const open =
|
|
726
|
+
const open = openSites(src);
|
|
707
727
|
if (!open.length) return { state: "guarded", sites: [] };
|
|
708
728
|
return { state: "needs", sites: open };
|
|
709
729
|
}
|
|
710
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
|
+
|
|
711
734
|
// 헬퍼가 쓰일 수 없는 자리면 그 이유를, 제자리면 null 을 낸다.
|
|
712
735
|
// - 선언이 블록 안(깊이 1 이상): 다른 블록의 자리에서 ReferenceError(옛 킷이 남긴 자리)
|
|
713
736
|
// - 선언을 못 찾음: 이름만으로는 «패치됨» 이라 할 근거가 없다. 앞쪽 글을 토크나이저가 잘못
|
|
@@ -833,15 +856,33 @@ function patchHooks(root) {
|
|
|
833
856
|
// 4.15.7 이 둘 다 겪었다 — PostToolUse 를 지우면 그 스크립트가 목록에서 빠지고(그래서
|
|
834
857
|
// .omcbak 이 있어도 안 돌아온다), 손으로 고친 파일은 .omcbak 조차 없어 아예 안 보인다.
|
|
835
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
|
+
}
|
|
836
865
|
for (const n of names) {
|
|
837
866
|
const f = join(root, "scripts", `${n}.mjs`);
|
|
838
|
-
if (
|
|
839
|
-
|
|
840
|
-
|
|
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);
|
|
841
871
|
}
|
|
842
872
|
return r;
|
|
843
873
|
}
|
|
844
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
|
+
|
|
845
886
|
// revert 가 훑는 이름: scripts/ 의 .mjs 전부. scripts/ 가 없는 판 폴더는 되돌릴 훅이 없어 한 줄로 알리고 넘어간다.
|
|
846
887
|
// 그 밖에 목록을 읽지 못하면(파일, 권한) 이름과 이유를 대고 실패로 센다 — 다음 판과 [3/4], [4/4] 는 그대로 간다.
|
|
847
888
|
function scriptNames(root, r) {
|
|
@@ -926,12 +967,20 @@ function handRestoreHint(bak, f) {
|
|
|
926
967
|
|
|
927
968
|
// 백업의 내용으로 파일을 바꾼다. 쓰지 못하면 그 이유, 백업이 읽으려는 순간 사라졌으면 GONE, 아니면 null.
|
|
928
969
|
const GONE = "백업이 먼저 사라짐";
|
|
970
|
+
// 빈 백업이나 문법 검사를 통과하지 못하는 백업(잘린 복사)으로는 되돌리지 않는다. 쓴 뒤 검사에 걸리면 쓰기 전 글로 되돌린다.
|
|
929
971
|
function restoreFrom(f, bak) {
|
|
930
972
|
let data;
|
|
931
973
|
try { data = readRegular(bak); }
|
|
932
974
|
catch (e) { return e.code === "ENOENT" ? GONE : `읽지 못함: ${failWhy(e)}`; }
|
|
933
|
-
|
|
975
|
+
if (!data.length) return "백업이 비어 있음";
|
|
976
|
+
let before;
|
|
977
|
+
try { before = readRegular(f); writeAtomic(f, data); }
|
|
934
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)}`;
|
|
935
984
|
}
|
|
936
985
|
|
|
937
986
|
// 되돌린 뒤 백업을 지운다. 다른 실행이 먼저 지웠으면(ENOENT) 된 것이다. 못 지우면 그 이유, 아니면 null.
|
|
@@ -947,6 +996,9 @@ function checkOrPatchScript(root, n, f, r) {
|
|
|
947
996
|
// 헬퍼 자리 결함은 check 와 apply 모두 이름을 댄다. 이미 감싼 파일이라 transform 으로는
|
|
948
997
|
// 고칠 수 없으므로 apply 도 건드리지 않고, 되돌린 뒤 다시 적용하라고 안내한다(misplacedHint).
|
|
949
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; }
|
|
950
1002
|
if (info.state !== "needs") { r.skip++; return; }
|
|
951
1003
|
const bak = `${f}.omcbak`;
|
|
952
1004
|
const why = whyNotWrite(root, f, bak);
|
|
@@ -1078,6 +1130,7 @@ function revertHooksJson(root, hj) {
|
|
|
1078
1130
|
const bak = `${hj}.omcbak`;
|
|
1079
1131
|
const saved = readBackup(bak);
|
|
1080
1132
|
if (saved !== null) {
|
|
1133
|
+
if (!isJson(saved)) { hjBad("hooks.json 을 되돌리지 않음 (백업이 비었거나 JSON 이 아님)"); return "unsafe"; }
|
|
1081
1134
|
if (staleHooksJson(hj, saved)) { hjBad(`hooks.json 을 되돌리지 않음 (지금 파일에 PostToolUse 가 있고 백업과 글이 다름. 백업이 옛 원본일 수 있음)`); return "unsafe"; }
|
|
1082
1135
|
writeAtomic(hj, saved);
|
|
1083
1136
|
log(` 되돌림 ${hjTag}hooks.json`);
|
|
@@ -1101,9 +1154,14 @@ function revertHooksJson(root, hj) {
|
|
|
1101
1154
|
return "reverted";
|
|
1102
1155
|
}
|
|
1103
1156
|
if (usable.length > 1) { hjBad(`hooks.json 수동 백업이 여러 개라 자동 복구하지 않음: ${usable.map(shown).join(", ")}`); return "ambiguous"; }
|
|
1104
|
-
|
|
1157
|
+
if (unsafe.length) return "linked";
|
|
1158
|
+
// 깨진 hooks.json 에 되돌릴 백업도 없으면 «이 도구가 지운 적 없음» 이라 할 근거가 없다. 그대로 두고 실패로 센다.
|
|
1159
|
+
if (!isJson(readHooksJson(hj))) { hjBad("hooks.json 이 JSON 이 아니고 되돌릴 백업도 없음"); return "unsafe"; }
|
|
1160
|
+
return "no-backup";
|
|
1105
1161
|
}
|
|
1106
1162
|
|
|
1163
|
+
const isJson = (text) => { try { JSON.parse(String(text)); return true; } catch { return false; } };
|
|
1164
|
+
|
|
1107
1165
|
// 파일에서 읽은 값(파일 이름, PINNED 의 host)과 출력하는 경로를 출력에 넣을 때 쓴다. 줄바꿈과 제어 문자를 ? 로 바꾸고 길이를 자른다.
|
|
1108
1166
|
// SKILL 은 [!] 줄을 그대로 보고하라고 하므로, 값이 제 줄을 새로 시작하면 도구가 낸 줄처럼 읽힌다.
|
|
1109
1167
|
// 바꾸는 문자: 제어 문자(Cc), 서식 문자(Cf: 폭 없는 공백, 방향 표시, 단어 결합자, 태그 문자), 줄과 문단 구분자,
|
|
@@ -1269,7 +1327,7 @@ function removePostToolUse(hj, why) {
|
|
|
1269
1327
|
// 고정과 되돌리기가 하지 않은 일을 했다고 끝난다.
|
|
1270
1328
|
function originUrl() {
|
|
1271
1329
|
try { return git(["config", "--get", "remote.origin.url"]); }
|
|
1272
|
-
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) }; }
|
|
1273
1331
|
}
|
|
1274
1332
|
|
|
1275
1333
|
// git 오류의 원인 줄: `fatal:` 이나 `error:` 로 시작하는 첫 줄, 없으면 첫 줄(`warning:` 이 먼저 올 수 있다), 정화하고
|
|
@@ -1687,7 +1745,8 @@ for (const v of targets) {
|
|
|
1687
1745
|
else if (hj === "needs") hjNeedBy[v] = true;
|
|
1688
1746
|
else if (hj === "reverted" || hj === "ambiguous" || hj === "linked") { /* patchHooksJson 이 출력함 */ }
|
|
1689
1747
|
else if (hj === "no-backup") ok(`${tag}되돌릴 백업 없음 (이 도구가 지운 적 없음)`);
|
|
1690
|
-
|
|
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"))); }
|
|
1691
1750
|
// "failed" 는 고친 hooks.json 이 JSON 으로 읽히지 않아 되돌린 경우다. 고치지 못했으니 실패로 센다.
|
|
1692
1751
|
else if (hj === "unsafe" || hj === "failed") { r.fail++; hjFailBy[v] = true; failed.add(fileKey(join(root, "hooks", "hooks.json"))); }
|
|
1693
1752
|
if (hj === "ambiguous" || hj === "linked") left.add(fileKey(join(root, "hooks", "hooks.json")));
|