@kaydash9999/banker-plugins 0.20.1 → 0.20.5

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.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +195 -1
  4. package/PRIVACY.md +13 -3
  5. package/README.md +27 -4
  6. package/bin/banker.js +84 -40
  7. package/bin/lib/telemetry-config.mjs +31 -16
  8. package/codex/manifest.json +1 -1
  9. package/codex/transform-matrix.md +1 -1
  10. package/commands/setup.md +23 -0
  11. package/hooks/progress.mjs +143 -38
  12. package/hooks/register.mjs +13 -3
  13. package/hooks/update-checkin.mjs +15 -7
  14. package/hooks/update-fetch.mjs +15 -9
  15. package/hooks/update-notify.mjs +2 -9
  16. package/package.json +1 -1
  17. package/skills/3d-intro-build/SKILL.md +11 -5
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  19. package/skills/3d-intro-build/references/curate.mjs +48 -14
  20. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  21. package/skills/3d-intro-build/references/prompts.md +4 -4
  22. package/skills/3d-intro-build/references/serve.mjs +6 -1
  23. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  24. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  25. package/skills/3d-intro-setup/SKILL.md +41 -10
  26. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  28. package/skills/anti-drm/SKILL.md +6 -3
  29. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  30. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  31. package/skills/audit-mock/SKILL.md +1 -1
  32. package/skills/audit-security/SKILL.md +64 -2
  33. package/skills/deep-research/SKILL.md +5 -0
  34. package/skills/docs-setup/SKILL.md +37 -7
  35. package/skills/lineage/SKILL.md +12 -12
  36. package/skills/lineage/lineage.py +399 -300
  37. package/skills/motion-graphic-make/SKILL.md +1 -1
  38. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  39. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  40. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  41. package/skills/motion-graphic-setup/SKILL.md +58 -13
  42. package/skills/obsidizer/SKILL.md +1 -1
  43. package/skills/obsidizer/obsidize.mjs +8 -1
  44. package/skills/omc-patch/SKILL.md +80 -23
  45. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  46. package/skills/payload-mon/SKILL.md +5 -2
  47. package/skills/payload-mon/scripts/payload-mon.mjs +137 -30
  48. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  49. package/skills/pdf-vision-extract/SKILL.md +11 -6
  50. package/skills/ralph-qa/SKILL.md +125 -151
  51. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  52. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  53. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  54. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  55. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  56. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  57. package/skills/setup-harness-factory/SKILL.md +17 -1
  58. package/skills/setup-insane-search/SKILL.md +16 -0
  59. package/skills/setup-java/SKILL.md +20 -1
  60. package/skills/setup-lsp/SKILL.md +56 -13
  61. package/skills/setup-mcp/SKILL.md +73 -4
  62. package/skills/setup-node/SKILL.md +31 -6
  63. package/skills/setup-omc/SKILL.md +24 -0
  64. package/skills/setup-omc-hud/SKILL.md +13 -5
  65. package/skills/setup-omc-hud/assets/apply-hud.mjs +381 -27
  66. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  67. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  68. package/skills/setup-playwright/SKILL.md +45 -2
  69. package/skills/setup-pwsh/SKILL.md +19 -0
  70. package/skills/setup-python/SKILL.md +45 -7
  71. package/skills/setup-sandbox/SKILL.md +30 -5
  72. package/skills/setup-stitch/SKILL.md +22 -0
  73. package/skills/setup-tmux/SKILL.md +24 -4
  74. package/skills/tone-compact/SKILL.md +2 -1
  75. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  76. package/skills/ultra-chain/SKILL.md +4 -3
  77. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  78. package/skills/ultra-chain/references/serve.mjs +6 -1
  79. package/skills/ultra-interview/SKILL.md +1 -0
  80. package/skills/update-banker/SKILL.md +19 -3
  81. package/skills/vertical-pptx/SKILL.md +6 -3
  82. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * 활성 버전은 installed_plugins.json 의 oh-my-claudecode@omc 항목(version, installPath)에서
18
18
  * 읽는다. 캐시에 TARGET_VERSION 이 있다고 그것을 활성으로 보지 않는다(--no-update 는 활성 버전을 패치).
19
- * OMC_PATCH_INSTALLED=<json 경로> 로 읽을 파일을 바꿀 수 있다(시험용, --check 와 함께).
19
+ * OMC_PATCH_INSTALLED=<json 경로> 로 읽을 파일을 바꿀 수 있다(시험용, --check 에서만 받음).
20
20
  *
21
21
  * OS: Windows / macOS / Linux 공통. 런처(apply.cmd, apply.sh)와 배경 문서(README.md)는
22
22
  * 킷 배포 폴더에만 있다. 이 파일만 옮겨 쓰는 곳(예: 스킬 폴더)에는 없다.
@@ -61,8 +61,9 @@ const CFG = join(homedir(), ".claude");
61
61
  const PLUGINS = join(CFG, "plugins");
62
62
  const CACHE = join(PLUGINS, "cache", MARKETPLACE, "oh-my-claudecode");
63
63
  const MARKET = join(PLUGINS, "marketplaces", MARKETPLACE);
64
- // OMC_PATCH_INSTALLED: 시험용. installed_plugins.json 대신 읽을 JSON 경로(읽기 전용 용도).
65
- const INSTALLED = process.env.OMC_PATCH_INSTALLED || join(PLUGINS, "installed_plugins.json");
64
+ // OMC_PATCH_INSTALLED: 시험용. --check 일 때만 installed_plugins.json 대신 읽을 JSON 경로(읽기 전용). 적용과 되돌리기는
65
+ // 늘 Claude Code 가 읽는 파일을 본다 — 다른 파일이 정한 판을 패치하거나 그 파일에 쓰지 않게.
66
+ const INSTALLED = (MODE === "check" && process.env.OMC_PATCH_INSTALLED) || join(PLUGINS, "installed_plugins.json");
66
67
  const PINFILE = join(CFG, "omc-local-patches", "PINNED");
67
68
 
68
69
  const log = (s = "") => console.log(s);
@@ -78,7 +79,7 @@ const ok = (s) => console.log(` ok ${s}`);
78
79
  // 심볼이 undefined 가 되고, 호출부에 이미 있는 null 검사/try-catch 가 no-op
79
80
  // 경로를 태운다. 정상 로드 시에는 동작이 전혀 바뀌지 않는다.
80
81
  const HELPER = `
81
- // --- OMC local patch: bound top-level dynamic import (see ~/.claude/omc-local-patches/README.md)
82
+ // --- OMC local patch: bound top-level dynamic import (made by omc-patch; undo with omc-patch --revert)
82
83
  const __omcRaceImport = (p) => {
83
84
  let t;
84
85
  const timed = new Promise((res) => {
@@ -87,6 +88,8 @@ const __omcRaceImport = (p) => {
87
88
  // 뜨지도 못한다(실측: 원본과 패치본이 똑같이 150ms 안에 죽었다). import 가 정상적으로
88
89
  // 끝나면 아래에서 clearTimeout 하므로 정상 경로는 한 톨도 느려지지 않는다.
89
90
  t = setTimeout(() => {
91
+ // 훅이 일을 건너뛴 것을 알 수 있게 한 줄 남긴다. 종료 코드는 바꾸지 않는다(0 이 아니면 Claude Code 가 훅 오류로 본다). 닫힌 stderr 의 쓰기 오류는 뒤에 error 이벤트로 오므로 먼저 받아 둔다.
92
+ try { process.stderr.on('error', () => {}); process.stderr.write('[OMC local patch] import 가 ${IMPORT_TIMEOUT_MS / 1000}초 안에 끝나지 않아 빈 모듈로 진행합니다\\n'); } catch { /* 알림만 */ }
90
93
  const k = setTimeout(() => process.exit(typeof process.exitCode === 'number' ? process.exitCode : 0), ${EXIT_GRACE_MS});
91
94
  if (k && k.unref) k.unref();
92
95
  // 빈 객체를 돌려주는 것이 중요하다. 호출부 다수가 \`const { a } = await import(...)\`
@@ -177,6 +180,11 @@ function fetchConfig(env) {
177
180
  return out;
178
181
  }
179
182
 
183
+ // 업데이트 fetch 만 받는 값. 받는 속도가 초당 1000바이트 밑으로 30초 이어지면 git(curl)이 스스로 끊고, 자격 증명을 터미널에서 묻지 않는다.
184
+ const NET_ENV = { GIT_HTTP_LOW_SPEED_LIMIT: "1000", GIT_HTTP_LOW_SPEED_TIME: "30", GIT_TERMINAL_PROMPT: "0" };
185
+ // fetch 전체의 시간 제한(ms). 응답하지 않는 망(연결을 붙잡는 방화벽, TLS 블랙홀)에서 멈추지 않게 한다. 시험이 줄일 수 있게
186
+ // OMC_PATCH_FETCH_TIMEOUT_MS 를 받되 줄이기만 한다. 0 이하나 숫자가 아니면 버린다. 문서에 적지 않는 시험용 장치다.
187
+ const FETCH_TIMEOUT_MS = Math.min(Number(process.env.OMC_PATCH_FETCH_TIMEOUT_MS) > 0 ? Number(process.env.OMC_PATCH_FETCH_TIMEOUT_MS) : 120000, 120000);
180
188
  // 마켓플레이스의 .git 이 깨졌으면(중단된 clone) git 은 위로 올라가 다음 저장소(dotfiles 로 관리하는 ~/.claude 등)를
181
189
  // 고른다. 바로 위 폴더를 천장(GIT_CEILING_DIRECTORIES)으로 두어 마켓플레이스 밖으로 나가지 못하게 한다.
182
190
  // 마켓플레이스가 링크면 실제 경로 쪽 위 폴더도 막는다. 셸에 있던 천장 값은 대소문자와 상관없이 이 값으로 바꾼다.
@@ -184,25 +192,66 @@ const gitEnv = ({ network = false } = {}) => {
184
192
  const vars = gitRepoVars();
185
193
  const drop = (k) => isEnvConfig(k) || vars.has(k.toUpperCase()) || k.toUpperCase() === "GIT_CEILING_DIRECTORIES";
186
194
  const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !drop(k)));
187
- if (network) Object.assign(env, fetchConfig(process.env));
195
+ if (network) Object.assign(env, fetchConfig(process.env), NET_ENV);
188
196
  env.GIT_CEILING_DIRECTORIES = [...new Set([dirname(MARKET), dirname(realOr(MARKET))])].join(PATH_LIST_SEP);
189
197
  return env;
190
198
  };
191
199
 
192
- function git(args, cwd = MARKET, opts = {}) {
193
- return execFileSync("git", args, { cwd, env: gitEnv(opts), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
200
+ // 저장소의 git 도 홈 폴더에서 돌리고 저장소는 -C 로 준다. Windows 는 이름만 준 프로그램을 일하는 폴더에서 PATH 보다 먼저
201
+ // 찾아, 마켓플레이스(남의 저장소 사본)에 든 git.exe 가 돌 수 있다.
202
+ function git(args, dir = MARKET, opts = {}) {
203
+ return execFileSync("git", ["-C", dir, ...args], { cwd: homedir(), env: gitEnv(opts), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: opts.timeout }).trim();
204
+ }
205
+
206
+ // 태그 받기를 띄우는 작은 node 프로세스. execFileSync 의 timeout 은 git 하나만 끝내, git 이 띄운 git remote-http 와
207
+ // git-remote-http 가 연결을 붙든 채 남는다(실측: http 는 curl 의 정체 감지 30초, TLS 는 curl 의 연결 제한 300초까지). 흐름은
208
+ // 동기 그대로 두고 이 프로세스가 git 을 띄워, 시간이 다 되거나 Ctrl+C(SIGINT), SIGTERM, SIGHUP 을 받으면 git 과 그 자식을 함께
209
+ // 끝낸다. POSIX 는 git 을 새 프로세스 묶음으로 띄워 묶음에 SIGTERM(git 이 ref 잠금 파일을 지움), git 이 끝나거나 2초가 지나면
210
+ // SIGKILL. Windows 는 git 이 살아 있을 때 taskkill /T /F 로 트리째 끝낸다. 시간이 다 되면 종료 124 다. git 은 홈 폴더에서
211
+ // -C 로 돈다(git 함수와 같은 까닭). 알려진 한계: 바깥에서 이 도구의 프로세스 묶음에 SIGKILL 을 보내면(`timeout -s KILL` 등)
212
+ // 이 프로세스가 먼저 끝나 새 묶음의 git 이 남는다. Ctrl+C, SIGTERM, Claude Code 의 작업 중지는 git 까지 끝낸다.
213
+ const FETCH_REAPER = `
214
+ const { spawn, spawnSync } = require("node:child_process");
215
+ const { constants } = require("node:os");
216
+ const [, ms, dir, ...args] = process.argv;
217
+ const win = process.platform === "win32";
218
+ const c = spawn("git", ["-C", dir, ...args], { cwd: require("node:os").homedir(), stdio: "inherit", detached: !win, windowsHide: true });
219
+ const group = (sig) => { try { process.kill(-c.pid, sig); } catch { /* the group is gone */ } };
220
+ const taskkill = \`\${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\taskkill.exe\`;
221
+ const tree = () => spawnSync(taskkill, ["/T", "/F", "/PID", String(c.pid)], { stdio: "ignore", windowsHide: true });
222
+ let code = null;
223
+ const stop = (why) => {
224
+ if (code !== null) return;
225
+ code = why;
226
+ if (win) { tree(); process.exit(code); }
227
+ group("SIGTERM");
228
+ setTimeout(() => { group("SIGKILL"); process.exit(code); }, 2000);
229
+ };
230
+ const t = setTimeout(() => stop(124), Number(ms));
231
+ for (const s of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(s, () => stop(130));
232
+ c.on("error", (e) => { clearTimeout(t); process.stderr.write("error: cannot run git (" + e.code + ")\\n"); process.exit(127); });
233
+ c.on("exit", (status, sig) => {
234
+ clearTimeout(t);
235
+ if (!win) group("SIGKILL");
236
+ process.exit(code ?? status ?? 128 + (constants.signals[sig] || 0));
237
+ });
238
+ `;
239
+ // 바깥 시간 제한은 위 프로세스가 끝내지 못할 때만 쓴다(node 를 띄우는 시간, SIGTERM 뒤 2초, taskkill).
240
+ const REAPER_GRACE_MS = 10000;
241
+ function gitFetch(args) {
242
+ try {
243
+ execFileSync(process.execPath, ["--input-type=commonjs", "-e", FETCH_REAPER, String(FETCH_TIMEOUT_MS), MARKET, ...args],
244
+ { cwd: homedir(), env: gitEnv({ network: true }), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: FETCH_TIMEOUT_MS + REAPER_GRACE_MS });
245
+ } catch (e) {
246
+ if (e.status === 124) e.code = "ETIMEDOUT";
247
+ throw e;
248
+ }
194
249
  }
195
250
 
196
251
  // ── 1. 업데이트 ──────────────────────────────────────────────────────────────
197
252
  function ensureVersion() {
198
253
  const have = installedVersions();
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
- }
254
+ if (have.includes(TARGET_VERSION)) return useCached();
206
255
  if (MODE === "check") { bad(`${TARGET_VERSION} 미설치 (설치된 버전: ${have.join(", ") || "없음"})`); return false; }
207
256
  if (!canInstallTarget()) return false;
208
257
 
@@ -212,29 +261,97 @@ function ensureVersion() {
212
261
  const dest = join(CACHE, TARGET_VERSION);
213
262
  unpackTarget(dest);
214
263
  linkNodeModules(dest, have);
215
- return switchActive(sha, have);
264
+ return switchActive(sha);
265
+ }
266
+
267
+ // TARGET_VERSION 이 캐시에 있을 때. 이미 활성이면 끝, 아니면 마켓플레이스 태그의 커밋으로 전환한다.
268
+ // 전환도 마켓플레이스와 git 이 있어야 하므로 먼저 확인한다(풀지 않으므로 tar 는 없어도 된다).
269
+ function useCached() {
270
+ const now = activeVersion().v;
271
+ if (MODE === "check" || now === TARGET_VERSION) { ok(`${TARGET_VERSION} 설치되어 있음`); return true; }
272
+ log(` ${TARGET_VERSION} 은 캐시에 있으나 활성 판은 ${oneLine(now)} 입니다. 전환합니다`);
273
+ if (!canInstallTarget({ tar: false })) return false;
274
+ const sha = targetSha();
275
+ return sha === null ? false : switchActive(sha);
216
276
  }
217
277
 
218
- // 마켓플레이스가 제 git 저장소이고 git 과 tar 가 있어야 TARGET_VERSION 을 꺼낼 수 있다.
219
- function canInstallTarget() {
278
+ // 마켓플레이스가 제 git 저장소이고 git 이 있어야 TARGET_VERSION 의 커밋을 찾고, 풀려면 tar 도 있어야 한다.
279
+ function canInstallTarget({ tar = true } = {}) {
220
280
  if (!existsSync(MARKET)) { bad(`마켓플레이스 없음: ${oneLine(MARKET)}\n Claude Code 에서 marketplace 를 먼저 추가하십시오: ${REPO_URL}`); return false; }
221
281
  if (!existsSync(join(MARKET, ".git"))) { bad(`마켓플레이스 폴더가 git 저장소가 아님: ${oneLine(MARKET)}`); return false; }
282
+ // 판을 바꾸기 전에 installed_plugins.json 의 백업 자리를 본다. 링크나 일반 파일이 아닌 자리면 있으면 실패하는 복사가
283
+ // EEXIST 로 끝나 이미 있는 백업으로 보여, 백업 없이 바꾸게 된다. 풀기 전에 멈춘다.
284
+ const spot = backupSpotWhy(`${INSTALLED}.omcbak`);
285
+ if (spot) { bad(`installed_plugins.json 을 바꾸지 않음 (${spot}): ${oneLine(INSTALLED)}.omcbak`); return false; }
222
286
 
223
287
  // git 과 tar 에 의존한다. Windows 10+ / macOS / 대부분의 Linux 배포판에는 둘 다 있지만,
224
288
  // 최소 설치 컨테이너나 tar 없는 환경에서는 없을 수 있다. 여기서 못 찾으면 아래에서
225
289
  // execFileSync 가 ENOENT 로 죽어 원인이 안 보이므로, 먼저 확인하고 무엇이 없는지 말한다.
226
- for (const [cmd, why] of [["git", "마켓플레이스에서 소스를 꺼내는 데"], ["tar", "그 소스를 푸는 데"]]) {
290
+ const tools = [["git", "마켓플레이스에서 소스를 꺼내는 데"], ["tar", "그 소스를 푸는 데"]].slice(0, tar ? 2 : 1);
291
+ for (const [cmd, why] of tools) {
227
292
  try { execFileSync(cmd, ["--version"], { cwd: homedir(), stdio: "ignore" }); }
228
293
  catch { bad(`${cmd} 를 찾을 수 없습니다 (${why} 필요). 설치한 뒤 다시 실행하거나, --no-update 로 패치만 적용하십시오.`); return false; }
229
294
  }
230
295
  return true;
231
296
  }
232
297
 
233
- // 태그 v<TARGET_VERSION> 의 커밋. 못 찾으면 null.
298
+ // origin 에서 태그를 받는다. 받았으면 null, 받지 못했으면 그 까닭 한 줄. 이 도구가 고정한 origin 은 받을 수 없으므로
299
+ // 부르지 않고 그렇다고 한다(git 은 "remote helper" 오류만 낸다). curl 은 비밀번호 안의 `/` 를 호스트의 끝으로 읽어 사용자
300
+ // 정보 조각을 문구에 싣는다("Port number ended with 'Q'", "Could not resolve host: alice"). 그래서 다음이면 문구는 싣지
301
+ // 않고 git 이 어떻게 끝났는지만 댄다: origin 에 사용자 정보가 있음, git 의 오류 줄에 사용자 정보가 든 주소가 남음(전역
302
+ // insteadOf 가 더한 것. 그 줄을 가린 자리 `//***@` 로 안다), 프록시 주소를 curl 이 잘못 읽을 수 있음(proxyMisread).
303
+ // 바른 꼴의 사용자 정보는 git 이 줄에서 떼고 curl 도 제대로 읽어, 그 줄은 그대로 싣는다.
304
+ function fetchTags() {
305
+ const url = originUrl();
306
+ if (typeof url === "string" && url.startsWith("omc-pinned://")) return "origin 이 고정되어 있음(omc-pinned://). 받으려면 먼저 --revert 로 고정을 푸십시오";
307
+ try { gitFetch(["fetch", "--tags", "--quiet", "origin"]); return null; }
308
+ catch (e) {
309
+ if (e.code === "ETIMEDOUT") return `${FETCH_TIMEOUT_MS / 1000}초 안에 끝나지 않아 끊음(응답하지 않는 망일 수 있음)`;
310
+ const line = gitLine(e);
311
+ const why = redactUrl(line);
312
+ const ended = `git 이 종료 ${oneLine(e.status ?? e.signal)} 로 끝남`;
313
+ if (hasUserinfo(url) || why.includes("//***@")) return `${ended}(주소에 사용자 정보가 있어 git 의 문구는 싣지 않음)`;
314
+ if (proxyMisread()) return `${ended}(프록시 주소의 사용자 정보를 curl 이 잘못 읽을 수 있어 git 의 문구는 싣지 않음)`;
315
+ return shownGit(line);
316
+ }
317
+ }
318
+
319
+ // 주소의 사용자 정보(처음 `//` 뒤부터, 없으면 처음부터 마지막 `@` 까지)에 날 `/` `?` `#` `\` 가 있는지. curl 은 그 앞에서
320
+ // authority 를 끊는다. 그러면 사용자 이름이 호스트가 되고 비밀번호 앞부분이 포트가 된다.
321
+ const brokenUserinfo = (v) => {
322
+ const s = String(v);
323
+ const at = s.lastIndexOf("@");
324
+ const i = s.indexOf("//");
325
+ return at !== -1 && /[/?#\\]/.test(s.slice(i !== -1 && i < at ? i + 2 : 0, at));
326
+ };
327
+
328
+ // 태그 받기가 쓸 수 있는 프록시 주소 가운데 curl 이 잘못 읽을 것이 있는지. 프록시 주소는 git 의 줄에 실리지 않아
329
+ // ("Could not resolve proxy: alice" 처럼 호스트만) 설정에서 본다: curl 이 읽는 환경 변수와 git 이 넘기는 `*.proxy` 설정
330
+ // (태그 받기와 같은 환경, `http.proxy`, `http.<url>.proxy`, `remote.origin.proxy`).
331
+ function proxyMisread() {
332
+ const env = Object.entries(process.env).filter(([k]) => /^(https?|all)_proxy$/i.test(k)).map(([, v]) => v);
333
+ let cfg = [];
334
+ try { cfg = git(["config", "--get-regexp", "\\.proxy$"], MARKET, { network: true }).split("\n").map((l) => l.slice(l.indexOf(" ") + 1)); }
335
+ catch { /* 그런 설정이 없으면 git 은 종료 1 */ }
336
+ return [...env, ...cfg].some(brokenUserinfo);
337
+ }
338
+
339
+ // URL 에 사용자 정보가 있는지(redactUrl 이 가리는 자리: 처음 `//` 뒤부터 마지막 `@` 까지).
340
+ const hasUserinfo = (url) => {
341
+ const s = String(url);
342
+ const i = s.indexOf("//");
343
+ return i !== -1 && s.lastIndexOf("@") > i + 1;
344
+ };
345
+
346
+ // 태그 v<TARGET_VERSION> 의 커밋. 못 찾으면 null. 받지 못해도 로컬 태그가 있으면 진행한다(오프라인). 없으면 받지 못한 까닭도 댄다.
234
347
  function targetSha() {
235
- try { git(["fetch", "--tags", "--quiet", "origin"], MARKET, { network: true }); } catch { /* 오프라인이어도 로컬 태그가 있으면 진행 */ }
348
+ const why = fetchTags();
236
349
  try { return git(["rev-parse", `v${TARGET_VERSION}^{}`]); }
237
- catch { bad(`태그 v${TARGET_VERSION} 를 마켓플레이스에서 찾을 수 없음`); return null; }
350
+ catch {
351
+ bad(`태그 v${TARGET_VERSION} 를 마켓플레이스에서 찾을 수 없음`);
352
+ if (why) log(` origin 에서 태그를 받지 못함: ${why}`);
353
+ return null;
354
+ }
238
355
  }
239
356
 
240
357
  // git archive | tar 로 소스를 편다. 저장소에 dist/ 가 포함되어 빌드는 불필요하다.
@@ -259,22 +376,31 @@ function linkNodeModules(dest, have) {
259
376
  }
260
377
  }
261
378
 
262
- // installed_plugins.json 을 읽는다. 읽지 못하거나 JSON 이 아니면 이름과 이유를 대고 null(파일 글은 내지 않는다).
379
+ // installed_plugins.json 을 읽는다. 읽지 못하거나 JSON 이 아니면 이름과 이유를 대고 undefined(파일 글은 내지 않는다).
380
+ // 읽은 값이 0, false, "", null 이어도 그 값을 돌려준다 — 실패와 섞이면 이유 없이 멈춘다.
263
381
  function readInstalled() {
264
382
  try { return JSON.parse(readRegular(INSTALLED, "utf8")); }
265
- catch (err) { bad(`읽지 못함: installed_plugins.json (${err instanceof SyntaxError ? "JSON 이 아님" : failWhy(err)})`); return null; }
383
+ catch (err) { bad(`읽지 못함: installed_plugins.json (${err instanceof SyntaxError ? "JSON 이 아님" : failWhy(err)})`); return undefined; }
384
+ }
385
+
386
+ // installPath 의 마지막 칸(판)을 v 로 바꾼다. 구분자는 그 경로가 쓰는 것을 따른다.
387
+ function withVersion(installPath, v) {
388
+ const sep = installPath.includes("//") ? "//" : installPath.includes("\\") ? "\\" : "/";
389
+ return installPath.split(sep).slice(0, -1).concat(v).join(sep);
266
390
  }
267
391
 
268
392
  // 활성 버전 전환. installed_plugins.json 을 읽거나 쓰지 못하면 이름과 이유를 대고 false(호출부가 중단한다).
269
- function switchActive(sha, have) {
393
+ // 완료 줄의 앞 판은 바꾸기 전 항목의 version 이다(캐시 목록의 첫 판이 아니라).
394
+ function switchActive(sha) {
270
395
  const j = readInstalled();
271
- if (!j) return false;
396
+ if (j === undefined) return false;
397
+ let from;
272
398
  try {
273
- const e = j.plugins?.[PLUGIN_KEY]?.[0];
399
+ const e = j?.plugins?.[PLUGIN_KEY]?.[0];
274
400
  if (!e) { bad(`installed_plugins.json 에 ${PLUGIN_KEY} 항목이 없음 — 수동 확인 필요`); return false; }
401
+ from = typeof e.version === "string" ? e.version : "?";
275
402
  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);
403
+ e.installPath = withVersion(e.installPath, TARGET_VERSION);
278
404
  e.version = TARGET_VERSION;
279
405
  e.gitCommitSha = sha;
280
406
  e.lastUpdated = new Date().toISOString();
@@ -283,7 +409,7 @@ function switchActive(sha, have) {
283
409
  bad(`쓰지 못함: installed_plugins.json (${failWhy(err)})`);
284
410
  return false;
285
411
  }
286
- ok(`${have[0] || "없음"} → ${TARGET_VERSION} (sha ${sha.slice(0, 12)})`);
412
+ ok(`${oneLine(from)} → ${TARGET_VERSION} (sha ${sha.slice(0, 12)})`);
287
413
  return true;
288
414
  }
289
415
 
@@ -292,14 +418,16 @@ function switchActive(sha, have) {
292
418
  // 파일)의 내용은 그대로 둔다. 쓸 파일이 링크면 쓰는 때의 실제 경로에서 바꿔 링크를 남긴다. 그래서 확인 뒤 그 자리가
293
419
  // 폴더 밖 링크로 바뀌면 그 실제 파일이 바뀐다(확인과 쓰기 사이의 틈은 줄었을 뿐 없지 않다). follow 가 false 면
294
420
  // 링크를 따라가지 않고 그 이름 자리를 새 파일로 바꾼다(링크는 사라지고 링크가 가리키던 파일은 그대로다. 고정 기록).
295
- // 권한은 mode 를 주면 그 값, 아니면 원래 파일의 것(없으면 0644)이다.
421
+ // 권한은 mode 를 주면 그 값, 아니면 원래 파일의 것(없으면 0644)이다. 임시 파일을 만들 때부터 그 권한으로 연다(umask 가
422
+ // 줄인 비트는 chmod 가 맞춘다). 만든 뒤에 맞추면 그 사이 기본 권한(0664 등)으로 남이 읽을 수 있다.
296
423
  function writeAtomic(file, data, mode, { follow = true } = {}) {
297
424
  const real = follow ? realOr(file) : file;
298
425
  const tmp = join(dirname(real), `.${basename(real)}.omc-tmp-${process.pid}`);
426
+ const m = mode ?? modeOf(real);
299
427
  rmSync(tmp, { force: true });
300
428
  try {
301
- writeSynced(tmp, data);
302
- chmodSync(tmp, mode ?? modeOf(real));
429
+ writeSynced(tmp, data, m);
430
+ chmodSync(tmp, m);
303
431
  renameRetry(tmp, real);
304
432
  } catch (e) {
305
433
  rmSync(tmp, { force: true });
@@ -309,16 +437,23 @@ function writeAtomic(file, data, mode, { follow = true } = {}) {
309
437
 
310
438
  // 새 임시 파일(있으면 실패하는 열기)에 쓰고 디스크까지 내린다. 내리지 않고 rename 하면 전원이 나가거나 NFS 에서
311
439
  // 새 이름 아래 짧거나 빈 파일이 남을 수 있다(OMC 의 atomicWriteFileSync, obsidizer 의 writeTemp 와 같은 꼴).
312
- function writeSynced(tmp, data) {
313
- const fd = openSync(tmp, "wx");
440
+ function writeSynced(tmp, data, mode) {
441
+ const fd = openSync(tmp, "wx", mode);
314
442
  try {
315
443
  writeFileSync(fd, data);
316
- fsyncSync(fd);
444
+ syncIfAble(fd);
317
445
  } finally {
318
446
  closeSync(fd);
319
447
  }
320
448
  }
321
449
 
450
+ // fsync 를 하지 않는 파일 시스템(EINVAL, ENOTSUP)에서도 새 파일은 rename 으로 통째로 바뀐다. 잃는 것은 정전 때의 안전뿐이다.
451
+ // 그 밖의 오류는 그대로 던진다(쓰지 못함으로 알림). azure-adapter 의 syncIfAble 과 같은 꼴이다.
452
+ const NO_FSYNC = new Set(["EINVAL", "ENOTSUP"]);
453
+ function syncIfAble(fd) {
454
+ try { fsyncSync(fd); } catch (e) { if (!NO_FSYNC.has(e.code)) throw e; }
455
+ }
456
+
322
457
  // 파일의 권한 비트. 읽을 수 없으면 0644.
323
458
  function modeOf(p) {
324
459
  try { return statSync(p).mode & 0o777; } catch { return 0o644; }
@@ -336,12 +471,26 @@ function renameRetry(from, to) {
336
471
  }
337
472
  }
338
473
 
339
- // 백업을 만든다. 이미 있으면 처음 백업을 지킨다. 있으면 실패하는 복사라 확인과 복사 사이에 틈이 없다.
474
+ // 백업을 만든다. 이미 있으면 그대로 둔다(적용은 그 전에 freshBackup 으로 지금 원본과 맞춘다). 있으면 실패하는 복사라 확인과
475
+ // 복사 사이에 틈이 없다.
340
476
  function backup(file, bak) {
341
477
  try { copyFileSync(file, bak, FS_CONST.COPYFILE_EXCL); }
342
478
  catch (e) { if (e.code !== "EEXIST") throw e; }
343
479
  }
344
480
 
481
+ // 적용 직전에 본다. 이미 있는 백업이 지금 원본(패치 전)과 다르면 그 백업은 옛 원본이다(같은 판 폴더에 새 원본이 깔림). 지금
482
+ // 원본으로 바꿔야 나중의 되돌리기가 새 원본을 돌려준다. 정상 흐름에서는 둘이 같아 바꾸지 않는다. 백업이 없으면 backup 이 만든다.
483
+ // 다시 읽은 글이 원본이 아니면(isOriginal 이 거짓) 바꾸지 않는다. 같이 돈 다른 실행이 이 실행의 두 읽기 사이에 먼저 패치했으면 그
484
+ // 글은 패치본이고, 그것을 백업에 쓰면 되돌리기가 패치본을 돌려준다. 확인한 글을 그대로 쓰므로 확인과 쓰기 사이에 틈이 없다.
485
+ function freshBackup(file, bak, name, isOriginal) {
486
+ const saved = readBackup(bak);
487
+ if (saved === null) return;
488
+ const now = readRegular(file);
489
+ if (saved.equals(now) || !isOriginal(now.toString("utf8"))) return;
490
+ writeAtomic(bak, now, modeOf(bak), { follow: false });
491
+ ok(`옛 백업을 지금 원본으로 바꿈: ${shown(name)}`);
492
+ }
493
+
345
494
  // 일반 파일만 읽는다. FIFO 는 열기만 하고 읽지 않으며, 폴더와 소켓, 장치는 던진다(형제 도구의 읽기와 같은 꼴).
346
495
  function readRegular(p, enc) {
347
496
  const fd = openSync(p, FS_CONST.O_RDONLY | (FS_CONST.O_NONBLOCK ?? 0));
@@ -361,9 +510,11 @@ const failWhy = (e) => oneLine(e.code || e.message);
361
510
  function readHook(n, f, r) {
362
511
  try { return readRegular(f, "utf8"); }
363
512
  catch (e) {
513
+ // 읽지 못한 훅은 --check 에서도 실패다. "패치 필요" 로 세면 결과 줄이 적용을 권하는데, 그 적용도 같은 읽기에서 실패한다.
364
514
  bad(`읽지 못함: ${shown(n)} (${failWhy(e)})`);
365
- if (MODE === "check") r.need++;
366
- else { r.fail++; r.failed.push(f); }
515
+ r.fail++;
516
+ r.failed.push(f);
517
+ if (MODE === "check") r.unread++;
367
518
  return null;
368
519
  }
369
520
  }
@@ -652,10 +803,11 @@ function syntaxError(file) {
652
803
  }
653
804
  }
654
805
 
655
- // 백업의 글. 없거나 읽으려는 순간 사라졌으면 null(다른 실행이 먼저 되돌렸을 수 있다).
806
+ // 백업의 글. 없거나 읽으려는 순간 사라졌으면 null(다른 실행이 먼저 되돌렸을 수 있다). 그 밖에 읽지 못하면 backupRead 표시를 달아
807
+ // 던진다 — 쓰기 실패와 같은 catch 가 "읽지 못함" 으로 알리게.
656
808
  function readBackup(bak) {
657
809
  try { return readRegular(bak); }
658
- catch (e) { if (e.code === "ENOENT") return null; throw e; }
810
+ catch (e) { if (e.code === "ENOENT") return null; throw Object.assign(e, { backupRead: true }); }
659
811
  }
660
812
 
661
813
  // 이 도구가 아닌 «손» 이 import 를 감싼 흔적. 4.15.7 의 손 수정은 최상위의 `await import(` 를
@@ -672,14 +824,15 @@ function looksHandPatched(src) {
672
824
  return found;
673
825
  }
674
826
 
827
+ // 패치(이 도구의 헬퍼, 손 패치)가 없는 글인가. 적용이 옛 백업을 바꾸기 전에 지금 글이 원본인지 본다.
828
+ const unpatched = (src) => !src.includes("__omcRaceImport") && !looksHandPatched(src);
829
+
675
830
  function patchHooks(root) {
676
- const r = { done: 0, skip: 0, fail: 0, need: 0, misplaced: 0, foreign: [], orphan: [], failed: [] };
831
+ const r = { done: 0, skip: 0, fail: 0, need: 0, misplaced: 0, unread: 0, foreign: [], orphan: [], failed: [] };
677
832
  // revert 는 scripts/ 전체를 훑는다. hooks.json 기반 목록은 두 번 새는데, 실측으로
678
833
  // 4.15.7 이 둘 다 겪었다 — PostToolUse 를 지우면 그 스크립트가 목록에서 빠지고(그래서
679
834
  // .omcbak 이 있어도 안 돌아온다), 손으로 고친 파일은 .omcbak 조차 없어 아예 안 보인다.
680
- const names = MODE === "revert"
681
- ? readdirSync(join(root, "scripts")).filter((f) => f.endsWith(".mjs")).map((f) => f.slice(0, -4)).sort()
682
- : hookNames(root);
835
+ const names = MODE === "revert" ? scriptNames(root, r) : hookNames(root);
683
836
  for (const n of names) {
684
837
  const f = join(root, "scripts", `${n}.mjs`);
685
838
  if (!existsSync(f)) continue;
@@ -689,6 +842,19 @@ function patchHooks(root) {
689
842
  return r;
690
843
  }
691
844
 
845
+ // revert 가 훑는 이름: scripts/ 의 .mjs 전부. scripts/ 가 없는 판 폴더는 되돌릴 훅이 없어 한 줄로 알리고 넘어간다.
846
+ // 그 밖에 목록을 읽지 못하면(파일, 권한) 이름과 이유를 대고 실패로 센다 — 다음 판과 [3/4], [4/4] 는 그대로 간다.
847
+ function scriptNames(root, r) {
848
+ const dir = join(root, "scripts");
849
+ try {
850
+ return readdirSync(dir).filter((f) => f.endsWith(".mjs")).map((f) => f.slice(0, -4)).sort();
851
+ } catch (e) {
852
+ if (e.code === "ENOENT") log(" 건너뜀: scripts 폴더 없음");
853
+ else { bad(`읽지 못함: scripts (${failWhy(e)}): ${oneLine(dir)}`); r.fail++; r.failed.push(dir); }
854
+ return [];
855
+ }
856
+ }
857
+
692
858
  function revertScript(root, n, f, r) {
693
859
  const bak = `${f}.omcbak`;
694
860
  if (existsSync(bak) && restoreScript(root, n, f, bak, r)) return;
@@ -702,10 +868,25 @@ function revertScript(root, n, f, r) {
702
868
  else if (looksHandPatched(src)) r.foreign.push(n);
703
869
  }
704
870
 
871
+ // 되돌리기 전에 본다(쓰기 조건 뒤). 지금 파일에 패치(헬퍼, 손 패치)가 없고 글이 백업과 다르면 그 사이 같은 판 폴더에 새 원본이
872
+ // 깔린 것이다. 옛 백업을 덮으면 새 원본을 잃는다. 그 이유를, 아니면 null. 어느 쪽이든 읽지 못하면 판단하지 않는다(전처럼 복구).
873
+ const STALE = "지금 파일에 패치가 없고 백업과 글이 다름. 백업이 옛 원본일 수 있음";
874
+ function staleWhy(f, bak) {
875
+ let now, saved;
876
+ try {
877
+ now = readRegular(f, "utf8");
878
+ saved = readRegular(bak, "utf8");
879
+ } catch {
880
+ return null;
881
+ }
882
+ if (now.includes("__omcRaceImport") || looksHandPatched(now)) return null;
883
+ return now === saved ? null : STALE;
884
+ }
885
+
705
886
  // 백업으로 되돌린다. 쓸 수 없는 자리거나 쓰지 못하면 이유를 대고 실패로 센다. 백업이 읽으려는 순간 사라졌으면 false
706
887
  // (다른 실행이 먼저 되돌렸을 수 있다. 호출부가 백업 없음으로 다시 본다).
707
888
  function restoreScript(root, n, f, bak, r) {
708
- const why = whyNotWrite(root, f, bak) ?? restoreFrom(f, bak);
889
+ const why = whyNotWrite(root, f, bak) ?? staleWhy(f, bak) ?? restoreFrom(f, bak);
709
890
  if (why === GONE) return false;
710
891
  if (!why) {
711
892
  log(` 되돌림 ${shown(n)}`); r.done++;
@@ -720,23 +901,27 @@ function restoreScript(root, n, f, bak, r) {
720
901
  return true;
721
902
  }
722
903
 
723
- // 링크 너머의 일반 파일(1MiB 이하)에 헬퍼가 있는가. 옛 도구가 링크를 따라가 거기서 고친 파일이면 참이다.
904
+ // 링크 너머의 일반 파일(1MiB 이하)이 백업을 이 도구가 패치한 글과 같은가. 옛 도구가 링크를 따라가 거기서 고친 파일이면 참이다.
905
+ // 헬퍼 이름만 든 다른 파일(메모, 손으로 고친 파일)은 거짓이다 — 거기에 백업을 덮어쓰라고 하면 그 파일의 글을 잃는다.
906
+ // 다른 자리에 헬퍼를 넣던 옛 킷의 패치본과 옛 헬퍼로 패치한 파일도 거짓이라 "먼저 확인" 쪽으로 간다(보수 쪽).
724
907
  const PEEK_MAX = 1 << 20;
725
- function patchedByOldTool(p) {
908
+ function patchedByOldTool(p, bak) {
726
909
  try {
727
910
  const st = statSync(p);
728
- return st.isFile() && st.size <= PEEK_MAX && readRegular(p, "utf8").includes("__omcRaceImport");
911
+ if (!st.isFile() || st.size > PEEK_MAX) return false;
912
+ const patched = transform(readRegular(bak, "utf8"));
913
+ return patched !== null && patched === readRegular(p, "utf8");
729
914
  } catch {
730
915
  return false;
731
916
  }
732
917
  }
733
918
 
734
- // 손 복구 안내. 링크 너머의 파일에 이 도구 계열의 패치가 있을 때만 백업으로 덮어쓰라고 하고, 아니면 링크부터 확인하게 한다.
919
+ // 손 복구 안내. 링크 너머의 파일이 이 도구의 패치본일 때만 백업으로 덮어쓰라고 하고, 아니면 링크부터 확인하게 한다.
735
920
  function handRestoreHint(bak, f) {
736
921
  const target = realOr(f);
737
- return patchedByOldTool(target)
922
+ return patchedByOldTool(target, bak)
738
923
  ? `손으로 되돌리려면 ${oneLine(realOr(bak))} 의 내용을 확인한 뒤 ${oneLine(target)} 에 덮어쓰고 백업을 지우십시오.`
739
- : `링크가 가리키는 ${oneLine(target)} 에 이 도구의 패치가 보이지 않습니다. 덮어쓰지 말고 링크가 가리키는 곳이 맞는지 먼저 확인하십시오.`;
924
+ : `링크가 가리키는 ${oneLine(target)} 의 글은 지금 이 도구가 만드는 패치본과 같지 않거나 읽어 확인할 수 없습니다. 덮어쓰지 말고 링크가 가리키는 곳이 맞는지 먼저 확인하십시오.`;
740
925
  }
741
926
 
742
927
  // 백업의 내용으로 파일을 바꾼다. 쓰지 못하면 그 이유, 백업이 읽으려는 순간 사라졌으면 GONE, 아니면 null.
@@ -778,10 +963,11 @@ function checkOrPatchScript(root, n, f, r) {
778
963
  // 쓰지 못하면 이유를 대고 false 다(예전에는 예외로 멈췄다).
779
964
  function writePatched(f, bak, out, n) {
780
965
  try {
966
+ freshBackup(f, bak, n, unpatched);
781
967
  backup(f, bak);
782
968
  writeAtomic(f, out);
783
969
  } catch (e) {
784
- bad(`쓰지 못함: ${n} (${failWhy(e)})`);
970
+ bad(e.backupRead ? `읽지 못함: ${n} 의 백업 (${failWhy(e)})` : `쓰지 못함: ${n} (${failWhy(e)})`);
785
971
  return false;
786
972
  }
787
973
  const err = syntaxError(f);
@@ -799,14 +985,32 @@ function writePatched(f, bak, out, n) {
799
985
  // 폴더 밖 쓰기를 거부한 이유. 되돌리기는 이 이유일 때 손 복구 방법을 함께 알린다.
800
986
  const OUTSIDE = "파일이 플러그인 폴더 밖에 있음";
801
987
 
802
- // 쓰기 전에 본다. 쓸 파일이 (링크를 따라가서) 플러그인 폴더 밖이거나 백업 자리가 링크면 그 이유를,
803
- // 아니면 null. 백업 자리가 깨진 링크면 existsSync 는 false 라, 복사가 링크가 가리키는 곳에 쓴다.
988
+ // 쓰기 전에 본다. 쓸 파일이 (링크를 따라가서) 플러그인 폴더 밖이거나 백업 자리가 링크, 또는 일반 파일이 아닌 것(FIFO,
989
+ // 폴더)이면 그 이유를, 아니면 null. 백업 자리가 깨진 링크면 existsSync 는 false 라, 복사가 링크가 가리키는 곳에 쓴다.
990
+ // 일반 파일이 아닌 백업 자리는 있으면 실패하는 복사가 EEXIST 로 끝나 "처음 백업" 으로 보이므로, 백업 없이 패치하게 된다.
804
991
  function whyNotWrite(root, file, bak) {
805
- if (isLink(bak)) return "백업 자리가 링크";
992
+ const spot = backupSpotWhy(bak);
993
+ if (spot) return spot;
806
994
  if (!insideRoot(root, file)) return OUTSIDE;
807
995
  return notWritable(file);
808
996
  }
809
997
 
998
+ // 백업 자리가 링크이거나 일반 파일이 아닌 것(FIFO, 폴더)이면 그 이유, 아니면 null.
999
+ function backupSpotWhy(bak) {
1000
+ if (isLink(bak)) return "백업 자리가 링크";
1001
+ return notRegularAt(bak) ? "백업 자리가 일반 파일이 아님" : null;
1002
+ }
1003
+
1004
+ // 그 자리에 일반 파일도 링크도 아닌 것(FIFO, 폴더, 장치)이 있으면 true. 없는 자리는 false.
1005
+ function notRegularAt(p) {
1006
+ try {
1007
+ const st = lstatSync(p);
1008
+ return !st.isFile() && !st.isSymbolicLink();
1009
+ } catch {
1010
+ return false;
1011
+ }
1012
+ }
1013
+
810
1014
  // 있는 자리가 일반 파일이 아니거나(FIFO, 폴더, 장치) 이 계정이 쓸 수 없으면 그 이유. 새 파일로 바꿔 쓰는 방식이 파일 권한을
811
1015
  // 우회하지 않게 하고, 특수 파일을 일반 파일로 바꾸지 않게 한다. 없는 자리는 괜찮다.
812
1016
  function notWritable(file) {
@@ -851,18 +1055,30 @@ function patchHooksJson(root) {
851
1055
  try {
852
1056
  return MODE === "revert" ? revertHooksJson(root, hj) : removePostToolUse(hj, why);
853
1057
  } 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})`);
1058
+ hjBad(hooksJsonFailure(e));
857
1059
  return "unsafe";
858
1060
  }
859
1061
  }
860
1062
 
1063
+ // JSON 오류 문구는 파일의 글을 담으므로 내지 않는다. check 는 읽기만 하므로 실패도 읽기다.
1064
+ function hooksJsonFailure(e) {
1065
+ const why = e instanceof SyntaxError ? "JSON 이 아님" : failWhy(e);
1066
+ if (e.backupRead) return `hooks.json 의 백업을 읽지 못함 (${why})`;
1067
+ return e instanceof SyntaxError || e.reading || MODE === "check" ? `hooks.json 을 읽지 못함 (${why})` : `hooks.json 을 쓰지 못함 (${why})`;
1068
+ }
1069
+
1070
+ // hooks.json 의 글. 읽기 실패에는 reading 표시를 달아 던진다 — 쓰기 실패와 같은 catch 가 맞는 문구를 고르게.
1071
+ function readHooksJson(hj) {
1072
+ try { return readRegular(hj, "utf8"); }
1073
+ catch (e) { throw Object.assign(e, { reading: true }); }
1074
+ }
1075
+
861
1076
  // 되돌린 결과. ambiguous 와 linked 는 hooks.json 을 되돌리지 못하고 남긴 것이라 끝 줄이 센다.
862
1077
  function revertHooksJson(root, hj) {
863
1078
  const bak = `${hj}.omcbak`;
864
1079
  const saved = readBackup(bak);
865
1080
  if (saved !== null) {
1081
+ if (staleHooksJson(hj, saved)) { hjBad(`hooks.json 을 되돌리지 않음 (지금 파일에 PostToolUse 가 있고 백업과 글이 다름. 백업이 옛 원본일 수 있음)`); return "unsafe"; }
866
1082
  writeAtomic(hj, saved);
867
1083
  log(` 되돌림 ${hjTag}hooks.json`);
868
1084
  const left = dropBackup(bak);
@@ -870,7 +1086,9 @@ function revertHooksJson(root, hj) {
870
1086
  return "reverted";
871
1087
  }
872
1088
  // 이 도구의 백업이 없을 때 PostToolUse 가 그대로면 되돌릴 것이 없다. 옆의 수동 백업으로 덮어쓰면
873
- // 지금 내용을 잃는다.
1089
+ // 지금 내용을 잃는다. 읽지 못하면 PostToolUse 가 있는지 모르므로 "되돌릴 것 없음" 이라 하지 않는다
1090
+ // (위 catch 가 읽기 실패로 알리고 센다). 깨진 JSON 은 아래 수동 백업 찾기로 넘긴다.
1091
+ readHooksJson(hj);
874
1092
  if (hasPostToolUse(hj)) return "no-backup";
875
1093
  // 손으로 만든 백업이 남아 있을 수 있다(실측: 4.15.7 의 hooks.json.bak-20260906-postoolremove).
876
1094
  // PostToolUse 를 담고 있는 유효한 JSON 이 딱 하나면 그것이 제거 직전 상태다 — 무엇을 썼는지 밝히고 되돌린다.
@@ -902,18 +1120,83 @@ process.on("uncaughtException", (e) => {
902
1120
  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
1121
  process.exit(1);
904
1122
  });
905
- // URL 의 사용자 정보(`//` 뒤 첫 `/` 앞의 마지막 `@` 까지. 공백, 탭, 비밀번호 안의 @ 도)와 쿼리의 값(이름과 상관없이)을
906
- // 가린다. 출력과 기록에 비밀이 남지 않는다. ssh 꼴(`git@host:`)과 경로 안의 @ 는 그대로다. 쿼리의 이름 칸은 `?` 를 받지
907
- // 않는다. 받으면 `=` 없는 `?` 가 많은 origin 에서 시작 자리마다 끝까지 읽어 길이의 제곱만큼 걸린다.
908
- const redactUrl = (url) => String(url).replace(/(\/\/)[^/]*@/g, "$1***@")
909
- .replace(/([?&][^=&#\s?]*=)[^&#\s]*/g, "$1***");
1123
+ // git 이 줄에서 주소를 닫는 글자(`'...':` 의 끝).
1124
+ const CLOSING = /'?:?$/;
1125
+ // URL 의 사용자 정보와 쿼리의 값(이름과 상관없이)을 가린다. 출력과 기록에 비밀이 남지 않는다. 사용자 정보는 처음 `//` 뒤부터
1126
+ // 마지막 `@` 까지다(공백, 탭, 비밀번호 안의 `/` `?` `#` `@` 도). 경로나 쿼리에 `@` 가 든 URL 은 호스트까지 가려진다 — 비밀번호를
1127
+ // 놓치는 것보다 낫다. `//` 가 없는 ssh 꼴(`git@host:`)은 그대로다. 위치로 찾아 길이에 비례해 끝난다(`//.*@` 꼴 정규식은 `@` 없이
1128
+ // `/` 가 많은 글에서 길이의 제곱만큼 걸린다). 쿼리의 이름 칸은 `?` 를 받지 않는다. 받으면 `=` 없는 `?` 가 많은 origin 에서
1129
+ // 시작 자리마다 끝까지 읽어 길이의 제곱만큼 걸린다. 값 끝에 git 이 주소를 닫는 `'` 와 `:` 가 붙으면 그 둘은 남긴다.
1130
+ function redactUrl(url) {
1131
+ let s = String(url);
1132
+ const i = s.indexOf("//");
1133
+ const at = s.lastIndexOf("@");
1134
+ if (i !== -1 && at > i + 1) s = `${s.slice(0, i + 2)}***${s.slice(at)}`;
1135
+ return s.replace(/([?&][^=&#\s?]*=)[^&#\s]*/g, (m, name) => `${name}***${CLOSING.exec(m)[0]}`);
1136
+ }
1137
+
1138
+ // 진단 줄의 주소(사용자 결정 2026-10-21): 기본 상류(REPO_URL)는 가린 꼴 그대로, 그 밖은 scheme 과 host 까지만. 미러의
1139
+ // 경로, 조각, `;` 매개변수에는 토큰이 들 수 있다. 기본 상류인지는 scheme, 사용자 정보(가렸든 아니든), 쿼리를 빼고 본다.
1140
+ const PIN = "omc-pinned://";
1141
+ const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
1142
+ function hostPath(url) {
1143
+ let s = String(url);
1144
+ if (s.startsWith(PIN)) s = s.slice(PIN.length);
1145
+ const scheme = SCHEME.exec(s);
1146
+ if (scheme) s = s.slice(scheme[0].length);
1147
+ // user information ends at an @ before the first / (and ? # \ in a URL): an @ further on is in the path
1148
+ const cut = s.search(scheme ? /[/?#\\]/ : /\//);
1149
+ const at = s.lastIndexOf("@", cut === -1 ? s.length : cut);
1150
+ if (at !== -1) s = s.slice(at + 1);
1151
+ // the query to the end, found by position (a pattern from each "?" took the square of the length with a "#" after
1152
+ // them); with a fragment after it the address is another one (a fragment is not masked)
1153
+ const q = s.indexOf("?", s.lastIndexOf("#") + 1);
1154
+ if (q !== -1) s = s.slice(0, q);
1155
+ // the closing slashes by a loop: `/\/+$/` takes the square of the length on an address of many slashes
1156
+ let end = s.length;
1157
+ while (end > 0 && s[end - 1] === "/") end--;
1158
+ return s.slice(0, end).replace(/\.git$/i, "").toLowerCase();
1159
+ }
1160
+ // 기본 상류는 scheme 이 있는 주소만이다. scheme 없는 `user:<토큰>@github.com/...` 은 git 이 scp 꼴(host 가 `user`)로 읽고,
1161
+ // 가림(처음 `//` 부터)도 그 사용자 정보에 닿지 않는다.
1162
+ const isUpstream = (url) => SCHEME.test(String(url).startsWith(PIN) ? String(url).slice(PIN.length) : String(url)) && hostPath(url) === hostPath(REPO_URL);
1163
+ // 기본 상류의 보이는 꼴: 쿼리는 이름 없는 토큰까지 통째로 `?***`(redactUrl 은 `이름=값` 만 가린다). 쿼리를 가림보다 먼저
1164
+ // 잘라, 쿼리 속 `@` 까지 가린 사용자 정보가 쿼리의 `?` 를 먹지 않게 한다. 기본 상류의 첫 `?` 는 쿼리다: host 앞에 날 `?` 가
1165
+ // 있으면 hostPath 가 거기서 잘라 기본 상류가 아니다.
1166
+ function upstreamShown(u) {
1167
+ const q = u.indexOf("?");
1168
+ return q === -1 ? redactUrl(u) : `${redactUrl(u.slice(0, q))}?***`;
1169
+ }
1170
+ // --check 가 보이는 origin(가리기 전의 글).
1171
+ const shownOrigin = (url) => (isUpstream(url) ? upstreamShown(String(url)) : hostOnly(url));
1172
+ // git 의 줄에 든 주소마다, git 이 찍은 그대로의 주소로 정한 꼴. 가린 뒤에 정하면 마지막 `@` 까지 먹은 가림 뒤의 글(경로나
1173
+ // 쿼리 속 `@` 뒤의 토큰)이 host 로 보였다. 고정한 주소는 앞의 `omc-pinned://` 를 남긴다. 주소는 공백 앞까지로 보고, git 이
1174
+ // 주소를 닫는 `'` 와 `:` 는 남긴다.
1175
+ function shownAddress(u) {
1176
+ if (isUpstream(u)) return upstreamShown(u);
1177
+ const pin = u.startsWith(PIN) ? PIN : "";
1178
+ const inner = u.slice(pin.length);
1179
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(inner)) return `${pin}${hostOnly(inner)}`;
1180
+ const host = hostPath(inner).split("/")[0];
1181
+ return `${pin}${HOST_SHAPE.test(host) ? host : "(호스트를 알아볼 수 없음)"}`;
1182
+ }
1183
+ const shownAddresses = (line) => line.replace(/[A-Za-z][A-Za-z0-9+.-]*:\/\/\S*/g, (u) => {
1184
+ const tail = CLOSING.exec(u)[0];
1185
+ return `${shownAddress(u.slice(0, u.length - tail.length))}${tail}`;
1186
+ });
910
1187
 
911
1188
  // URL 의 scheme 과 host 까지만. 비밀은 사용자 정보와 쿼리 밖(경로, 조각, `;` 매개변수)에도 들 수 있어 나머지는 버린다.
912
1189
  // scp 꼴(`git@host:org/repo.git`)은 host 만이다. 파서가 host 로 읽어도 host 모양(이름이나 [IPv6], 선택 포트)이 아니면
913
1190
  // 보이지 않는다(ssh 같은 scheme 은 host 뒤의 `;k=...` 까지 host 로 읽는다).
914
1191
  const HOST_SHAPE = /^(?:[A-Za-z0-9._-]{1,253}|\[[0-9A-Fa-f:.]{2,45}\])(?::\d{1,5})?$/;
1192
+ // `scheme://` 뒤 첫 `/` `?` `#` `\` 앞까지(파서가 읽는 authority). 비밀번호에 그 글자가 날로 들면 파서는 그 앞의 `이름:숫자` 를
1193
+ // host:port 로 읽고 `@` 를 경로로 보낸다. 사용자 이름에도 날 `@` 가 들면(이메일 주소) 그 뒤의 `도메인:숫자` 를 읽는다. 그래서
1194
+ // authority 뒤에 `@` 가 있으면 host 를 믿지 않는다(가림이 마지막 `@` 까지인 것과 같은 쪽. 경로에 `@` 가 든 URL 은 host 도 감춘다).
1195
+ const AUTHORITY = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#\\]*)/;
915
1196
  function hostOnly(url) {
916
1197
  let scheme = "", host;
1198
+ const auth = AUTHORITY.exec(String(url));
1199
+ if (auth && String(url).includes("@", auth[0].length)) return "(호스트를 알아볼 수 없음)";
917
1200
  try {
918
1201
  const u = new URL(String(url));
919
1202
  if (!u.host) return "(호스트가 없는 URL 또는 로컬 경로)";
@@ -939,17 +1222,31 @@ function manualBackups(root, dir) {
939
1222
  return { usable, unsafe };
940
1223
  }
941
1224
 
1225
+ // 되돌리기 전에 본다. 이 도구가 지운 PostToolUse 가 지금 hooks.json 에 다시 있고 글이 백업과 다르면 그 사이 새 hooks.json 이
1226
+ // 깔린 것이다. 옛 백업을 덮으면 새 훅 등록을 잃는다. 읽지 못하면 판단하지 않는다(전처럼 복구).
1227
+ function staleHooksJson(hj, saved) {
1228
+ let now;
1229
+ try { now = readRegular(hj); } catch { return false; }
1230
+ return !now.equals(saved) && hasPostToolUse(hj);
1231
+ }
1232
+
1233
+ // 글이 PostToolUse 가 든 JSON 인가. 적용이 옛 hooks.json 백업을 바꾸기 전에 지금 글이 원본(지우기 전)인지도 본다.
1234
+ const postToolUseIn = (text) => {
1235
+ try { return !!JSON.parse(text)?.hooks?.PostToolUse; }
1236
+ catch { return false; }
1237
+ };
942
1238
  const hasPostToolUse = (p) => {
943
- try { return !!JSON.parse(readRegular(p, "utf8"))?.hooks?.PostToolUse; }
1239
+ try { return postToolUseIn(readRegular(p, "utf8")); }
944
1240
  catch { return false; }
945
1241
  };
946
1242
 
947
1243
  function removePostToolUse(hj, why) {
948
1244
  const bak = `${hj}.omcbak`;
949
- const j = JSON.parse(readRegular(hj, "utf8"));
1245
+ const j = JSON.parse(readHooksJson(hj));
950
1246
  if (!j.hooks?.PostToolUse) return "already";
951
1247
  const list = j.hooks.PostToolUse.flatMap((g) => (g.hooks || []).map((h) => (h.command || "").match(/([\w-]+)\.mjs/)?.[1] || "?"));
952
1248
  if (MODE === "check") { hjBad(`hooks.json PostToolUse 남아있음${why ? `, 쓸 수 없음 (${why})` : ""}: ${list.join(", ")}`); return "needs"; }
1249
+ freshBackup(hj, bak, `${hjTag}hooks.json`, postToolUseIn);
953
1250
  backup(hj, bak);
954
1251
  delete j.hooks.PostToolUse;
955
1252
  writeAtomic(hj, JSON.stringify(j, null, 2) + "\n");
@@ -972,20 +1269,21 @@ function removePostToolUse(hj, why) {
972
1269
  // 고정과 되돌리기가 하지 않은 일을 했다고 끝난다.
973
1270
  function originUrl() {
974
1271
  try { return git(["config", "--get", "remote.origin.url"]); }
975
- catch (e) { return e.status === 1 ? null : { unreadable: gitError(e) }; }
1272
+ catch (e) { return e.status === 1 ? null : { unreadable: gitLine(e) }; }
976
1273
  }
977
1274
 
978
- // git 오류의 원인 줄: `fatal:` 이나 `error:` 로 시작하는 첫 줄, 없으면 첫 줄(`warning:` 이 먼저 올 수 있다).
979
- // 정화하고 URL 의 인증 정보를 가린다. git 이 오류 문구 없이 끝나면 Node 의 문구(명령 줄 전체, 곧 URL 전체가 든다)를
980
- // 쓰지 않고 어떻게 끝났는지만 댄다.
981
- const gitError = (e) => {
1275
+ // git 오류의 원인 줄: `fatal:` 이나 `error:` 로 시작하는 첫 줄, 없으면 첫 줄(`warning:` 이 먼저 올 수 있다), 정화하고
1276
+ // 200자까지, 아직 가리지 않은 글. git 이 오류 문구 없이 끝나면 Node 의 문구(명령 줄 전체, 곧 URL 전체가 든다)를 쓰지 않고
1277
+ // 어떻게 끝났는지만 댄다.
1278
+ const gitLine = (e) => {
982
1279
  const lines = String(e.stderr || "").split("\n").filter((l) => l.trim());
983
1280
  if (!lines.length) {
984
1281
  return e.status == null && !e.signal && e.code ? `git 을 실행하지 못함 (${oneLine(e.code)})` : `git 이 오류 문구 없이 끝남 (${oneLine(e.signal || e.status)})`;
985
1282
  }
986
- const line = lines.find((l) => /^(fatal|error):/.test(l)) || lines[0];
987
- return oneLine(redactUrl(line)).slice(0, 200);
1283
+ return oneLine(lines.find((l) => /^(fatal|error):/.test(l)) || lines[0]).slice(0, 200);
988
1284
  };
1285
+ // 출력에 싣는 꼴: 주소마다 git 이 찍은 주소로 정한 꼴(shownAddress), 나머지는 가림.
1286
+ const shownGit = (line) => redactUrl(shownAddresses(line));
989
1287
 
990
1288
  function pinMarketplace() {
991
1289
  if (!existsSync(MARKET)) return "no-marketplace";
@@ -995,14 +1293,14 @@ function pinMarketplace() {
995
1293
  if (!existsSync(join(MARKET, ".git"))) return "no-git";
996
1294
  const url = originUrl();
997
1295
  if (url === null) return "no-remote";
998
- if (typeof url !== "string") { bad(`origin 을 읽지 못함 (${url.unreadable}): ${oneLine(MARKET)}`); return "origin-unreadable"; }
1296
+ if (typeof url !== "string") { bad(`origin 을 읽지 못함 (${shownGit(url.unreadable)}): ${oneLine(MARKET)}`); return "origin-unreadable"; }
999
1297
 
1000
1298
  if (MODE === "revert") return unpinMarketplace(url);
1001
- if (url.startsWith("omc-pinned://")) return alreadyPinned();
1002
- if (MODE === "check") { bad(`마켓플레이스 미고정 (origin=${oneLine(redactUrl(url))})`); return "needs"; }
1299
+ if (url.startsWith(PIN)) return alreadyPinned();
1300
+ if (MODE === "check") { bad(`마켓플레이스 미고정 (origin=${oneLine(shownOrigin(url))})`); return "needs"; }
1003
1301
 
1004
1302
  try { git(["remote", "set-url", "--", "origin", `omc-pinned://${url}`]); }
1005
- catch (e) { bad(`고정 실패: origin 을 바꾸지 못했습니다 (${gitError(e)})`); return "pin-failed"; }
1303
+ catch (e) { bad(`고정 실패: origin 을 바꾸지 못했습니다 (${shownGit(gitLine(e))})`); return "pin-failed"; }
1006
1304
  return writePinRecord() ? "pinned" : "record-failed";
1007
1305
  }
1008
1306
 
@@ -1011,25 +1309,33 @@ function pinMarketplace() {
1011
1309
  // URL 은 남기지 않는다. 원래 URL 은 origin 의 `omc-pinned://` 뒤에 있고, 인증 정보가 든 URL 이 기록에 남는다.
1012
1310
  // 고정이 된 뒤에만 쓴다. 실패한 고정의 기록은 남기지 않는다. 기록을 쓰지 못하면 고정은 둔 채 실패로 센다.
1013
1311
  // 덮어쓴 옛 기록에 URL 칸이 있었고 같은 파일의 다른 이름이 있으면, 그쪽에 남은 칸을 알리고 실패로 센다.
1312
+ // 판은 패치한 활성 판이다(--no-update 면 그 판, 인자 없는 적용이면 전환한 판). 기록 폴더는 처음 만들 때 이 계정만 쓰게(0700) 한다.
1014
1313
  function writePinRecord() {
1015
1314
  let ino = null;
1315
+ let held = null;
1016
1316
  try {
1017
- mkdirSync(dirname(PINFILE), { recursive: true });
1317
+ mkdirSync(dirname(PINFILE), { recursive: true, mode: 0o700 });
1018
1318
  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 });
1319
+ held = urlInRecord();
1320
+ ino = held ? sharedIno() : null;
1321
+ writeAtomic(PINFILE, JSON.stringify({ pinnedAt: new Date().toISOString(), version: active, host: hostname() }, null, 2) + "\n", 0o600, { follow: false });
1021
1322
  } catch (e) {
1022
1323
  bad(`고정 기록(PINNED)을 쓰지 못함 (${failWhy(e)}). origin 은 고정됨: ${oneLine(PINFILE)}`);
1023
1324
  return false;
1024
1325
  }
1025
- return ino === null ? true : sharedLeft(SHARED_WHY, ino);
1326
+ return ino === null ? true : sharedLeft(sharedWhy(held), ino);
1026
1327
  }
1027
1328
 
1028
- // 덮어쓸 옛 기록에 URL 칸이 있고 같은 파일의 다른 이름이 있으면 그 inode 다. 칸이 없거나, 다른 이름이 없거나,
1029
- // 기록을 읽지 못하면 null 이다.
1030
- function oldCellIno() {
1031
- try { return hasUrlKey(parseRecord(pinRecordText()).rec) ? sharedIno() : null; }
1032
- catch { return null; }
1329
+ // 덮어쓰거나 지울 옛 기록이 URL 을 담는가. "yes" 는 JSON 의 최상위 칸을 봄, "maybe" 는 읽지 못했거나 JSON 이 아닌 글에
1330
+ // originUrl 이 있음(칸인지 모름), null 은 없음(일반 파일로 이어지지 않는 자리 포함). 같은 파일의 다른 이름을 알릴지와
1331
+ // 그 문구("남음", "남았을 수 있음")를 가른다.
1332
+ function urlInRecord() {
1333
+ let text;
1334
+ try { text = pinRecordText(); } catch { return "maybe"; }
1335
+ if (text === null) return null;
1336
+ const { ok, rec } = parseRecord(text);
1337
+ if (ok) return hasUrlKey(rec) ? "yes" : null;
1338
+ return text.includes("originUrl") ? "maybe" : null;
1033
1339
  }
1034
1340
 
1035
1341
  // 이미 고정된 origin. 옛 기록의 URL 칸을 지우지 못했으면 고정은 그대로 두고 실패로 센다.
@@ -1053,6 +1359,8 @@ function scrubPinRecord() {
1053
1359
  return ino === null ? true : sharedLeft(SHARED_WHY, ino);
1054
1360
  }
1055
1361
  const SHARED_WHY = "이 이름에서만 지움 (같은 파일의 다른 이름이 있어 그쪽에는 남음)";
1362
+ const SHARED_MAYBE_WHY = "이 이름에서만 지움 (같은 파일의 다른 이름이 있어 그쪽에 남았을 수 있음)";
1363
+ const sharedWhy = (held) => (held === "yes" ? SHARED_WHY : SHARED_MAYBE_WHY);
1056
1364
  const SHARED_CHECK_WHY = "이 이름에서만 지울 수 있음 (같은 파일의 다른 이름이 있어 그쪽에는 남음)";
1057
1365
 
1058
1366
  // 기록에 같은 파일의 다른 이름(하드 링크)이 있으면 그 파일의 inode(Windows 는 파일 ID, 정밀도를 잃지 않게 BigInt)다.
@@ -1068,7 +1376,7 @@ function sharedIno() {
1068
1376
  const sharedLeft = (what, ino) => pinUrlLeft(what, `\n 다른 이름은 같은 파일 시스템에서 inode(Windows 는 파일 ID) ${ino} 로 찾습니다.`);
1069
1377
 
1070
1378
  // --check 의 판정. 남은 칸을 적용이 지울 수 있으면 scrub-needs, 이 도구로 지울 수 없으면 그 까닭을 알리고
1071
- // scrub-blocked 다. 남은 칸이 없으면 already 다.
1379
+ // scrub-blocked 다. 기록을 읽지 못했거나 JSON 이 아니어서 칸이 있는지 모르면 scrub-unsure 다. 남은 칸이 없으면 already 다.
1072
1380
  function pinUrlCheck() {
1073
1381
  const left = pinUrlLeftover();
1074
1382
  if (!left) return "already";
@@ -1077,7 +1385,7 @@ function pinUrlCheck() {
1077
1385
  if (why || ino !== null) {
1078
1386
  if (why) pinUrlLeft(why);
1079
1387
  else sharedLeft(SHARED_CHECK_WHY, ino);
1080
- return "scrub-blocked";
1388
+ return left.unsure ? "scrub-unsure" : "scrub-blocked";
1081
1389
  }
1082
1390
  bad(`고정 기록(PINNED)에 옛 URL 칸이 남아 있음 (적용하면 지움): ${oneLine(PINFILE)}`);
1083
1391
  return "scrub-needs";
@@ -1095,12 +1403,13 @@ function pinDirWhy() {
1095
1403
  function pinUrlLeftover() {
1096
1404
  let text;
1097
1405
  try { text = pinRecordText(); }
1098
- catch (e) { return { why: `확인하지 못함 (읽지 못함: ${failWhy(e)})` }; }
1406
+ catch (e) { return { why: `확인하지 못함 (읽지 못함: ${failWhy(e)})`, unsure: true }; }
1099
1407
  if (text === null) return null;
1100
1408
  const { ok, rec } = parseRecord(text);
1101
1409
  if (ok ? !hasUrlKey(rec) : !text.includes("originUrl")) return null;
1102
1410
  if (isLink(PINFILE)) return { why: "지울 수 없음 (링크라 따라가 쓰지 않음)" };
1103
- return ok ? { rec } : { why: "지울 수 없음 (JSON 이 아님)" };
1411
+ // JSON 이 아닌 글은 originUrl 이 칸인지 알 수 없다. 읽지 못한 기록과 함께 "남았을 수 있음" 이다(unsure).
1412
+ return ok ? { rec } : { why: "지울 수 없음 (JSON 이 아님)", unsure: true };
1104
1413
  }
1105
1414
 
1106
1415
  // 기록의 글을 JSON 으로 읽는다. 읽히면 ok 가 true 다. 최상위 칸만 본다: 글에 그 이름이 있어도 칸이 아니면 없는 것이다.
@@ -1139,7 +1448,7 @@ function unpinMarketplace(url) {
1139
1448
  if (!restored) return "not-pinned";
1140
1449
  try { git(["remote", "set-url", "--", "origin", restored]); }
1141
1450
  catch (e) {
1142
- bad(`고정 해제 실패: origin 을 되돌리지 못했습니다 (${gitError(e)})`);
1451
+ bad(`고정 해제 실패: origin 을 되돌리지 못했습니다 (${shownGit(gitLine(e))})`);
1143
1452
  log(` 원래 URL(호스트까지만): ${oneLine(hostOnly(restored))}. 원문은 origin 의 omc-pinned:// 뒤에 그대로 있습니다.`);
1144
1453
  return "unpin-failed";
1145
1454
  }
@@ -1155,14 +1464,15 @@ function dropPinRecord() {
1155
1464
  if (left) return pinUrlLeft(left.why);
1156
1465
  let saved = null;
1157
1466
  try { saved = JSON.parse(pinRecordText()); } catch { /* 깨졌거나 읽지 못하면 무시 */ }
1158
- const ino = hasUrlKey(saved) ? sharedIno() : null;
1467
+ const held = urlInRecord();
1468
+ const ino = held ? sharedIno() : null;
1159
1469
  try { unlinkSync(PINFILE); }
1160
1470
  catch (e) {
1161
1471
  bad(`고정 기록(PINNED)을 지우지 못함 (${failWhy(e)}): ${oneLine(PINFILE)}`);
1162
1472
  return false;
1163
1473
  }
1164
1474
  warnForeignPin(saved);
1165
- return ino === null ? true : sharedLeft(SHARED_WHY, ino);
1475
+ return ino === null ? true : sharedLeft(sharedWhy(held), ino);
1166
1476
  }
1167
1477
 
1168
1478
  // 고정 기록 자리에 무엇이 있는가(끊어진 링크 포함). 위 폴더를 열 수 없어 확인하지 못하면 있는 것으로 본다.
@@ -1173,7 +1483,7 @@ function pinRecordThere() {
1173
1483
 
1174
1484
  // 다른 기기의 기록이면 알린다. 그 값은 쓰지 않는다.
1175
1485
  function warnForeignPin(saved) {
1176
- if (saved && typeof saved === "object" && saved.host && saved.host !== hostname()) {
1486
+ if (saved && typeof saved === "object" && typeof saved.host === "string" && saved.host && saved.host !== hostname()) {
1177
1487
  bad(`PINNED 는 다른 기기(${shown(saved.host)})의 기록입니다 — 그 값은 쓰지 않습니다.`);
1178
1488
  }
1179
1489
  }
@@ -1261,6 +1571,8 @@ log();
1261
1571
  // targets 는 «고칠 플러그인 폴더» 의 이름표 목록이다. 기본은 캐시의 판 번호이고, --root 는
1262
1572
  // 그 폴더 경로 하나다. rootOf 가 이름표를 폴더로 바꾼다.
1263
1573
  let active, targets;
1574
+ // installed_plugins.json 에서 활성 판을 찾지 못해 추정했는가. 적용은 멈추고 --check 는 그렇다고 결과 줄에 낸다.
1575
+ let activeGuessed = false;
1264
1576
  const rootOf = (v) => (ROOT ? v : join(CACHE, v));
1265
1577
  // 헬퍼 자리 결함은 이 판의 apply 로 고칠 수 없다(이미 감싼 파일). 원본으로 되돌린 뒤 다시 적용한다.
1266
1578
  // 되돌리기는 그 폴더 하나만 한다. 인자 없는 --revert 는 설치된 모든 판을 되돌리고 고정까지 푼다.
@@ -1293,12 +1605,18 @@ if (ROOT) {
1293
1605
  // [1/4] 뒤에 계산해야 일반 apply 가 TARGET_VERSION 으로 전환한 결과가 반영된다.
1294
1606
  const { v, src: activeSrc } = activeVersion();
1295
1607
  active = v;
1608
+ activeGuessed = activeSrc.startsWith("추정");
1296
1609
  if (MODE !== "revert" && (MODE === "check" || NO_UPDATE)) {
1297
1610
  log(` 활성 버전: ${oneLine(active)} (${activeSrc})`);
1298
1611
  if (active !== TARGET_VERSION) {
1299
1612
  log(` 활성 버전이 대상(${TARGET_VERSION})과 다름 — 기본 apply 는 ${TARGET_VERSION} 로 전환하고, --no-update 는 활성 버전(${oneLine(active)})을 그 자리에서 패치함`);
1300
1613
  }
1301
1614
  }
1615
+ // 추정한 판에 적용하면 Claude Code 가 쓰는 판은 그대로 남고, 그 뒤 --check 는 추정한 판을 보고 적용됐다고 한다. 쓰기 전에 멈춘다.
1616
+ if (MODE === "apply" && activeGuessed) {
1617
+ bad(`활성 버전을 installed_plugins.json 에서 확인하지 못해 적용하지 않음 (추정한 판 ${oneLine(active)})`);
1618
+ process.exit(1);
1619
+ }
1302
1620
  // apply 는 활성 버전만 손댄다. revert 와 check 는 설치된 모든 버전을 훑는다 —
1303
1621
  // 여러 버전이 나란히 있을 때 이전 버전에 수정이 남으면, 롤백했을 때 예상과 다른
1304
1622
  // 상태가 된다(실제로 4.15.7 의 hooks.json 수정이 그렇게 남았다).
@@ -1311,6 +1629,7 @@ log(`[2/4] 훅 패치${MODE === "apply" || ROOT ? ` (${oneLine(active)})` : `
1311
1629
  const r = { done: 0, skip: 0, fail: 0, need: 0, misplaced: 0 };
1312
1630
  const needBy = {};
1313
1631
  const misplacedBy = {};
1632
+ const unreadBy = {};
1314
1633
  const foreignBy = {};
1315
1634
  const orphanBy = {};
1316
1635
  // 되돌리기 끝 줄이 세는 파일. 쓰지 못한 것(failed)과 백업이 없어 남은 것(left)이다. 링크로 이어진 판 폴더는
@@ -1324,6 +1643,7 @@ for (const v of targets) {
1324
1643
  const x = patchHooks(root);
1325
1644
  needBy[v] = x.need;
1326
1645
  misplacedBy[v] = x.misplaced;
1646
+ unreadBy[v] = x.unread;
1327
1647
  for (const k of ["done", "skip", "fail", "need", "misplaced"]) r[k] += x[k];
1328
1648
  if (x.foreign?.length) foreignBy[v] = x.foreign;
1329
1649
  if (x.orphan?.length) orphanBy[v] = x.orphan;
@@ -1336,12 +1656,19 @@ log(` ${MODE === "check" ? `패치 필요 ${r.need}건` : MODE === "revert" ? `
1336
1656
  function reportNoBackup(v, list, what) {
1337
1657
  bad(`${oneLine(v)}: ${what} — ${list.map(shown).join(", ")}`);
1338
1658
  log(` 백업(.omcbak)이 없어 되돌릴 원본이 디스크에 없습니다. 원본이 필요하면:`);
1339
- if (ROOT) log(` 같은 판의 상류 원본에서 scripts/<이름>.mjs 를 다시 가져오십시오.`);
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))}"`);
1659
+ if (ROOT) { log(` 같은 판의 상류 원본에서 scripts/<이름>.mjs 를 다시 가져오십시오.`); return; }
1660
+ const [market, dest] = [MARKET, join(CACHE, v)].map((p) => (oneLine(p) === p ? shellArg(p) : null));
1661
+ if (!market || !dest) {
1662
+ log(` 폴더 경로를 셸 명령에 그대로 넣을 수 없어 명령을 싣지 않습니다. 마켓플레이스 폴더 ${oneLine(MARKET)} 의 태그 v${oneLine(v)} 에서 scripts/<이름>.mjs 를 꺼내 ${oneLine(join(CACHE, v))} 에 푸십시오.`);
1663
+ return;
1343
1664
  }
1665
+ log(` git -C ${market} archive -o "<임시 tar>" 'v${oneLine(v)}' 'scripts/<이름>.mjs'`);
1666
+ log(` tar -xf "<임시 tar>" -C ${dest}`);
1667
+ log(" <임시 tar> 는 새로 만든 임시 폴더(bash: mktemp -d) 안의 파일로 정하고, 다 쓴 뒤 폴더째 지우십시오.");
1344
1668
  }
1669
+ // 손으로 칠 명령에 넣는 값. bash 와 PowerShell 모두 작은따옴표 안의 글은 그대로 읽는다(PowerShell 은 ‘ ’ ‚ ‛ 도 작은따옴표로
1670
+ // 읽는다). 그런 글자가 든 값은 큰따옴표로 감싸되, 두 셸이 큰따옴표 안에서 달리 읽는 글자($ ` " ! 와 “ ” „)도 들면 null 이다.
1671
+ const shellArg = (v) => (!/['\u{2018}-\u{201B}]/u.test(v) ? `'${v}'` : /[$`"!\u{201C}-\u{201E}]/u.test(v) ? null : `"${v}"`);
1345
1672
  for (const [v, list] of Object.entries(foreignBy)) reportNoBackup(v, list, "이 도구가 만들지 않은 수정이 남아 있습니다");
1346
1673
  for (const [v, list] of Object.entries(orphanBy)) reportNoBackup(v, list, "패치는 있는데 백업이 없어 되돌리지 못했습니다");
1347
1674
 
@@ -1398,6 +1725,7 @@ if (ROOT) {
1398
1725
  "scrub-failed": () => ok("이미 고정됨"),
1399
1726
  "scrub-needs": () => ok("이미 고정됨"),
1400
1727
  "scrub-blocked": () => ok("이미 고정됨"),
1728
+ "scrub-unsure": () => ok("이미 고정됨"),
1401
1729
  }[pin] || (() => {}))();
1402
1730
  // 마켓플레이스를 못 찾아 고정하지 않은 PC 에도 옛 판이 남긴 기록은 있을 수 있다. 같은 판정으로 다룬다.
1403
1731
  if (MODE !== "revert" && ["no-marketplace", "no-git", "no-remote"].includes(pin)) {
@@ -1423,27 +1751,42 @@ if (MODE === "check") {
1423
1751
  // 헬퍼 자리 결함이 하나라도 있으면 «모두 적용» 이 아니다. 다시 적용만으로는 안 고쳐지므로
1424
1752
  // 그 안내를 먼저 낸다(되돌린 뒤 적용하면 «패치 필요» 항목도 함께 처리된다).
1425
1753
  const misplacedActive = misplacedBy[active] || 0;
1426
- const pinNeeds = ["needs", "scrub-needs", "scrub-blocked"].includes(pin);
1754
+ const unreadActive = unreadBy[active] || 0;
1755
+ const pinNeeds = ["needs", "scrub-needs", "scrub-blocked", "scrub-unsure"].includes(pin);
1427
1756
  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;
1432
- const others = targets.filter((v) => v !== active && ((needBy[v] || 0) > 0 || hjNeedBy[v] || (misplacedBy[v] || 0) > 0));
1433
- log(clean ? "결과: 모두 적용된 상태입니다."
1434
- : hjFailBy[active] ? "결과: hooks.json 을 확인하지 못했습니다. 위의 [!] 줄을 확인하십시오."
1435
- : pinUnread ? "결과: 마켓플레이스 origin 을 확인하지 못했습니다. 위의 [!] 줄을 확인하십시오."
1436
- : misplacedActive ? `결과: 헬퍼 자리 결함 ${misplacedActive}건이 있습니다. ${misplacedHint()}`
1437
- : blockedResult ? "결과: 고정 기록(PINNED)의 옛 URL 칸이 이 도구로 지울 수 없는 곳에 있습니다. 위의 [!] 줄을 확인하십시오."
1438
- : ROOT ? "결과: 적용되지 않은 항목이 있습니다. --check 없이 같은 --root 로 다시 실행하십시오."
1439
- : NO_UPDATE ? "결과: 적용되지 않은 항목이 있습니다. --check 없이 --no-update 로 다시 실행하십시오."
1440
- : "결과: 적용되지 않은 항목이 있습니다. 인자 없이 다시 실행하십시오.");
1441
- if (pin === "scrub-blocked" && !blockedResult) {
1442
- log(" 참고: 고정 기록(PINNED)의 옛 URL 칸이 이 도구로 지울 수 없는 곳에 있습니다. 위의 [!] 줄을 확인하십시오.");
1443
- log(" 적용해도 그곳의 칸은 남고, 적용은 이 항목을 실패로 세어 종료 1 로 끝납니다.");
1757
+ const toApply = (needBy[active] || 0) > 0 || !!hjNeedBy[active];
1758
+ const clean = !toApply && !hjFailBy[active] && !pinNeeds && !pinUnread && misplacedActive === 0 && !unreadActive && !activeGuessed;
1759
+ // 고정 기록 줄이 결과가 되는 때는 적용으로 고칠 항목이 없고 그 줄만 남은 때다. 고칠 항목이 함께 있으면 그 항목이 결과이고
1760
+ // 고정 기록 줄은 참고로 알린다(적용은 훅을 패치하고 그 칸은 남겨 실패로 센다).
1761
+ const pinOnly = !toApply && !hjFailBy[active] && !misplacedActive && !unreadActive && !activeGuessed;
1762
+ const PIN_LINES = {
1763
+ "scrub-blocked": "결과: 고정 기록(PINNED)의 옛 URL 칸이 이 도구로 지울 수 없는 곳에 있습니다. 위의 [!] 줄을 확인하십시오.",
1764
+ "scrub-unsure": "결과: 고정 기록(PINNED)을 확인하지 못했습니다. 옛 URL 칸이 남았을 수 있습니다. 위의 [!] 줄을 확인하십시오.",
1765
+ };
1766
+ const rerun = ROOT ? "--check 없이 같은 --root 로 다시 실행하십시오."
1767
+ : NO_UPDATE ? "--check 없이 --no-update 로 다시 실행하십시오." : "인자 없이 다시 실행하십시오.";
1768
+ // 결과 줄 표. 위에서부터 처음 맞는 줄 하나를 낸다. SKILL 1단계의 결과 줄 표도 이 순서다.
1769
+ const RESULTS = [
1770
+ [activeGuessed, "결과: 활성 버전을 확인하지 못했습니다. 위의 활성 버전 줄을 확인하십시오."],
1771
+ [hjFailBy[active], "결과: hooks.json 을 확인하지 못했습니다. 위의 [!] 줄을 확인하십시오."],
1772
+ [unreadActive, "결과: 훅 스크립트를 확인하지 못했습니다. 위의 [!] 줄을 확인하십시오."],
1773
+ [pinUnread, "결과: 마켓플레이스 origin 을 확인하지 못했습니다. 위의 [!] 줄을 확인하십시오."],
1774
+ [misplacedActive, `결과: 헬퍼 자리 결함 ${misplacedActive}건이 있습니다. ${misplacedHint()}`],
1775
+ [pinOnly && pin === "scrub-blocked", PIN_LINES["scrub-blocked"]],
1776
+ [pinOnly && pin === "scrub-unsure", PIN_LINES["scrub-unsure"]],
1777
+ [!clean, `결과: 적용되지 않은 항목이 있습니다. ${rerun}`],
1778
+ [true, "결과: 모두 적용된 상태입니다."],
1779
+ ];
1780
+ log(RESULTS.find(([when]) => when)[1]);
1781
+ if (PIN_LINES[pin] && !pinOnly) {
1782
+ log(` 참고: ${PIN_LINES[pin].slice("결과: ".length)}`);
1783
+ log(` ${pin === "scrub-unsure" ? "칸이 남아 있으면 적용도 지우지 못하고" : "적용해도 그곳의 칸은 남고"}, 적용은 이 항목을 실패로 세어 종료 1 로 끝납니다.`);
1444
1784
  }
1785
+ const others = targets.filter((v) => v !== active && ((needBy[v] || 0) > 0 || hjNeedBy[v] || (misplacedBy[v] || 0) > 0));
1445
1786
  const unreadable = targets.filter((v) => v !== active && hjFailBy[v]);
1446
1787
  if (unreadable.length) log(` 참고: 비활성 버전 ${unreadable.map(oneLine).join(", ")} 의 hooks.json 을 확인하지 못했습니다. 판정에는 넣지 않았습니다.`);
1788
+ const unreadHooks = targets.filter((v) => v !== active && unreadBy[v]);
1789
+ if (unreadHooks.length) log(` 참고: 비활성 버전 ${unreadHooks.map(oneLine).join(", ")} 의 훅 스크립트를 확인하지 못했습니다. 판정에는 넣지 않았습니다.`);
1447
1790
  if (others.length) {
1448
1791
  log(` 참고: 비활성 버전 ${others.map(oneLine).join(", ")} 에 미적용분이 있습니다.`);
1449
1792
  log(" 지금 쓰이지 않는 버전이라 판정에 넣지 않았습니다. 그 버전으로 되돌릴 계획이면 함께 적용하십시오.");
@@ -1460,4 +1803,5 @@ if (MODE === "check") {
1460
1803
  log(" 훅 스크립트 패치는 즉시 적용됩니다 (훅이 호출될 때마다 파일을 새로 읽습니다).");
1461
1804
  log("[!] PostToolUse 훅 제거는 hooks.json 변경이라 각 세션이 다시 시작될 때부터 적용됩니다.");
1462
1805
  }
1463
- process.exit(r.fail > 0 || left.size > 0 ? 1 : 0);
1806
+ // --check 가 활성 판을 추정했으면 확인하지 못한 것이라 종료 1 이다(읽지 못한 hooks.json, 훅, origin 과 같다).
1807
+ process.exit(r.fail > 0 || left.size > 0 || (MODE === "check" && activeGuessed) ? 1 : 0);