@kaydash9999/banker-plugins 0.17.0 → 0.17.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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "banker",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.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,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.17.1] - 2026-10-14
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- `omc-patch` 의 `--revert` 가 헬퍼는 있는데 백업(`.omcbak`)이 없는 파일을 말없이 건너뛰던 것. 이제 `패치는 있는데 백업이 없어 되돌리지 못했습니다` 로 이름을 대고, 원본을 되찾는 방법을 보인다.
|
|
7
|
+
- `omc-patch` 가 헬퍼 이름만 있고 선언을 찾지 못한 파일, 선언이 첫 사용보다 뒤인 파일을 패치됨으로 보던 것. 둘 다 `헬퍼 자리 결함` 으로 센다. 선언이 뒤에 있으면 첫 사용이 먼저 돌아 ReferenceError 가 난다(끌어올려지는 function 선언은 제외).
|
|
8
|
+
- `omc-patch` 가 링크를 따라 플러그인 폴더 밖에 쓸 수 있던 것. 모든 모드에서 쓸 파일이 링크로 폴더 밖을 가리키거나 백업 자리가 링크면 쓰지 않고 실패로 센다. 그래서 되돌리기도 종료 코드 1 과 `되돌리기를 끝내지 못했습니다.` 로 끝날 수 있다. 되돌리기는 링크로 심은 hooks.json 수동 백업 후보도 쓰지 않는다.
|
|
9
|
+
- `omc-patch` 의 `--root` 가 다른 플러그인 폴더도 받던 것. 이름표(`.claude-plugin/plugin.json` 의 name, 없으면 `package.json` 의 name)가 OMC 가 아니면 멈춘다. 이름표가 없는 폴더는 통과한다.
|
|
10
|
+
- `omc-patch` 가 `--check` 와 `--revert` 를 함께 받으면 되돌리기로 파일을 쓰던 것. 이제 아무것도 하지 않고 종료 코드 1 로 멈춘다.
|
|
11
|
+
- `omc-patch` 의 git 호출이 셸의 `GIT_DIR` 같은 변수를 물려받던 것. git 훅처럼 그 변수를 내보낸 셸에서 부르면 마켓플레이스가 아니라 그 저장소의 origin 을 고정했다. 이제 git 이 다른 저장소로 옮겨 갈 때 지우는 변수(`git rev-parse --local-env-vars`)를 지우고 부른다.
|
|
12
|
+
- `omc-patch` 가 마켓플레이스의 `.git` 이 깨졌을 때(중단된 clone 등) 위쪽 저장소의 origin 을 고정하던 것. git 이 마켓플레이스 밖으로 올라가 dotfiles 로 관리하는 `~/.claude` 같은 저장소를 골랐다. 이제 마켓플레이스 바로 위 폴더를 `GIT_CEILING_DIRECTORIES` 로 막는다.
|
|
13
|
+
- `omc-patch` 의 되돌리기가 되돌리지 못한 것이 남아도 `되돌리기 완료.` 와 종료 코드 0 으로 끝나던 것. 백업이 없는 패치 파일, 손 수정, 되돌리지 못한 hooks.json(수동 백업이 여러 개이거나, 후보가 링크뿐일 때)을 세어 `되돌리기를 끝내지 못했습니다. 되돌리지 못한 것 N건.` 과 종료 코드 1 로 끝낸다. 끝 줄의 건수는 `실패 N건` 까지 링크로 같은 폴더를 가리키는 판 폴더가 여럿이어도 파일마다 한 번 센다. 링크라서 쓰지 않은 후보는 이름을 댄다.
|
|
14
|
+
- `omc-patch` 의 되돌리기가 hooks.json 에 PostToolUse 가 그대로인데도 수동 백업을 찾던 것. 후보가 하나면 지금 내용을 그 백업으로 덮어썼고, 여럿이면 되돌릴 것이 없는데도 경고를 냈다.
|
|
15
|
+
- `omc-patch` 의 되돌리기가 `PINNED` 내용이 `null` 이면 TypeError 로 멈추던 것.
|
|
16
|
+
- `omc-patch` 의 되돌리기가 origin 이 고정 상태가 아니면 `PINNED` 의 값으로 origin 을 쓰던 것. 다른 기기의 값은 쓰지 않는다고 알리면서도 썼고, host 가 없는 기록은 그 검사도 지나갔다. 원래 URL 은 고정한 origin(`omc-pinned://` 뒤)에 있으므로, 이제 거기서만 되돌리고 `PINNED` 는 기록으로만 본다.
|
|
17
|
+
- `omc-patch` 가 파일에서 읽은 값(`PINNED` 의 host, hooks.json 수동 백업과 훅 스크립트의 파일 이름, `--check` 가 보이는 origin)을 그대로 출력하던 것. 줄바꿈이 든 값이 도구가 낸 것 같은 `[!]` 줄을 만들 수 있었다. 이제 줄바꿈, 제어 문자, 글자 순서를 바꾸는 유니코드 서식 문자를 `?` 로 바꾼다.
|
|
18
|
+
- `update-banker` 가 적은 `claude plugin update` 옵션. Claude Code 2.1.296 의 help 에 맞춰 `--json`, `-y/--yes`, `--accept-command` 를 더하고 `--scope` 기본값을 auto-detect 로 고쳤다.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- `omc-patch` 의 안내 문구. 헬퍼 자리 결함에는 인자 없는 `--revert`(모든 판, 고정 해제) 대신 `--revert --root "<활성 폴더>"` 를 안내한다. `--no-update` 진단은 `인자 없이` 대신 `--check 없이 --no-update 로` 다시 실행하라고 안내한다. 그래서 스킬의 대체 규칙 두 개를 지웠다.
|
|
22
|
+
- `omc-patch` 도구의 함수를 나눠 함수 규칙(50줄, 복잡도 10)을 지킨다. npm 에 올라간 OMC 32개 판(4.14.0~5.7.1)에서 옛 도구와 진단, 패치 결과, 되돌리기, `--scan` 결과가 바이트까지 같다.
|
|
23
|
+
- 도구 머리 주석이 `--root` 를 활성 캐시 폴더에도 쓴다고 적고, 런처와 README 는 킷 배포 폴더에만 있다고 적는다.
|
|
24
|
+
- 이번 수정 뒤에도 npm 의 OMC 33개 판(4.14.0~5.7.2)에서 깨끗한 폴더의 진단, 적용, 재진단, `--scan`, 되돌리기 출력과 결과 파일이 0.17.0 의 도구와 바이트까지 같다. 패치하지 않은 파일 41,411개의 `--scan` 결과도 같다.
|
|
25
|
+
- `omc-patch` 시험이 개발자의 전역, 시스템 git 설정, 템플릿 훅, 셸의 저장소 변수(`GIT_DIR` 등)를 읽지 않는다. git 2.32 미만도 전역 설정을 읽지 않게 `HOME` 을 바꾼다. git 훅에서 시험을 돌려도 개발자의 저장소를 건드리지 않는다.
|
|
26
|
+
|
|
3
27
|
## [0.17.0] - 2026-10-14
|
|
4
28
|
|
|
5
29
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaydash9999/banker-plugins",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.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"
|
|
@@ -22,6 +22,7 @@ OMC(oh-my-claudecode)가 새 버전으로 자동 업데이트되면 로컬 훅
|
|
|
22
22
|
- 그러면 다른 블록의 import 가 ReferenceError 로 실패하고, 훅의 `catch` 가 그 오류를 삼킨다. 예: `project-memory-session` 은 종료 코드 0 으로 끝나지만 project-memory 를 만들지 않는다.
|
|
23
23
|
- banker 0.16.x 의 도구는 새로 패치할 때 맨 앞에 넣었지만, 옛 킷이 남긴 이 결함은 진단하지 못하고 `결과: 모두 적용된 상태입니다.` 를 냈다.
|
|
24
24
|
- 지금 도구의 `--check` 는 이 결함을 `헬퍼 자리 결함` 으로 알린다. 이미 감싼 파일이라 다시 적용만으로는 고쳐지지 않는다. 활성 폴더를 되돌린 뒤 다시 적용한다.
|
|
25
|
+
- 헬퍼 선언을 찾지 못하거나 선언이 첫 사용보다 뒤에 있는 파일도 같은 결함으로 센다. 뒤에 있으면 첫 사용이 먼저 돌아 ReferenceError 가 난다.
|
|
25
26
|
|
|
26
27
|
## 인자
|
|
27
28
|
|
|
@@ -35,8 +36,10 @@ OMC(oh-my-claudecode)가 새 버전으로 자동 업데이트되면 로컬 훅
|
|
|
35
36
|
|
|
36
37
|
- 이 작업은 사용자 설정 폴더(`~/.claude`)의 플러그인 캐시를 고치는 설정 변경이다. 서브에이전트에 맡기지 않고 메인 세션에서 직접 실행하고 직접 검증한다.
|
|
37
38
|
- 적용은 `--no-update` 로만 실행한다. 인자 없는 apply 는 쓰지 않는다. 활성 OMC 를 도구의 `TARGET_VERSION`(5.7.0)으로 바꾸고, `revert` 로도 버전이 돌아오지 않는다.
|
|
38
|
-
- 도구가
|
|
39
|
-
-
|
|
39
|
+
- 진단도 늘 `--no-update` 와 함께 한다. 그것 없이 진단하면 도구가 `인자 없이 다시 실행하십시오.` 라고 안내하는데, 인자 없는 apply 를 뜻하므로 따르지 않는다.
|
|
40
|
+
- 인자 없는 `--revert` 는 설치된 모든 버전을 되돌리고 고정까지 푼다. 헬퍼 자리 결함에는 도구가 안내하는 대로 활성 폴더만 `--revert --root` 로 되돌린다(3단계 1).
|
|
41
|
+
- `--root` 는 폴더의 이름표가 OMC 가 아니면 `OMC 플러그인 폴더가 아님` 으로 멈춘다. `.claude-plugin/plugin.json` 의 name 이 있으면 그것으로, 없으면 `package.json` 의 name 으로 정한다. 다른 폴더를 잘못 준 실수를 막는 확인이고, 이름표가 없는 폴더는 통과한다.
|
|
42
|
+
- 쓸 파일이 링크로 플러그인 폴더 밖을 가리키거나 백업 자리(`.omcbak`)가 링크면 도구는 쓰지 않고 실패로 센다. 문구는 적용 때 `쓰지 않음`, 되돌리기 때 `되돌리지 않음`, hooks.json 은 `hooks.json 을 건드리지 않음` 이다.
|
|
40
43
|
- 패치 규칙은 OMC 훅 코드의 모양에 맞춰져 있다. 처음 보는 OMC 버전이면 반드시 `check` 를 먼저 실행한다.
|
|
41
44
|
- `실패 N건` 이 1건 이상이면 멈추고 출력을 그대로 보고한다. 문법 오류가 난 파일은 도구가 원본으로 자동 롤백한다.
|
|
42
45
|
- 판정은 출력 문구로 한다. 종료 코드 0 만으로 완료라고 보고하지 않는다.
|
|
@@ -102,7 +105,8 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --check --no
|
|
|
102
105
|
```
|
|
103
106
|
|
|
104
107
|
- `되돌림 <훅 이름>` 줄과 `되돌리기 완료.` 를 확인한다. 끝의 `재시작해야 반영됩니다` 는 이 중간 단계에서는 무시한다. 2 에서 바로 다시 패치한다.
|
|
105
|
-
-
|
|
108
|
+
- `되돌리기를 끝내지 못했습니다.` 가 나오면 멈추고 위의 `[!]` 줄을 그대로 보고한다. 종료 코드는 1 이다. 끝 줄의 `실패 N건` 은 쓰기를 거부한 파일, `되돌리지 못한 것 N건` 은 백업이 없거나 수동 백업 후보를 쓸 수 없어 남은 파일이다. 끝 줄은 링크로 같은 폴더를 가리키는 판이 여럿이어도 파일마다 한 번 센다.
|
|
109
|
+
- `[!]` 줄에 `패치는 있는데 백업이 없어 되돌리지 못했습니다 — <이름>` 이 있으면 그 파일에 `.omcbak` 백업이 없는 것이다. 이 도구로는 되돌릴 원본이 없으니 멈추고 보고한다. 원본은 같은 버전의 OMC 저장소에서 다시 받아야 한다. 파이프 대신 파일을 거친다(Windows PowerShell 5.1 의 파이프는 바이너리를 텍스트로 바꿔 파일을 깨뜨린다): `git -C "<마켓플레이스 폴더>" archive -o "<임시 tar>" v<버전> scripts/<이름>.mjs` 뒤 `tar -xf "<임시 tar>" -C "<활성 폴더>"`. 이 명령은 사용자 승인 뒤에만 실행한다.
|
|
106
110
|
|
|
107
111
|
2. 적용한다.
|
|
108
112
|
|
|
@@ -204,6 +208,7 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --revert
|
|
|
204
208
|
|
|
205
209
|
- `.omcbak` 백업으로 설치된 모든 버전의 훅 스크립트와 `hooks.json` 을 되돌리고 마켓플레이스 `origin` 을 원래 URL 로 복구한다.
|
|
206
210
|
- 되돌린 뒤 Claude Code 를 다시 시작해야 반영된다.
|
|
211
|
+
- `되돌리기를 끝내지 못했습니다.` 가 나오면 위의 `[!]` 줄을 그대로 보고한다. 종료 코드는 1 이다. 백업이 없어 남은 파일과 hooks.json 도 이 끝 줄로 끝난다.
|
|
207
212
|
- `이 도구가 만들지 않은 수정이 남아 있습니다` 가 나오면 백업이 없는 손 수정이다. 출력에 적힌 복구 명령을 그대로 보고하고, 사용자 승인 없이 실행하지 않는다.
|
|
208
213
|
|
|
209
214
|
## 함정
|
|
@@ -213,6 +218,9 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --revert
|
|
|
213
218
|
- 헬퍼 자리 결함이 남은 채 `revert` 없이 적용을 다시 돌려도 바뀌는 것이 없다. 이미 감싼 파일은 건너뛰기 때문이다.
|
|
214
219
|
- `--root` 와 `--revert --root` 는 그 폴더만 다룬다. 고정 해제까지 하려면 인자 없는 `--revert` 를 쓴다.
|
|
215
220
|
- 고정을 풀지 않고 OMC 를 올리려면 먼저 `revert` 한다. 그렇지 않으면 마켓플레이스 fetch 가 계속 실패한다.
|
|
216
|
-
- 고정 기록(`PINNED`)은 `~/.claude/omc-local-patches/PINNED` 에 생긴다. 기록일 뿐이라 지워도 고정은 풀리지 않고, 이미 고정된 상태에서는 다시 만들어지지 않는다. `revert` 는 origin
|
|
221
|
+
- 고정 기록(`PINNED`)은 `~/.claude/omc-local-patches/PINNED` 에 생긴다. 기록일 뿐이라 지워도 고정은 풀리지 않고, 이미 고정된 상태에서는 다시 만들어지지 않는다. `revert` 는 origin 값에서만 원래 URL 을 되찾고, `PINNED` 의 값으로 origin 을 쓰지 않는다. 다른 기기의 기록이면 알리기만 한다.
|
|
222
|
+
- `--check` 와 `--revert` 를 함께 주면 도구는 아무것도 하지 않고 종료 코드 1 로 멈춘다. 진단만 하려면 `--check` 만 준다.
|
|
223
|
+
- 도구는 git 을 부를 때 저장소를 고르는 변수(`GIT_DIR`, `GIT_WORK_TREE` 등 `git rev-parse --local-env-vars` 목록)를 지운다. git 훅 안에서 불러도 마켓플레이스 저장소만 다룬다.
|
|
224
|
+
- 마켓플레이스의 `.git` 이 깨졌으면(중단된 clone 등) git 이 위쪽 저장소로 올라가지 못하게 막는다. 그때 `[4/4]` 는 `origin remote 없음` 으로 끝나고 고정하지 않는다.
|
|
217
225
|
- `--scan` 은 읽기만 한다. 플러그인 캐시도 `installed_plugins.json` 도 읽지 않고 준 파일만 본다.
|
|
218
226
|
- 도구의 주석과 출력 문구는 한글이다.
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* node omc-patch.mjs --revert 패치·고정 되돌리기
|
|
8
8
|
* node omc-patch.mjs --no-update 버전은 그대로 두고 패치만
|
|
9
9
|
* node omc-patch.mjs --root <플러그인 폴더> [--check | --revert]
|
|
10
|
-
* 그 폴더의 hooks/hooks.json 과 scripts/ 만 다룬다(번들
|
|
10
|
+
* 그 폴더의 hooks/hooks.json 과 scripts/ 만 다룬다(번들 사본, 활성 캐시 폴더 하나).
|
|
11
11
|
* installed_plugins.json, 마켓플레이스 origin, 판은 건드리지 않는다.
|
|
12
|
+
* 이름표(.claude-plugin/plugin.json 의 name, 없으면 package.json 의 name)가
|
|
13
|
+
* OMC 가 아니면 멈춘다.
|
|
12
14
|
* node omc-patch.mjs --scan <파일>...
|
|
13
15
|
* 헬퍼 선언의 블록 깊이와 래핑 자리를 JSON 으로 낸다(읽기 전용).
|
|
14
16
|
*
|
|
@@ -16,14 +18,14 @@
|
|
|
16
18
|
* 읽는다. 캐시에 TARGET_VERSION 이 있다고 그것을 활성으로 보지 않는다(--no-update 는 활성 버전을 패치).
|
|
17
19
|
* OMC_PATCH_INSTALLED=<json 경로> 로 읽을 파일을 바꿀 수 있다(시험용, --check 와 함께).
|
|
18
20
|
*
|
|
19
|
-
* OS: Windows / macOS / Linux 공통.
|
|
20
|
-
*
|
|
21
|
+
* OS: Windows / macOS / Linux 공통. 런처(apply.cmd, apply.sh)와 배경 문서(README.md)는
|
|
22
|
+
* 킷 배포 폴더에만 있다. 이 파일만 옮겨 쓰는 곳(예: 스킬 폴더)에는 없다.
|
|
21
23
|
*/
|
|
22
24
|
import {
|
|
23
25
|
readFileSync, writeFileSync, writeSync, existsSync, copyFileSync, readdirSync,
|
|
24
|
-
unlinkSync, mkdirSync, rmSync, symlinkSync,
|
|
26
|
+
unlinkSync, mkdirSync, rmSync, symlinkSync, lstatSync, realpathSync,
|
|
25
27
|
} from "node:fs";
|
|
26
|
-
import { join, dirname, resolve } from "node:path";
|
|
28
|
+
import { join, dirname, resolve, sep as PATH_SEP, delimiter as PATH_LIST_SEP } from "node:path";
|
|
27
29
|
import { homedir, hostname } from "node:os";
|
|
28
30
|
import { execFileSync } from "node:child_process";
|
|
29
31
|
|
|
@@ -47,7 +49,8 @@ const argv = new Set(args);
|
|
|
47
49
|
const MODE = argv.has("--revert") ? "revert" : argv.has("--check") ? "check" : "apply";
|
|
48
50
|
const NO_UPDATE = argv.has("--no-update");
|
|
49
51
|
// --root <플러그인 폴더>: 이 PC 의 설치 상태와 상관없이 그 폴더 하나만 고친다. 번들 사본처럼
|
|
50
|
-
// 판 폴더도 installed_plugins.json 도 없는
|
|
52
|
+
// 판 폴더도 installed_plugins.json 도 없는 트리나, 활성 캐시 폴더 하나만 고칠 때 쓴다.
|
|
53
|
+
// 값이 없거나 다음 플래그면 멈춘다.
|
|
51
54
|
const ROOT_AT = args.indexOf("--root");
|
|
52
55
|
const ROOT_ARG = ROOT_AT >= 0 ? args[ROOT_AT + 1] : null;
|
|
53
56
|
const ROOT = ROOT_ARG && !ROOT_ARG.startsWith("--") ? resolve(ROOT_ARG) : null;
|
|
@@ -113,19 +116,44 @@ const installedVersions = () =>
|
|
|
113
116
|
// installPath 마지막 구간 → 옛 규칙(TARGET 이 캐시에 있으면 TARGET, 아니면 최신) 순으로 본다.
|
|
114
117
|
function activeVersion() {
|
|
115
118
|
const versions = installedVersions();
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (e) {
|
|
119
|
-
if (typeof e.version === "string" && /^\d+\.\d+\.\d+$/.test(e.version) && versions.includes(e.version)) return { v: e.version, src: "installed_plugins.json" };
|
|
120
|
-
const last = typeof e.installPath === "string" ? e.installPath.split(/[\\/]+/).filter(Boolean).pop() : null;
|
|
121
|
-
if (last && versions.includes(last)) return { v: last, src: "installed_plugins.json installPath" };
|
|
122
|
-
}
|
|
123
|
-
} catch { /* 읽기·파싱 실패는 옛 규칙으로 */ }
|
|
119
|
+
const found = entryVersion(installedEntry(), versions);
|
|
120
|
+
if (found) return found;
|
|
124
121
|
return { v: versions.includes(TARGET_VERSION) ? TARGET_VERSION : versions[0] || TARGET_VERSION, src: "추정: installed_plugins.json 에서 확인 못 함" };
|
|
125
122
|
}
|
|
126
123
|
|
|
124
|
+
// installed_plugins.json 의 omc 항목. 읽기·파싱 실패는 null(옛 규칙으로).
|
|
125
|
+
function installedEntry() {
|
|
126
|
+
try { return JSON.parse(readFileSync(INSTALLED, "utf8")).plugins?.[PLUGIN_KEY]?.[0] || null; }
|
|
127
|
+
catch { return null; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 항목의 version 필드, 없으면 installPath 마지막 구간. 캐시에 있는 판이어야 한다.
|
|
131
|
+
function entryVersion(e, versions) {
|
|
132
|
+
if (!e) return null;
|
|
133
|
+
if (typeof e.version === "string" && /^\d+\.\d+\.\d+$/.test(e.version) && versions.includes(e.version)) return { v: e.version, src: "installed_plugins.json" };
|
|
134
|
+
const last = typeof e.installPath === "string" ? e.installPath.split(/[\\/]+/).filter(Boolean).pop() : null;
|
|
135
|
+
if (last && versions.includes(last)) return { v: last, src: "installed_plugins.json installPath" };
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// git 이 일할 저장소를 cwd 와 상관없이 정하는 변수. git 훅처럼 GIT_DIR 을 내보낸 셸에서 이 도구를 부르면
|
|
140
|
+
// 마켓플레이스가 아니라 그 저장소의 origin 을 고정했다(/tmp 에서 재현). git 이 다른 저장소로 옮겨 갈 때
|
|
141
|
+
// 지우는 목록(`git rev-parse --local-env-vars`, git 2.43)과 같다. Windows 는 변수 이름의 대소문자를 가리지 않는다.
|
|
142
|
+
const GIT_REPO_VARS = new Set(["GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS",
|
|
143
|
+
"GIT_CONFIG_COUNT", "GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE",
|
|
144
|
+
"GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS", "GIT_REPLACE_REF_BASE", "GIT_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR"]);
|
|
145
|
+
// 마켓플레이스의 .git 이 깨졌으면(중단된 clone) git 은 위로 올라가 다음 저장소(dotfiles 로 관리하는 ~/.claude 등)를
|
|
146
|
+
// 고른다. 바로 위 폴더를 천장(GIT_CEILING_DIRECTORIES)으로 두어 마켓플레이스 밖으로 나가지 못하게 한다.
|
|
147
|
+
// 마켓플레이스가 링크면 실제 경로 쪽 위 폴더도 막는다. 셸에 있던 천장 값은 대소문자와 상관없이 이 값으로 바꾼다.
|
|
148
|
+
const gitEnv = () => {
|
|
149
|
+
const drop = (k) => GIT_REPO_VARS.has(k.toUpperCase()) || k.toUpperCase() === "GIT_CEILING_DIRECTORIES";
|
|
150
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !drop(k)));
|
|
151
|
+
env.GIT_CEILING_DIRECTORIES = [...new Set([dirname(MARKET), dirname(realOr(MARKET))])].join(PATH_LIST_SEP);
|
|
152
|
+
return env;
|
|
153
|
+
};
|
|
154
|
+
|
|
127
155
|
function git(args, cwd = MARKET) {
|
|
128
|
-
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
156
|
+
return execFileSync("git", args, { cwd, env: gitEnv(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
129
157
|
}
|
|
130
158
|
|
|
131
159
|
// ── 1. 업데이트 ──────────────────────────────────────────────────────────────
|
|
@@ -133,6 +161,19 @@ function ensureVersion() {
|
|
|
133
161
|
const have = installedVersions();
|
|
134
162
|
if (have.includes(TARGET_VERSION)) { ok(`${TARGET_VERSION} 설치되어 있음`); return true; }
|
|
135
163
|
if (MODE === "check") { bad(`${TARGET_VERSION} 미설치 (설치된 버전: ${have.join(", ") || "없음"})`); return false; }
|
|
164
|
+
if (!canInstallTarget()) return false;
|
|
165
|
+
|
|
166
|
+
log(` ${TARGET_VERSION} 설치 중...`);
|
|
167
|
+
const sha = targetSha();
|
|
168
|
+
if (sha === null) return false;
|
|
169
|
+
const dest = join(CACHE, TARGET_VERSION);
|
|
170
|
+
unpackTarget(dest);
|
|
171
|
+
linkNodeModules(dest, have);
|
|
172
|
+
return switchActive(sha, have);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 마켓플레이스가 제 git 저장소이고 git 과 tar 가 있어야 TARGET_VERSION 을 꺼낼 수 있다.
|
|
176
|
+
function canInstallTarget() {
|
|
136
177
|
if (!existsSync(MARKET)) { bad(`마켓플레이스 없음: ${MARKET}\n Claude Code 에서 marketplace 를 먼저 추가하십시오: ${REPO_URL}`); return false; }
|
|
137
178
|
if (!existsSync(join(MARKET, ".git"))) { bad(`마켓플레이스 폴더가 git 저장소가 아님: ${MARKET}`); return false; }
|
|
138
179
|
|
|
@@ -143,23 +184,28 @@ function ensureVersion() {
|
|
|
143
184
|
try { execFileSync(cmd, ["--version"], { stdio: "ignore" }); }
|
|
144
185
|
catch { bad(`${cmd} 를 찾을 수 없습니다 (${why} 필요). 설치한 뒤 다시 실행하거나, --no-update 로 패치만 적용하십시오.`); return false; }
|
|
145
186
|
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
146
189
|
|
|
147
|
-
|
|
190
|
+
// 태그 v<TARGET_VERSION> 의 커밋. 못 찾으면 null.
|
|
191
|
+
function targetSha() {
|
|
148
192
|
try { git(["fetch", "--tags", "--quiet", "origin"]); } catch { /* 오프라인이어도 로컬 태그가 있으면 진행 */ }
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
193
|
+
try { return git(["rev-parse", `v${TARGET_VERSION}^{}`]); }
|
|
194
|
+
catch { bad(`태그 v${TARGET_VERSION} 를 마켓플레이스에서 찾을 수 없음`); return null; }
|
|
195
|
+
}
|
|
152
196
|
|
|
153
|
-
|
|
197
|
+
// git archive | tar 로 소스를 편다. 저장소에 dist/ 가 포함되어 빌드는 불필요하다.
|
|
198
|
+
function unpackTarget(dest) {
|
|
154
199
|
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
|
|
155
200
|
mkdirSync(dest, { recursive: true });
|
|
156
|
-
|
|
157
|
-
execFileSync("git", ["-C", MARKET, "archive", `v${TARGET_VERSION}`, "-o", join(dest, "_src.tar")], { stdio: "ignore" });
|
|
201
|
+
git(["archive", `v${TARGET_VERSION}`, "-o", join(dest, "_src.tar")]);
|
|
158
202
|
execFileSync("tar", ["-xf", join(dest, "_src.tar"), "-C", dest], { stdio: "ignore" });
|
|
159
203
|
unlinkSync(join(dest, "_src.tar"));
|
|
204
|
+
}
|
|
160
205
|
|
|
161
|
-
|
|
162
|
-
|
|
206
|
+
// node_modules 는 복사하지 않고 직전 버전 것을 링크한다. Windows 는 junction
|
|
207
|
+
// (관리자 권한 불필요), POSIX 는 type 인자가 무시되고 디렉터리 symlink 가 된다.
|
|
208
|
+
function linkNodeModules(dest, have) {
|
|
163
209
|
const prev = have.find((v) => v !== TARGET_VERSION);
|
|
164
210
|
const nm = join(dest, "node_modules");
|
|
165
211
|
if (!existsSync(nm) && prev && existsSync(join(CACHE, prev, "node_modules"))) {
|
|
@@ -168,8 +214,10 @@ function ensureVersion() {
|
|
|
168
214
|
} else if (!existsSync(nm)) {
|
|
169
215
|
bad(`node_modules 없음. ${dest} 에서 \`npm install --omit=dev\` 를 실행하십시오`);
|
|
170
216
|
}
|
|
217
|
+
}
|
|
171
218
|
|
|
172
|
-
|
|
219
|
+
// 활성 버전 전환
|
|
220
|
+
function switchActive(sha, have) {
|
|
173
221
|
const j = JSON.parse(readFileSync(INSTALLED, "utf8"));
|
|
174
222
|
const e = j.plugins?.[PLUGIN_KEY]?.[0];
|
|
175
223
|
if (!e) { bad(`installed_plugins.json 에 ${PLUGIN_KEY} 항목이 없음 — 수동 확인 필요`); return false; }
|
|
@@ -210,109 +258,160 @@ const REGEX_PREV_KW = new Set(["return", "typeof", "instanceof", "in", "of", "ne
|
|
|
210
258
|
// probe(i, fnStack) 를 주면 코드 글자(문자열·주석·정규식·중괄호가 아닌 자리)마다 부른다.
|
|
211
259
|
// --scan 이 같은 토크나이저로 헬퍼 선언의 깊이를 재는 데 쓴다. 패치 경로는 probe 없이 부른다.
|
|
212
260
|
function scanTopLevelAwaitImports(src, probe) {
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
let e = j;
|
|
220
|
-
while (j >= 0 && ID_CH.test(src[j])) j--;
|
|
221
|
-
return src.slice(j + 1, e + 1);
|
|
222
|
-
};
|
|
223
|
-
const backSkip = (j) => {
|
|
224
|
-
for (;;) {
|
|
225
|
-
while (j >= 0 && /\s/.test(src[j])) j--;
|
|
226
|
-
if (j >= 1 && src[j] === "/" && src[j - 1] === "*") { // */ 로 끝나는 주석
|
|
227
|
-
const s = src.lastIndexOf("/*", j - 1);
|
|
228
|
-
if (s < 0) return -1;
|
|
229
|
-
j = s - 1; continue;
|
|
230
|
-
}
|
|
231
|
-
return j;
|
|
232
|
-
}
|
|
233
|
-
};
|
|
234
|
-
const skipString = (q) => {
|
|
235
|
-
i++;
|
|
236
|
-
while (i < n) {
|
|
237
|
-
const c = src[i];
|
|
238
|
-
if (c === "\\") { i += 2; continue; }
|
|
239
|
-
if (c === q) { i++; return; }
|
|
240
|
-
if (q === "`" && c === "$" && src[i + 1] === "{") {
|
|
241
|
-
i += 2;
|
|
242
|
-
let d = 1;
|
|
243
|
-
while (i < n && d > 0) {
|
|
244
|
-
const k = src[i];
|
|
245
|
-
if (k === "\\") { i += 2; continue; }
|
|
246
|
-
if (k === "'" || k === '"' || k === "`") { skipString(k); continue; }
|
|
247
|
-
if (k === "{") d++;
|
|
248
|
-
else if (k === "}") d--;
|
|
249
|
-
i++;
|
|
250
|
-
}
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
i++;
|
|
254
|
-
}
|
|
255
|
-
};
|
|
256
|
-
// 이 중괄호가 «함수 몸통» 을 여는가
|
|
257
|
-
const isFunctionBrace = (at) => {
|
|
258
|
-
let j = backSkip(at - 1);
|
|
259
|
-
if (j < 0) return false;
|
|
260
|
-
if (src[j] === ">" && src[j - 1] === "=") return true; // () => {
|
|
261
|
-
if (src[j] !== ")") return false; // class/객체/블록
|
|
262
|
-
let d = 0;
|
|
263
|
-
for (; j >= 0; j--) { // 짝 맞는 ( 찾기
|
|
264
|
-
if (src[j] === ")") d++;
|
|
265
|
-
else if (src[j] === "(") { if (--d === 0) break; }
|
|
266
|
-
}
|
|
267
|
-
if (j < 0) return false;
|
|
268
|
-
const k = backSkip(j - 1);
|
|
269
|
-
if (k < 0 || !ID_CH.test(src[k])) return false;
|
|
270
|
-
return !BLOCK_KW.has(wordEndingAt(k)); // if/for/while/catch… 은 블록
|
|
271
|
-
};
|
|
261
|
+
const s = { src, n: src.length, i: 0, prev: "", prevIdx: -1, fnStack: [], hits: [] };
|
|
262
|
+
while (s.i < s.n) {
|
|
263
|
+
if (!skipNonCode(s)) stepCode(s, probe);
|
|
264
|
+
}
|
|
265
|
+
return s.hits;
|
|
266
|
+
}
|
|
272
267
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (k === "\\") { i += 2; continue; }
|
|
288
|
-
if (k === "[") inClass = true;
|
|
289
|
-
else if (k === "]") inClass = false;
|
|
290
|
-
else if (k === "/" && !inClass) { i++; break; }
|
|
291
|
-
else if (k === "\n") break;
|
|
292
|
-
i++;
|
|
293
|
-
}
|
|
294
|
-
while (i < n && ID_CH.test(src[i])) i++; // 플래그
|
|
295
|
-
prev = "/"; prevIdx = i - 1; continue;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
if (c === "{") { fnStack.push(isFunctionBrace(i)); i++; prev = "{"; prevIdx = i - 1; continue; }
|
|
299
|
-
if (c === "}") { fnStack.pop(); i++; prev = "}"; prevIdx = i - 1; continue; }
|
|
300
|
-
if (c === "a" && !(i > 0 && ID_CH.test(src[i - 1])) && /^await\s+import\s*\(/.test(src.slice(i, i + 40))) {
|
|
301
|
-
if (!fnStack.some(Boolean)) hits.push(i);
|
|
302
|
-
}
|
|
303
|
-
if (probe) probe(i, fnStack);
|
|
304
|
-
prev = c; prevIdx = i; i++;
|
|
268
|
+
const wordEndingAt = (src, j) => {
|
|
269
|
+
const e = j;
|
|
270
|
+
while (j >= 0 && ID_CH.test(src[j])) j--;
|
|
271
|
+
return src.slice(j + 1, e + 1);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// j 에서 거꾸로 공백과 /* */ 주석을 건너뛴 자리. 주석의 시작이 없으면 -1.
|
|
275
|
+
function backSkip(src, j) {
|
|
276
|
+
for (;;) {
|
|
277
|
+
while (j >= 0 && /\s/.test(src[j])) j--;
|
|
278
|
+
if (!(j >= 1 && src[j] === "/" && src[j - 1] === "*")) return j; // */ 로 끝나는 주석이 아님
|
|
279
|
+
const s = src.lastIndexOf("/*", j - 1);
|
|
280
|
+
if (s < 0) return -1;
|
|
281
|
+
j = s - 1;
|
|
305
282
|
}
|
|
306
|
-
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 아래 함수들은 토크나이저 상태 s 를 함께 쓴다. src 와 n(길이), i(지금 자리), prev 와
|
|
286
|
+
// prevIdx(직전 코드 글자와 그 자리), fnStack(열린 중괄호마다 함수 몸통이면 true), hits.
|
|
287
|
+
|
|
288
|
+
// 주석, 문자열, 공백, 정규식이면 건너뛰고 true. 코드 글자면 그대로 두고 false.
|
|
289
|
+
function skipNonCode(s) {
|
|
290
|
+
const c = s.src[s.i];
|
|
291
|
+
if (skipComment(s)) return true;
|
|
292
|
+
if (c === "'" || c === '"' || c === "`") { skipString(s, c); s.prev = c; return true; }
|
|
293
|
+
if (/\s/.test(c)) { s.i++; return true; }
|
|
294
|
+
if (c === "/" && startsRegex(s)) { skipRegex(s); return true; }
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function skipComment(s) {
|
|
299
|
+
const { src, n } = s;
|
|
300
|
+
if (src[s.i] !== "/") return false;
|
|
301
|
+
if (src[s.i + 1] === "/") {
|
|
302
|
+
while (s.i < n && src[s.i] !== "\n") s.i++;
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
if (src[s.i + 1] !== "*") return false;
|
|
306
|
+
const e = src.indexOf("*/", s.i + 2);
|
|
307
|
+
s.i = e < 0 ? n : e + 2;
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// s.i 는 여는 따옴표. 닫는 따옴표 다음으로 옮긴다. 템플릿의 ${ } 는 skipTemplateExpr 가 맡는다.
|
|
312
|
+
function skipString(s, q) {
|
|
313
|
+
const { src, n } = s;
|
|
314
|
+
s.i++;
|
|
315
|
+
while (s.i < n) {
|
|
316
|
+
const c = src[s.i];
|
|
317
|
+
if (c === "\\") { s.i += 2; continue; }
|
|
318
|
+
if (c === q) { s.i++; return; }
|
|
319
|
+
if (q === "`" && c === "$" && src[s.i + 1] === "{") { skipTemplateExpr(s); continue; }
|
|
320
|
+
s.i++;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// s.i 는 ${ 의 $. 짝 맞는 } 다음으로 옮긴다. 그 안의 문자열은 다시 skipString 으로 건너뛴다.
|
|
325
|
+
function skipTemplateExpr(s) {
|
|
326
|
+
const { src, n } = s;
|
|
327
|
+
s.i += 2;
|
|
328
|
+
let d = 1;
|
|
329
|
+
while (s.i < n && d > 0) {
|
|
330
|
+
const k = src[s.i];
|
|
331
|
+
if (k === "\\") { s.i += 2; continue; }
|
|
332
|
+
if (k === "'" || k === '"' || k === "`") { skipString(s, k); continue; }
|
|
333
|
+
if (k === "{") d++;
|
|
334
|
+
else if (k === "}") d--;
|
|
335
|
+
s.i++;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 여기의 / 가 나눗셈이 아니라 정규식을 여는가. 직전 코드 글자나 직전 낱말로 정한다.
|
|
340
|
+
function startsRegex(s) {
|
|
341
|
+
const { src, prev, prevIdx } = s;
|
|
342
|
+
return prev === "" || REGEX_PREV_CH.has(prev)
|
|
343
|
+
|| (prevIdx >= 0 && ID_CH.test(prev) && REGEX_PREV_KW.has(wordEndingAt(src, prevIdx)));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// s.i 는 정규식을 여는 /. 닫는 / 와 플래그 다음으로 옮긴다. 줄이 끝나면 거기서 멈춘다.
|
|
347
|
+
function skipRegex(s) {
|
|
348
|
+
const { src, n } = s;
|
|
349
|
+
s.i++;
|
|
350
|
+
let inClass = false;
|
|
351
|
+
while (s.i < n) {
|
|
352
|
+
const k = src[s.i];
|
|
353
|
+
if (k === "\\") { s.i += 2; continue; }
|
|
354
|
+
if (k === "[") inClass = true;
|
|
355
|
+
else if (k === "]") inClass = false;
|
|
356
|
+
else if (k === "/" && !inClass) { s.i++; break; }
|
|
357
|
+
else if (k === "\n") break;
|
|
358
|
+
s.i++;
|
|
359
|
+
}
|
|
360
|
+
skipWord(s); // 플래그
|
|
361
|
+
s.prev = "/"; s.prevIdx = s.i - 1;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function skipWord(s) {
|
|
365
|
+
while (s.i < s.n && ID_CH.test(s.src[s.i])) s.i++;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 코드 글자 하나. 중괄호면 함수 스택을 바꾸고, 함수 밖의 `await import(` 면 hits 에 넣는다.
|
|
369
|
+
function stepCode(s, probe) {
|
|
370
|
+
const { src } = s;
|
|
371
|
+
const c = src[s.i];
|
|
372
|
+
if (c === "{" || c === "}") {
|
|
373
|
+
if (c === "{") s.fnStack.push(isFunctionBrace(src, s.i));
|
|
374
|
+
else s.fnStack.pop();
|
|
375
|
+
s.i++; s.prev = c; s.prevIdx = s.i - 1;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (c === "a" && isAwaitImportAt(src, s.i) && !s.fnStack.some(Boolean)) s.hits.push(s.i);
|
|
379
|
+
if (probe) probe(s.i, s.fnStack);
|
|
380
|
+
s.prev = c; s.prevIdx = s.i; s.i++;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const isAwaitImportAt = (src, i) => !(i > 0 && ID_CH.test(src[i - 1])) && /^await\s+import\s*\(/.test(src.slice(i, i + 40));
|
|
384
|
+
|
|
385
|
+
// 이 중괄호가 «함수 몸통» 을 여는가
|
|
386
|
+
function isFunctionBrace(src, at) {
|
|
387
|
+
let j = backSkip(src, at - 1);
|
|
388
|
+
if (j < 0) return false;
|
|
389
|
+
if (src[j] === ">" && src[j - 1] === "=") return true; // () => {
|
|
390
|
+
if (src[j] !== ")") return false; // class/객체/블록
|
|
391
|
+
j = openParenOf(src, j); // 짝 맞는 ( 찾기
|
|
392
|
+
if (j < 0) return false;
|
|
393
|
+
const k = backSkip(src, j - 1);
|
|
394
|
+
if (k < 0 || !ID_CH.test(src[k])) return false;
|
|
395
|
+
return !BLOCK_KW.has(wordEndingAt(src, k)); // if/for/while/catch… 은 블록
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// j 의 ) 와 짝인 ( 의 자리. 없으면 -1.
|
|
399
|
+
function openParenOf(src, j) {
|
|
400
|
+
let d = 0;
|
|
401
|
+
for (; j >= 0; j--) {
|
|
402
|
+
if (src[j] === ")") d++;
|
|
403
|
+
else if (src[j] === "(" && --d === 0) return j;
|
|
404
|
+
}
|
|
405
|
+
return -1;
|
|
307
406
|
}
|
|
308
407
|
|
|
309
408
|
// 자리별 상태. 이미 손으로 Promise.race 를 두른 자리는 건드리지 않는다(4.15.7 에 그런 것이 둘 있다).
|
|
310
|
-
// 이미 감싼 파일이라도
|
|
409
|
+
// 이미 감싼 파일이라도 헬퍼가 쓰일 수 없는 자리면(옛 판의 자리) «패치됨» 이 아니라 «misplaced» 다.
|
|
311
410
|
// 그 상태를 «패치됨» 으로 넘기면 --check 가 결함 트리에 «모두 적용» 을 낸다(2026-10-12 실측).
|
|
312
411
|
function siteInfo(src) {
|
|
313
412
|
if (src.includes("__omcRaceImport")) {
|
|
314
|
-
const
|
|
315
|
-
return
|
|
413
|
+
const why = helperDefect(scanFacts(src));
|
|
414
|
+
return why ? { state: "misplaced", sites: [], why } : { state: "patched", sites: [] };
|
|
316
415
|
}
|
|
317
416
|
const all = scanTopLevelAwaitImports(src);
|
|
318
417
|
if (!all.length) return { state: "n/a", sites: [] };
|
|
@@ -321,20 +420,26 @@ function siteInfo(src) {
|
|
|
321
420
|
return { state: "needs", sites: open };
|
|
322
421
|
}
|
|
323
422
|
|
|
423
|
+
// 헬퍼가 쓰일 수 없는 자리면 그 이유를, 제자리면 null 을 낸다.
|
|
424
|
+
// - 선언이 블록 안(깊이 1 이상): 다른 블록의 자리에서 ReferenceError(옛 킷이 남긴 자리)
|
|
425
|
+
// - 선언을 못 찾음: 이름만으로는 «패치됨» 이라 할 근거가 없다. 앞쪽 글을 토크나이저가 잘못
|
|
426
|
+
// 읽어 선언이 문자열 안으로 보였을 수도 있다
|
|
427
|
+
// - 최상위 사용이 선언보다 앞: 그 자리가 먼저 돌아 const, let 은 TDZ 의 ReferenceError, var 는
|
|
428
|
+
// undefined 호출. function 선언은 끌어올려져 앞에서 불러도 된다. 순서는 줄이 아니라 글자 자리로 본다
|
|
429
|
+
function helperDefect({ helperDecls, wrapped }) {
|
|
430
|
+
const inBlock = helperDecls.find((d) => d.depth !== 0);
|
|
431
|
+
if (inBlock) return `헬퍼 선언 :${inBlock.line} 이 블록 안, 깊이 ${inBlock.depth}`;
|
|
432
|
+
if (!helperDecls.length) return "헬퍼 선언을 찾지 못함";
|
|
433
|
+
const decl = helperDecls[0];
|
|
434
|
+
const first = wrapped.find((w) => !w.inFunction);
|
|
435
|
+
if (first && first.at < decl.at && decl.kind !== "function") return `헬퍼 선언 :${decl.line} 이 첫 사용 :${first.line} 보다 뒤`;
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
324
439
|
function transform(src) {
|
|
325
440
|
const info = siteInfo(src);
|
|
326
441
|
if (info.state !== "needs") return null;
|
|
327
|
-
const edits =
|
|
328
|
-
for (const start of info.sites) {
|
|
329
|
-
const open = src.indexOf("(", src.indexOf("import", start));
|
|
330
|
-
let depth = 0, end = -1;
|
|
331
|
-
for (let i = open; i < src.length; i++) {
|
|
332
|
-
const c = src[i];
|
|
333
|
-
if (c === "(") depth++;
|
|
334
|
-
else if (c === ")" && --depth === 0) { end = i; break; }
|
|
335
|
-
}
|
|
336
|
-
if (end !== -1) edits.push({ start, end });
|
|
337
|
-
}
|
|
442
|
+
const edits = importSpans(src, info.sites);
|
|
338
443
|
if (!edits.length) return null;
|
|
339
444
|
let out = src;
|
|
340
445
|
for (const e of edits.reverse()) {
|
|
@@ -351,6 +456,27 @@ function transform(src) {
|
|
|
351
456
|
return out.slice(0, ls) + HELPER + out.slice(ls);
|
|
352
457
|
}
|
|
353
458
|
|
|
459
|
+
// 감쌀 자리마다 `await` 에서 import( 의 짝 맞는 ) 까지.
|
|
460
|
+
function importSpans(src, sites) {
|
|
461
|
+
const edits = [];
|
|
462
|
+
for (const start of sites) {
|
|
463
|
+
const end = closingParenOf(src, src.indexOf("(", src.indexOf("import", start)));
|
|
464
|
+
if (end !== -1) edits.push({ start, end });
|
|
465
|
+
}
|
|
466
|
+
return edits;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// open 의 ( 와 짝인 ) 의 자리. 없으면 -1.
|
|
470
|
+
function closingParenOf(src, open) {
|
|
471
|
+
let depth = 0;
|
|
472
|
+
for (let i = open; i < src.length; i++) {
|
|
473
|
+
const c = src[i];
|
|
474
|
+
if (c === "(") depth++;
|
|
475
|
+
else if (c === ")" && --depth === 0) return i;
|
|
476
|
+
}
|
|
477
|
+
return -1;
|
|
478
|
+
}
|
|
479
|
+
|
|
354
480
|
// ── 읽기 전용 진단 (--scan) ──────────────────────────────────────────────────
|
|
355
481
|
// 패치가 «제자리» 에 앉았는지를 위 토크나이저 하나로 잰다. 시험이 토크나이저를 따로 가지면
|
|
356
482
|
// 패치가 고르는 자리와 시험이 재는 자리가 서로 다른 규칙을 따르게 된다.
|
|
@@ -367,12 +493,20 @@ function scanFacts(src) {
|
|
|
367
493
|
if (ch !== "c" && ch !== "l" && ch !== "v" && ch !== "f" && ch !== "a") return;
|
|
368
494
|
if (i > 0 && ID_CH.test(src[i - 1])) return;
|
|
369
495
|
const head = src.slice(i, i + 48);
|
|
370
|
-
|
|
371
|
-
|
|
496
|
+
const decl = /^(const|let|var|function)\s+__omcRaceImport\b/.exec(head);
|
|
497
|
+
if (decl) helperDecls.push({ line: lineOf(i), depth: fnStack.length, at: i, kind: decl[1] });
|
|
498
|
+
else if (/^await\s+__omcRaceImport\s*\(/.test(head)) wrapped.push({ line: lineOf(i), inFunction: fnStack.some(Boolean), at: i });
|
|
372
499
|
}).map(lineOf);
|
|
373
500
|
return { helperDecls, wrapped, unwrapped };
|
|
374
501
|
}
|
|
375
502
|
|
|
503
|
+
// --scan 이 내는 칸. at 과 kind 는 판정용이라 내보내지 않는다(출력 형식은 그대로 둔다).
|
|
504
|
+
const publicFacts = ({ helperDecls, wrapped, unwrapped }) => ({
|
|
505
|
+
helperDecls: helperDecls.map(({ line, depth }) => ({ line, depth })),
|
|
506
|
+
wrapped: wrapped.map(({ line, inFunction }) => ({ line, inFunction })),
|
|
507
|
+
unwrapped,
|
|
508
|
+
});
|
|
509
|
+
|
|
376
510
|
function syntaxError(file) {
|
|
377
511
|
try { execFileSync(process.execPath, ["--check", file], { stdio: ["ignore", "ignore", "pipe"] }); return null; }
|
|
378
512
|
catch (e) {
|
|
@@ -387,8 +521,7 @@ const looksHandPatched = (src) =>
|
|
|
387
521
|
!src.includes("__omcRaceImport") && /Promise\.race\s*\(\s*\[?[\s\S]{0,120}?\bimport\s*\(/.test(src);
|
|
388
522
|
|
|
389
523
|
function patchHooks(root) {
|
|
390
|
-
|
|
391
|
-
const foreign = [];
|
|
524
|
+
const r = { done: 0, skip: 0, fail: 0, need: 0, misplaced: 0, foreign: [], orphan: [], failed: [] };
|
|
392
525
|
// revert 는 scripts/ 전체를 훑는다. hooks.json 기반 목록은 두 번 새는데, 실측으로
|
|
393
526
|
// 4.15.7 이 둘 다 겪었다 — PostToolUse 를 지우면 그 스크립트가 목록에서 빠지고(그래서
|
|
394
527
|
// .omcbak 이 있어도 안 돌아온다), 손으로 고친 파일은 .omcbak 조차 없어 아예 안 보인다.
|
|
@@ -397,64 +530,138 @@ function patchHooks(root) {
|
|
|
397
530
|
: hookNames(root);
|
|
398
531
|
for (const n of names) {
|
|
399
532
|
const f = join(root, "scripts", `${n}.mjs`);
|
|
400
|
-
const bak = `${f}.omcbak`;
|
|
401
533
|
if (!existsSync(f)) continue;
|
|
534
|
+
if (MODE === "revert") revertScript(root, n, f, r);
|
|
535
|
+
else checkOrPatchScript(root, n, f, r);
|
|
536
|
+
}
|
|
537
|
+
return r;
|
|
538
|
+
}
|
|
402
539
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if (looksHandPatched(readFileSync(f, "utf8"))) foreign.push(n);
|
|
410
|
-
continue;
|
|
411
|
-
}
|
|
412
|
-
const info = siteInfo(readFileSync(f, "utf8"));
|
|
413
|
-
// 헬퍼 자리 결함은 check 와 apply 모두 이름을 댄다. 이미 감싼 파일이라 transform 으로는
|
|
414
|
-
// 고칠 수 없으므로 apply 도 건드리지 않고, 되돌린 뒤 다시 적용하라고 안내한다(MISPLACED_HINT).
|
|
415
|
-
if (info.state === "misplaced") {
|
|
416
|
-
bad(`헬퍼 자리 결함: ${n} (헬퍼 선언 :${info.decl.line} 이 블록 안, 깊이 ${info.decl.depth})`);
|
|
417
|
-
misplaced++; continue;
|
|
418
|
-
}
|
|
419
|
-
if (info.state !== "needs") { skip++; continue; }
|
|
420
|
-
if (MODE === "check") { bad(`패치 필요: ${n}`); need++; continue; }
|
|
421
|
-
|
|
422
|
-
const out = transform(readFileSync(f, "utf8"));
|
|
423
|
-
if (!out) { bad(`변환 실패: ${n}`); fail++; continue; }
|
|
424
|
-
if (!existsSync(bak)) copyFileSync(f, bak);
|
|
425
|
-
writeFileSync(f, out, "utf8");
|
|
426
|
-
const err = syntaxError(f);
|
|
427
|
-
if (err) { copyFileSync(bak, f); bad(`문법오류로 롤백: ${n}\n ${err}`); fail++; continue; }
|
|
428
|
-
done++;
|
|
540
|
+
function revertScript(root, n, f, r) {
|
|
541
|
+
const bak = `${f}.omcbak`;
|
|
542
|
+
if (existsSync(bak)) {
|
|
543
|
+
const why = whyNotWrite(root, f, bak);
|
|
544
|
+
if (why) { bad(`되돌리지 않음: ${shown(n)} (${why})`); r.fail++; r.failed.push(f); return; }
|
|
545
|
+
copyFileSync(bak, f); unlinkSync(bak); log(` 되돌림 ${shown(n)}`); r.done++; return;
|
|
429
546
|
}
|
|
430
|
-
|
|
547
|
+
// 백업이 없는데 이미 감싸여 있으면 되돌릴 원본이 디스크에 없다. 조용히 넘기지 않고 이름을 댄다.
|
|
548
|
+
// - 우리 헬퍼가 있으면: 이 도구나 옛 킷이 고쳤는데 백업이 사라진 파일(복사해 온 폴더 등)
|
|
549
|
+
// - 손 패치: `await import(` 를 `await Promise.race([import(` 로 «바꿔치기» 해서 await import
|
|
550
|
+
// 패턴 자체가 사라진다. siteInfo 로는 잡히지 않아 원문에서 직접 찾는다(실측: 4.15.7 의 두 파일)
|
|
551
|
+
const src = readFileSync(f, "utf8");
|
|
552
|
+
if (src.includes("__omcRaceImport")) r.orphan.push(n);
|
|
553
|
+
else if (looksHandPatched(src)) r.foreign.push(n);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function checkOrPatchScript(root, n, f, r) {
|
|
557
|
+
const info = siteInfo(readFileSync(f, "utf8"));
|
|
558
|
+
// 헬퍼 자리 결함은 check 와 apply 모두 이름을 댄다. 이미 감싼 파일이라 transform 으로는
|
|
559
|
+
// 고칠 수 없으므로 apply 도 건드리지 않고, 되돌린 뒤 다시 적용하라고 안내한다(misplacedHint).
|
|
560
|
+
if (info.state === "misplaced") { bad(`헬퍼 자리 결함: ${n} (${info.why})`); r.misplaced++; return; }
|
|
561
|
+
if (info.state !== "needs") { r.skip++; return; }
|
|
562
|
+
if (MODE === "check") { bad(`패치 필요: ${n}`); r.need++; return; }
|
|
563
|
+
|
|
564
|
+
const bak = `${f}.omcbak`;
|
|
565
|
+
const why = whyNotWrite(root, f, bak);
|
|
566
|
+
if (why) { bad(`쓰지 않음: ${n} (${why})`); r.fail++; return; }
|
|
567
|
+
const out = transform(readFileSync(f, "utf8"));
|
|
568
|
+
if (!out) { bad(`변환 실패: ${n}`); r.fail++; return; }
|
|
569
|
+
if (!existsSync(bak)) copyFileSync(f, bak);
|
|
570
|
+
writeFileSync(f, out, "utf8");
|
|
571
|
+
const err = syntaxError(f);
|
|
572
|
+
if (err) { copyFileSync(bak, f); bad(`문법오류로 롤백: ${n}\n ${err}`); r.fail++; return; }
|
|
573
|
+
r.done++;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// 쓰기 전에 본다. 쓸 파일이 (링크를 따라가서) 플러그인 폴더 밖이거나 백업 자리가 링크면 그 이유를,
|
|
577
|
+
// 아니면 null. 백업 자리가 깨진 링크면 existsSync 는 false 라, copyFileSync 가 링크가 가리키는 곳에 쓴다.
|
|
578
|
+
function whyNotWrite(root, file, bak) {
|
|
579
|
+
if (isLink(bak)) return "백업 자리가 링크";
|
|
580
|
+
if (!insideRoot(root, file)) return "파일이 플러그인 폴더 밖에 있음";
|
|
581
|
+
return null;
|
|
431
582
|
}
|
|
432
583
|
|
|
584
|
+
const isLink = (p) => {
|
|
585
|
+
try { return lstatSync(p).isSymbolicLink(); } catch { return false; }
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// 실제 경로로 p 가 root 안인가. 버전 폴더 자체가 링크(옛 판이 새 판을 가리킴)여도 root 를 같이
|
|
589
|
+
// 풀므로 맞다. Windows 는 경로의 대소문자를 구분하지 않아 소문자로 비교한다.
|
|
590
|
+
function insideRoot(root, p) {
|
|
591
|
+
const fold = (x) => (process.platform === "win32" ? x.toLowerCase() : x);
|
|
592
|
+
try { return fold(realpathSync(p)).startsWith(fold(realpathSync(root)) + PATH_SEP); }
|
|
593
|
+
catch { return false; }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 실제 경로. 풀 수 없으면 받은 그대로.
|
|
597
|
+
const realOr = (p) => {
|
|
598
|
+
try { return realpathSync(p); } catch { return p; }
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// 같은 파일인지 셀 때 쓰는 열쇠. Windows 는 경로의 대소문자를 가리지 않아 소문자로 접는다. junction 이 대상을
|
|
602
|
+
// 캐시 경로와 다른 글자로 적어 두면 같은 파일이 다른 철자로 풀리기 때문이다.
|
|
603
|
+
const fileKey = (p) => (process.platform === "win32" ? realOr(p).toLowerCase() : realOr(p));
|
|
604
|
+
|
|
433
605
|
// ── 3. PostToolUse 훅 제거 ───────────────────────────────────────────────────
|
|
434
606
|
function patchHooksJson(root) {
|
|
435
607
|
const hj = join(root, "hooks", "hooks.json");
|
|
436
|
-
const bak = `${hj}.omcbak`;
|
|
437
608
|
if (!existsSync(hj)) return "no-file";
|
|
438
|
-
if (MODE
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
609
|
+
if (MODE !== "check") {
|
|
610
|
+
const why = whyNotWrite(root, hj, `${hj}.omcbak`);
|
|
611
|
+
if (why) { bad(`hooks.json 을 건드리지 않음 (${why})`); return "unsafe"; }
|
|
612
|
+
}
|
|
613
|
+
return MODE === "revert" ? revertHooksJson(root, hj) : removePostToolUse(hj);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// 되돌린 결과. ambiguous 와 linked 는 hooks.json 을 되돌리지 못하고 남긴 것이라 끝 줄이 센다.
|
|
617
|
+
function revertHooksJson(root, hj) {
|
|
618
|
+
const bak = `${hj}.omcbak`;
|
|
619
|
+
if (existsSync(bak)) { copyFileSync(bak, hj); unlinkSync(bak); log(" 되돌림 hooks.json"); return "reverted"; }
|
|
620
|
+
// 이 도구의 백업이 없을 때 PostToolUse 가 그대로면 되돌릴 것이 없다. 옆의 수동 백업으로 덮어쓰면
|
|
621
|
+
// 지금 내용을 잃는다.
|
|
622
|
+
if (hasPostToolUse(hj)) return "no-backup";
|
|
623
|
+
// 손으로 만든 백업이 남아 있을 수 있다(실측: 4.15.7 의 hooks.json.bak-20260906-postoolremove).
|
|
624
|
+
// PostToolUse 를 담고 있는 유효한 JSON 이 딱 하나면 그것이 제거 직전 상태다 — 무엇을 썼는지 밝히고 되돌린다.
|
|
625
|
+
const dir = dirname(hj);
|
|
626
|
+
const { usable, unsafe } = manualBackups(root, dir);
|
|
627
|
+
if (unsafe.length) bad(`hooks.json 수동 백업 후보가 링크이거나 플러그인 폴더 밖이라 쓰지 않음: ${unsafe.map(shown).join(", ")}`);
|
|
628
|
+
if (usable.length === 1) {
|
|
629
|
+
copyFileSync(join(dir, usable[0]), hj);
|
|
630
|
+
log(` 되돌림 hooks.json (수동 백업 사용: ${shown(usable[0])})`);
|
|
631
|
+
return "reverted";
|
|
632
|
+
}
|
|
633
|
+
if (usable.length > 1) { bad(`hooks.json 수동 백업이 여러 개라 자동 복구하지 않음: ${usable.map(shown).join(", ")}`); return "ambiguous"; }
|
|
634
|
+
return unsafe.length ? "linked" : "no-backup";
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// 파일에서 읽은 값(파일 이름, PINNED 의 host)을 출력에 넣을 때 쓴다. 줄바꿈과 제어 문자를 ? 로 바꾸고 길이를 자른다.
|
|
638
|
+
// SKILL 은 [!] 줄을 그대로 보고하라고 하므로, 값이 제 줄을 새로 시작하면 도구가 낸 줄처럼 읽힌다.
|
|
639
|
+
// 줄을 바꾸거나 글자 순서를 바꾸는 문자: C0 과 C1 제어 문자, 폭 없는 공백과 방향 표시, 줄과 문단 구분자,
|
|
640
|
+
// 방향 덮어쓰기와 격리, BOM. oneLine 은 길이를 두고(URL), shown 은 120자로 자른다(이름, host).
|
|
641
|
+
const UNSAFE_CH = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/g;
|
|
642
|
+
const oneLine = (s) => String(s).replace(UNSAFE_CH, "?");
|
|
643
|
+
const shown = (s) => oneLine(s).slice(0, 120);
|
|
644
|
+
|
|
645
|
+
// hooks.json 옆의 수동 백업 후보. usable 은 PostToolUse 를 담은 유효한 JSON 이다. 링크로 심은 후보는
|
|
646
|
+
// unsafe 로 따로 두고 읽지도 않는다. 폴더 밖 파일(PostToolUse 키가 있는 settings.json 등)이 hooks.json 이 된다.
|
|
647
|
+
function manualBackups(root, dir) {
|
|
648
|
+
const usable = [], unsafe = [];
|
|
649
|
+
for (const f of readdirSync(dir)) {
|
|
650
|
+
if (!f.startsWith("hooks.json.") || f === "hooks.json.omcbak") continue;
|
|
651
|
+
const p = join(dir, f);
|
|
652
|
+
if (isLink(p) || !insideRoot(root, p)) unsafe.push(f);
|
|
653
|
+
else if (hasPostToolUse(p)) usable.push(f);
|
|
457
654
|
}
|
|
655
|
+
return { usable, unsafe };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const hasPostToolUse = (p) => {
|
|
659
|
+
try { return !!JSON.parse(readFileSync(p, "utf8"))?.hooks?.PostToolUse; }
|
|
660
|
+
catch { return false; }
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
function removePostToolUse(hj) {
|
|
664
|
+
const bak = `${hj}.omcbak`;
|
|
458
665
|
const j = JSON.parse(readFileSync(hj, "utf8"));
|
|
459
666
|
if (!j.hooks?.PostToolUse) return "already";
|
|
460
667
|
const list = j.hooks.PostToolUse.flatMap((g) => (g.hooks || []).map((h) => (h.command || "").match(/([\w-]+)\.mjs/)?.[1] || "?"));
|
|
@@ -472,38 +679,20 @@ function patchHooksJson(root) {
|
|
|
472
679
|
// 올릴 수 있다. 그러면 위 패치가 통째로 덮어써진다. 로컬에서 막을 수 있는
|
|
473
680
|
// 지점은 "마켓플레이스 저장소가 새 커밋을 가져오지 못하게" 하는 것뿐이다.
|
|
474
681
|
// origin remote 의 URL 을 무효 주소로 바꿔 fetch 를 실패시킨다(원래 URL 은
|
|
475
|
-
//
|
|
476
|
-
// 이미 받은 태그로 재설치·검증이 가능하다.
|
|
682
|
+
// `omc-pinned://` 뒤에 그대로 남기고 --revert 가 거기서 복구한다. PINNED 파일은 기록이다).
|
|
683
|
+
// 저장소 자체는 그대로라 이미 받은 태그로 재설치·검증이 가능하다.
|
|
477
684
|
function pinMarketplace() {
|
|
478
685
|
if (!existsSync(MARKET)) return "no-marketplace";
|
|
479
686
|
// 마켓플레이스 폴더가 제 git 저장소가 아니면 git 이 위쪽 저장소(예: dotfiles 로 관리하는
|
|
480
|
-
// ~/.claude)를 찾아 그 origin 을 바꾼다. 제 .git 이 있을 때만 고정하고 되돌린다.
|
|
687
|
+
// ~/.claude)를 찾아 그 origin 을 바꾼다. 제 .git 이 있을 때만 고정하고 되돌린다. .git 이 있어도
|
|
688
|
+
// 깨졌으면 git 이 위로 올라가는데, 그것은 gitEnv 의 천장이 막는다(origin 을 못 읽어 no-remote).
|
|
481
689
|
if (!existsSync(join(MARKET, ".git"))) return "no-git";
|
|
482
690
|
let url;
|
|
483
691
|
try { url = git(["remote", "get-url", "origin"]); } catch { return "no-remote"; }
|
|
484
692
|
|
|
485
|
-
if (MODE === "revert")
|
|
486
|
-
// 원본 URL 은 현재 origin 안에 그대로 들어 있다(`omc-pinned://` + 원본). 파일이 아니라
|
|
487
|
-
// 거기서 복원한다. PINNED 는 배포 폴더에 같이 딸려 오므로 다른 PC 의 값일 수 있고,
|
|
488
|
-
// PINFILE 은 스크립트 위치가 아니라 homedir() 기준이라 그 사실이 눈에 띄지도 않는다.
|
|
489
|
-
const PIN_PREFIX = "omc-pinned://";
|
|
490
|
-
let restored = null;
|
|
491
|
-
if (url.startsWith(PIN_PREFIX)) restored = url.slice(PIN_PREFIX.length);
|
|
492
|
-
if (existsSync(PINFILE)) {
|
|
493
|
-
let saved = {};
|
|
494
|
-
try { saved = JSON.parse(readFileSync(PINFILE, "utf8")); } catch { /* 깨졌으면 무시 */ }
|
|
495
|
-
if (saved.host && saved.host !== hostname()) {
|
|
496
|
-
bad(`PINNED 는 다른 기기(${saved.host})의 기록입니다 — 그 값은 쓰지 않고 현재 origin 에서 복원합니다.`);
|
|
497
|
-
}
|
|
498
|
-
if (!restored && saved.originUrl) restored = saved.originUrl;
|
|
499
|
-
unlinkSync(PINFILE);
|
|
500
|
-
}
|
|
501
|
-
if (!restored) return "not-pinned";
|
|
502
|
-
try { git(["remote", "set-url", "origin", restored]); } catch { /* noop */ }
|
|
503
|
-
return "unpinned";
|
|
504
|
-
}
|
|
693
|
+
if (MODE === "revert") return unpinMarketplace(url);
|
|
505
694
|
if (url.startsWith("omc-pinned://")) return "already";
|
|
506
|
-
if (MODE === "check") { bad(`마켓플레이스 미고정 (origin=${url})`); return "needs"; }
|
|
695
|
+
if (MODE === "check") { bad(`마켓플레이스 미고정 (origin=${oneLine(url)})`); return "needs"; }
|
|
507
696
|
|
|
508
697
|
mkdirSync(dirname(PINFILE), { recursive: true });
|
|
509
698
|
// host 를 남긴다. 이 폴더는 통째로 복사해 배포하므로 PINNED 도 같이 딸려 가는데,
|
|
@@ -513,6 +702,44 @@ function pinMarketplace() {
|
|
|
513
702
|
return "pinned";
|
|
514
703
|
}
|
|
515
704
|
|
|
705
|
+
// 원본 URL 은 현재 origin 안에 그대로 들어 있다(`omc-pinned://` + 원본). 거기서만 복원한다.
|
|
706
|
+
// PINNED 의 값으로 origin 을 쓰지 않는다. PINNED 는 배포 폴더에 같이 딸려 오므로 다른 PC 의 값일 수 있고,
|
|
707
|
+
// host 가 없는 기록은 어느 기기의 것인지 모른다. origin 이 고정 상태가 아니면 되돌릴 것도 없다.
|
|
708
|
+
function unpinMarketplace(url) {
|
|
709
|
+
const PIN_PREFIX = "omc-pinned://";
|
|
710
|
+
if (existsSync(PINFILE)) dropPinRecord();
|
|
711
|
+
const restored = url.startsWith(PIN_PREFIX) ? url.slice(PIN_PREFIX.length) : "";
|
|
712
|
+
if (!restored) return "not-pinned";
|
|
713
|
+
try { git(["remote", "set-url", "origin", restored]); } catch { /* noop */ }
|
|
714
|
+
return "unpinned";
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// PINNED 를 지운다. 다른 기기의 기록이면 알린다. 깨진 기록(JSON 이 아니거나, null 처럼 객체가 아닌 값)은 말없이 지운다.
|
|
718
|
+
function dropPinRecord() {
|
|
719
|
+
let saved = null;
|
|
720
|
+
try { saved = JSON.parse(readFileSync(PINFILE, "utf8")); } catch { /* 깨졌으면 무시 */ }
|
|
721
|
+
unlinkSync(PINFILE);
|
|
722
|
+
if (saved && typeof saved === "object" && saved.host && saved.host !== hostname()) {
|
|
723
|
+
bad(`PINNED 는 다른 기기(${shown(saved.host)})의 기록입니다 — 그 값은 쓰지 않습니다.`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// --root 폴더의 이름표. .claude-plugin/plugin.json 의 name 이 있으면 그것으로, 없으면 package.json 의 name
|
|
728
|
+
// 으로 정한다. OMC 가 아니면 그 설명을, 아니면 null. 둘 다 없거나 읽을 수 없으면 확인할 길이 없어 null 이다
|
|
729
|
+
// (번들 사본에는 없을 수 있다). 다른 폴더를 잘못 준 실수를 막는 확인이다. 폴더 밖 쓰기를 막는 것은 whyNotWrite.
|
|
730
|
+
const OMC_NAMES = [[".claude-plugin/plugin.json", "oh-my-claudecode"], ["package.json", "oh-my-claude-sisyphus"]];
|
|
731
|
+
function otherPluginName(root) {
|
|
732
|
+
for (const [rel, want] of OMC_NAMES) {
|
|
733
|
+
let name;
|
|
734
|
+
try { name = JSON.parse(readFileSync(join(root, rel), "utf8")).name; } catch { continue; }
|
|
735
|
+
if (name === undefined) continue;
|
|
736
|
+
if (name === want) return null;
|
|
737
|
+
const quoted = JSON.stringify(name);
|
|
738
|
+
return `${rel} 의 name 이 ${quoted.length > 80 ? `${quoted.slice(0, 80)}...` : quoted}`;
|
|
739
|
+
}
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
|
|
516
743
|
// ── --scan: 사실만 내고 끝낸다 ───────────────────────────────────────────────
|
|
517
744
|
// 아래 main 보다 먼저 끝나야 한다. 이 모드는 installed_plugins.json 도 플러그인 캐시도 읽지 않는다.
|
|
518
745
|
// 결과는 입력 순서대로 JSON 배열 한 줄이다. 동기 쓰기로 내보내야 process.exit 가 잘라먹지 않는다.
|
|
@@ -520,7 +747,7 @@ if (SCAN_AT >= 0) {
|
|
|
520
747
|
const files = args.slice(SCAN_AT + 1);
|
|
521
748
|
if (!files.length) { console.error("--scan 뒤에 파일 경로가 필요합니다."); process.exit(1); }
|
|
522
749
|
const out = files.map((file) => {
|
|
523
|
-
try { return { file, ...scanFacts(readFileSync(file, "utf8")) }; }
|
|
750
|
+
try { return { file, ...publicFacts(scanFacts(readFileSync(file, "utf8"))) }; }
|
|
524
751
|
catch (e) { return { file, error: String(e.code || e.message) }; }
|
|
525
752
|
});
|
|
526
753
|
writeSync(1, JSON.stringify(out) + "\n");
|
|
@@ -529,28 +756,36 @@ if (SCAN_AT >= 0) {
|
|
|
529
756
|
|
|
530
757
|
// ── main ─────────────────────────────────────────────────────────────────────
|
|
531
758
|
if (ROOT_AT >= 0 && !ROOT) { bad("--root 뒤에 플러그인 폴더 경로가 필요합니다."); process.exit(1); }
|
|
759
|
+
// --check 는 읽기 전용이다. --revert 와 함께 받으면 예전에는 되돌리기가 이겨 파일을 썼다. 무엇도 하지 않고 멈춘다.
|
|
760
|
+
if (argv.has("--check") && argv.has("--revert")) {
|
|
761
|
+
bad("--check 와 --revert 는 함께 쓸 수 없습니다. 진단만 하려면 --check 만 주십시오.");
|
|
762
|
+
process.exit(1);
|
|
763
|
+
}
|
|
532
764
|
log(ROOT
|
|
533
765
|
? `OMC 패치 (대상 폴더 ${ROOT}) 모드: ${MODE}`
|
|
534
766
|
: `OMC 패치 (대상 ${TARGET_VERSION}) 모드: ${MODE}${NO_UPDATE ? " --no-update" : ""}`);
|
|
535
767
|
log(`플랫폼: ${process.platform} node ${process.version}`);
|
|
536
768
|
log();
|
|
537
769
|
|
|
538
|
-
// 헬퍼 자리 결함은 이 판의 apply 로 고칠 수 없다(이미 감싼 파일). 원본으로 되돌린 뒤 다시 적용한다.
|
|
539
|
-
const MISPLACED_HINT = ROOT
|
|
540
|
-
? "--revert --root 로 되돌린 뒤 같은 --root 로 다시 적용하십시오."
|
|
541
|
-
: "--revert 로 되돌린 뒤 다시 적용하십시오.";
|
|
542
|
-
|
|
543
770
|
// targets 는 «고칠 플러그인 폴더» 의 이름표 목록이다. 기본은 캐시의 판 번호이고, --root 는
|
|
544
771
|
// 그 폴더 경로 하나다. rootOf 가 이름표를 폴더로 바꾼다.
|
|
545
772
|
let active, targets;
|
|
546
773
|
const rootOf = (v) => (ROOT ? v : join(CACHE, v));
|
|
774
|
+
// 헬퍼 자리 결함은 이 판의 apply 로 고칠 수 없다(이미 감싼 파일). 원본으로 되돌린 뒤 다시 적용한다.
|
|
775
|
+
// 되돌리기는 그 폴더 하나만 한다. 인자 없는 --revert 는 설치된 모든 판을 되돌리고 고정까지 푼다.
|
|
776
|
+
// active 는 아래에서 정해지므로 쓰는 자리에서 부른다.
|
|
777
|
+
const misplacedHint = () => (ROOT
|
|
778
|
+
? "--revert --root 로 되돌린 뒤 같은 --root 로 다시 적용하십시오."
|
|
779
|
+
: `--revert --root "${join(CACHE, active)}" 로 활성 폴더만 되돌린 뒤 다시 적용하십시오.`);
|
|
547
780
|
if (ROOT) {
|
|
548
|
-
// 판 폴더도 installed_plugins.json 도 없는
|
|
549
|
-
// 마켓플레이스 고정([4/4])은 하지 않는다 — 그 둘은 이 PC 의 설정이고 이 폴더의 것이 아니다.
|
|
781
|
+
// 그 폴더 하나만 고친다(판 폴더도 installed_plugins.json 도 없는 번들 사본, 또는 활성 캐시 폴더).
|
|
782
|
+
// 판 바꾸기([1/4])와 마켓플레이스 고정([4/4])은 하지 않는다 — 그 둘은 이 PC 의 설정이고 이 폴더의 것이 아니다.
|
|
550
783
|
if (!existsSync(join(ROOT, "hooks", "hooks.json")) || !existsSync(join(ROOT, "scripts"))) {
|
|
551
784
|
bad(`플러그인 폴더가 아님 (hooks/hooks.json 과 scripts/ 가 있어야 함): ${ROOT}`);
|
|
552
785
|
process.exit(1);
|
|
553
786
|
}
|
|
787
|
+
const other = otherPluginName(ROOT);
|
|
788
|
+
if (other) { bad(`OMC 플러그인 폴더가 아님 (${other}): ${ROOT}`); process.exit(1); }
|
|
554
789
|
log("[1/4] 버전");
|
|
555
790
|
log(" 건너뜀 (--root 는 판을 바꾸지 않음)");
|
|
556
791
|
active = ROOT;
|
|
@@ -560,7 +795,7 @@ if (ROOT) {
|
|
|
560
795
|
|
|
561
796
|
log("[1/4] 버전");
|
|
562
797
|
if (NO_UPDATE) log(" 건너뜀 (--no-update)");
|
|
563
|
-
else if (MODE === "revert") log(" 건너뜀 (revert 는 버전을 되돌리지 않음 — README 의 롤백 절차 참고)");
|
|
798
|
+
else if (MODE === "revert") log(" 건너뜀 (revert 는 버전을 되돌리지 않음 — 킷 배포 폴더 README 의 롤백 절차 참고)");
|
|
564
799
|
else if (!ensureVersion() && MODE !== "check") { bad("버전 확보 실패 — 중단"); process.exit(1); }
|
|
565
800
|
|
|
566
801
|
const versions = installedVersions();
|
|
@@ -586,6 +821,11 @@ const r = { done: 0, skip: 0, fail: 0, need: 0, misplaced: 0 };
|
|
|
586
821
|
const needBy = {};
|
|
587
822
|
const misplacedBy = {};
|
|
588
823
|
const foreignBy = {};
|
|
824
|
+
const orphanBy = {};
|
|
825
|
+
// 되돌리기 끝 줄이 세는 파일. 쓰지 못한 것(failed)과 백업이 없어 남은 것(left)이다. 링크로 이어진 판 폴더는
|
|
826
|
+
// 같은 파일을 여러 이름으로 보이므로 fileKey 로 한 번만 센다. 위 [2/4] 줄의 건수는 판 폴더마다 센다.
|
|
827
|
+
const failed = new Set();
|
|
828
|
+
const left = new Set();
|
|
589
829
|
for (const v of targets) {
|
|
590
830
|
const root = rootOf(v);
|
|
591
831
|
if (!existsSync(root)) continue;
|
|
@@ -595,16 +835,21 @@ for (const v of targets) {
|
|
|
595
835
|
misplacedBy[v] = x.misplaced;
|
|
596
836
|
for (const k of ["done", "skip", "fail", "need", "misplaced"]) r[k] += x[k];
|
|
597
837
|
if (x.foreign?.length) foreignBy[v] = x.foreign;
|
|
838
|
+
if (x.orphan?.length) orphanBy[v] = x.orphan;
|
|
839
|
+
for (const n of [...x.foreign, ...x.orphan]) left.add(fileKey(join(root, "scripts", `${n}.mjs`)));
|
|
840
|
+
for (const p of x.failed) failed.add(fileKey(p));
|
|
598
841
|
}
|
|
599
|
-
log(` ${MODE === "check" ? `패치 필요 ${r.need}건` : MODE === "revert" ? `되돌림 ${r.done}건` : `패치 ${r.done}건 / 실패 ${r.fail}건`}${r.misplaced ? ` / 헬퍼 자리 결함 ${r.misplaced}건` : ""} / 대상 아님 ${r.skip}건`);
|
|
600
|
-
//
|
|
842
|
+
log(` ${MODE === "check" ? `패치 필요 ${r.need}건` : MODE === "revert" ? `되돌림 ${r.done}건${r.fail ? ` / 실패 ${r.fail}건` : ""}` : `패치 ${r.done}건 / 실패 ${r.fail}건`}${r.misplaced ? ` / 헬퍼 자리 결함 ${r.misplaced}건` : ""} / 대상 아님 ${r.skip}건`);
|
|
843
|
+
// 백업이 없어 되돌리지 못한 파일은 되돌릴 원본이 디스크에 없다. 조용히 넘기면 «완전히
|
|
601
844
|
// 되돌렸다» 는 잘못된 인상을 준다 — 무엇이 남았고 어떻게 되찾는지 정확히 말한다.
|
|
602
|
-
|
|
603
|
-
bad(`${v}:
|
|
845
|
+
function reportNoBackup(v, list, what) {
|
|
846
|
+
bad(`${v}: ${what} — ${list.map(shown).join(", ")}`);
|
|
604
847
|
log(` 백업(.omcbak)이 없어 되돌릴 원본이 디스크에 없습니다. 원본이 필요하면:`);
|
|
605
848
|
if (ROOT) log(` 같은 판의 상류 원본에서 scripts/<이름>.mjs 를 다시 가져오십시오.`);
|
|
606
849
|
else log(` git -C "${MARKET}" archive v${v} scripts/<이름>.mjs | tar -x -C "${join(CACHE, v)}"`);
|
|
607
850
|
}
|
|
851
|
+
for (const [v, list] of Object.entries(foreignBy)) reportNoBackup(v, list, "이 도구가 만들지 않은 수정이 남아 있습니다");
|
|
852
|
+
for (const [v, list] of Object.entries(orphanBy)) reportNoBackup(v, list, "패치는 있는데 백업이 없어 되돌리지 못했습니다");
|
|
608
853
|
|
|
609
854
|
log();
|
|
610
855
|
log("[3/4] PostToolUse 훅 제거");
|
|
@@ -617,9 +862,11 @@ for (const v of targets) {
|
|
|
617
862
|
if (Array.isArray(hj)) ok(`${tag}제거: ${hj.join(", ")}`);
|
|
618
863
|
else if (hj === "already") ok(`${tag}이미 제거됨`);
|
|
619
864
|
else if (hj === "needs") hjNeedBy[v] = true;
|
|
620
|
-
else if (hj === "reverted" || hj === "ambiguous") { /* patchHooksJson 이 출력함 */ }
|
|
865
|
+
else if (hj === "reverted" || hj === "ambiguous" || hj === "linked") { /* patchHooksJson 이 출력함 */ }
|
|
621
866
|
else if (hj === "no-backup") ok(`${tag}되돌릴 백업 없음 (이 도구가 지운 적 없음)`);
|
|
622
867
|
else if (hj === "no-file") bad(`${tag}hooks.json 파일 자체가 없음`);
|
|
868
|
+
else if (hj === "unsafe") { r.fail++; failed.add(fileKey(join(root, "hooks", "hooks.json"))); }
|
|
869
|
+
if (hj === "ambiguous" || hj === "linked") left.add(fileKey(join(root, "hooks", "hooks.json")));
|
|
623
870
|
}
|
|
624
871
|
|
|
625
872
|
log();
|
|
@@ -661,19 +908,24 @@ if (MODE === "check") {
|
|
|
661
908
|
const clean = (needBy[active] || 0) === 0 && !hjNeedBy[active] && pin !== "needs" && misplacedActive === 0;
|
|
662
909
|
const others = targets.filter((v) => v !== active && ((needBy[v] || 0) > 0 || hjNeedBy[v] || (misplacedBy[v] || 0) > 0));
|
|
663
910
|
log(clean ? "결과: 모두 적용된 상태입니다."
|
|
664
|
-
: misplacedActive ? `결과: 헬퍼 자리 결함 ${misplacedActive}건이 있습니다. ${
|
|
911
|
+
: misplacedActive ? `결과: 헬퍼 자리 결함 ${misplacedActive}건이 있습니다. ${misplacedHint()}`
|
|
665
912
|
: ROOT ? "결과: 적용되지 않은 항목이 있습니다. --check 없이 같은 --root 로 다시 실행하십시오."
|
|
913
|
+
: NO_UPDATE ? "결과: 적용되지 않은 항목이 있습니다. --check 없이 --no-update 로 다시 실행하십시오."
|
|
666
914
|
: "결과: 적용되지 않은 항목이 있습니다. 인자 없이 다시 실행하십시오.");
|
|
667
915
|
if (others.length) {
|
|
668
916
|
log(` 참고: 비활성 버전 ${others.join(", ")} 에 미적용분이 있습니다.`);
|
|
669
917
|
log(" 지금 쓰이지 않는 버전이라 판정에 넣지 않았습니다. 그 버전으로 되돌릴 계획이면 함께 적용하십시오.");
|
|
670
918
|
}
|
|
671
919
|
} else if (MODE === "revert") {
|
|
672
|
-
|
|
920
|
+
// 백업이 없어 남은 파일과 hooks.json 도 «끝내지 못함» 이다. 완료라고 하면 남은 것을 보지 못하고 넘어간다.
|
|
921
|
+
const why = [failed.size && `실패 ${failed.size}건`, left.size && `되돌리지 못한 것 ${left.size}건`].filter(Boolean);
|
|
922
|
+
log(why.length
|
|
923
|
+
? `되돌리기를 끝내지 못했습니다. ${why.join(", ")}. 위의 [!] 줄을 확인하십시오.`
|
|
924
|
+
: "되돌리기 완료. Claude Code 를 재시작해야 반영됩니다.");
|
|
673
925
|
} else {
|
|
674
926
|
log(`완료. 실패 ${r.fail}건.`);
|
|
675
|
-
if (r.misplaced) log(`[!] 헬퍼 자리 결함 ${r.misplaced}건은 고치지 않았습니다. ${
|
|
927
|
+
if (r.misplaced) log(`[!] 헬퍼 자리 결함 ${r.misplaced}건은 고치지 않았습니다. ${misplacedHint()}`);
|
|
676
928
|
log(" 훅 스크립트 패치는 즉시 적용됩니다 (훅이 호출될 때마다 파일을 새로 읽습니다).");
|
|
677
929
|
log("[!] PostToolUse 훅 제거는 hooks.json 변경이라 각 세션이 다시 시작될 때부터 적용됩니다.");
|
|
678
930
|
}
|
|
679
|
-
process.exit(r.fail > 0 ? 1 : 0);
|
|
931
|
+
process.exit(r.fail > 0 || left.size > 0 ? 1 : 0);
|
|
@@ -116,7 +116,8 @@ PATH 의 banker : 0.2.0 (구버전)
|
|
|
116
116
|
claude plugin update banker@banker-plugins
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
-
- 옵션은 `-s/--scope`(`user`|`project`|`local`|`managed`, 기본 `
|
|
119
|
+
- Claude Code 2.1.296 의 옵션은 `-s/--scope`(`user`|`project`|`local`|`managed`, 기본 auto-detect), `--json`(사람용 문구 대신 기계용 결과 한 줄), `-y/--yes`, `--accept-command <sha256>`, `-h/--help` 다. help 문구는 "Update a plugin to the latest version (restart required to apply)".
|
|
120
|
+
- `-y` 와 `--accept-command` 는 마켓플레이스가 선언한 설치 명령(command 원천이나 headersHelper)을 승인할 때만 쓴다. banker 마켓플레이스는 원천이 저장소 폴더(`"source": "."`)라 선언한 명령이 없어 둘 다 필요 없다.
|
|
120
121
|
- **옵션은 문서가 아니라 live `--help` 가 정본이다.** 공식문서의 옵션표는 실제 CLI 보다 뒤처져 있다(`plugin install` 의 `--config` 누락 확인). 옵션을 쓰기 전에 `claude plugin update --help` 로 확인하라.
|
|
121
122
|
- `<plugin>` 은 bare name(`banker`) 또는 `plugin@marketplace`(`banker@banker-plugins`) 둘 다 받는다. 마켓플레이스가 여럿이면 후자로 특정한다.
|
|
122
123
|
- **반영에는 재시작이 필요하다**(help 가 명시). 재시작 전 관측이 옛 값인 것은 실패가 아니다.
|