@kaydash9999/banker-plugins 0.16.3 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +57 -0
- package/README.md +14 -11
- package/bin/banker.js +2 -2
- package/codex/manifest.json +2 -1
- package/codex/transform-matrix.md +2 -1
- package/commands/setup.md +2 -2
- package/hooks/progress-plan.mjs +408 -20
- package/hooks/progress.mjs +202 -50
- package/hooks/register.mjs +7 -1
- package/package.json +1 -1
- package/skills/3d-intro-build/references/assemble.mjs +10 -2
- package/skills/3d-intro-build/references/curate.mjs +10 -2
- package/skills/3d-intro-build/references/preview-lib.mjs +2 -0
- package/skills/3d-intro-build/references/serve.mjs +10 -2
- package/skills/3d-intro-build/references/verify-intro.mjs +10 -2
- package/skills/motion-graphic-make/references/curate.mjs +10 -2
- package/skills/motion-graphic-make/references/preview-lib.mjs +2 -0
- package/skills/motion-graphic-make/references/serve.mjs +10 -2
- package/skills/obsidizer/obsidize.mjs +10 -2
- package/skills/omc-patch/SKILL.md +105 -22
- package/skills/omc-patch/scripts/omc-patch.mjs +609 -255
- package/skills/omc-patch/scripts/probe-pm.mjs +192 -0
- package/skills/ultra-chain/SKILL.md +475 -0
- package/skills/ultra-chain/references/capture.mjs +232 -0
- package/skills/ultra-chain/references/chain-ledger.mjs +491 -0
- package/skills/ultra-chain/references/chain-report.mjs +257 -0
- package/skills/ultra-chain/references/preview-lib.mjs +258 -0
- package/skills/ultra-chain/references/serve.mjs +139 -0
- package/skills/update-banker/SKILL.md +2 -1
- package/skills/vertical-pptx/verify_a4p.mjs +10 -2
|
@@ -2,27 +2,35 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* OMC 패치 — 버전 고정 + 훅 결함 수정 + 자동 업데이트 차단.
|
|
4
4
|
*
|
|
5
|
-
* node omc-patch.mjs 업데이트(5.
|
|
5
|
+
* node omc-patch.mjs 업데이트(5.7.0) + 패치 + 고정
|
|
6
6
|
* node omc-patch.mjs --check 진단만 (아무것도 바꾸지 않음)
|
|
7
7
|
* node omc-patch.mjs --revert 패치·고정 되돌리기
|
|
8
8
|
* node omc-patch.mjs --no-update 버전은 그대로 두고 패치만
|
|
9
|
+
* node omc-patch.mjs --root <플러그인 폴더> [--check | --revert]
|
|
10
|
+
* 그 폴더의 hooks/hooks.json 과 scripts/ 만 다룬다(번들 사본, 활성 캐시 폴더 하나).
|
|
11
|
+
* installed_plugins.json, 마켓플레이스 origin, 판은 건드리지 않는다.
|
|
12
|
+
* 이름표(.claude-plugin/plugin.json 의 name, 없으면 package.json 의 name)가
|
|
13
|
+
* OMC 가 아니면 멈춘다.
|
|
14
|
+
* node omc-patch.mjs --scan <파일>...
|
|
15
|
+
* 헬퍼 선언의 블록 깊이와 래핑 자리를 JSON 으로 낸다(읽기 전용).
|
|
9
16
|
*
|
|
10
17
|
* 활성 버전은 installed_plugins.json 의 oh-my-claudecode@omc 항목(version, installPath)에서
|
|
11
|
-
* 읽는다. 캐시에
|
|
18
|
+
* 읽는다. 캐시에 TARGET_VERSION 이 있다고 그것을 활성으로 보지 않는다(--no-update 는 활성 버전을 패치).
|
|
12
19
|
* OMC_PATCH_INSTALLED=<json 경로> 로 읽을 파일을 바꿀 수 있다(시험용, --check 와 함께).
|
|
13
20
|
*
|
|
14
|
-
* OS: Windows / macOS / Linux 공통.
|
|
15
|
-
*
|
|
21
|
+
* OS: Windows / macOS / Linux 공통. 런처(apply.cmd, apply.sh)와 배경 문서(README.md)는
|
|
22
|
+
* 킷 배포 폴더에만 있다. 이 파일만 옮겨 쓰는 곳(예: 스킬 폴더)에는 없다.
|
|
16
23
|
*/
|
|
17
24
|
import {
|
|
18
|
-
readFileSync, writeFileSync, existsSync, copyFileSync, readdirSync,
|
|
19
|
-
unlinkSync, mkdirSync, rmSync, symlinkSync,
|
|
25
|
+
readFileSync, writeFileSync, writeSync, existsSync, copyFileSync, readdirSync,
|
|
26
|
+
unlinkSync, mkdirSync, rmSync, symlinkSync, lstatSync, realpathSync,
|
|
20
27
|
} from "node:fs";
|
|
21
|
-
import { join, dirname } from "node:path";
|
|
28
|
+
import { join, dirname, resolve, sep as PATH_SEP, delimiter as PATH_LIST_SEP } from "node:path";
|
|
22
29
|
import { homedir, hostname } from "node:os";
|
|
23
30
|
import { execFileSync } from "node:child_process";
|
|
24
31
|
|
|
25
|
-
|
|
32
|
+
// 인자 없는 apply 가 맞춰 놓는 판. banker 의 omc-patch 스킬은 늘 --no-update 로 돌려 이 값으로 판을 바꾸지 않는다.
|
|
33
|
+
const TARGET_VERSION = "5.7.0";
|
|
26
34
|
// 3초는 좁았다. 이 예산은 «정상이지만 느린» 로드까지 잘라내는데, 파일 스캔이 8.2배 느려진
|
|
27
35
|
// 이 PC 가 정확히 그 조건이다(실측: 4초 지연에서 session-end 가 조용히 건너뛰어졌다).
|
|
28
36
|
// 대안이 «무한 정지» 인 자리에만 걸리므로 넉넉히 잡는다.
|
|
@@ -36,9 +44,17 @@ const MARKETPLACE = "omc";
|
|
|
36
44
|
const PLUGIN_KEY = "oh-my-claudecode@omc";
|
|
37
45
|
const REPO_URL = "https://github.com/Yeachan-Heo/oh-my-claudecode.git";
|
|
38
46
|
|
|
39
|
-
const
|
|
47
|
+
const args = process.argv.slice(2);
|
|
48
|
+
const argv = new Set(args);
|
|
40
49
|
const MODE = argv.has("--revert") ? "revert" : argv.has("--check") ? "check" : "apply";
|
|
41
50
|
const NO_UPDATE = argv.has("--no-update");
|
|
51
|
+
// --root <플러그인 폴더>: 이 PC 의 설치 상태와 상관없이 그 폴더 하나만 고친다. 번들 사본처럼
|
|
52
|
+
// 판 폴더도 installed_plugins.json 도 없는 트리나, 활성 캐시 폴더 하나만 고칠 때 쓴다.
|
|
53
|
+
// 값이 없거나 다음 플래그면 멈춘다.
|
|
54
|
+
const ROOT_AT = args.indexOf("--root");
|
|
55
|
+
const ROOT_ARG = ROOT_AT >= 0 ? args[ROOT_AT + 1] : null;
|
|
56
|
+
const ROOT = ROOT_ARG && !ROOT_ARG.startsWith("--") ? resolve(ROOT_ARG) : null;
|
|
57
|
+
const SCAN_AT = args.indexOf("--scan");
|
|
42
58
|
|
|
43
59
|
const CFG = join(homedir(), ".claude");
|
|
44
60
|
const PLUGINS = join(CFG, "plugins");
|
|
@@ -100,19 +116,44 @@ const installedVersions = () =>
|
|
|
100
116
|
// installPath 마지막 구간 → 옛 규칙(TARGET 이 캐시에 있으면 TARGET, 아니면 최신) 순으로 본다.
|
|
101
117
|
function activeVersion() {
|
|
102
118
|
const versions = installedVersions();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (e) {
|
|
106
|
-
if (typeof e.version === "string" && /^\d+\.\d+\.\d+$/.test(e.version) && versions.includes(e.version)) return { v: e.version, src: "installed_plugins.json" };
|
|
107
|
-
const last = typeof e.installPath === "string" ? e.installPath.split(/[\\/]+/).filter(Boolean).pop() : null;
|
|
108
|
-
if (last && versions.includes(last)) return { v: last, src: "installed_plugins.json installPath" };
|
|
109
|
-
}
|
|
110
|
-
} catch { /* 읽기·파싱 실패는 옛 규칙으로 */ }
|
|
119
|
+
const found = entryVersion(installedEntry(), versions);
|
|
120
|
+
if (found) return found;
|
|
111
121
|
return { v: versions.includes(TARGET_VERSION) ? TARGET_VERSION : versions[0] || TARGET_VERSION, src: "추정: installed_plugins.json 에서 확인 못 함" };
|
|
112
122
|
}
|
|
113
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
|
+
|
|
114
155
|
function git(args, cwd = MARKET) {
|
|
115
|
-
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();
|
|
116
157
|
}
|
|
117
158
|
|
|
118
159
|
// ── 1. 업데이트 ──────────────────────────────────────────────────────────────
|
|
@@ -120,6 +161,19 @@ function ensureVersion() {
|
|
|
120
161
|
const have = installedVersions();
|
|
121
162
|
if (have.includes(TARGET_VERSION)) { ok(`${TARGET_VERSION} 설치되어 있음`); return true; }
|
|
122
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() {
|
|
123
177
|
if (!existsSync(MARKET)) { bad(`마켓플레이스 없음: ${MARKET}\n Claude Code 에서 marketplace 를 먼저 추가하십시오: ${REPO_URL}`); return false; }
|
|
124
178
|
if (!existsSync(join(MARKET, ".git"))) { bad(`마켓플레이스 폴더가 git 저장소가 아님: ${MARKET}`); return false; }
|
|
125
179
|
|
|
@@ -130,23 +184,28 @@ function ensureVersion() {
|
|
|
130
184
|
try { execFileSync(cmd, ["--version"], { stdio: "ignore" }); }
|
|
131
185
|
catch { bad(`${cmd} 를 찾을 수 없습니다 (${why} 필요). 설치한 뒤 다시 실행하거나, --no-update 로 패치만 적용하십시오.`); return false; }
|
|
132
186
|
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
133
189
|
|
|
134
|
-
|
|
190
|
+
// 태그 v<TARGET_VERSION> 의 커밋. 못 찾으면 null.
|
|
191
|
+
function targetSha() {
|
|
135
192
|
try { git(["fetch", "--tags", "--quiet", "origin"]); } catch { /* 오프라인이어도 로컬 태그가 있으면 진행 */ }
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
193
|
+
try { return git(["rev-parse", `v${TARGET_VERSION}^{}`]); }
|
|
194
|
+
catch { bad(`태그 v${TARGET_VERSION} 를 마켓플레이스에서 찾을 수 없음`); return null; }
|
|
195
|
+
}
|
|
139
196
|
|
|
140
|
-
|
|
197
|
+
// git archive | tar 로 소스를 편다. 저장소에 dist/ 가 포함되어 빌드는 불필요하다.
|
|
198
|
+
function unpackTarget(dest) {
|
|
141
199
|
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
|
|
142
200
|
mkdirSync(dest, { recursive: true });
|
|
143
|
-
|
|
144
|
-
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")]);
|
|
145
202
|
execFileSync("tar", ["-xf", join(dest, "_src.tar"), "-C", dest], { stdio: "ignore" });
|
|
146
203
|
unlinkSync(join(dest, "_src.tar"));
|
|
204
|
+
}
|
|
147
205
|
|
|
148
|
-
|
|
149
|
-
|
|
206
|
+
// node_modules 는 복사하지 않고 직전 버전 것을 링크한다. Windows 는 junction
|
|
207
|
+
// (관리자 권한 불필요), POSIX 는 type 인자가 무시되고 디렉터리 symlink 가 된다.
|
|
208
|
+
function linkNodeModules(dest, have) {
|
|
150
209
|
const prev = have.find((v) => v !== TARGET_VERSION);
|
|
151
210
|
const nm = join(dest, "node_modules");
|
|
152
211
|
if (!existsSync(nm) && prev && existsSync(join(CACHE, prev, "node_modules"))) {
|
|
@@ -155,8 +214,10 @@ function ensureVersion() {
|
|
|
155
214
|
} else if (!existsSync(nm)) {
|
|
156
215
|
bad(`node_modules 없음. ${dest} 에서 \`npm install --omit=dev\` 를 실행하십시오`);
|
|
157
216
|
}
|
|
217
|
+
}
|
|
158
218
|
|
|
159
|
-
|
|
219
|
+
// 활성 버전 전환
|
|
220
|
+
function switchActive(sha, have) {
|
|
160
221
|
const j = JSON.parse(readFileSync(INSTALLED, "utf8"));
|
|
161
222
|
const e = j.plugins?.[PLUGIN_KEY]?.[0];
|
|
162
223
|
if (!e) { bad(`installed_plugins.json 에 ${PLUGIN_KEY} 항목이 없음 — 수동 확인 필요`); return false; }
|
|
@@ -194,105 +255,164 @@ const REGEX_PREV_CH = new Set("(,=:[!&|?{};+-*%~^".split(""));
|
|
|
194
255
|
const REGEX_PREV_KW = new Set(["return", "typeof", "instanceof", "in", "of", "new", "delete",
|
|
195
256
|
"void", "case", "do", "else", "yield", "await"]);
|
|
196
257
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return src.slice(j + 1, e + 1);
|
|
207
|
-
};
|
|
208
|
-
const backSkip = (j) => {
|
|
209
|
-
for (;;) {
|
|
210
|
-
while (j >= 0 && /\s/.test(src[j])) j--;
|
|
211
|
-
if (j >= 1 && src[j] === "/" && src[j - 1] === "*") { // */ 로 끝나는 주석
|
|
212
|
-
const s = src.lastIndexOf("/*", j - 1);
|
|
213
|
-
if (s < 0) return -1;
|
|
214
|
-
j = s - 1; continue;
|
|
215
|
-
}
|
|
216
|
-
return j;
|
|
217
|
-
}
|
|
218
|
-
};
|
|
219
|
-
const skipString = (q) => {
|
|
220
|
-
i++;
|
|
221
|
-
while (i < n) {
|
|
222
|
-
const c = src[i];
|
|
223
|
-
if (c === "\\") { i += 2; continue; }
|
|
224
|
-
if (c === q) { i++; return; }
|
|
225
|
-
if (q === "`" && c === "$" && src[i + 1] === "{") {
|
|
226
|
-
i += 2;
|
|
227
|
-
let d = 1;
|
|
228
|
-
while (i < n && d > 0) {
|
|
229
|
-
const k = src[i];
|
|
230
|
-
if (k === "\\") { i += 2; continue; }
|
|
231
|
-
if (k === "'" || k === '"' || k === "`") { skipString(k); continue; }
|
|
232
|
-
if (k === "{") d++;
|
|
233
|
-
else if (k === "}") d--;
|
|
234
|
-
i++;
|
|
235
|
-
}
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
i++;
|
|
239
|
-
}
|
|
240
|
-
};
|
|
241
|
-
// 이 중괄호가 «함수 몸통» 을 여는가
|
|
242
|
-
const isFunctionBrace = (at) => {
|
|
243
|
-
let j = backSkip(at - 1);
|
|
244
|
-
if (j < 0) return false;
|
|
245
|
-
if (src[j] === ">" && src[j - 1] === "=") return true; // () => {
|
|
246
|
-
if (src[j] !== ")") return false; // class/객체/블록
|
|
247
|
-
let d = 0;
|
|
248
|
-
for (; j >= 0; j--) { // 짝 맞는 ( 찾기
|
|
249
|
-
if (src[j] === ")") d++;
|
|
250
|
-
else if (src[j] === "(") { if (--d === 0) break; }
|
|
251
|
-
}
|
|
252
|
-
if (j < 0) return false;
|
|
253
|
-
const k = backSkip(j - 1);
|
|
254
|
-
if (k < 0 || !ID_CH.test(src[k])) return false;
|
|
255
|
-
return !BLOCK_KW.has(wordEndingAt(k)); // if/for/while/catch… 은 블록
|
|
256
|
-
};
|
|
258
|
+
// probe(i, fnStack) 를 주면 코드 글자(문자열·주석·정규식·중괄호가 아닌 자리)마다 부른다.
|
|
259
|
+
// --scan 이 같은 토크나이저로 헬퍼 선언의 깊이를 재는 데 쓴다. 패치 경로는 probe 없이 부른다.
|
|
260
|
+
function scanTopLevelAwaitImports(src, probe) {
|
|
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
|
+
}
|
|
257
267
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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;
|
|
282
|
+
}
|
|
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++;
|
|
289
359
|
}
|
|
290
|
-
|
|
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;
|
|
291
406
|
}
|
|
292
407
|
|
|
293
408
|
// 자리별 상태. 이미 손으로 Promise.race 를 두른 자리는 건드리지 않는다(4.15.7 에 그런 것이 둘 있다).
|
|
409
|
+
// 이미 감싼 파일이라도 헬퍼가 쓰일 수 없는 자리면(옛 판의 자리) «패치됨» 이 아니라 «misplaced» 다.
|
|
410
|
+
// 그 상태를 «패치됨» 으로 넘기면 --check 가 결함 트리에 «모두 적용» 을 낸다(2026-10-12 실측).
|
|
294
411
|
function siteInfo(src) {
|
|
295
|
-
if (src.includes("__omcRaceImport"))
|
|
412
|
+
if (src.includes("__omcRaceImport")) {
|
|
413
|
+
const why = helperDefect(scanFacts(src));
|
|
414
|
+
return why ? { state: "misplaced", sites: [], why } : { state: "patched", sites: [] };
|
|
415
|
+
}
|
|
296
416
|
const all = scanTopLevelAwaitImports(src);
|
|
297
417
|
if (!all.length) return { state: "n/a", sites: [] };
|
|
298
418
|
const open = all.filter((p) => !/Promise\.race/.test(src.slice(Math.max(0, p - 300), p)));
|
|
@@ -300,22 +420,26 @@ function siteInfo(src) {
|
|
|
300
420
|
return { state: "needs", sites: open };
|
|
301
421
|
}
|
|
302
422
|
|
|
303
|
-
|
|
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
|
+
}
|
|
304
438
|
|
|
305
439
|
function transform(src) {
|
|
306
440
|
const info = siteInfo(src);
|
|
307
441
|
if (info.state !== "needs") return null;
|
|
308
|
-
const edits =
|
|
309
|
-
for (const start of info.sites) {
|
|
310
|
-
const open = src.indexOf("(", src.indexOf("import", start));
|
|
311
|
-
let depth = 0, end = -1;
|
|
312
|
-
for (let i = open; i < src.length; i++) {
|
|
313
|
-
const c = src[i];
|
|
314
|
-
if (c === "(") depth++;
|
|
315
|
-
else if (c === ")" && --depth === 0) { end = i; break; }
|
|
316
|
-
}
|
|
317
|
-
if (end !== -1) edits.push({ start, end });
|
|
318
|
-
}
|
|
442
|
+
const edits = importSpans(src, info.sites);
|
|
319
443
|
if (!edits.length) return null;
|
|
320
444
|
let out = src;
|
|
321
445
|
for (const e of edits.reverse()) {
|
|
@@ -332,6 +456,57 @@ function transform(src) {
|
|
|
332
456
|
return out.slice(0, ls) + HELPER + out.slice(ls);
|
|
333
457
|
}
|
|
334
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
|
+
|
|
480
|
+
// ── 읽기 전용 진단 (--scan) ──────────────────────────────────────────────────
|
|
481
|
+
// 패치가 «제자리» 에 앉았는지를 위 토크나이저 하나로 잰다. 시험이 토크나이저를 따로 가지면
|
|
482
|
+
// 패치가 고르는 자리와 시험이 재는 자리가 서로 다른 규칙을 따르게 된다.
|
|
483
|
+
// helperDecls 헬퍼 선언마다 { line, depth }. depth 는 둘러싼 중괄호 수(함수 몸통과 블록).
|
|
484
|
+
// 0 이어야 최상위 문장이다. 블록 안 const 는 블록 범위라 다른 블록의 자리에서
|
|
485
|
+
// ReferenceError 가 나고, 훅의 catch 가 그것을 삼킨다(옛 자리 결함).
|
|
486
|
+
// wrapped `await __omcRaceImport(` 자리마다 { line, inFunction }. 패치는 최상위 자리만 감싼다.
|
|
487
|
+
// unwrapped 아직 감싸지지 않은 최상위 `await import(` 의 줄.
|
|
488
|
+
function scanFacts(src) {
|
|
489
|
+
const lineOf = (at) => src.slice(0, at).split("\n").length;
|
|
490
|
+
const helperDecls = [], wrapped = [];
|
|
491
|
+
const unwrapped = scanTopLevelAwaitImports(src, (i, fnStack) => {
|
|
492
|
+
const ch = src[i];
|
|
493
|
+
if (ch !== "c" && ch !== "l" && ch !== "v" && ch !== "f" && ch !== "a") return;
|
|
494
|
+
if (i > 0 && ID_CH.test(src[i - 1])) return;
|
|
495
|
+
const head = src.slice(i, i + 48);
|
|
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 });
|
|
499
|
+
}).map(lineOf);
|
|
500
|
+
return { helperDecls, wrapped, unwrapped };
|
|
501
|
+
}
|
|
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
|
+
|
|
335
510
|
function syntaxError(file) {
|
|
336
511
|
try { execFileSync(process.execPath, ["--check", file], { stdio: ["ignore", "ignore", "pipe"] }); return null; }
|
|
337
512
|
catch (e) {
|
|
@@ -346,8 +521,7 @@ const looksHandPatched = (src) =>
|
|
|
346
521
|
!src.includes("__omcRaceImport") && /Promise\.race\s*\(\s*\[?[\s\S]{0,120}?\bimport\s*\(/.test(src);
|
|
347
522
|
|
|
348
523
|
function patchHooks(root) {
|
|
349
|
-
|
|
350
|
-
const foreign = [];
|
|
524
|
+
const r = { done: 0, skip: 0, fail: 0, need: 0, misplaced: 0, foreign: [], orphan: [], failed: [] };
|
|
351
525
|
// revert 는 scripts/ 전체를 훑는다. hooks.json 기반 목록은 두 번 새는데, 실측으로
|
|
352
526
|
// 4.15.7 이 둘 다 겪었다 — PostToolUse 를 지우면 그 스크립트가 목록에서 빠지고(그래서
|
|
353
527
|
// .omcbak 이 있어도 안 돌아온다), 손으로 고친 파일은 .omcbak 조차 없어 아예 안 보인다.
|
|
@@ -356,58 +530,138 @@ function patchHooks(root) {
|
|
|
356
530
|
: hookNames(root);
|
|
357
531
|
for (const n of names) {
|
|
358
532
|
const f = join(root, "scripts", `${n}.mjs`);
|
|
359
|
-
const bak = `${f}.omcbak`;
|
|
360
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
|
+
}
|
|
361
539
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (looksHandPatched(readFileSync(f, "utf8"))) foreign.push(n);
|
|
369
|
-
continue;
|
|
370
|
-
}
|
|
371
|
-
const st = needsPatch(readFileSync(f, "utf8"));
|
|
372
|
-
if (st !== "needs") { skip++; continue; }
|
|
373
|
-
if (MODE === "check") { bad(`패치 필요: ${n}`); need++; continue; }
|
|
374
|
-
|
|
375
|
-
const out = transform(readFileSync(f, "utf8"));
|
|
376
|
-
if (!out) { bad(`변환 실패: ${n}`); fail++; continue; }
|
|
377
|
-
if (!existsSync(bak)) copyFileSync(f, bak);
|
|
378
|
-
writeFileSync(f, out, "utf8");
|
|
379
|
-
const err = syntaxError(f);
|
|
380
|
-
if (err) { copyFileSync(bak, f); bad(`문법오류로 롤백: ${n}\n ${err}`); fail++; continue; }
|
|
381
|
-
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;
|
|
382
546
|
}
|
|
383
|
-
|
|
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);
|
|
384
554
|
}
|
|
385
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;
|
|
582
|
+
}
|
|
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
|
+
|
|
386
605
|
// ── 3. PostToolUse 훅 제거 ───────────────────────────────────────────────────
|
|
387
606
|
function patchHooksJson(root) {
|
|
388
607
|
const hj = join(root, "hooks", "hooks.json");
|
|
389
|
-
const bak = `${hj}.omcbak`;
|
|
390
608
|
if (!existsSync(hj)) return "no-file";
|
|
391
|
-
if (MODE
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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);
|
|
410
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`;
|
|
411
665
|
const j = JSON.parse(readFileSync(hj, "utf8"));
|
|
412
666
|
if (!j.hooks?.PostToolUse) return "already";
|
|
413
667
|
const list = j.hooks.PostToolUse.flatMap((g) => (g.hooks || []).map((h) => (h.command || "").match(/([\w-]+)\.mjs/)?.[1] || "?"));
|
|
@@ -425,38 +679,20 @@ function patchHooksJson(root) {
|
|
|
425
679
|
// 올릴 수 있다. 그러면 위 패치가 통째로 덮어써진다. 로컬에서 막을 수 있는
|
|
426
680
|
// 지점은 "마켓플레이스 저장소가 새 커밋을 가져오지 못하게" 하는 것뿐이다.
|
|
427
681
|
// origin remote 의 URL 을 무효 주소로 바꿔 fetch 를 실패시킨다(원래 URL 은
|
|
428
|
-
//
|
|
429
|
-
// 이미 받은 태그로 재설치·검증이 가능하다.
|
|
682
|
+
// `omc-pinned://` 뒤에 그대로 남기고 --revert 가 거기서 복구한다. PINNED 파일은 기록이다).
|
|
683
|
+
// 저장소 자체는 그대로라 이미 받은 태그로 재설치·검증이 가능하다.
|
|
430
684
|
function pinMarketplace() {
|
|
431
685
|
if (!existsSync(MARKET)) return "no-marketplace";
|
|
432
686
|
// 마켓플레이스 폴더가 제 git 저장소가 아니면 git 이 위쪽 저장소(예: dotfiles 로 관리하는
|
|
433
|
-
// ~/.claude)를 찾아 그 origin 을 바꾼다. 제 .git 이 있을 때만 고정하고 되돌린다.
|
|
687
|
+
// ~/.claude)를 찾아 그 origin 을 바꾼다. 제 .git 이 있을 때만 고정하고 되돌린다. .git 이 있어도
|
|
688
|
+
// 깨졌으면 git 이 위로 올라가는데, 그것은 gitEnv 의 천장이 막는다(origin 을 못 읽어 no-remote).
|
|
434
689
|
if (!existsSync(join(MARKET, ".git"))) return "no-git";
|
|
435
690
|
let url;
|
|
436
691
|
try { url = git(["remote", "get-url", "origin"]); } catch { return "no-remote"; }
|
|
437
692
|
|
|
438
|
-
if (MODE === "revert")
|
|
439
|
-
// 원본 URL 은 현재 origin 안에 그대로 들어 있다(`omc-pinned://` + 원본). 파일이 아니라
|
|
440
|
-
// 거기서 복원한다. PINNED 는 배포 폴더에 같이 딸려 오므로 다른 PC 의 값일 수 있고,
|
|
441
|
-
// PINFILE 은 스크립트 위치가 아니라 homedir() 기준이라 그 사실이 눈에 띄지도 않는다.
|
|
442
|
-
const PIN_PREFIX = "omc-pinned://";
|
|
443
|
-
let restored = null;
|
|
444
|
-
if (url.startsWith(PIN_PREFIX)) restored = url.slice(PIN_PREFIX.length);
|
|
445
|
-
if (existsSync(PINFILE)) {
|
|
446
|
-
let saved = {};
|
|
447
|
-
try { saved = JSON.parse(readFileSync(PINFILE, "utf8")); } catch { /* 깨졌으면 무시 */ }
|
|
448
|
-
if (saved.host && saved.host !== hostname()) {
|
|
449
|
-
bad(`PINNED 는 다른 기기(${saved.host})의 기록입니다 — 그 값은 쓰지 않고 현재 origin 에서 복원합니다.`);
|
|
450
|
-
}
|
|
451
|
-
if (!restored && saved.originUrl) restored = saved.originUrl;
|
|
452
|
-
unlinkSync(PINFILE);
|
|
453
|
-
}
|
|
454
|
-
if (!restored) return "not-pinned";
|
|
455
|
-
try { git(["remote", "set-url", "origin", restored]); } catch { /* noop */ }
|
|
456
|
-
return "unpinned";
|
|
457
|
-
}
|
|
693
|
+
if (MODE === "revert") return unpinMarketplace(url);
|
|
458
694
|
if (url.startsWith("omc-pinned://")) return "already";
|
|
459
|
-
if (MODE === "check") { bad(`마켓플레이스 미고정 (origin=${url})`); return "needs"; }
|
|
695
|
+
if (MODE === "check") { bad(`마켓플레이스 미고정 (origin=${oneLine(url)})`); return "needs"; }
|
|
460
696
|
|
|
461
697
|
mkdirSync(dirname(PINFILE), { recursive: true });
|
|
462
698
|
// host 를 남긴다. 이 폴더는 통째로 복사해 배포하므로 PINNED 도 같이 딸려 가는데,
|
|
@@ -466,112 +702,230 @@ function pinMarketplace() {
|
|
|
466
702
|
return "pinned";
|
|
467
703
|
}
|
|
468
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
|
+
|
|
743
|
+
// ── --scan: 사실만 내고 끝낸다 ───────────────────────────────────────────────
|
|
744
|
+
// 아래 main 보다 먼저 끝나야 한다. 이 모드는 installed_plugins.json 도 플러그인 캐시도 읽지 않는다.
|
|
745
|
+
// 결과는 입력 순서대로 JSON 배열 한 줄이다. 동기 쓰기로 내보내야 process.exit 가 잘라먹지 않는다.
|
|
746
|
+
if (SCAN_AT >= 0) {
|
|
747
|
+
const files = args.slice(SCAN_AT + 1);
|
|
748
|
+
if (!files.length) { console.error("--scan 뒤에 파일 경로가 필요합니다."); process.exit(1); }
|
|
749
|
+
const out = files.map((file) => {
|
|
750
|
+
try { return { file, ...publicFacts(scanFacts(readFileSync(file, "utf8"))) }; }
|
|
751
|
+
catch (e) { return { file, error: String(e.code || e.message) }; }
|
|
752
|
+
});
|
|
753
|
+
writeSync(1, JSON.stringify(out) + "\n");
|
|
754
|
+
process.exit(out.some((x) => x.error) ? 1 : 0);
|
|
755
|
+
}
|
|
756
|
+
|
|
469
757
|
// ── main ─────────────────────────────────────────────────────────────────────
|
|
470
|
-
|
|
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
|
+
}
|
|
764
|
+
log(ROOT
|
|
765
|
+
? `OMC 패치 (대상 폴더 ${ROOT}) 모드: ${MODE}`
|
|
766
|
+
: `OMC 패치 (대상 ${TARGET_VERSION}) 모드: ${MODE}${NO_UPDATE ? " --no-update" : ""}`);
|
|
471
767
|
log(`플랫폼: ${process.platform} node ${process.version}`);
|
|
472
768
|
log();
|
|
473
769
|
|
|
474
|
-
|
|
770
|
+
// targets 는 «고칠 플러그인 폴더» 의 이름표 목록이다. 기본은 캐시의 판 번호이고, --root 는
|
|
771
|
+
// 그 폴더 경로 하나다. rootOf 가 이름표를 폴더로 바꾼다.
|
|
772
|
+
let active, targets;
|
|
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)}" 로 활성 폴더만 되돌린 뒤 다시 적용하십시오.`);
|
|
780
|
+
if (ROOT) {
|
|
781
|
+
// 그 폴더 하나만 고친다(판 폴더도 installed_plugins.json 도 없는 번들 사본, 또는 활성 캐시 폴더).
|
|
782
|
+
// 판 바꾸기([1/4])와 마켓플레이스 고정([4/4])은 하지 않는다 — 그 둘은 이 PC 의 설정이고 이 폴더의 것이 아니다.
|
|
783
|
+
if (!existsSync(join(ROOT, "hooks", "hooks.json")) || !existsSync(join(ROOT, "scripts"))) {
|
|
784
|
+
bad(`플러그인 폴더가 아님 (hooks/hooks.json 과 scripts/ 가 있어야 함): ${ROOT}`);
|
|
785
|
+
process.exit(1);
|
|
786
|
+
}
|
|
787
|
+
const other = otherPluginName(ROOT);
|
|
788
|
+
if (other) { bad(`OMC 플러그인 폴더가 아님 (${other}): ${ROOT}`); process.exit(1); }
|
|
789
|
+
log("[1/4] 버전");
|
|
790
|
+
log(" 건너뜀 (--root 는 판을 바꾸지 않음)");
|
|
791
|
+
active = ROOT;
|
|
792
|
+
targets = [ROOT];
|
|
793
|
+
} else {
|
|
794
|
+
if (!existsSync(INSTALLED)) { bad(`Claude Code 플러그인 설정을 찾을 수 없음: ${INSTALLED}`); process.exit(1); }
|
|
475
795
|
|
|
476
|
-
log("[1/4] 버전");
|
|
477
|
-
if (NO_UPDATE) log(" 건너뜀 (--no-update)");
|
|
478
|
-
else if (MODE === "revert") log(" 건너뜀 (revert 는 버전을 되돌리지 않음 — README 의 롤백 절차 참고)");
|
|
479
|
-
else if (!ensureVersion() && MODE !== "check") { bad("버전 확보 실패 — 중단"); process.exit(1); }
|
|
796
|
+
log("[1/4] 버전");
|
|
797
|
+
if (NO_UPDATE) log(" 건너뜀 (--no-update)");
|
|
798
|
+
else if (MODE === "revert") log(" 건너뜀 (revert 는 버전을 되돌리지 않음 — 킷 배포 폴더 README 의 롤백 절차 참고)");
|
|
799
|
+
else if (!ensureVersion() && MODE !== "check") { bad("버전 확보 실패 — 중단"); process.exit(1); }
|
|
480
800
|
|
|
481
|
-
const versions = installedVersions();
|
|
482
|
-
// [1/4] 뒤에 계산해야 일반 apply 가 TARGET_VERSION 으로 전환한 결과가 반영된다.
|
|
483
|
-
const { v
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
801
|
+
const versions = installedVersions();
|
|
802
|
+
// [1/4] 뒤에 계산해야 일반 apply 가 TARGET_VERSION 으로 전환한 결과가 반영된다.
|
|
803
|
+
const { v, src: activeSrc } = activeVersion();
|
|
804
|
+
active = v;
|
|
805
|
+
if (MODE !== "revert" && (MODE === "check" || NO_UPDATE)) {
|
|
806
|
+
log(` 활성 버전: ${active} (${activeSrc})`);
|
|
807
|
+
if (active !== TARGET_VERSION) {
|
|
808
|
+
log(` 활성 버전이 대상(${TARGET_VERSION})과 다름 — 기본 apply 는 ${TARGET_VERSION} 로 전환하고, --no-update 는 활성 버전(${active})을 그 자리에서 패치함`);
|
|
809
|
+
}
|
|
488
810
|
}
|
|
811
|
+
// apply 는 활성 버전만 손댄다. revert 와 check 는 설치된 모든 버전을 훑는다 —
|
|
812
|
+
// 여러 버전이 나란히 있을 때 이전 버전에 수정이 남으면, 롤백했을 때 예상과 다른
|
|
813
|
+
// 상태가 된다(실제로 4.15.7 의 hooks.json 수정이 그렇게 남았다).
|
|
814
|
+
targets = MODE === "apply" ? [active] : (versions.length ? versions : [active]);
|
|
815
|
+
if (!existsSync(join(CACHE, active))) { bad(`설치 경로 없음: ${join(CACHE, active)}`); process.exit(1); }
|
|
489
816
|
}
|
|
490
|
-
// apply 는 활성 버전만 손댄다. revert 와 check 는 설치된 모든 버전을 훑는다 —
|
|
491
|
-
// 여러 버전이 나란히 있을 때 이전 버전에 수정이 남으면, 롤백했을 때 예상과 다른
|
|
492
|
-
// 상태가 된다(실제로 4.15.7 의 hooks.json 수정이 그렇게 남았다).
|
|
493
|
-
const targets = MODE === "apply" ? [active] : (versions.length ? versions : [active]);
|
|
494
|
-
if (!existsSync(join(CACHE, active))) { bad(`설치 경로 없음: ${join(CACHE, active)}`); process.exit(1); }
|
|
495
817
|
|
|
496
818
|
log();
|
|
497
|
-
log(`[2/4] 훅 패치${MODE === "apply" ? ` (${active})` : ` (대상 ${targets.length}개 버전)`}`);
|
|
498
|
-
const r = { done: 0, skip: 0, fail: 0, need: 0 };
|
|
819
|
+
log(`[2/4] 훅 패치${MODE === "apply" || ROOT ? ` (${active})` : ` (대상 ${targets.length}개 버전)`}`);
|
|
820
|
+
const r = { done: 0, skip: 0, fail: 0, need: 0, misplaced: 0 };
|
|
499
821
|
const needBy = {};
|
|
822
|
+
const misplacedBy = {};
|
|
500
823
|
const foreignBy = {};
|
|
824
|
+
const orphanBy = {};
|
|
825
|
+
// 되돌리기 끝 줄이 세는 파일. 쓰지 못한 것(failed)과 백업이 없어 남은 것(left)이다. 링크로 이어진 판 폴더는
|
|
826
|
+
// 같은 파일을 여러 이름으로 보이므로 fileKey 로 한 번만 센다. 위 [2/4] 줄의 건수는 판 폴더마다 센다.
|
|
827
|
+
const failed = new Set();
|
|
828
|
+
const left = new Set();
|
|
501
829
|
for (const v of targets) {
|
|
502
|
-
const root =
|
|
830
|
+
const root = rootOf(v);
|
|
503
831
|
if (!existsSync(root)) continue;
|
|
504
832
|
if (targets.length > 1) log(` — ${v}`);
|
|
505
833
|
const x = patchHooks(root);
|
|
506
834
|
needBy[v] = x.need;
|
|
507
|
-
|
|
835
|
+
misplacedBy[v] = x.misplaced;
|
|
836
|
+
for (const k of ["done", "skip", "fail", "need", "misplaced"]) r[k] += x[k];
|
|
508
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));
|
|
509
841
|
}
|
|
510
|
-
log(` ${MODE === "check" ? `패치 필요 ${r.need}건` : MODE === "revert" ? `되돌림 ${r.done}건` : `패치 ${r.done}건 / 실패 ${r.fail}건`} / 대상 아님 ${r.skip}건`);
|
|
511
|
-
//
|
|
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
|
+
// 백업이 없어 되돌리지 못한 파일은 되돌릴 원본이 디스크에 없다. 조용히 넘기면 «완전히
|
|
512
844
|
// 되돌렸다» 는 잘못된 인상을 준다 — 무엇이 남았고 어떻게 되찾는지 정확히 말한다.
|
|
513
|
-
|
|
514
|
-
bad(`${v}:
|
|
845
|
+
function reportNoBackup(v, list, what) {
|
|
846
|
+
bad(`${v}: ${what} — ${list.map(shown).join(", ")}`);
|
|
515
847
|
log(` 백업(.omcbak)이 없어 되돌릴 원본이 디스크에 없습니다. 원본이 필요하면:`);
|
|
516
|
-
log(`
|
|
848
|
+
if (ROOT) log(` 같은 판의 상류 원본에서 scripts/<이름>.mjs 를 다시 가져오십시오.`);
|
|
849
|
+
else log(` git -C "${MARKET}" archive v${v} scripts/<이름>.mjs | tar -x -C "${join(CACHE, v)}"`);
|
|
517
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, "패치는 있는데 백업이 없어 되돌리지 못했습니다");
|
|
518
853
|
|
|
519
854
|
log();
|
|
520
855
|
log("[3/4] PostToolUse 훅 제거");
|
|
521
856
|
const hjNeedBy = {};
|
|
522
857
|
for (const v of targets) {
|
|
523
|
-
const root =
|
|
858
|
+
const root = rootOf(v);
|
|
524
859
|
if (!existsSync(root)) continue;
|
|
525
860
|
const hj = patchHooksJson(root);
|
|
526
861
|
const tag = targets.length > 1 ? `${v}: ` : "";
|
|
527
862
|
if (Array.isArray(hj)) ok(`${tag}제거: ${hj.join(", ")}`);
|
|
528
863
|
else if (hj === "already") ok(`${tag}이미 제거됨`);
|
|
529
864
|
else if (hj === "needs") hjNeedBy[v] = true;
|
|
530
|
-
else if (hj === "reverted" || hj === "ambiguous") { /* patchHooksJson 이 출력함 */ }
|
|
865
|
+
else if (hj === "reverted" || hj === "ambiguous" || hj === "linked") { /* patchHooksJson 이 출력함 */ }
|
|
531
866
|
else if (hj === "no-backup") ok(`${tag}되돌릴 백업 없음 (이 도구가 지운 적 없음)`);
|
|
532
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")));
|
|
533
870
|
}
|
|
534
871
|
|
|
535
872
|
log();
|
|
536
873
|
log("[4/4] 자동 업데이트 차단");
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
874
|
+
let pin = "skipped";
|
|
875
|
+
if (ROOT) {
|
|
876
|
+
log(" 건너뜀 (--root 는 마켓플레이스와 settings.json 을 건드리지 않음)");
|
|
877
|
+
} else {
|
|
878
|
+
// Claude Code 는 세션 시작 후 최대 10분 지연으로 마켓플레이스를 자동 fetch 한다.
|
|
879
|
+
// 공식 차단 수단은 두 가지뿐이고 각각 한계가 있어, 현재 상태를 같이 보여준다.
|
|
880
|
+
// - DISABLE_AUTOUPDATER=1 : 확실하지만 전역(다른 마켓플레이스도 멈춤)
|
|
881
|
+
// - /plugin UI 의 마켓플레이스별 토글 : omc 만 끄지만 UI 상태라 파일로 확인 불가
|
|
882
|
+
try {
|
|
883
|
+
const st = JSON.parse(readFileSync(join(CFG, "settings.json"), "utf8"));
|
|
884
|
+
const off = st?.env?.DISABLE_AUTOUPDATER;
|
|
885
|
+
log(` DISABLE_AUTOUPDATER: ${off ? `${off} (전역 차단 중)` : "미설정 (다른 마켓플레이스는 계속 자동 업데이트)"}`);
|
|
886
|
+
} catch { /* settings.json 이 없거나 읽을 수 없으면 표시만 생략 */ }
|
|
887
|
+
pin = pinMarketplace();
|
|
888
|
+
({
|
|
889
|
+
pinned: () => ok("마켓플레이스 고정 (origin fetch 차단)"),
|
|
890
|
+
already: () => ok("이미 고정됨"),
|
|
891
|
+
unpinned: () => ok("고정 해제 (origin 복구)"),
|
|
892
|
+
"not-pinned": () => ok("고정된 적 없음"),
|
|
893
|
+
needs: () => {},
|
|
894
|
+
"no-marketplace": () => bad("마켓플레이스 디렉터리 없음 — 고정 불필요"),
|
|
895
|
+
"no-git": () => bad("마켓플레이스 폴더가 git 저장소가 아님 — 고정하지 않음"),
|
|
896
|
+
"no-remote": () => ok("origin remote 없음 — 이미 fetch 불가"),
|
|
897
|
+
}[pin] || (() => {}))();
|
|
898
|
+
}
|
|
557
899
|
|
|
558
900
|
log();
|
|
559
901
|
if (MODE === "check") {
|
|
560
902
|
// 판정은 «활성 버전» 기준이다. apply 는 활성 버전만 손대므로, 판정만 모든 버전을 보면
|
|
561
903
|
// 캐시에 옛 버전이 남아 있는 한 apply 를 몇 번 돌려도 영원히 «미적용» 으로 나온다
|
|
562
904
|
// (3-OS 에서 동일하게 재현). 비활성 버전은 정보로만 알린다.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
905
|
+
// 헬퍼 자리 결함이 하나라도 있으면 «모두 적용» 이 아니다. 다시 적용만으로는 안 고쳐지므로
|
|
906
|
+
// 그 안내를 먼저 낸다(되돌린 뒤 적용하면 «패치 필요» 항목도 함께 처리된다).
|
|
907
|
+
const misplacedActive = misplacedBy[active] || 0;
|
|
908
|
+
const clean = (needBy[active] || 0) === 0 && !hjNeedBy[active] && pin !== "needs" && misplacedActive === 0;
|
|
909
|
+
const others = targets.filter((v) => v !== active && ((needBy[v] || 0) > 0 || hjNeedBy[v] || (misplacedBy[v] || 0) > 0));
|
|
910
|
+
log(clean ? "결과: 모두 적용된 상태입니다."
|
|
911
|
+
: misplacedActive ? `결과: 헬퍼 자리 결함 ${misplacedActive}건이 있습니다. ${misplacedHint()}`
|
|
912
|
+
: ROOT ? "결과: 적용되지 않은 항목이 있습니다. --check 없이 같은 --root 로 다시 실행하십시오."
|
|
913
|
+
: NO_UPDATE ? "결과: 적용되지 않은 항목이 있습니다. --check 없이 --no-update 로 다시 실행하십시오."
|
|
914
|
+
: "결과: 적용되지 않은 항목이 있습니다. 인자 없이 다시 실행하십시오.");
|
|
566
915
|
if (others.length) {
|
|
567
916
|
log(` 참고: 비활성 버전 ${others.join(", ")} 에 미적용분이 있습니다.`);
|
|
568
917
|
log(" 지금 쓰이지 않는 버전이라 판정에 넣지 않았습니다. 그 버전으로 되돌릴 계획이면 함께 적용하십시오.");
|
|
569
918
|
}
|
|
570
919
|
} else if (MODE === "revert") {
|
|
571
|
-
|
|
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 를 재시작해야 반영됩니다.");
|
|
572
925
|
} else {
|
|
573
926
|
log(`완료. 실패 ${r.fail}건.`);
|
|
927
|
+
if (r.misplaced) log(`[!] 헬퍼 자리 결함 ${r.misplaced}건은 고치지 않았습니다. ${misplacedHint()}`);
|
|
574
928
|
log(" 훅 스크립트 패치는 즉시 적용됩니다 (훅이 호출될 때마다 파일을 새로 읽습니다).");
|
|
575
929
|
log("[!] PostToolUse 훅 제거는 hooks.json 변경이라 각 세션이 다시 시작될 때부터 적용됩니다.");
|
|
576
930
|
}
|
|
577
|
-
process.exit(r.fail > 0 ? 1 : 0);
|
|
931
|
+
process.exit(r.fail > 0 || left.size > 0 ? 1 : 0);
|