@kaydash9999/banker-plugins 0.18.0 → 0.19.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 +103 -0
- package/README.md +18 -7
- package/bin/lib/telemetry-config.mjs +25 -0
- package/codex/manifest.json +1 -0
- package/codex/transform-matrix.md +1 -0
- package/hooks/graceful-pause.mjs +4 -2
- package/hooks/obsidize-hook.mjs +61 -32
- package/hooks/progress-plan.mjs +14 -8
- package/hooks/progress.mjs +187 -48
- package/hooks/register.mjs +15 -7
- package/hooks/telemetry-count-skill.mjs +4 -6
- package/hooks/telemetry-count.mjs +4 -6
- package/hooks/update-checkin.mjs +46 -43
- package/hooks/update-fetch.mjs +8 -7
- package/package.json +1 -1
- package/skills/anti-drm/SKILL.md +46 -15
- package/skills/anti-drm/scripts/anti-drm.mjs +213 -61
- package/skills/anti-drm/scripts/instruction-toggle.mjs +280 -44
- package/skills/lineage/SKILL.md +4 -1
- package/skills/lineage/lineage.py +196 -73
- package/skills/omc-patch/SKILL.md +37 -11
- package/skills/omc-patch/scripts/omc-patch.mjs +635 -103
- package/skills/theme-selection/SKILL.md +165 -0
- package/skills/theme-selection/assets/LICENSE +21 -0
- package/skills/theme-selection/assets/NOTICE.md +30 -0
- package/skills/theme-selection/assets/palettes.json +81 -0
- package/skills/theme-selection/references/contrast.md +138 -0
- package/skills/theme-selection/references/integrate.md +161 -0
- package/skills/theme-selection/scripts/themes.mjs +1067 -0
- package/skills/tone-compact/SKILL.md +14 -5
- package/skills/tone-compact/scripts/instruction-toggle.mjs +280 -44
- package/skills/tone-compact/scripts/tone-compact.mjs +4 -4
- package/skills/update-banker/SKILL.md +1 -1
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
import {
|
|
25
25
|
readFileSync, writeFileSync, writeSync, existsSync, copyFileSync, readdirSync,
|
|
26
26
|
unlinkSync, mkdirSync, rmSync, symlinkSync, lstatSync, realpathSync,
|
|
27
|
+
chmodSync, renameSync, statSync, accessSync, openSync, fstatSync, closeSync, fsyncSync, constants as FS_CONST,
|
|
27
28
|
} from "node:fs";
|
|
28
|
-
import { join, dirname, resolve, sep as PATH_SEP, delimiter as PATH_LIST_SEP } from "node:path";
|
|
29
|
+
import { join, dirname, basename, resolve, sep as PATH_SEP, delimiter as PATH_LIST_SEP } from "node:path";
|
|
29
30
|
import { homedir, hostname } from "node:os";
|
|
30
31
|
import { execFileSync } from "node:child_process";
|
|
31
32
|
|
|
@@ -123,7 +124,7 @@ function activeVersion() {
|
|
|
123
124
|
|
|
124
125
|
// installed_plugins.json 의 omc 항목. 읽기·파싱 실패는 null(옛 규칙으로).
|
|
125
126
|
function installedEntry() {
|
|
126
|
-
try { return JSON.parse(
|
|
127
|
+
try { return JSON.parse(readRegular(INSTALLED, "utf8")).plugins?.[PLUGIN_KEY]?.[0] || null; }
|
|
127
128
|
catch { return null; }
|
|
128
129
|
}
|
|
129
130
|
|
|
@@ -138,28 +139,70 @@ function entryVersion(e, versions) {
|
|
|
138
139
|
|
|
139
140
|
// git 이 일할 저장소를 cwd 와 상관없이 정하는 변수. git 훅처럼 GIT_DIR 을 내보낸 셸에서 이 도구를 부르면
|
|
140
141
|
// 마켓플레이스가 아니라 그 저장소의 origin 을 고정했다(/tmp 에서 재현). git 이 다른 저장소로 옮겨 갈 때
|
|
141
|
-
// 지우는 목록(`git rev-parse --local-env-vars`, git 2.43)
|
|
142
|
+
// 지우는 목록(`git rev-parse --local-env-vars`, git 2.43)의 고정 사본이다. 실행할 때 이 PC 의 git 이 말하는 목록을
|
|
143
|
+
// 더한다(gitRepoVars). Windows 는 변수 이름의 대소문자를 가리지 않는다.
|
|
142
144
|
const GIT_REPO_VARS = new Set(["GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS",
|
|
143
145
|
"GIT_CONFIG_COUNT", "GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE",
|
|
144
146
|
"GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS", "GIT_REPLACE_REF_BASE", "GIT_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR"]);
|
|
147
|
+
|
|
148
|
+
// 지울 변수: 고정 목록에 이 PC 의 git 이 말하는 목록을 더한다. 새 git 이 변수를 더해도 따라간다. git 을 부를 수
|
|
149
|
+
// 없으면 고정 목록만 쓴다. 처음 한 번만 묻는다.
|
|
150
|
+
let repoVars = null;
|
|
151
|
+
function gitRepoVars() {
|
|
152
|
+
if (repoVars) return repoVars;
|
|
153
|
+
repoVars = new Set(GIT_REPO_VARS);
|
|
154
|
+
try {
|
|
155
|
+
// 이름으로 부르는 프로그램(git, tar)은 홈 폴더에서 돌린다. Windows 는 현재 폴더의 git.exe 를 PATH 보다 먼저 찾는다.
|
|
156
|
+
const out = execFileSync("git", ["rev-parse", "--local-env-vars"], { cwd: homedir(), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
157
|
+
for (const v of out.split(/\s+/)) if (/^GIT_[A-Z0-9_]+$/.test(v)) repoVars.add(v);
|
|
158
|
+
} catch { /* 고정 목록만 */ }
|
|
159
|
+
return repoVars;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 환경 변수로 준 git 설정은 모든 호출에서 지운다. 그 값이 origin 을 바꿔 보이게 할 수 있다. 업데이트 경로의 fetch 에만
|
|
163
|
+
// 프록시와 TLS 신뢰 설정(회사망의 http.proxy 같은 값)을 다시 번호를 매겨 넘긴다. url.*.insteadOf 는 다른 저장소의 태그를
|
|
164
|
+
// 받아 오고 core.hooksPath 는 훅을 돌리므로 넘기지 않는다. GIT_CONFIG_PARAMETERS(`git -c`)도 넘기지 않는다.
|
|
165
|
+
const isEnvConfig = (k) => /^GIT_CONFIG_(PARAMETERS|COUNT|KEY_\d+|VALUE_\d+)$/.test(k.toUpperCase());
|
|
166
|
+
const FETCH_KEYS = /^https?\.(proxy|sslcainfo|sslverify|proxyauthmethod)$/i;
|
|
167
|
+
// 많아야 64쌍을 보고, 빠진 키에서 멈춘다(엄청난 GIT_CONFIG_COUNT 를 끝까지 돌지 않는다).
|
|
168
|
+
function fetchConfig(env) {
|
|
169
|
+
const kept = [];
|
|
170
|
+
for (let i = 0; i < Math.min(Number(env.GIT_CONFIG_COUNT) || 0, 64); i++) {
|
|
171
|
+
const key = env[`GIT_CONFIG_KEY_${i}`];
|
|
172
|
+
if (key === undefined) break;
|
|
173
|
+
if (FETCH_KEYS.test(key)) kept.push([key, env[`GIT_CONFIG_VALUE_${i}`] ?? ""]);
|
|
174
|
+
}
|
|
175
|
+
const out = kept.length ? { GIT_CONFIG_COUNT: String(kept.length) } : {};
|
|
176
|
+
kept.forEach(([k, v], i) => Object.assign(out, { [`GIT_CONFIG_KEY_${i}`]: k, [`GIT_CONFIG_VALUE_${i}`]: v }));
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
145
180
|
// 마켓플레이스의 .git 이 깨졌으면(중단된 clone) git 은 위로 올라가 다음 저장소(dotfiles 로 관리하는 ~/.claude 등)를
|
|
146
181
|
// 고른다. 바로 위 폴더를 천장(GIT_CEILING_DIRECTORIES)으로 두어 마켓플레이스 밖으로 나가지 못하게 한다.
|
|
147
182
|
// 마켓플레이스가 링크면 실제 경로 쪽 위 폴더도 막는다. 셸에 있던 천장 값은 대소문자와 상관없이 이 값으로 바꾼다.
|
|
148
|
-
const gitEnv = () => {
|
|
149
|
-
const
|
|
183
|
+
const gitEnv = ({ network = false } = {}) => {
|
|
184
|
+
const vars = gitRepoVars();
|
|
185
|
+
const drop = (k) => isEnvConfig(k) || vars.has(k.toUpperCase()) || k.toUpperCase() === "GIT_CEILING_DIRECTORIES";
|
|
150
186
|
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !drop(k)));
|
|
187
|
+
if (network) Object.assign(env, fetchConfig(process.env));
|
|
151
188
|
env.GIT_CEILING_DIRECTORIES = [...new Set([dirname(MARKET), dirname(realOr(MARKET))])].join(PATH_LIST_SEP);
|
|
152
189
|
return env;
|
|
153
190
|
};
|
|
154
191
|
|
|
155
|
-
function git(args, cwd = MARKET) {
|
|
156
|
-
return execFileSync("git", args, { cwd, env: gitEnv(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
192
|
+
function git(args, cwd = MARKET, opts = {}) {
|
|
193
|
+
return execFileSync("git", args, { cwd, env: gitEnv(opts), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
157
194
|
}
|
|
158
195
|
|
|
159
196
|
// ── 1. 업데이트 ──────────────────────────────────────────────────────────────
|
|
160
197
|
function ensureVersion() {
|
|
161
198
|
const have = installedVersions();
|
|
162
|
-
if (have.includes(TARGET_VERSION)) {
|
|
199
|
+
if (have.includes(TARGET_VERSION)) {
|
|
200
|
+
const now = activeVersion().v;
|
|
201
|
+
if (MODE === "check" || now === TARGET_VERSION) { ok(`${TARGET_VERSION} 설치되어 있음`); return true; }
|
|
202
|
+
log(` ${TARGET_VERSION} 은 캐시에 있으나 활성 판은 ${oneLine(now)} 입니다. 전환합니다`);
|
|
203
|
+
const sha = targetSha();
|
|
204
|
+
return sha === null ? false : switchActive(sha, have);
|
|
205
|
+
}
|
|
163
206
|
if (MODE === "check") { bad(`${TARGET_VERSION} 미설치 (설치된 버전: ${have.join(", ") || "없음"})`); return false; }
|
|
164
207
|
if (!canInstallTarget()) return false;
|
|
165
208
|
|
|
@@ -174,14 +217,14 @@ function ensureVersion() {
|
|
|
174
217
|
|
|
175
218
|
// 마켓플레이스가 제 git 저장소이고 git 과 tar 가 있어야 TARGET_VERSION 을 꺼낼 수 있다.
|
|
176
219
|
function canInstallTarget() {
|
|
177
|
-
if (!existsSync(MARKET)) { bad(`마켓플레이스 없음: ${MARKET}\n Claude Code 에서 marketplace 를 먼저 추가하십시오: ${REPO_URL}`); return false; }
|
|
178
|
-
if (!existsSync(join(MARKET, ".git"))) { bad(`마켓플레이스 폴더가 git 저장소가 아님: ${MARKET}`); return false; }
|
|
220
|
+
if (!existsSync(MARKET)) { bad(`마켓플레이스 없음: ${oneLine(MARKET)}\n Claude Code 에서 marketplace 를 먼저 추가하십시오: ${REPO_URL}`); return false; }
|
|
221
|
+
if (!existsSync(join(MARKET, ".git"))) { bad(`마켓플레이스 폴더가 git 저장소가 아님: ${oneLine(MARKET)}`); return false; }
|
|
179
222
|
|
|
180
223
|
// git 과 tar 에 의존한다. Windows 10+ / macOS / 대부분의 Linux 배포판에는 둘 다 있지만,
|
|
181
224
|
// 최소 설치 컨테이너나 tar 없는 환경에서는 없을 수 있다. 여기서 못 찾으면 아래에서
|
|
182
225
|
// execFileSync 가 ENOENT 로 죽어 원인이 안 보이므로, 먼저 확인하고 무엇이 없는지 말한다.
|
|
183
226
|
for (const [cmd, why] of [["git", "마켓플레이스에서 소스를 꺼내는 데"], ["tar", "그 소스를 푸는 데"]]) {
|
|
184
|
-
try { execFileSync(cmd, ["--version"], { stdio: "ignore" }); }
|
|
227
|
+
try { execFileSync(cmd, ["--version"], { cwd: homedir(), stdio: "ignore" }); }
|
|
185
228
|
catch { bad(`${cmd} 를 찾을 수 없습니다 (${why} 필요). 설치한 뒤 다시 실행하거나, --no-update 로 패치만 적용하십시오.`); return false; }
|
|
186
229
|
}
|
|
187
230
|
return true;
|
|
@@ -189,7 +232,7 @@ function canInstallTarget() {
|
|
|
189
232
|
|
|
190
233
|
// 태그 v<TARGET_VERSION> 의 커밋. 못 찾으면 null.
|
|
191
234
|
function targetSha() {
|
|
192
|
-
try { git(["fetch", "--tags", "--quiet", "origin"]); } catch { /* 오프라인이어도 로컬 태그가 있으면 진행 */ }
|
|
235
|
+
try { git(["fetch", "--tags", "--quiet", "origin"], MARKET, { network: true }); } catch { /* 오프라인이어도 로컬 태그가 있으면 진행 */ }
|
|
193
236
|
try { return git(["rev-parse", `v${TARGET_VERSION}^{}`]); }
|
|
194
237
|
catch { bad(`태그 v${TARGET_VERSION} 를 마켓플레이스에서 찾을 수 없음`); return null; }
|
|
195
238
|
}
|
|
@@ -199,7 +242,7 @@ function unpackTarget(dest) {
|
|
|
199
242
|
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
|
|
200
243
|
mkdirSync(dest, { recursive: true });
|
|
201
244
|
git(["archive", `v${TARGET_VERSION}`, "-o", join(dest, "_src.tar")]);
|
|
202
|
-
execFileSync("tar", ["-xf", join(dest, "_src.tar"), "-C", dest], { stdio: "ignore" });
|
|
245
|
+
execFileSync("tar", ["-xf", join(dest, "_src.tar"), "-C", dest], { cwd: homedir(), stdio: "ignore" });
|
|
203
246
|
unlinkSync(join(dest, "_src.tar"));
|
|
204
247
|
}
|
|
205
248
|
|
|
@@ -212,30 +255,124 @@ function linkNodeModules(dest, have) {
|
|
|
212
255
|
try { symlinkSync(join(CACHE, prev, "node_modules"), nm, "junction"); ok(`node_modules → ${prev} 링크`); }
|
|
213
256
|
catch (e) { bad(`node_modules 링크 실패(${e.code}). \`npm install\` 이 필요할 수 있음`); }
|
|
214
257
|
} else if (!existsSync(nm)) {
|
|
215
|
-
bad(`node_modules 없음. ${dest} 에서 \`npm install --omit=dev\` 를 실행하십시오`);
|
|
258
|
+
bad(`node_modules 없음. ${oneLine(dest)} 에서 \`npm install --omit=dev\` 를 실행하십시오`);
|
|
216
259
|
}
|
|
217
260
|
}
|
|
218
261
|
|
|
219
|
-
//
|
|
262
|
+
// installed_plugins.json 을 읽는다. 읽지 못하거나 JSON 이 아니면 이름과 이유를 대고 null(파일 글은 내지 않는다).
|
|
263
|
+
function readInstalled() {
|
|
264
|
+
try { return JSON.parse(readRegular(INSTALLED, "utf8")); }
|
|
265
|
+
catch (err) { bad(`읽지 못함: installed_plugins.json (${err instanceof SyntaxError ? "JSON 이 아님" : failWhy(err)})`); return null; }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 활성 버전 전환. installed_plugins.json 을 읽거나 쓰지 못하면 이름과 이유를 대고 false(호출부가 중단한다).
|
|
220
269
|
function switchActive(sha, have) {
|
|
221
|
-
const j =
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
270
|
+
const j = readInstalled();
|
|
271
|
+
if (!j) return false;
|
|
272
|
+
try {
|
|
273
|
+
const e = j.plugins?.[PLUGIN_KEY]?.[0];
|
|
274
|
+
if (!e) { bad(`installed_plugins.json 에 ${PLUGIN_KEY} 항목이 없음 — 수동 확인 필요`); return false; }
|
|
275
|
+
backup(INSTALLED, `${INSTALLED}.omcbak`);
|
|
276
|
+
const sep = e.installPath.includes("//") ? "//" : e.installPath.includes("\\") ? "\\" : "/";
|
|
277
|
+
e.installPath = e.installPath.split(sep).slice(0, -1).concat(TARGET_VERSION).join(sep);
|
|
278
|
+
e.version = TARGET_VERSION;
|
|
279
|
+
e.gitCommitSha = sha;
|
|
280
|
+
e.lastUpdated = new Date().toISOString();
|
|
281
|
+
writeAtomic(INSTALLED, JSON.stringify(j, null, 2) + "\n");
|
|
282
|
+
} catch (err) {
|
|
283
|
+
bad(`쓰지 못함: installed_plugins.json (${failWhy(err)})`);
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
231
286
|
ok(`${have[0] || "없음"} → ${TARGET_VERSION} (sha ${sha.slice(0, 12)})`);
|
|
232
287
|
return true;
|
|
233
288
|
}
|
|
234
289
|
|
|
290
|
+
// ── 쓰기 ─────────────────────────────────────────────────────────────────────
|
|
291
|
+
// 같은 폴더의 새 임시 파일(있으면 실패하는 열기)에 쓴 뒤 rename 으로 바꾼다. 하드 링크로 이어진 다른 이름(폴더 밖
|
|
292
|
+
// 파일)의 내용은 그대로 둔다. 쓸 파일이 링크면 쓰는 때의 실제 경로에서 바꿔 링크를 남긴다. 그래서 확인 뒤 그 자리가
|
|
293
|
+
// 폴더 밖 링크로 바뀌면 그 실제 파일이 바뀐다(확인과 쓰기 사이의 틈은 줄었을 뿐 없지 않다). follow 가 false 면
|
|
294
|
+
// 링크를 따라가지 않고 그 이름 자리를 새 파일로 바꾼다(링크는 사라지고 링크가 가리키던 파일은 그대로다. 고정 기록).
|
|
295
|
+
// 권한은 mode 를 주면 그 값, 아니면 원래 파일의 것(없으면 0644)이다.
|
|
296
|
+
function writeAtomic(file, data, mode, { follow = true } = {}) {
|
|
297
|
+
const real = follow ? realOr(file) : file;
|
|
298
|
+
const tmp = join(dirname(real), `.${basename(real)}.omc-tmp-${process.pid}`);
|
|
299
|
+
rmSync(tmp, { force: true });
|
|
300
|
+
try {
|
|
301
|
+
writeSynced(tmp, data);
|
|
302
|
+
chmodSync(tmp, mode ?? modeOf(real));
|
|
303
|
+
renameRetry(tmp, real);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
rmSync(tmp, { force: true });
|
|
306
|
+
throw e;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 새 임시 파일(있으면 실패하는 열기)에 쓰고 디스크까지 내린다. 내리지 않고 rename 하면 전원이 나가거나 NFS 에서
|
|
311
|
+
// 새 이름 아래 짧거나 빈 파일이 남을 수 있다(OMC 의 atomicWriteFileSync, obsidizer 의 writeTemp 와 같은 꼴).
|
|
312
|
+
function writeSynced(tmp, data) {
|
|
313
|
+
const fd = openSync(tmp, "wx");
|
|
314
|
+
try {
|
|
315
|
+
writeFileSync(fd, data);
|
|
316
|
+
fsyncSync(fd);
|
|
317
|
+
} finally {
|
|
318
|
+
closeSync(fd);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 파일의 권한 비트. 읽을 수 없으면 0644.
|
|
323
|
+
function modeOf(p) {
|
|
324
|
+
try { return statSync(p).mode & 0o777; } catch { return 0o644; }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Windows 에서 다른 프로세스가 연 파일은 rename 이 잠시 거부된다(EPERM, EBUSY, EACCES). 100ms 간격으로 5번까지 다시 한다.
|
|
328
|
+
const RENAME_PAUSE = new Int32Array(new SharedArrayBuffer(4));
|
|
329
|
+
function renameRetry(from, to) {
|
|
330
|
+
for (let i = 0; ; i++) {
|
|
331
|
+
try { renameSync(from, to); return; }
|
|
332
|
+
catch (e) {
|
|
333
|
+
if (i >= 5 || !["EPERM", "EBUSY", "EACCES"].includes(e.code)) throw e;
|
|
334
|
+
Atomics.wait(RENAME_PAUSE, 0, 0, 100);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 백업을 만든다. 이미 있으면 처음 백업을 지킨다. 있으면 실패하는 복사라 확인과 복사 사이에 틈이 없다.
|
|
340
|
+
function backup(file, bak) {
|
|
341
|
+
try { copyFileSync(file, bak, FS_CONST.COPYFILE_EXCL); }
|
|
342
|
+
catch (e) { if (e.code !== "EEXIST") throw e; }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 일반 파일만 읽는다. FIFO 는 열기만 하고 읽지 않으며, 폴더와 소켓, 장치는 던진다(형제 도구의 읽기와 같은 꼴).
|
|
346
|
+
function readRegular(p, enc) {
|
|
347
|
+
const fd = openSync(p, FS_CONST.O_RDONLY | (FS_CONST.O_NONBLOCK ?? 0));
|
|
348
|
+
try {
|
|
349
|
+
if (!fstatSync(fd).isFile()) throw Object.assign(new Error("일반 파일이 아님"), { code: "ENOTFILE" });
|
|
350
|
+
return readFileSync(fd, enc);
|
|
351
|
+
} finally {
|
|
352
|
+
closeSync(fd);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 쓰기 실패의 이유 한 줄.
|
|
357
|
+
const failWhy = (e) => oneLine(e.code || e.message);
|
|
358
|
+
|
|
235
359
|
// ── 2. 훅 패치 ───────────────────────────────────────────────────────────────
|
|
360
|
+
// 훅 글을 읽는다. 읽지 못하면(권한, 일반 파일이 아님) 이름과 이유를 대고 null. check 는 확인하지 못한 것이라 모두 적용으로 보지 않는다.
|
|
361
|
+
function readHook(n, f, r) {
|
|
362
|
+
try { return readRegular(f, "utf8"); }
|
|
363
|
+
catch (e) {
|
|
364
|
+
bad(`읽지 못함: ${shown(n)} (${failWhy(e)})`);
|
|
365
|
+
if (MODE === "check") r.need++;
|
|
366
|
+
else { r.fail++; r.failed.push(f); }
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// hooks.json 과 그 백업이 부르는 훅 이름. 읽지 못하는 파일은 건너뛴다. hooks.json 은 [3/4] 가 이름과 이유를 대고 센다.
|
|
236
372
|
function hookNames(root) {
|
|
237
373
|
const files = [join(root, "hooks", "hooks.json"), `${join(root, "hooks", "hooks.json")}.omcbak`];
|
|
238
|
-
const
|
|
374
|
+
const readOrEmpty = (f) => { try { return readRegular(f, "utf8"); } catch { return ""; } };
|
|
375
|
+
const src = files.filter(existsSync).map(readOrEmpty).join("");
|
|
239
376
|
return [...new Set([...src.matchAll(/scripts\/([\w-]+)\.mjs/g)].map((m) => m[1]))].sort();
|
|
240
377
|
}
|
|
241
378
|
|
|
@@ -515,10 +652,25 @@ function syntaxError(file) {
|
|
|
515
652
|
}
|
|
516
653
|
}
|
|
517
654
|
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
655
|
+
// 백업의 글. 없거나 읽으려는 순간 사라졌으면 null(다른 실행이 먼저 되돌렸을 수 있다).
|
|
656
|
+
function readBackup(bak) {
|
|
657
|
+
try { return readRegular(bak); }
|
|
658
|
+
catch (e) { if (e.code === "ENOENT") return null; throw e; }
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// 이 도구가 아닌 «손» 이 import 를 감싼 흔적. 4.15.7 의 손 수정은 최상위의 `await import(` 를
|
|
662
|
+
// `await Promise.race([import(` 로 바꿔치기했다. 그 모양이 최상위(함수 밖) 코드 자리에 있을 때만 손 수정으로 본다.
|
|
663
|
+
// 문자열, 주석, 함수 몸통 안의 같은 모양이나 import 가 첫 원소가 아닌 race 는 상류 코드일 수 있어 세지 않는다.
|
|
664
|
+
const HAND_PATCH = /^await\s+Promise\.race\s*\(\s*\[\s*import\s*\(/;
|
|
665
|
+
function looksHandPatched(src) {
|
|
666
|
+
if (src.includes("__omcRaceImport")) return false;
|
|
667
|
+
let found = false;
|
|
668
|
+
scanTopLevelAwaitImports(src, (i, fnStack) => {
|
|
669
|
+
if (found || src[i] !== "a" || fnStack.some(Boolean) || (i > 0 && ID_CH.test(src[i - 1]))) return;
|
|
670
|
+
found = HAND_PATCH.test(src.slice(i, i + 80));
|
|
671
|
+
});
|
|
672
|
+
return found;
|
|
673
|
+
}
|
|
522
674
|
|
|
523
675
|
function patchHooks(root) {
|
|
524
676
|
const r = { done: 0, skip: 0, fail: 0, need: 0, misplaced: 0, foreign: [], orphan: [], failed: [] };
|
|
@@ -539,45 +691,129 @@ function patchHooks(root) {
|
|
|
539
691
|
|
|
540
692
|
function revertScript(root, n, f, r) {
|
|
541
693
|
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;
|
|
546
|
-
}
|
|
694
|
+
if (existsSync(bak) && restoreScript(root, n, f, bak, r)) return;
|
|
547
695
|
// 백업이 없는데 이미 감싸여 있으면 되돌릴 원본이 디스크에 없다. 조용히 넘기지 않고 이름을 댄다.
|
|
548
696
|
// - 우리 헬퍼가 있으면: 이 도구나 옛 킷이 고쳤는데 백업이 사라진 파일(복사해 온 폴더 등)
|
|
549
697
|
// - 손 패치: `await import(` 를 `await Promise.race([import(` 로 «바꿔치기» 해서 await import
|
|
550
698
|
// 패턴 자체가 사라진다. siteInfo 로는 잡히지 않아 원문에서 직접 찾는다(실측: 4.15.7 의 두 파일)
|
|
551
|
-
const src =
|
|
699
|
+
const src = readHook(n, f, r);
|
|
700
|
+
if (src === null) return;
|
|
552
701
|
if (src.includes("__omcRaceImport")) r.orphan.push(n);
|
|
553
702
|
else if (looksHandPatched(src)) r.foreign.push(n);
|
|
554
703
|
}
|
|
555
704
|
|
|
705
|
+
// 백업으로 되돌린다. 쓸 수 없는 자리거나 쓰지 못하면 이유를 대고 실패로 센다. 백업이 읽으려는 순간 사라졌으면 false
|
|
706
|
+
// (다른 실행이 먼저 되돌렸을 수 있다. 호출부가 백업 없음으로 다시 본다).
|
|
707
|
+
function restoreScript(root, n, f, bak, r) {
|
|
708
|
+
const why = whyNotWrite(root, f, bak) ?? restoreFrom(f, bak);
|
|
709
|
+
if (why === GONE) return false;
|
|
710
|
+
if (!why) {
|
|
711
|
+
log(` 되돌림 ${shown(n)}`); r.done++;
|
|
712
|
+
const left = dropBackup(bak);
|
|
713
|
+
if (left) { bad(`되돌렸으나 백업을 지우지 못함: ${shown(n)} (${left}): ${oneLine(bak)}`); r.fail++; r.failed.push(f); }
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
bad(`되돌리지 않음: ${shown(n)} (${why})`);
|
|
717
|
+
// 옛 도구가 폴더 밖 링크 너머에서 고친 파일이다. 이 도구는 폴더 밖에 쓰지 않으므로 손 복구 방법을 알린다.
|
|
718
|
+
if (why === OUTSIDE) log(` ${handRestoreHint(bak, f)}`);
|
|
719
|
+
r.fail++; r.failed.push(f);
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// 링크 너머의 일반 파일(1MiB 이하)에 헬퍼가 있는가. 옛 도구가 링크를 따라가 거기서 고친 파일이면 참이다.
|
|
724
|
+
const PEEK_MAX = 1 << 20;
|
|
725
|
+
function patchedByOldTool(p) {
|
|
726
|
+
try {
|
|
727
|
+
const st = statSync(p);
|
|
728
|
+
return st.isFile() && st.size <= PEEK_MAX && readRegular(p, "utf8").includes("__omcRaceImport");
|
|
729
|
+
} catch {
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// 손 복구 안내. 링크 너머의 파일에 이 도구 계열의 패치가 있을 때만 백업으로 덮어쓰라고 하고, 아니면 링크부터 확인하게 한다.
|
|
735
|
+
function handRestoreHint(bak, f) {
|
|
736
|
+
const target = realOr(f);
|
|
737
|
+
return patchedByOldTool(target)
|
|
738
|
+
? `손으로 되돌리려면 ${oneLine(realOr(bak))} 의 내용을 확인한 뒤 ${oneLine(target)} 에 덮어쓰고 백업을 지우십시오.`
|
|
739
|
+
: `링크가 가리키는 ${oneLine(target)} 에 이 도구의 패치가 보이지 않습니다. 덮어쓰지 말고 링크가 가리키는 곳이 맞는지 먼저 확인하십시오.`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// 백업의 내용으로 파일을 바꾼다. 쓰지 못하면 그 이유, 백업이 읽으려는 순간 사라졌으면 GONE, 아니면 null.
|
|
743
|
+
const GONE = "백업이 먼저 사라짐";
|
|
744
|
+
function restoreFrom(f, bak) {
|
|
745
|
+
let data;
|
|
746
|
+
try { data = readRegular(bak); }
|
|
747
|
+
catch (e) { return e.code === "ENOENT" ? GONE : `읽지 못함: ${failWhy(e)}`; }
|
|
748
|
+
try { writeAtomic(f, data); return null; }
|
|
749
|
+
catch (e) { return `쓰지 못함: ${failWhy(e)}`; }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// 되돌린 뒤 백업을 지운다. 다른 실행이 먼저 지웠으면(ENOENT) 된 것이다. 못 지우면 그 이유, 아니면 null.
|
|
753
|
+
function dropBackup(bak) {
|
|
754
|
+
try { unlinkSync(bak); return null; }
|
|
755
|
+
catch (e) { return e.code === "ENOENT" ? null : failWhy(e); }
|
|
756
|
+
}
|
|
757
|
+
|
|
556
758
|
function checkOrPatchScript(root, n, f, r) {
|
|
557
|
-
const
|
|
759
|
+
const text = readHook(n, f, r);
|
|
760
|
+
if (text === null) return;
|
|
761
|
+
const info = siteInfo(text);
|
|
558
762
|
// 헬퍼 자리 결함은 check 와 apply 모두 이름을 댄다. 이미 감싼 파일이라 transform 으로는
|
|
559
763
|
// 고칠 수 없으므로 apply 도 건드리지 않고, 되돌린 뒤 다시 적용하라고 안내한다(misplacedHint).
|
|
560
764
|
if (info.state === "misplaced") { bad(`헬퍼 자리 결함: ${n} (${info.why})`); r.misplaced++; return; }
|
|
561
765
|
if (info.state !== "needs") { r.skip++; return; }
|
|
562
|
-
if (MODE === "check") { bad(`패치 필요: ${n}`); r.need++; return; }
|
|
563
|
-
|
|
564
766
|
const bak = `${f}.omcbak`;
|
|
565
767
|
const why = whyNotWrite(root, f, bak);
|
|
768
|
+
// --check 도 쓰기 조건을 본다. apply 에서야 «쓰지 않음» 이 되는 파일을 진단에서 미리 알린다.
|
|
769
|
+
if (MODE === "check") { bad(why ? `패치 필요, 쓸 수 없음: ${n} (${why})` : `패치 필요: ${n}`); r.need++; return; }
|
|
566
770
|
if (why) { bad(`쓰지 않음: ${n} (${why})`); r.fail++; return; }
|
|
567
|
-
const out = transform(
|
|
771
|
+
const out = transform(text);
|
|
568
772
|
if (!out) { bad(`변환 실패: ${n}`); r.fail++; return; }
|
|
569
|
-
if (
|
|
570
|
-
|
|
773
|
+
if (writePatched(f, bak, out, n)) r.done++;
|
|
774
|
+
else r.fail++;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// 패치한 글을 쓴다. 백업을 먼저 만들고, 쓴 파일이 문법 검사를 통과하지 못하면 백업으로 되돌린다. 성공이면 true.
|
|
778
|
+
// 쓰지 못하면 이유를 대고 false 다(예전에는 예외로 멈췄다).
|
|
779
|
+
function writePatched(f, bak, out, n) {
|
|
780
|
+
try {
|
|
781
|
+
backup(f, bak);
|
|
782
|
+
writeAtomic(f, out);
|
|
783
|
+
} catch (e) {
|
|
784
|
+
bad(`쓰지 못함: ${n} (${failWhy(e)})`);
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
571
787
|
const err = syntaxError(f);
|
|
572
|
-
if (err)
|
|
573
|
-
|
|
788
|
+
if (!err) return true;
|
|
789
|
+
try {
|
|
790
|
+
writeAtomic(f, readRegular(bak));
|
|
791
|
+
} catch (e) {
|
|
792
|
+
bad(`롤백도 실패: ${n} (${failWhy(e)}). ${oneLine(realOr(bak))} 으로 손수 되돌리십시오\n ${oneLine(err)}`);
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
bad(`문법오류로 롤백: ${n}\n ${oneLine(err)}`);
|
|
796
|
+
return false;
|
|
574
797
|
}
|
|
575
798
|
|
|
799
|
+
// 폴더 밖 쓰기를 거부한 이유. 되돌리기는 이 이유일 때 손 복구 방법을 함께 알린다.
|
|
800
|
+
const OUTSIDE = "파일이 플러그인 폴더 밖에 있음";
|
|
801
|
+
|
|
576
802
|
// 쓰기 전에 본다. 쓸 파일이 (링크를 따라가서) 플러그인 폴더 밖이거나 백업 자리가 링크면 그 이유를,
|
|
577
|
-
// 아니면 null. 백업 자리가 깨진 링크면 existsSync 는 false 라,
|
|
803
|
+
// 아니면 null. 백업 자리가 깨진 링크면 existsSync 는 false 라, 복사가 링크가 가리키는 곳에 쓴다.
|
|
578
804
|
function whyNotWrite(root, file, bak) {
|
|
579
805
|
if (isLink(bak)) return "백업 자리가 링크";
|
|
580
|
-
if (!insideRoot(root, file)) return
|
|
806
|
+
if (!insideRoot(root, file)) return OUTSIDE;
|
|
807
|
+
return notWritable(file);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// 있는 자리가 일반 파일이 아니거나(FIFO, 폴더, 장치) 이 계정이 쓸 수 없으면 그 이유. 새 파일로 바꿔 쓰는 방식이 파일 권한을
|
|
811
|
+
// 우회하지 않게 하고, 특수 파일을 일반 파일로 바꾸지 않게 한다. 없는 자리는 괜찮다.
|
|
812
|
+
function notWritable(file) {
|
|
813
|
+
let st;
|
|
814
|
+
try { st = statSync(file); } catch { return null; }
|
|
815
|
+
if (!st.isFile()) return "일반 파일이 아님";
|
|
816
|
+
try { accessSync(file, FS_CONST.W_OK); } catch { return "쓸 권한이 없음"; }
|
|
581
817
|
return null;
|
|
582
818
|
}
|
|
583
819
|
|
|
@@ -603,20 +839,36 @@ const realOr = (p) => {
|
|
|
603
839
|
const fileKey = (p) => (process.platform === "win32" ? realOr(p).toLowerCase() : realOr(p));
|
|
604
840
|
|
|
605
841
|
// ── 3. PostToolUse 훅 제거 ───────────────────────────────────────────────────
|
|
842
|
+
// [3/4] 에서 지금 다루는 판의 이름표(`5.6.1: `, 판이 하나면 빈 글). hooks.json 의 [!] 줄도 어느 판의 것인지 알려야 한다.
|
|
843
|
+
let hjTag = "";
|
|
844
|
+
const hjBad = (s) => bad(`${hjTag}${s}`);
|
|
845
|
+
|
|
606
846
|
function patchHooksJson(root) {
|
|
607
847
|
const hj = join(root, "hooks", "hooks.json");
|
|
608
848
|
if (!existsSync(hj)) return "no-file";
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
849
|
+
const why = whyNotWrite(root, hj, `${hj}.omcbak`);
|
|
850
|
+
if (why && MODE !== "check") { hjBad(`hooks.json 을 건드리지 않음 (${why})`); return "unsafe"; }
|
|
851
|
+
try {
|
|
852
|
+
return MODE === "revert" ? revertHooksJson(root, hj) : removePostToolUse(hj, why);
|
|
853
|
+
} catch (e) {
|
|
854
|
+
// JSON 오류 문구는 파일의 글을 담으므로 내지 않는다. check 는 읽기만 하므로 실패도 읽기다.
|
|
855
|
+
const why = e instanceof SyntaxError ? "JSON 이 아님" : failWhy(e);
|
|
856
|
+
hjBad(e instanceof SyntaxError || MODE === "check" ? `hooks.json 을 읽지 못함 (${why})` : `hooks.json 을 쓰지 못함 (${why})`);
|
|
857
|
+
return "unsafe";
|
|
612
858
|
}
|
|
613
|
-
return MODE === "revert" ? revertHooksJson(root, hj) : removePostToolUse(hj);
|
|
614
859
|
}
|
|
615
860
|
|
|
616
861
|
// 되돌린 결과. ambiguous 와 linked 는 hooks.json 을 되돌리지 못하고 남긴 것이라 끝 줄이 센다.
|
|
617
862
|
function revertHooksJson(root, hj) {
|
|
618
863
|
const bak = `${hj}.omcbak`;
|
|
619
|
-
|
|
864
|
+
const saved = readBackup(bak);
|
|
865
|
+
if (saved !== null) {
|
|
866
|
+
writeAtomic(hj, saved);
|
|
867
|
+
log(` 되돌림 ${hjTag}hooks.json`);
|
|
868
|
+
const left = dropBackup(bak);
|
|
869
|
+
if (left) { hjBad(`되돌렸으나 hooks.json 의 백업을 지우지 못함 (${left}): ${oneLine(bak)}`); return "unsafe"; }
|
|
870
|
+
return "reverted";
|
|
871
|
+
}
|
|
620
872
|
// 이 도구의 백업이 없을 때 PostToolUse 가 그대로면 되돌릴 것이 없다. 옆의 수동 백업으로 덮어쓰면
|
|
621
873
|
// 지금 내용을 잃는다.
|
|
622
874
|
if (hasPostToolUse(hj)) return "no-backup";
|
|
@@ -624,23 +876,55 @@ function revertHooksJson(root, hj) {
|
|
|
624
876
|
// PostToolUse 를 담고 있는 유효한 JSON 이 딱 하나면 그것이 제거 직전 상태다 — 무엇을 썼는지 밝히고 되돌린다.
|
|
625
877
|
const dir = dirname(hj);
|
|
626
878
|
const { usable, unsafe } = manualBackups(root, dir);
|
|
627
|
-
if (unsafe.length)
|
|
879
|
+
if (unsafe.length) hjBad(`hooks.json 수동 백업 후보가 링크이거나 플러그인 폴더 밖이라 쓰지 않음: ${unsafe.map(shown).join(", ")}`);
|
|
628
880
|
if (usable.length === 1) {
|
|
629
|
-
|
|
630
|
-
log(` 되돌림 hooks.json (수동 백업 사용: ${shown(usable[0])})`);
|
|
881
|
+
writeAtomic(hj, readRegular(join(dir, usable[0])));
|
|
882
|
+
log(` 되돌림 ${hjTag}hooks.json (수동 백업 사용: ${shown(usable[0])})`);
|
|
631
883
|
return "reverted";
|
|
632
884
|
}
|
|
633
|
-
if (usable.length > 1) {
|
|
885
|
+
if (usable.length > 1) { hjBad(`hooks.json 수동 백업이 여러 개라 자동 복구하지 않음: ${usable.map(shown).join(", ")}`); return "ambiguous"; }
|
|
634
886
|
return unsafe.length ? "linked" : "no-backup";
|
|
635
887
|
}
|
|
636
888
|
|
|
637
|
-
// 파일에서 읽은 값(파일 이름, PINNED 의 host)
|
|
889
|
+
// 파일에서 읽은 값(파일 이름, PINNED 의 host)과 출력하는 경로를 출력에 넣을 때 쓴다. 줄바꿈과 제어 문자를 ? 로 바꾸고 길이를 자른다.
|
|
638
890
|
// SKILL 은 [!] 줄을 그대로 보고하라고 하므로, 값이 제 줄을 새로 시작하면 도구가 낸 줄처럼 읽힌다.
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
|
|
891
|
+
// 바꾸는 문자: 제어 문자(Cc), 서식 문자(Cf: 폭 없는 공백, 방향 표시, 단어 결합자, 태그 문자), 줄과 문단 구분자,
|
|
892
|
+
// 사용자 영역과 미할당, 변이 선택자, 기본 무시 글자(U+3164 같은 빈 글자), 점자 빈칸, ASCII 밖 공백.
|
|
893
|
+
// 태그 문자는 보이지 않는 ASCII 로 읽혀 출력을 읽는 에이전트에게 지시를 숨길 수 있다.
|
|
894
|
+
// oneLine 은 길이를 두고(URL), shown 은 120자로 자른다(이름, host).
|
|
895
|
+
const UNSAFE_CH = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cn}\p{Variation_Selector}\p{Default_Ignorable_Code_Point}\u{2800}\u{a0}\u{1680}\u{2000}-\u{200a}\u{202f}\u{205f}\u{3000}]/gu;
|
|
642
896
|
const oneLine = (s) => String(s).replace(UNSAFE_CH, "?");
|
|
643
897
|
const shown = (s) => oneLine(s).slice(0, 120);
|
|
898
|
+
// 처리하지 못한 오류도 스택 트레이스 대신 정화한 한 줄과 종료 1 로 끝낸다. 오류 문구는 경로를 담아 줄바꿈이 들 수 있다.
|
|
899
|
+
// 시스템 오류(code 가 있는 것)가 아닌 도구 자신의 결함이면 어디서 났는지 앞 세 곳만 정화해 보인다.
|
|
900
|
+
process.on("uncaughtException", (e) => {
|
|
901
|
+
bad(`처리하지 못한 오류: ${oneLine(e?.message ?? e)}`);
|
|
902
|
+
if (!e?.code) for (const l of String(e?.stack ?? "").split("\n").filter((x) => /^\s+at /.test(x)).slice(0, 3)) log(` ${oneLine(l.trim())}`);
|
|
903
|
+
process.exit(1);
|
|
904
|
+
});
|
|
905
|
+
// URL 의 사용자 정보(`//` 뒤 첫 `/` 앞의 마지막 `@` 까지. 공백, 탭, 비밀번호 안의 @ 도)와 쿼리의 값(이름과 상관없이)을
|
|
906
|
+
// 가린다. 출력과 기록에 비밀이 남지 않는다. ssh 꼴(`git@host:`)과 경로 안의 @ 는 그대로다. 쿼리의 이름 칸은 `?` 를 받지
|
|
907
|
+
// 않는다. 받으면 `=` 없는 `?` 가 많은 origin 에서 시작 자리마다 끝까지 읽어 길이의 제곱만큼 걸린다.
|
|
908
|
+
const redactUrl = (url) => String(url).replace(/(\/\/)[^/]*@/g, "$1***@")
|
|
909
|
+
.replace(/([?&][^=&#\s?]*=)[^&#\s]*/g, "$1***");
|
|
910
|
+
|
|
911
|
+
// URL 의 scheme 과 host 까지만. 비밀은 사용자 정보와 쿼리 밖(경로, 조각, `;` 매개변수)에도 들 수 있어 나머지는 버린다.
|
|
912
|
+
// scp 꼴(`git@host:org/repo.git`)은 host 만이다. 파서가 host 로 읽어도 host 모양(이름이나 [IPv6], 선택 포트)이 아니면
|
|
913
|
+
// 보이지 않는다(ssh 같은 scheme 은 host 뒤의 `;k=...` 까지 host 로 읽는다).
|
|
914
|
+
const HOST_SHAPE = /^(?:[A-Za-z0-9._-]{1,253}|\[[0-9A-Fa-f:.]{2,45}\])(?::\d{1,5})?$/;
|
|
915
|
+
function hostOnly(url) {
|
|
916
|
+
let scheme = "", host;
|
|
917
|
+
try {
|
|
918
|
+
const u = new URL(String(url));
|
|
919
|
+
if (!u.host) return "(호스트가 없는 URL 또는 로컬 경로)";
|
|
920
|
+
[scheme, host] = [`${u.protocol}//`, u.host];
|
|
921
|
+
} catch {
|
|
922
|
+
const scp = /^(?:[^@/:]+@)?([^:/]+):(?!\/\/)/.exec(String(url));
|
|
923
|
+
if (!scp) return "(URL 꼴이 아님)";
|
|
924
|
+
host = scp[1];
|
|
925
|
+
}
|
|
926
|
+
return HOST_SHAPE.test(host) ? `${scheme}${host}` : "(호스트를 알아볼 수 없음)";
|
|
927
|
+
}
|
|
644
928
|
|
|
645
929
|
// hooks.json 옆의 수동 백업 후보. usable 은 PostToolUse 를 담은 유효한 JSON 이다. 링크로 심은 후보는
|
|
646
930
|
// unsafe 로 따로 두고 읽지도 않는다. 폴더 밖 파일(PostToolUse 키가 있는 settings.json 등)이 hooks.json 이 된다.
|
|
@@ -656,21 +940,21 @@ function manualBackups(root, dir) {
|
|
|
656
940
|
}
|
|
657
941
|
|
|
658
942
|
const hasPostToolUse = (p) => {
|
|
659
|
-
try { return !!JSON.parse(
|
|
943
|
+
try { return !!JSON.parse(readRegular(p, "utf8"))?.hooks?.PostToolUse; }
|
|
660
944
|
catch { return false; }
|
|
661
945
|
};
|
|
662
946
|
|
|
663
|
-
function removePostToolUse(hj) {
|
|
947
|
+
function removePostToolUse(hj, why) {
|
|
664
948
|
const bak = `${hj}.omcbak`;
|
|
665
|
-
const j = JSON.parse(
|
|
949
|
+
const j = JSON.parse(readRegular(hj, "utf8"));
|
|
666
950
|
if (!j.hooks?.PostToolUse) return "already";
|
|
667
951
|
const list = j.hooks.PostToolUse.flatMap((g) => (g.hooks || []).map((h) => (h.command || "").match(/([\w-]+)\.mjs/)?.[1] || "?"));
|
|
668
|
-
if (MODE === "check") {
|
|
669
|
-
|
|
952
|
+
if (MODE === "check") { hjBad(`hooks.json PostToolUse 남아있음${why ? `, 쓸 수 없음 (${why})` : ""}: ${list.join(", ")}`); return "needs"; }
|
|
953
|
+
backup(hj, bak);
|
|
670
954
|
delete j.hooks.PostToolUse;
|
|
671
|
-
|
|
672
|
-
try { JSON.parse(
|
|
673
|
-
catch {
|
|
955
|
+
writeAtomic(hj, JSON.stringify(j, null, 2) + "\n");
|
|
956
|
+
try { JSON.parse(readRegular(hj, "utf8")); return list; }
|
|
957
|
+
catch { writeAtomic(hj, readRegular(bak)); hjBad("hooks.json 손상으로 롤백"); return "failed"; }
|
|
674
958
|
}
|
|
675
959
|
|
|
676
960
|
// ── 4. 자동 업데이트 차단 ────────────────────────────────────────────────────
|
|
@@ -681,44 +965,214 @@ function removePostToolUse(hj) {
|
|
|
681
965
|
// origin remote 의 URL 을 무효 주소로 바꿔 fetch 를 실패시킨다(원래 URL 은
|
|
682
966
|
// `omc-pinned://` 뒤에 그대로 남기고 --revert 가 거기서 복구한다. PINNED 파일은 기록이다).
|
|
683
967
|
// 저장소 자체는 그대로라 이미 받은 태그로 재설치·검증이 가능하다.
|
|
968
|
+
|
|
969
|
+
// origin 의 원문. `git remote get-url` 은 전역 insteadOf 를 펼친 값을 내서, 그 값이 고정되고 되돌린 뒤에도 원래
|
|
970
|
+
// 값 대신 남았다. 설정 값을 그대로 읽는다. 없으면(git 이 1 로 끝남) null.
|
|
971
|
+
// git 이 그 밖의 까닭으로 읽지 못하면(git 없음, 다른 계정 저장소 거부, 깨진 설정) { unreadable } 이다. 없는 것으로 보면
|
|
972
|
+
// 고정과 되돌리기가 하지 않은 일을 했다고 끝난다.
|
|
973
|
+
function originUrl() {
|
|
974
|
+
try { return git(["config", "--get", "remote.origin.url"]); }
|
|
975
|
+
catch (e) { return e.status === 1 ? null : { unreadable: gitError(e) }; }
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// git 오류의 원인 줄: `fatal:` 이나 `error:` 로 시작하는 첫 줄, 없으면 첫 줄(`warning:` 이 먼저 올 수 있다).
|
|
979
|
+
// 정화하고 URL 의 인증 정보를 가린다. git 이 오류 문구 없이 끝나면 Node 의 문구(명령 줄 전체, 곧 URL 전체가 든다)를
|
|
980
|
+
// 쓰지 않고 어떻게 끝났는지만 댄다.
|
|
981
|
+
const gitError = (e) => {
|
|
982
|
+
const lines = String(e.stderr || "").split("\n").filter((l) => l.trim());
|
|
983
|
+
if (!lines.length) {
|
|
984
|
+
return e.status == null && !e.signal && e.code ? `git 을 실행하지 못함 (${oneLine(e.code)})` : `git 이 오류 문구 없이 끝남 (${oneLine(e.signal || e.status)})`;
|
|
985
|
+
}
|
|
986
|
+
const line = lines.find((l) => /^(fatal|error):/.test(l)) || lines[0];
|
|
987
|
+
return oneLine(redactUrl(line)).slice(0, 200);
|
|
988
|
+
};
|
|
989
|
+
|
|
684
990
|
function pinMarketplace() {
|
|
685
991
|
if (!existsSync(MARKET)) return "no-marketplace";
|
|
686
992
|
// 마켓플레이스 폴더가 제 git 저장소가 아니면 git 이 위쪽 저장소(예: dotfiles 로 관리하는
|
|
687
993
|
// ~/.claude)를 찾아 그 origin 을 바꾼다. 제 .git 이 있을 때만 고정하고 되돌린다. .git 이 있어도
|
|
688
994
|
// 깨졌으면 git 이 위로 올라가는데, 그것은 gitEnv 의 천장이 막는다(origin 을 못 읽어 no-remote).
|
|
689
995
|
if (!existsSync(join(MARKET, ".git"))) return "no-git";
|
|
690
|
-
|
|
691
|
-
|
|
996
|
+
const url = originUrl();
|
|
997
|
+
if (url === null) return "no-remote";
|
|
998
|
+
if (typeof url !== "string") { bad(`origin 을 읽지 못함 (${url.unreadable}): ${oneLine(MARKET)}`); return "origin-unreadable"; }
|
|
692
999
|
|
|
693
1000
|
if (MODE === "revert") return unpinMarketplace(url);
|
|
694
|
-
if (url.startsWith("omc-pinned://")) return
|
|
695
|
-
if (MODE === "check") { bad(`마켓플레이스 미고정 (origin=${oneLine(url)})`); return "needs"; }
|
|
1001
|
+
if (url.startsWith("omc-pinned://")) return alreadyPinned();
|
|
1002
|
+
if (MODE === "check") { bad(`마켓플레이스 미고정 (origin=${oneLine(redactUrl(url))})`); return "needs"; }
|
|
1003
|
+
|
|
1004
|
+
try { git(["remote", "set-url", "--", "origin", `omc-pinned://${url}`]); }
|
|
1005
|
+
catch (e) { bad(`고정 실패: origin 을 바꾸지 못했습니다 (${gitError(e)})`); return "pin-failed"; }
|
|
1006
|
+
return writePinRecord() ? "pinned" : "record-failed";
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// host 를 남긴다. 이 폴더는 통째로 복사해 배포하므로 PINNED 도 같이 딸려 가는데,
|
|
1010
|
+
// PINFILE 은 스크립트 위치가 아니라 homedir() 기준이라 남의 기록인 줄 모르고 쓰기 쉽다.
|
|
1011
|
+
// URL 은 남기지 않는다. 원래 URL 은 origin 의 `omc-pinned://` 뒤에 있고, 인증 정보가 든 URL 이 기록에 남는다.
|
|
1012
|
+
// 고정이 된 뒤에만 쓴다. 실패한 고정의 기록은 남기지 않는다. 기록을 쓰지 못하면 고정은 둔 채 실패로 센다.
|
|
1013
|
+
// 덮어쓴 옛 기록에 URL 칸이 있었고 같은 파일의 다른 이름이 있으면, 그쪽에 남은 칸을 알리고 실패로 센다.
|
|
1014
|
+
function writePinRecord() {
|
|
1015
|
+
let ino = null;
|
|
1016
|
+
try {
|
|
1017
|
+
mkdirSync(dirname(PINFILE), { recursive: true });
|
|
1018
|
+
if (isLink(PINFILE)) throw new Error("링크라 따라가 쓰지 않음");
|
|
1019
|
+
ino = oldCellIno();
|
|
1020
|
+
writeAtomic(PINFILE, JSON.stringify({ pinnedAt: new Date().toISOString(), version: TARGET_VERSION, host: hostname() }, null, 2) + "\n", 0o600, { follow: false });
|
|
1021
|
+
} catch (e) {
|
|
1022
|
+
bad(`고정 기록(PINNED)을 쓰지 못함 (${failWhy(e)}). origin 은 고정됨: ${oneLine(PINFILE)}`);
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
return ino === null ? true : sharedLeft(SHARED_WHY, ino);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// 덮어쓸 옛 기록에 URL 칸이 있고 같은 파일의 다른 이름이 있으면 그 inode 다. 칸이 없거나, 다른 이름이 없거나,
|
|
1029
|
+
// 기록을 읽지 못하면 null 이다.
|
|
1030
|
+
function oldCellIno() {
|
|
1031
|
+
try { return hasUrlKey(parseRecord(pinRecordText()).rec) ? sharedIno() : null; }
|
|
1032
|
+
catch { return null; }
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// 이미 고정된 origin. 옛 기록의 URL 칸을 지우지 못했으면 고정은 그대로 두고 실패로 센다.
|
|
1036
|
+
// --check 는 쓰지 않고 그 칸이 남았는지만 알린다. 모두 적용됐다고 하면 SKILL 이 적용을 건너뛰어, 이미 적용한 PC 에는
|
|
1037
|
+
// 그 칸을 지울 적용이 오지 않는다(고정된 OMC 는 업데이트도 오지 않는다).
|
|
1038
|
+
function alreadyPinned() {
|
|
1039
|
+
if (MODE === "check") return pinUrlCheck();
|
|
1040
|
+
return scrubPinRecord() ? "already" : "scrub-failed";
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// 옛 도구가 남긴 고정 기록의 URL(인증 정보가 들 수 있음)을 지우고 이 계정만 읽게(0600) 한다.
|
|
1044
|
+
// 할 일이 없거나 지웠으면 true 다. 지우지 못했거나 기록을 읽지 못해 확인하지 못했으면 [!] 로 알리고 false 다.
|
|
1045
|
+
function scrubPinRecord() {
|
|
1046
|
+
const left = pinUrlLeftover();
|
|
1047
|
+
if (!left) return true;
|
|
1048
|
+
if (left.why) return pinUrlLeft(left.why);
|
|
1049
|
+
delete left.rec.originUrl;
|
|
1050
|
+
const ino = sharedIno();
|
|
1051
|
+
try { writeAtomic(PINFILE, JSON.stringify(left.rec, null, 2) + "\n", 0o600, { follow: false }); }
|
|
1052
|
+
catch (e) { return pinUrlLeft(`지우지 못함 (쓰지 못함: ${failWhy(e)})`); }
|
|
1053
|
+
return ino === null ? true : sharedLeft(SHARED_WHY, ino);
|
|
1054
|
+
}
|
|
1055
|
+
const SHARED_WHY = "이 이름에서만 지움 (같은 파일의 다른 이름이 있어 그쪽에는 남음)";
|
|
1056
|
+
const SHARED_CHECK_WHY = "이 이름에서만 지울 수 있음 (같은 파일의 다른 이름이 있어 그쪽에는 남음)";
|
|
1057
|
+
|
|
1058
|
+
// 기록에 같은 파일의 다른 이름(하드 링크)이 있으면 그 파일의 inode(Windows 는 파일 ID, 정밀도를 잃지 않게 BigInt)다.
|
|
1059
|
+
// 없거나 확인하지 못하면 null. 이 이름을 rename 이나 unlink 로 바꾼 뒤에는 그 번호가 다른 이름들에만 남는다.
|
|
1060
|
+
function sharedIno() {
|
|
1061
|
+
try {
|
|
1062
|
+
const st = lstatSync(PINFILE, { bigint: true });
|
|
1063
|
+
return st.nlink > 1n ? st.ino : null;
|
|
1064
|
+
} catch {
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
const sharedLeft = (what, ino) => pinUrlLeft(what, `\n 다른 이름은 같은 파일 시스템에서 inode(Windows 는 파일 ID) ${ino} 로 찾습니다.`);
|
|
1069
|
+
|
|
1070
|
+
// --check 의 판정. 남은 칸을 적용이 지울 수 있으면 scrub-needs, 이 도구로 지울 수 없으면 그 까닭을 알리고
|
|
1071
|
+
// scrub-blocked 다. 남은 칸이 없으면 already 다.
|
|
1072
|
+
function pinUrlCheck() {
|
|
1073
|
+
const left = pinUrlLeftover();
|
|
1074
|
+
if (!left) return "already";
|
|
1075
|
+
const why = left.why || pinDirWhy();
|
|
1076
|
+
const ino = why ? null : sharedIno();
|
|
1077
|
+
if (why || ino !== null) {
|
|
1078
|
+
if (why) pinUrlLeft(why);
|
|
1079
|
+
else sharedLeft(SHARED_CHECK_WHY, ino);
|
|
1080
|
+
return "scrub-blocked";
|
|
1081
|
+
}
|
|
1082
|
+
bad(`고정 기록(PINNED)에 옛 URL 칸이 남아 있음 (적용하면 지움): ${oneLine(PINFILE)}`);
|
|
1083
|
+
return "scrub-needs";
|
|
1084
|
+
}
|
|
696
1085
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
1086
|
+
// 적용이 새 임시 파일을 만들 폴더에 쓸 수 없으면 그 까닭이다. --check 는 쓰지 않고 권한만 본다.
|
|
1087
|
+
function pinDirWhy() {
|
|
1088
|
+
try { accessSync(dirname(realOr(PINFILE)), FS_CONST.W_OK); return null; }
|
|
1089
|
+
catch (e) { return `지울 수 없음 (쓰지 못함: ${failWhy(e)})`; }
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// 고정 기록에 남은 옛 URL 칸. 없으면 null 이다. 있으면 그 칸을 뺄 기록(rec)이나 이 도구가 지우지 못하는 까닭(why)이다.
|
|
1093
|
+
// 글에 그 이름이 있어도 최상위 칸이 아니면 없는 것이다. 링크인 기록은 따라가 쓰지 않으므로(사용자가 고친다) 링크에는
|
|
1094
|
+
// rec 를 내지 않는다.
|
|
1095
|
+
function pinUrlLeftover() {
|
|
1096
|
+
let text;
|
|
1097
|
+
try { text = pinRecordText(); }
|
|
1098
|
+
catch (e) { return { why: `확인하지 못함 (읽지 못함: ${failWhy(e)})` }; }
|
|
1099
|
+
if (text === null) return null;
|
|
1100
|
+
const { ok, rec } = parseRecord(text);
|
|
1101
|
+
if (ok ? !hasUrlKey(rec) : !text.includes("originUrl")) return null;
|
|
1102
|
+
if (isLink(PINFILE)) return { why: "지울 수 없음 (링크라 따라가 쓰지 않음)" };
|
|
1103
|
+
return ok ? { rec } : { why: "지울 수 없음 (JSON 이 아님)" };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// 기록의 글을 JSON 으로 읽는다. 읽히면 ok 가 true 다. 최상위 칸만 본다: 글에 그 이름이 있어도 칸이 아니면 없는 것이다.
|
|
1107
|
+
function parseRecord(text) {
|
|
1108
|
+
try { return { ok: true, rec: JSON.parse(text) }; }
|
|
1109
|
+
catch { return { ok: false }; }
|
|
1110
|
+
}
|
|
1111
|
+
const hasUrlKey = (rec) => !!rec && typeof rec === "object" && "originUrl" in rec;
|
|
1112
|
+
|
|
1113
|
+
// 고정 기록의 글. 없거나 일반 파일로 이어지지 않으면(폴더, FIFO, 소켓, 끊어진 링크, 링크 고리) null 이다. 그런 자리에는
|
|
1114
|
+
// URL 이 들 수 없다. 읽지 못하면 던진다. stat 으로 먼저 걸러야 소켓에서 열기 오류(ENXIO)가 나지 않고, 연 뒤 다시 보아야
|
|
1115
|
+
// stat 과 읽기 사이에 FIFO 로 바뀐 자리에서 멈추지 않는다.
|
|
1116
|
+
function pinRecordText() {
|
|
1117
|
+
try {
|
|
1118
|
+
return statSync(PINFILE).isFile() ? readRegular(PINFILE, "utf8") : null;
|
|
1119
|
+
} catch (e) {
|
|
1120
|
+
if (["ENOENT", "ENOTDIR", "ELOOP", "ENOTFILE"].includes(e.code)) return null;
|
|
1121
|
+
throw e;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// 옛 URL 칸이 남았을 수 있음을 알린다. 인증 정보가 든 URL 이 남을 수 있어 적용과 되돌리기는 실패로 센다.
|
|
1126
|
+
// 기록의 글은 내지 않는다. more 는 경로 줄 뒤에 붙일 줄이다.
|
|
1127
|
+
function pinUrlLeft(what, more = "") {
|
|
1128
|
+
bad(`고정 기록(PINNED)의 옛 URL 칸을 ${what}.\n 인증 정보가 든 URL 이 남았을 수 있습니다: ${oneLine(PINFILE)}${more}`);
|
|
1129
|
+
return false;
|
|
703
1130
|
}
|
|
704
1131
|
|
|
705
1132
|
// 원본 URL 은 현재 origin 안에 그대로 들어 있다(`omc-pinned://` + 원본). 거기서만 복원한다.
|
|
706
1133
|
// PINNED 의 값으로 origin 을 쓰지 않는다. PINNED 는 배포 폴더에 같이 딸려 오므로 다른 PC 의 값일 수 있고,
|
|
707
1134
|
// host 가 없는 기록은 어느 기기의 것인지 모른다. origin 이 고정 상태가 아니면 되돌릴 것도 없다.
|
|
1135
|
+
// 고정 기록은 이 뒤에 dropPinRecord 가 지운다. 기록을 지우지 못해도 origin 은 되돌린다.
|
|
708
1136
|
function unpinMarketplace(url) {
|
|
709
1137
|
const PIN_PREFIX = "omc-pinned://";
|
|
710
|
-
if (existsSync(PINFILE)) dropPinRecord();
|
|
711
1138
|
const restored = url.startsWith(PIN_PREFIX) ? url.slice(PIN_PREFIX.length) : "";
|
|
712
1139
|
if (!restored) return "not-pinned";
|
|
713
|
-
try { git(["remote", "set-url", "origin", restored]); }
|
|
1140
|
+
try { git(["remote", "set-url", "--", "origin", restored]); }
|
|
1141
|
+
catch (e) {
|
|
1142
|
+
bad(`고정 해제 실패: origin 을 되돌리지 못했습니다 (${gitError(e)})`);
|
|
1143
|
+
log(` 원래 URL(호스트까지만): ${oneLine(hostOnly(restored))}. 원문은 origin 의 omc-pinned:// 뒤에 그대로 있습니다.`);
|
|
1144
|
+
return "unpin-failed";
|
|
1145
|
+
}
|
|
714
1146
|
return "unpinned";
|
|
715
1147
|
}
|
|
716
1148
|
|
|
717
|
-
// PINNED 를 지운다.
|
|
1149
|
+
// PINNED 를 지운다. 지웠거나 지울 것이 없으면 true 다. 깨진 기록(JSON 이 아니거나, null 처럼 객체가 아닌 값)은 말없이
|
|
1150
|
+
// 지운다. 링크는 링크만 지우는데, 링크 너머에 옛 URL 칸이 있거나 그것을 확인하지 못하면 링크를 두고 알린다(링크 너머는
|
|
1151
|
+
// 쓰지 않음). 지우지 못해도(읽기 전용 폴더, 폴더인 PINNED) 알린다. 둘 다 false 다. 일반 파일이 아니면 읽지 않는다(FIFO).
|
|
718
1152
|
function dropPinRecord() {
|
|
1153
|
+
if (!pinRecordThere()) return true;
|
|
1154
|
+
const left = isLink(PINFILE) && pinUrlLeftover();
|
|
1155
|
+
if (left) return pinUrlLeft(left.why);
|
|
719
1156
|
let saved = null;
|
|
720
|
-
try { saved = JSON.parse(
|
|
721
|
-
|
|
1157
|
+
try { saved = JSON.parse(pinRecordText()); } catch { /* 깨졌거나 읽지 못하면 무시 */ }
|
|
1158
|
+
const ino = hasUrlKey(saved) ? sharedIno() : null;
|
|
1159
|
+
try { unlinkSync(PINFILE); }
|
|
1160
|
+
catch (e) {
|
|
1161
|
+
bad(`고정 기록(PINNED)을 지우지 못함 (${failWhy(e)}): ${oneLine(PINFILE)}`);
|
|
1162
|
+
return false;
|
|
1163
|
+
}
|
|
1164
|
+
warnForeignPin(saved);
|
|
1165
|
+
return ino === null ? true : sharedLeft(SHARED_WHY, ino);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// 고정 기록 자리에 무엇이 있는가(끊어진 링크 포함). 위 폴더를 열 수 없어 확인하지 못하면 있는 것으로 본다.
|
|
1169
|
+
function pinRecordThere() {
|
|
1170
|
+
try { lstatSync(PINFILE); return true; }
|
|
1171
|
+
catch (e) { return !["ENOENT", "ENOTDIR"].includes(e.code); }
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// 다른 기기의 기록이면 알린다. 그 값은 쓰지 않는다.
|
|
1175
|
+
function warnForeignPin(saved) {
|
|
722
1176
|
if (saved && typeof saved === "object" && saved.host && saved.host !== hostname()) {
|
|
723
1177
|
bad(`PINNED 는 다른 기기(${shown(saved.host)})의 기록입니다 — 그 값은 쓰지 않습니다.`);
|
|
724
1178
|
}
|
|
@@ -731,15 +1185,52 @@ const OMC_NAMES = [[".claude-plugin/plugin.json", "oh-my-claudecode"], ["package
|
|
|
731
1185
|
function otherPluginName(root) {
|
|
732
1186
|
for (const [rel, want] of OMC_NAMES) {
|
|
733
1187
|
let name;
|
|
734
|
-
try { name = JSON.parse(
|
|
1188
|
+
try { name = JSON.parse(readRegular(join(root, rel), "utf8")).name; } catch { continue; }
|
|
735
1189
|
if (name === undefined) continue;
|
|
736
1190
|
if (name === want) return null;
|
|
737
|
-
|
|
1191
|
+
// 파일에서 읽은 값이다. JSON.stringify 는 U+2028, U+202E 같은 글자를 그대로 두므로 oneLine 을 거친다.
|
|
1192
|
+
const quoted = oneLine(JSON.stringify(name));
|
|
738
1193
|
return `${rel} 의 name 이 ${quoted.length > 80 ? `${quoted.slice(0, 80)}...` : quoted}`;
|
|
739
1194
|
}
|
|
740
1195
|
return null;
|
|
741
1196
|
}
|
|
742
1197
|
|
|
1198
|
+
// ── 인자 확인 ────────────────────────────────────────────────────────────
|
|
1199
|
+
// 모르는 인자를 말없이 넘기면 --dry-run 이 실제로 적용하고, --root=<폴더> 가 무시돼 --revert 가 모든 판을
|
|
1200
|
+
// 되돌리고 고정까지 푼다. 아는 꼴이 아니면 아무것도 읽거나 쓰기 전에 멈춘다.
|
|
1201
|
+
const USAGE = [
|
|
1202
|
+
"사용법:",
|
|
1203
|
+
` node omc-patch.mjs 업데이트(${TARGET_VERSION}) + 패치 + 고정`,
|
|
1204
|
+
" node omc-patch.mjs --check 진단만 (아무것도 바꾸지 않음)",
|
|
1205
|
+
" node omc-patch.mjs --revert 패치와 고정 되돌리기",
|
|
1206
|
+
" node omc-patch.mjs --no-update 버전은 그대로 두고 패치만",
|
|
1207
|
+
" node omc-patch.mjs --root <플러그인 폴더> [--check | --revert]",
|
|
1208
|
+
" node omc-patch.mjs --scan <파일>...",
|
|
1209
|
+
].join("\n");
|
|
1210
|
+
const FLAGS = new Set(["--check", "--revert", "--no-update"]);
|
|
1211
|
+
|
|
1212
|
+
// 인자 목록의 문제, 없으면 null. --scan 은 첫 인자일 때만 뒤를 모두 파일로 본다. --root 는 다음 인자를
|
|
1213
|
+
// 값으로 가져가고, 값이 없거나 플래그인 경우는 main 의 확인이 맡는다.
|
|
1214
|
+
function argProblem(list) {
|
|
1215
|
+
if (list[0] === "--scan") return null;
|
|
1216
|
+
let roots = 0;
|
|
1217
|
+
for (let i = 0; i < list.length; i++) {
|
|
1218
|
+
const a = list[i];
|
|
1219
|
+
if (FLAGS.has(a)) continue;
|
|
1220
|
+
if (a === "--root") { roots++; i++; continue; }
|
|
1221
|
+
if (a === "--scan") return "--scan 은 첫 인자로만 쓸 수 있습니다";
|
|
1222
|
+
if (a.startsWith("--root=")) return "--root 와 폴더는 띄어 쓰십시오: --root <폴더>";
|
|
1223
|
+
return `알 수 없는 인자: ${shown(a)}`;
|
|
1224
|
+
}
|
|
1225
|
+
return roots > 1 ? "--root 는 한 번만 쓸 수 있습니다" : null;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (args[0] !== "--scan" && (argv.has("--help") || argv.has("-h"))) { log(USAGE); process.exit(0); }
|
|
1229
|
+
{
|
|
1230
|
+
const problem = argProblem(args);
|
|
1231
|
+
if (problem) { bad(problem); log(USAGE); process.exit(1); }
|
|
1232
|
+
}
|
|
1233
|
+
|
|
743
1234
|
// ── --scan: 사실만 내고 끝낸다 ───────────────────────────────────────────────
|
|
744
1235
|
// 아래 main 보다 먼저 끝나야 한다. 이 모드는 installed_plugins.json 도 플러그인 캐시도 읽지 않는다.
|
|
745
1236
|
// 결과는 입력 순서대로 JSON 배열 한 줄이다. 동기 쓰기로 내보내야 process.exit 가 잘라먹지 않는다.
|
|
@@ -747,7 +1238,7 @@ if (SCAN_AT >= 0) {
|
|
|
747
1238
|
const files = args.slice(SCAN_AT + 1);
|
|
748
1239
|
if (!files.length) { console.error("--scan 뒤에 파일 경로가 필요합니다."); process.exit(1); }
|
|
749
1240
|
const out = files.map((file) => {
|
|
750
|
-
try { return { file, ...publicFacts(scanFacts(
|
|
1241
|
+
try { return { file, ...publicFacts(scanFacts(readRegular(file, "utf8"))) }; }
|
|
751
1242
|
catch (e) { return { file, error: String(e.code || e.message) }; }
|
|
752
1243
|
});
|
|
753
1244
|
writeSync(1, JSON.stringify(out) + "\n");
|
|
@@ -762,7 +1253,7 @@ if (argv.has("--check") && argv.has("--revert")) {
|
|
|
762
1253
|
process.exit(1);
|
|
763
1254
|
}
|
|
764
1255
|
log(ROOT
|
|
765
|
-
? `OMC 패치 (대상 폴더 ${ROOT}) 모드: ${MODE}`
|
|
1256
|
+
? `OMC 패치 (대상 폴더 ${oneLine(ROOT)}) 모드: ${MODE}`
|
|
766
1257
|
: `OMC 패치 (대상 ${TARGET_VERSION}) 모드: ${MODE}${NO_UPDATE ? " --no-update" : ""}`);
|
|
767
1258
|
log(`플랫폼: ${process.platform} node ${process.version}`);
|
|
768
1259
|
log();
|
|
@@ -776,22 +1267,22 @@ const rootOf = (v) => (ROOT ? v : join(CACHE, v));
|
|
|
776
1267
|
// active 는 아래에서 정해지므로 쓰는 자리에서 부른다.
|
|
777
1268
|
const misplacedHint = () => (ROOT
|
|
778
1269
|
? "--revert --root 로 되돌린 뒤 같은 --root 로 다시 적용하십시오."
|
|
779
|
-
: `--revert --root "${join(CACHE, active)}" 로 활성 폴더만 되돌린 뒤 다시 적용하십시오.`);
|
|
1270
|
+
: `--revert --root "${oneLine(join(CACHE, active))}" 로 활성 폴더만 되돌린 뒤 다시 적용하십시오.`);
|
|
780
1271
|
if (ROOT) {
|
|
781
1272
|
// 그 폴더 하나만 고친다(판 폴더도 installed_plugins.json 도 없는 번들 사본, 또는 활성 캐시 폴더).
|
|
782
1273
|
// 판 바꾸기([1/4])와 마켓플레이스 고정([4/4])은 하지 않는다 — 그 둘은 이 PC 의 설정이고 이 폴더의 것이 아니다.
|
|
783
1274
|
if (!existsSync(join(ROOT, "hooks", "hooks.json")) || !existsSync(join(ROOT, "scripts"))) {
|
|
784
|
-
bad(`플러그인 폴더가 아님 (hooks/hooks.json 과 scripts/ 가 있어야 함): ${ROOT}`);
|
|
1275
|
+
bad(`플러그인 폴더가 아님 (hooks/hooks.json 과 scripts/ 가 있어야 함): ${oneLine(ROOT)}`);
|
|
785
1276
|
process.exit(1);
|
|
786
1277
|
}
|
|
787
1278
|
const other = otherPluginName(ROOT);
|
|
788
|
-
if (other) { bad(`OMC 플러그인 폴더가 아님 (${other}): ${ROOT}`); process.exit(1); }
|
|
1279
|
+
if (other) { bad(`OMC 플러그인 폴더가 아님 (${other}): ${oneLine(ROOT)}`); process.exit(1); }
|
|
789
1280
|
log("[1/4] 버전");
|
|
790
1281
|
log(" 건너뜀 (--root 는 판을 바꾸지 않음)");
|
|
791
1282
|
active = ROOT;
|
|
792
1283
|
targets = [ROOT];
|
|
793
1284
|
} else {
|
|
794
|
-
if (!existsSync(INSTALLED)) { bad(`Claude Code 플러그인 설정을 찾을 수 없음: ${INSTALLED}`); process.exit(1); }
|
|
1285
|
+
if (!existsSync(INSTALLED)) { bad(`Claude Code 플러그인 설정을 찾을 수 없음: ${oneLine(INSTALLED)}`); process.exit(1); }
|
|
795
1286
|
|
|
796
1287
|
log("[1/4] 버전");
|
|
797
1288
|
if (NO_UPDATE) log(" 건너뜀 (--no-update)");
|
|
@@ -803,20 +1294,20 @@ if (ROOT) {
|
|
|
803
1294
|
const { v, src: activeSrc } = activeVersion();
|
|
804
1295
|
active = v;
|
|
805
1296
|
if (MODE !== "revert" && (MODE === "check" || NO_UPDATE)) {
|
|
806
|
-
log(` 활성 버전: ${active} (${activeSrc})`);
|
|
1297
|
+
log(` 활성 버전: ${oneLine(active)} (${activeSrc})`);
|
|
807
1298
|
if (active !== TARGET_VERSION) {
|
|
808
|
-
log(` 활성 버전이 대상(${TARGET_VERSION})과 다름 — 기본 apply 는 ${TARGET_VERSION} 로 전환하고, --no-update 는 활성 버전(${active})을 그 자리에서 패치함`);
|
|
1299
|
+
log(` 활성 버전이 대상(${TARGET_VERSION})과 다름 — 기본 apply 는 ${TARGET_VERSION} 로 전환하고, --no-update 는 활성 버전(${oneLine(active)})을 그 자리에서 패치함`);
|
|
809
1300
|
}
|
|
810
1301
|
}
|
|
811
1302
|
// apply 는 활성 버전만 손댄다. revert 와 check 는 설치된 모든 버전을 훑는다 —
|
|
812
1303
|
// 여러 버전이 나란히 있을 때 이전 버전에 수정이 남으면, 롤백했을 때 예상과 다른
|
|
813
1304
|
// 상태가 된다(실제로 4.15.7 의 hooks.json 수정이 그렇게 남았다).
|
|
814
1305
|
targets = MODE === "apply" ? [active] : (versions.length ? versions : [active]);
|
|
815
|
-
if (!existsSync(join(CACHE, active))) { bad(`설치 경로 없음: ${join(CACHE, active)}`); process.exit(1); }
|
|
1306
|
+
if (!existsSync(join(CACHE, active))) { bad(`설치 경로 없음: ${oneLine(join(CACHE, active))}`); process.exit(1); }
|
|
816
1307
|
}
|
|
817
1308
|
|
|
818
1309
|
log();
|
|
819
|
-
log(`[2/4] 훅 패치${MODE === "apply" || ROOT ? ` (${active})` : ` (대상 ${targets.length}개 버전)`}`);
|
|
1310
|
+
log(`[2/4] 훅 패치${MODE === "apply" || ROOT ? ` (${oneLine(active)})` : ` (대상 ${targets.length}개 버전)`}`);
|
|
820
1311
|
const r = { done: 0, skip: 0, fail: 0, need: 0, misplaced: 0 };
|
|
821
1312
|
const needBy = {};
|
|
822
1313
|
const misplacedBy = {};
|
|
@@ -829,7 +1320,7 @@ const left = new Set();
|
|
|
829
1320
|
for (const v of targets) {
|
|
830
1321
|
const root = rootOf(v);
|
|
831
1322
|
if (!existsSync(root)) continue;
|
|
832
|
-
if (targets.length > 1) log(` — ${v}`);
|
|
1323
|
+
if (targets.length > 1) log(` — ${oneLine(v)}`);
|
|
833
1324
|
const x = patchHooks(root);
|
|
834
1325
|
needBy[v] = x.need;
|
|
835
1326
|
misplacedBy[v] = x.misplaced;
|
|
@@ -843,10 +1334,13 @@ log(` ${MODE === "check" ? `패치 필요 ${r.need}건` : MODE === "revert" ? `
|
|
|
843
1334
|
// 백업이 없어 되돌리지 못한 파일은 되돌릴 원본이 디스크에 없다. 조용히 넘기면 «완전히
|
|
844
1335
|
// 되돌렸다» 는 잘못된 인상을 준다 — 무엇이 남았고 어떻게 되찾는지 정확히 말한다.
|
|
845
1336
|
function reportNoBackup(v, list, what) {
|
|
846
|
-
bad(`${v}: ${what} — ${list.map(shown).join(", ")}`);
|
|
1337
|
+
bad(`${oneLine(v)}: ${what} — ${list.map(shown).join(", ")}`);
|
|
847
1338
|
log(` 백업(.omcbak)이 없어 되돌릴 원본이 디스크에 없습니다. 원본이 필요하면:`);
|
|
848
1339
|
if (ROOT) log(` 같은 판의 상류 원본에서 scripts/<이름>.mjs 를 다시 가져오십시오.`);
|
|
849
|
-
else
|
|
1340
|
+
else {
|
|
1341
|
+
log(` git -C "${oneLine(MARKET)}" archive -o "<임시 tar>" v${oneLine(v)} scripts/<이름>.mjs`);
|
|
1342
|
+
log(` tar -xf "<임시 tar>" -C "${oneLine(join(CACHE, v))}"`);
|
|
1343
|
+
}
|
|
850
1344
|
}
|
|
851
1345
|
for (const [v, list] of Object.entries(foreignBy)) reportNoBackup(v, list, "이 도구가 만들지 않은 수정이 남아 있습니다");
|
|
852
1346
|
for (const [v, list] of Object.entries(orphanBy)) reportNoBackup(v, list, "패치는 있는데 백업이 없어 되돌리지 못했습니다");
|
|
@@ -854,18 +1348,21 @@ for (const [v, list] of Object.entries(orphanBy)) reportNoBackup(v, list, "패
|
|
|
854
1348
|
log();
|
|
855
1349
|
log("[3/4] PostToolUse 훅 제거");
|
|
856
1350
|
const hjNeedBy = {};
|
|
1351
|
+
const hjFailBy = {};
|
|
857
1352
|
for (const v of targets) {
|
|
858
1353
|
const root = rootOf(v);
|
|
859
1354
|
if (!existsSync(root)) continue;
|
|
860
|
-
const hj = patchHooksJson(root);
|
|
861
1355
|
const tag = targets.length > 1 ? `${v}: ` : "";
|
|
1356
|
+
hjTag = tag;
|
|
1357
|
+
const hj = patchHooksJson(root);
|
|
862
1358
|
if (Array.isArray(hj)) ok(`${tag}제거: ${hj.join(", ")}`);
|
|
863
1359
|
else if (hj === "already") ok(`${tag}이미 제거됨`);
|
|
864
1360
|
else if (hj === "needs") hjNeedBy[v] = true;
|
|
865
1361
|
else if (hj === "reverted" || hj === "ambiguous" || hj === "linked") { /* patchHooksJson 이 출력함 */ }
|
|
866
1362
|
else if (hj === "no-backup") ok(`${tag}되돌릴 백업 없음 (이 도구가 지운 적 없음)`);
|
|
867
1363
|
else if (hj === "no-file") bad(`${tag}hooks.json 파일 자체가 없음`);
|
|
868
|
-
|
|
1364
|
+
// "failed" 는 고친 hooks.json 이 JSON 으로 읽히지 않아 되돌린 경우다. 고치지 못했으니 실패로 센다.
|
|
1365
|
+
else if (hj === "unsafe" || hj === "failed") { r.fail++; hjFailBy[v] = true; failed.add(fileKey(join(root, "hooks", "hooks.json"))); }
|
|
869
1366
|
if (hj === "ambiguous" || hj === "linked") left.add(fileKey(join(root, "hooks", "hooks.json")));
|
|
870
1367
|
}
|
|
871
1368
|
|
|
@@ -880,9 +1377,9 @@ if (ROOT) {
|
|
|
880
1377
|
// - DISABLE_AUTOUPDATER=1 : 확실하지만 전역(다른 마켓플레이스도 멈춤)
|
|
881
1378
|
// - /plugin UI 의 마켓플레이스별 토글 : omc 만 끄지만 UI 상태라 파일로 확인 불가
|
|
882
1379
|
try {
|
|
883
|
-
const st = JSON.parse(
|
|
1380
|
+
const st = JSON.parse(readRegular(join(CFG, "settings.json"), "utf8"));
|
|
884
1381
|
const off = st?.env?.DISABLE_AUTOUPDATER;
|
|
885
|
-
log(` DISABLE_AUTOUPDATER: ${off ? `${off} (전역 차단 중)` : "미설정 (다른 마켓플레이스는 계속 자동 업데이트)"}`);
|
|
1382
|
+
log(` DISABLE_AUTOUPDATER: ${off ? `${oneLine(off)} (전역 차단 중)` : "미설정 (다른 마켓플레이스는 계속 자동 업데이트)"}`);
|
|
886
1383
|
} catch { /* settings.json 이 없거나 읽을 수 없으면 표시만 생략 */ }
|
|
887
1384
|
pin = pinMarketplace();
|
|
888
1385
|
({
|
|
@@ -894,7 +1391,28 @@ if (ROOT) {
|
|
|
894
1391
|
"no-marketplace": () => bad("마켓플레이스 디렉터리 없음 — 고정 불필요"),
|
|
895
1392
|
"no-git": () => bad("마켓플레이스 폴더가 git 저장소가 아님 — 고정하지 않음"),
|
|
896
1393
|
"no-remote": () => ok("origin remote 없음 — 이미 fetch 불가"),
|
|
1394
|
+
"pin-failed": () => {},
|
|
1395
|
+
"origin-unreadable": () => {},
|
|
1396
|
+
"unpin-failed": () => {},
|
|
1397
|
+
"record-failed": () => {},
|
|
1398
|
+
"scrub-failed": () => ok("이미 고정됨"),
|
|
1399
|
+
"scrub-needs": () => ok("이미 고정됨"),
|
|
1400
|
+
"scrub-blocked": () => ok("이미 고정됨"),
|
|
897
1401
|
}[pin] || (() => {}))();
|
|
1402
|
+
// 마켓플레이스를 못 찾아 고정하지 않은 PC 에도 옛 판이 남긴 기록은 있을 수 있다. 같은 판정으로 다룬다.
|
|
1403
|
+
if (MODE !== "revert" && ["no-marketplace", "no-git", "no-remote"].includes(pin)) {
|
|
1404
|
+
const rec = alreadyPinned();
|
|
1405
|
+
if (rec !== "already") pin = rec;
|
|
1406
|
+
}
|
|
1407
|
+
if (["pin-failed", "origin-unreadable", "unpin-failed", "record-failed", "scrub-failed"].includes(pin)) {
|
|
1408
|
+
r.fail++;
|
|
1409
|
+
failed.add(["record-failed", "scrub-failed"].includes(pin) ? "PINNED" : "origin");
|
|
1410
|
+
}
|
|
1411
|
+
// 되돌리기는 origin 을 다룬 뒤 고정 기록을 지운다. 지우지 못하면 origin 과 따로 실패로 센다.
|
|
1412
|
+
if (MODE === "revert" && !dropPinRecord()) {
|
|
1413
|
+
r.fail++;
|
|
1414
|
+
failed.add("PINNED");
|
|
1415
|
+
}
|
|
898
1416
|
}
|
|
899
1417
|
|
|
900
1418
|
log();
|
|
@@ -905,15 +1423,29 @@ if (MODE === "check") {
|
|
|
905
1423
|
// 헬퍼 자리 결함이 하나라도 있으면 «모두 적용» 이 아니다. 다시 적용만으로는 안 고쳐지므로
|
|
906
1424
|
// 그 안내를 먼저 낸다(되돌린 뒤 적용하면 «패치 필요» 항목도 함께 처리된다).
|
|
907
1425
|
const misplacedActive = misplacedBy[active] || 0;
|
|
908
|
-
const
|
|
1426
|
+
const pinNeeds = ["needs", "scrub-needs", "scrub-blocked"].includes(pin);
|
|
1427
|
+
const pinUnread = pin === "origin-unreadable";
|
|
1428
|
+
const clean = (needBy[active] || 0) === 0 && !hjNeedBy[active] && !hjFailBy[active] && !pinNeeds && !pinUnread && misplacedActive === 0;
|
|
1429
|
+
// 막힘 줄이 결과가 되는 때는 적용으로 고칠 항목이 없고 막힘만 남은 때다. 고칠 항목이 함께 있으면 그 항목이 결과이고
|
|
1430
|
+
// 막힘은 참고로 알린다(적용은 훅을 패치하고 그 칸은 남겨 실패로 센다).
|
|
1431
|
+
const blockedResult = pin === "scrub-blocked" && (needBy[active] || 0) === 0 && !hjNeedBy[active] && !hjFailBy[active] && !misplacedActive;
|
|
909
1432
|
const others = targets.filter((v) => v !== active && ((needBy[v] || 0) > 0 || hjNeedBy[v] || (misplacedBy[v] || 0) > 0));
|
|
910
1433
|
log(clean ? "결과: 모두 적용된 상태입니다."
|
|
1434
|
+
: hjFailBy[active] ? "결과: hooks.json 을 확인하지 못했습니다. 위의 [!] 줄을 확인하십시오."
|
|
1435
|
+
: pinUnread ? "결과: 마켓플레이스 origin 을 확인하지 못했습니다. 위의 [!] 줄을 확인하십시오."
|
|
911
1436
|
: misplacedActive ? `결과: 헬퍼 자리 결함 ${misplacedActive}건이 있습니다. ${misplacedHint()}`
|
|
1437
|
+
: blockedResult ? "결과: 고정 기록(PINNED)의 옛 URL 칸이 이 도구로 지울 수 없는 곳에 있습니다. 위의 [!] 줄을 확인하십시오."
|
|
912
1438
|
: ROOT ? "결과: 적용되지 않은 항목이 있습니다. --check 없이 같은 --root 로 다시 실행하십시오."
|
|
913
1439
|
: NO_UPDATE ? "결과: 적용되지 않은 항목이 있습니다. --check 없이 --no-update 로 다시 실행하십시오."
|
|
914
1440
|
: "결과: 적용되지 않은 항목이 있습니다. 인자 없이 다시 실행하십시오.");
|
|
1441
|
+
if (pin === "scrub-blocked" && !blockedResult) {
|
|
1442
|
+
log(" 참고: 고정 기록(PINNED)의 옛 URL 칸이 이 도구로 지울 수 없는 곳에 있습니다. 위의 [!] 줄을 확인하십시오.");
|
|
1443
|
+
log(" 적용해도 그곳의 칸은 남고, 적용은 이 항목을 실패로 세어 종료 1 로 끝납니다.");
|
|
1444
|
+
}
|
|
1445
|
+
const unreadable = targets.filter((v) => v !== active && hjFailBy[v]);
|
|
1446
|
+
if (unreadable.length) log(` 참고: 비활성 버전 ${unreadable.map(oneLine).join(", ")} 의 hooks.json 을 확인하지 못했습니다. 판정에는 넣지 않았습니다.`);
|
|
915
1447
|
if (others.length) {
|
|
916
|
-
log(` 참고: 비활성 버전 ${others.join(", ")} 에 미적용분이 있습니다.`);
|
|
1448
|
+
log(` 참고: 비활성 버전 ${others.map(oneLine).join(", ")} 에 미적용분이 있습니다.`);
|
|
917
1449
|
log(" 지금 쓰이지 않는 버전이라 판정에 넣지 않았습니다. 그 버전으로 되돌릴 계획이면 함께 적용하십시오.");
|
|
918
1450
|
}
|
|
919
1451
|
} else if (MODE === "revert") {
|