@kaydash9999/banker-plugins 0.15.1 → 0.16.0
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 +60 -0
- package/README.md +31 -9
- package/codex/manifest.json +6 -3
- package/codex/transform-matrix.md +2 -0
- package/commands/progress.md +10 -0
- package/commands/setup.md +1 -1
- package/hooks/graceful-pause.mjs +31 -71
- package/hooks/hooks.json +11 -1
- package/hooks/progress-fallback.mjs +10 -0
- package/hooks/progress.mjs +427 -0
- package/hooks/register.mjs +82 -5
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +235 -122
- package/skills/3d-intro-build/references/NOTICE.md +2 -1
- package/skills/3d-intro-build/references/assemble.mjs +164 -108
- package/skills/3d-intro-build/references/azure-adapter.mjs +115 -29
- package/skills/3d-intro-build/references/curate.mjs +573 -267
- package/skills/3d-intro-build/references/intro-fixes.css +61 -0
- package/skills/3d-intro-build/references/panel-glass.css +55 -0
- package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
- package/skills/3d-intro-build/references/prompts.md +32 -9
- package/skills/3d-intro-build/references/serve.mjs +111 -103
- package/skills/3d-intro-build/references/step-nav.css +59 -0
- package/skills/3d-intro-build/references/step-nav.js +503 -0
- package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
- package/skills/3d-intro-build/references/verify.md +45 -0
- package/skills/3d-intro-build/references/video-pool.mjs +780 -0
- package/skills/3d-intro-setup/SKILL.md +49 -12
- package/skills/3d-intro-setup/references/azure-adapter.mjs +115 -29
- package/skills/3d-intro-setup/references/video-pool.mjs +780 -0
- package/skills/lineage/SKILL.md +12 -8
- package/skills/lineage/lineage.py +190 -59
- package/skills/motion-graphic-make/SKILL.md +73 -28
- package/skills/motion-graphic-make/references/curate.mjs +607 -0
- package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
- package/skills/motion-graphic-make/references/serve.mjs +131 -0
- package/skills/omc-patch/SKILL.md +143 -0
- package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
- package/skills/remains/SKILL.md +125 -0
- package/skills/remains/scripts/boxes.mjs +448 -0
- package/skills/trouble-shooting/SKILL.md +50 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared by serve.mjs and curate.mjs: the local servers of the 3d-intro-build skill (mirrored
|
|
3
|
+
* byte-identical into motion-graphic-make by scripts/sync-adapter.js).
|
|
4
|
+
*
|
|
5
|
+
* - A port the system picks (a preferred one first when asked), bound to loopback by default.
|
|
6
|
+
* - A state file recording { pid, port, requestedPort, host, url, dir }: serve.mjs keeps it beside
|
|
7
|
+
* the folder it serves, curate.mjs in the project folder, which it does not serve (it serves
|
|
8
|
+
* listed takes only).
|
|
9
|
+
* - Requests whose Host is a name other than localhost are refused, so a DNS name rebound to this
|
|
10
|
+
* machine cannot read what is served.
|
|
11
|
+
* - A stop that ends only the process answering on that port with the recorded PID (a whole number
|
|
12
|
+
* above 1), then waits for the port to close. Nothing is matched by command line (no pkill -f).
|
|
13
|
+
* - Files open before any header goes out, so one that cannot be read gets 403 and the server
|
|
14
|
+
* stays up, and a download the browser drops closes its file.
|
|
15
|
+
* - An optional Content-Security-Policy header, so a page is checked under the policy it ships with.
|
|
16
|
+
*
|
|
17
|
+
* Runtime: Node >=18 builtins only (node:fs / node:http / node:net / node:path). No shell.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import net from 'node:net';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { pipeline } from 'node:stream';
|
|
24
|
+
|
|
25
|
+
export const LOOPBACK = '127.0.0.1';
|
|
26
|
+
// Answers { pid } so a stop can tell this server from another process on the same port.
|
|
27
|
+
export const PID_ROUTE = '/__preview/pid';
|
|
28
|
+
// As strict as a production site: no inline script or style. The scrub engine fetches its clips
|
|
29
|
+
// from its own origin and plays them from blob: URLs.
|
|
30
|
+
export const STRICT_CSP = [
|
|
31
|
+
"default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self' data: blob:",
|
|
32
|
+
"media-src 'self' blob:", "connect-src 'self'", "font-src 'self'", "object-src 'none'",
|
|
33
|
+
"base-uri 'self'", "frame-ancestors 'none'",
|
|
34
|
+
].join('; ');
|
|
35
|
+
|
|
36
|
+
export const MIME = {
|
|
37
|
+
'.html': 'text/html;charset=utf-8', '.css': 'text/css;charset=utf-8', '.js': 'text/javascript;charset=utf-8',
|
|
38
|
+
'.mjs': 'text/javascript;charset=utf-8', '.json': 'application/json;charset=utf-8', '.png': 'image/png',
|
|
39
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
|
40
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.woff2': 'font/woff2',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// `strict` is the policy above; any other text is a policy as written; nothing means no header.
|
|
44
|
+
export const cspFrom = (value) => (value ? (value === 'strict' ? STRICT_CSP : String(value)) : null);
|
|
45
|
+
|
|
46
|
+
export function hostWarning(host) {
|
|
47
|
+
if (!host || [LOOPBACK, 'localhost', '::1'].includes(host)) return null;
|
|
48
|
+
return `주의: ${host} 에 바인드합니다. 같은 네트워크의 기기가 아직 공개하지 않은 산출물을 볼 수 있습니다. 끝나면 --stop 으로 닫으세요.`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// True for a Host header naming this machine as localhost, by an IP address, or by a name the server
|
|
52
|
+
// was bound to (`names`); any other DNS name fails, which is what a rebinding attack needs.
|
|
53
|
+
export function hostAllowed(hostHeader, names = []) {
|
|
54
|
+
const h = String(hostHeader || '').trim().toLowerCase();
|
|
55
|
+
if (!h) return false;
|
|
56
|
+
if (h.startsWith('[')) return /^\[[0-9a-f:.]+\](:\d+)?$/.test(h);
|
|
57
|
+
const name = h.split(':')[0];
|
|
58
|
+
return name === 'localhost' || /^\d{1,3}(\.\d{1,3}){3}$/.test(name) || names.map((n) => String(n).toLowerCase()).includes(name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The request path without its query, or null when its escapes are broken.
|
|
62
|
+
export function decodePath(rawUrl) {
|
|
63
|
+
try { return decodeURIComponent(String(rawUrl || '/').split('?')[0]); } catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The file a request path names inside `dir`, or null when the path leaves it.
|
|
67
|
+
export function resolveInside(dir, urlPath) {
|
|
68
|
+
const fp = path.normalize(path.join(dir, urlPath));
|
|
69
|
+
return fp === dir || fp.startsWith(dir + path.sep) ? fp : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function send(res, status, body, headers = {}) {
|
|
73
|
+
res.writeHead(status, { ...headers, 'Content-Type': 'text/plain;charset=utf-8', 'Cache-Control': 'no-store' });
|
|
74
|
+
res.end(String(body));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Read flags that never wait: a path that has turned into a FIFO opens at once and is then refused.
|
|
78
|
+
export const READ_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0);
|
|
79
|
+
|
|
80
|
+
// An open regular file: { fd, size }, or { status } when there is none (404) or it cannot be read (403).
|
|
81
|
+
function openRegular(fp) {
|
|
82
|
+
let fd;
|
|
83
|
+
try { fd = fs.openSync(fp, READ_FLAGS); } catch (e) { return { status: ['ENOENT', 'ENOTDIR', 'EISDIR'].includes(e.code) ? 404 : 403 }; }
|
|
84
|
+
let st = null;
|
|
85
|
+
try { st = fs.fstatSync(fd); } catch { /* a status the system cannot give (EIO on a network drive) */ }
|
|
86
|
+
if (st && st.isFile()) return { fd, size: st.size };
|
|
87
|
+
fs.closeSync(fd);
|
|
88
|
+
return { status: st ? 404 : 403 };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Sends the bytes `start`..`end` of an open file (what the headers declared, even if the file grows
|
|
92
|
+
// meanwhile) and closes it, also when the browser drops the download midway (a pipe would keep the
|
|
93
|
+
// file open). Nothing to send closes the file and ends the response.
|
|
94
|
+
function stream(res, fp, { fd, start, end }) {
|
|
95
|
+
if (end < start) {
|
|
96
|
+
fs.closeSync(fd);
|
|
97
|
+
return void res.end();
|
|
98
|
+
}
|
|
99
|
+
pipeline(fs.createReadStream(fp, { fd, start, end }), res, () => {});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A file, with byte ranges for video (smoother scrubbing). Never cached: a rebuilt page is fresh.
|
|
103
|
+
// The file is opened before any header goes out, so one that cannot be read gets 403 instead of
|
|
104
|
+
// ending the server.
|
|
105
|
+
export function sendFile(req, res, fp, headers = {}) {
|
|
106
|
+
const f = openRegular(fp);
|
|
107
|
+
if (f.status) return send(res, f.status, String(f.status), headers);
|
|
108
|
+
const type = MIME[path.extname(fp).toLowerCase()] || 'application/octet-stream';
|
|
109
|
+
const base = { ...headers, 'Content-Type': type, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' };
|
|
110
|
+
const range = type.startsWith('video/') ? /bytes=(\d+)-(\d*)/.exec(req.headers.range || '') : null;
|
|
111
|
+
if (range) return sendRange(res, fp, f, range, base, headers);
|
|
112
|
+
res.writeHead(200, { ...base, 'Content-Length': f.size });
|
|
113
|
+
stream(res, fp, { fd: f.fd, start: 0, end: f.size - 1 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sendRange(res, fp, f, range, base, headers) {
|
|
117
|
+
const start = Number(range[1]);
|
|
118
|
+
const end = Math.min(range[2] ? Number(range[2]) : f.size - 1, f.size - 1);
|
|
119
|
+
if (start >= f.size || start > end) {
|
|
120
|
+
fs.closeSync(f.fd);
|
|
121
|
+
res.writeHead(416, { ...headers, 'Content-Range': `bytes */${f.size}` });
|
|
122
|
+
return void res.end();
|
|
123
|
+
}
|
|
124
|
+
res.writeHead(206, { ...base, 'Content-Range': `bytes ${start}-${end}/${f.size}`, 'Content-Length': end - start + 1 });
|
|
125
|
+
stream(res, fp, { fd: f.fd, start, end });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// True when the request was the PID question, which this answers.
|
|
129
|
+
export function answersPid(urlPath, res) {
|
|
130
|
+
if (urlPath !== PID_ROUTE) return false;
|
|
131
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
132
|
+
res.end(JSON.stringify({ pid: process.pid }));
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Listens on `port` when given and free, else on a port the system picks. Resolves to the port.
|
|
137
|
+
export function listen(server, { host = LOOPBACK, port } = {}) {
|
|
138
|
+
const attempt = (p) => new Promise((resolve, reject) => {
|
|
139
|
+
const onError = (e) => { server.off('listening', onListening); reject(e); };
|
|
140
|
+
const onListening = () => { server.off('error', onError); resolve(server.address().port); };
|
|
141
|
+
server.once('error', onError);
|
|
142
|
+
server.once('listening', onListening);
|
|
143
|
+
server.listen(p, host);
|
|
144
|
+
});
|
|
145
|
+
if (!port) return attempt(0);
|
|
146
|
+
return attempt(port).catch((e) => (e.code === 'EADDRINUSE' ? attempt(0) : Promise.reject(e)));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The address a person opens: localhost for loopback and the wildcard, IPv6 in brackets.
|
|
150
|
+
export function urlFor(host, port) {
|
|
151
|
+
if (!host || host === LOOPBACK || host === '0.0.0.0') return `http://localhost:${port}/`;
|
|
152
|
+
if (host === '::' || host === '::1') return `http://[::1]:${port}/`;
|
|
153
|
+
return host.includes(':') ? `http://[${host}]:${port}/` : `http://${host}:${port}/`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Where a stop asks a server for its PID: the address it was bound to, a wildcard at loopback.
|
|
157
|
+
export function askHost(host) {
|
|
158
|
+
if (!host || host === '0.0.0.0') return LOOPBACK;
|
|
159
|
+
return host === '::' ? '::1' : host;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Whether the server a state file records answers a start that asks for `port`: one that asks no
|
|
163
|
+
* port, the port it listens on, or the port its own start asked for (busy then, so it got another).
|
|
164
|
+
*/
|
|
165
|
+
export const portMatches = (st, port) => !port || st.port === port || st.requestedPort === port;
|
|
166
|
+
|
|
167
|
+
// The state file of a server for `dir`: beside the folder, so a deployed folder never carries it.
|
|
168
|
+
export const siblingState = (dir) => path.join(path.dirname(dir), `${path.basename(dir)}.server.json`);
|
|
169
|
+
|
|
170
|
+
export function writeState(file, info) {
|
|
171
|
+
fs.writeFileSync(file, JSON.stringify(info, null, 2) + '\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function readState(file) {
|
|
175
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const removeState = (file) => fs.rmSync(file, { force: true });
|
|
179
|
+
|
|
180
|
+
// Removes a state file if it can. One that cannot be removed (a folder made read-only) stays; the
|
|
181
|
+
// next start finds it stale. Stopping and closing go on either way.
|
|
182
|
+
function dropState(file) {
|
|
183
|
+
try { removeState(file); } catch { /* left behind */ }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Removes the state file when the process recorded there is this one.
|
|
187
|
+
export function forgetSelf(file) {
|
|
188
|
+
if (readState(file)?.pid === process.pid) dropState(file);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const NONE = Symbol('none');
|
|
192
|
+
// The PID the server on `port` says it has; NONE when nothing listens; null for another program.
|
|
193
|
+
function askPid(port, timeoutMs, host) {
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
const req = http.get({ host: askHost(host), port, path: PID_ROUTE, timeout: timeoutMs, headers: { Host: 'localhost' } }, (res) => {
|
|
196
|
+
let body = '';
|
|
197
|
+
res.on('data', (c) => { body += c; });
|
|
198
|
+
res.on('end', () => { try { resolve(JSON.parse(body).pid ?? null); } catch { resolve(null); } });
|
|
199
|
+
});
|
|
200
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
201
|
+
req.on('error', (e) => resolve(e.code === 'ECONNREFUSED' ? NONE : null));
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const portOpen = (port, host) => new Promise((resolve) => {
|
|
206
|
+
const s = net.connect({ host: askHost(host), port }, () => { s.destroy(); resolve(true); });
|
|
207
|
+
s.on('error', () => resolve(false));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
async function waitClosed(port, timeoutMs, host) {
|
|
211
|
+
for (const until = Date.now() + timeoutMs; Date.now() < until;) {
|
|
212
|
+
if (!(await portOpen(port, host))) return true;
|
|
213
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
214
|
+
}
|
|
215
|
+
return !(await portOpen(port, host));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A PID a state file may name: a whole number above 1 (never a process group, never init).
|
|
219
|
+
const validPid = (pid) => Number.isInteger(pid) && pid > 1;
|
|
220
|
+
|
|
221
|
+
/** True when the server a state file records still answers on its port with its PID. */
|
|
222
|
+
export async function isRunning(stateFile) {
|
|
223
|
+
const st = readState(stateFile);
|
|
224
|
+
return Boolean(st && st.port) && validPid(st.pid) && (await askPid(st.port, 1500, st.host)) === st.pid;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function signal(pid, sig) {
|
|
228
|
+
try { process.kill(pid, sig); } catch { /* already gone */ }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Stops the server a state file records. Result reasons: stopped, no-state, invalid-state (a PID
|
|
232
|
+
// that is no process id; the file is left for a person to look at), not-running (the stale record
|
|
233
|
+
// is removed), other-process (left alone), still-open.
|
|
234
|
+
export async function stopServer(stateFile, { timeoutMs = 3000 } = {}) {
|
|
235
|
+
const st = readState(stateFile);
|
|
236
|
+
if (!st || !st.port) return { stopped: false, reason: 'no-state' };
|
|
237
|
+
if (!validPid(st.pid)) return { stopped: false, reason: 'invalid-state' };
|
|
238
|
+
const who = await askPid(st.port, Math.min(timeoutMs, 1500), st.host);
|
|
239
|
+
if (who === NONE) {
|
|
240
|
+
dropState(stateFile);
|
|
241
|
+
return { stopped: false, reason: 'not-running' };
|
|
242
|
+
}
|
|
243
|
+
if (who !== st.pid) return { stopped: false, reason: 'other-process' };
|
|
244
|
+
if (!(await terminate(st, timeoutMs))) return { stopped: false, reason: 'still-open' };
|
|
245
|
+
dropState(stateFile);
|
|
246
|
+
return { stopped: true, reason: 'stopped' };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Ends a server that answered with its PID: SIGTERM, then SIGKILL while its port stays open. True
|
|
250
|
+
// once the port is closed.
|
|
251
|
+
async function terminate(st, timeoutMs) {
|
|
252
|
+
signal(st.pid, 'SIGTERM');
|
|
253
|
+
if (await waitClosed(st.port, timeoutMs, st.host)) return true;
|
|
254
|
+
signal(st.pid, 'SIGKILL');
|
|
255
|
+
return waitClosed(st.port, timeoutMs, st.host);
|
|
256
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Local preview server for an assembled intro (index.html + scrub-engine.js + assets/), so the
|
|
4
|
+
* scroll-scrubbed page is checked in a real browser. The project convention is a self-contained
|
|
5
|
+
* local page on an unused port, never a claude.ai artifact. Shared by 3d-intro-build and
|
|
6
|
+
* motion-graphic-make (scripts/sync-adapter.js keeps the copies identical).
|
|
7
|
+
*
|
|
8
|
+
* Why a server at all: the engine loads each clip with fetch() into a Blob, which file:// cannot
|
|
9
|
+
* serve, and video plays smoother with byte ranges (206).
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node serve.mjs [dir] [--port N] [--csp strict|"<policy>"] [--state file]
|
|
13
|
+
* node serve.mjs --stop [dir] [--state file]
|
|
14
|
+
* dir folder to serve (default: the current one)
|
|
15
|
+
* --port preferred port; when busy or absent the system picks a free one
|
|
16
|
+
* --csp sends Content-Security-Policy on every response: `strict` (no inline script or
|
|
17
|
+
* style; blob: media) or the production policy as written. Check a page for a site
|
|
18
|
+
* with a CSP under that same policy: a plain server hides CSP errors.
|
|
19
|
+
* --state state file (default: <dir>.server.json beside the folder, never inside it)
|
|
20
|
+
* --stop ends the server the state file records, only when it answers with that PID
|
|
21
|
+
* A start while the recorded server still answers with the same settings only reports its
|
|
22
|
+
* address; with other settings (another folder sharing the --state file, --csp, PREVIEW_HOST, a
|
|
23
|
+
* --port other than the one it listens on or its own start asked for) it exits 1: --stop first.
|
|
24
|
+
* A request whose Host is a DNS name other than localhost is refused (DNS rebinding).
|
|
25
|
+
* On listen it writes the state file and prints PREVIEW http://localhost:<port>/
|
|
26
|
+
* PREVIEW_HOST=0.0.0.0 binds every interface (on-device phone checks) and prints a warning.
|
|
27
|
+
*
|
|
28
|
+
* Runtime: Node >=18 builtins only. No shell, cross-platform.
|
|
29
|
+
*/
|
|
30
|
+
import http from 'node:http';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import {
|
|
35
|
+
LOOPBACK, answersPid, cspFrom, decodePath, forgetSelf, hostAllowed, hostWarning, isRunning, listen, portMatches, readState,
|
|
36
|
+
resolveInside, send, sendFile, siblingState, stopServer, urlFor, writeState,
|
|
37
|
+
} from './preview-lib.mjs';
|
|
38
|
+
|
|
39
|
+
function handler(dir, headers, host) {
|
|
40
|
+
return (req, res) => {
|
|
41
|
+
if (!hostAllowed(req.headers.host, [host])) return send(res, 403, 'host not allowed', headers);
|
|
42
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, '405', headers);
|
|
43
|
+
const urlPath = decodePath(req.url);
|
|
44
|
+
if (urlPath === null) return send(res, 400, '400', headers);
|
|
45
|
+
if (answersPid(urlPath, res)) return;
|
|
46
|
+
const fp = resolveInside(dir, urlPath === '/' ? '/index.html' : urlPath);
|
|
47
|
+
if (!fp) return send(res, 403, '403', headers);
|
|
48
|
+
sendFile(req, res, fp, headers);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Serves `dir` until close(). Resolves to { server, port, host, url, stateFile, close }.
|
|
54
|
+
* @param {{ port?: number, host?: string, csp?: string, stateFile?: string }} opts
|
|
55
|
+
*/
|
|
56
|
+
export async function startServe(dir, { port, host = process.env.PREVIEW_HOST || LOOPBACK, csp, stateFile } = {}) {
|
|
57
|
+
const root = path.resolve(dir);
|
|
58
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`serve: not a directory: ${root}`);
|
|
59
|
+
const policy = cspFrom(csp);
|
|
60
|
+
const server = http.createServer(handler(root, policy ? { 'Content-Security-Policy': policy } : {}, host));
|
|
61
|
+
const bound = await listen(server, { host, port });
|
|
62
|
+
const url = urlFor(host, bound);
|
|
63
|
+
const file = stateFile ? path.resolve(stateFile) : siblingState(root);
|
|
64
|
+
writeState(file, {
|
|
65
|
+
pid: process.pid, port: bound, requestedPort: port || null, host, url, dir: root, csp: policy, startedAt: new Date().toISOString(),
|
|
66
|
+
});
|
|
67
|
+
const close = () => new Promise((resolve) => { forgetSelf(file); server.close(() => resolve()); });
|
|
68
|
+
return { server, port: bound, host, url, stateFile: file, close };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseArgs(argv) {
|
|
72
|
+
const o = { dir: null, port: null, csp: null, state: null, stop: false };
|
|
73
|
+
for (let i = 0; i < argv.length; i++) {
|
|
74
|
+
const a = argv[i];
|
|
75
|
+
const [key, inline] = a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, null];
|
|
76
|
+
const value = () => (inline !== null ? inline : argv[++i]);
|
|
77
|
+
if (key === '--port') o.port = Number(value());
|
|
78
|
+
else if (key === '--csp') o.csp = value();
|
|
79
|
+
else if (key === '--state') o.state = value();
|
|
80
|
+
else if (key === '--stop') o.stop = true;
|
|
81
|
+
else if (!a.startsWith('--')) o.dir = a;
|
|
82
|
+
}
|
|
83
|
+
return o;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Nothing left running is a success; a port held by another program, or one that stays open, is not.
|
|
87
|
+
async function stopMain(o, dir) {
|
|
88
|
+
const r = await stopServer(o.state ? path.resolve(o.state) : siblingState(dir));
|
|
89
|
+
console.log(`STOP ${r.reason}`);
|
|
90
|
+
process.exit(['stopped', 'not-running', 'no-state'].includes(r.reason) ? 0 : 1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Whether the recorded server answers this start: this folder, the same CSP and host, and the port
|
|
94
|
+
// asked for. A state file shared through --state can record another folder's server.
|
|
95
|
+
const sameSettings = (st, o, dir) => path.resolve(String(st.dir || '')) === dir && (st.csp || null) === cspFrom(o.csp)
|
|
96
|
+
&& (st.host || LOOPBACK) === (process.env.PREVIEW_HOST || LOOPBACK) && portMatches(st, o.port);
|
|
97
|
+
|
|
98
|
+
// A server already running for this state file: the same settings only report its address; other
|
|
99
|
+
// settings (folder, CSP, host, port) never pass for it, so a check meant to run under a CSP cannot
|
|
100
|
+
// run without one, and a page meant for one folder or port is not checked on another.
|
|
101
|
+
async function reportRunning(recorded, o, dir) {
|
|
102
|
+
if (!(await isRunning(recorded))) return false;
|
|
103
|
+
const st = readState(recorded);
|
|
104
|
+
if (sameSettings(st, o, dir)) {
|
|
105
|
+
console.log(`PREVIEW ${st.url} (already running, pid ${st.pid})`);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
console.error(`serve: the state file records a server running with different settings (folder: ${st.dir}, `
|
|
109
|
+
+ `csp: ${st.csp ? 'on' : 'off'}, host: ${st.host}, port: ${st.port}); stop it with --stop and start again`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function main() {
|
|
114
|
+
const o = parseArgs(process.argv.slice(2));
|
|
115
|
+
const dir = path.resolve(o.dir || process.cwd());
|
|
116
|
+
if (o.stop) return stopMain(o, dir);
|
|
117
|
+
if (await reportRunning(o.state ? path.resolve(o.state) : siblingState(dir), o, dir)) return;
|
|
118
|
+
const s = await startServe(dir, { port: o.port || undefined, csp: o.csp || undefined, stateFile: o.state || undefined });
|
|
119
|
+
const warn = hostWarning(s.host);
|
|
120
|
+
if (warn) console.warn(warn);
|
|
121
|
+
console.log(`PREVIEW ${s.url}`);
|
|
122
|
+
console.log(`serving ${dir}${o.csp ? ' (with Content-Security-Policy)' : ''} state: ${s.stateFile}`);
|
|
123
|
+
const quit = () => { forgetSelf(s.stateFile); s.server.close(); process.exit(0); };
|
|
124
|
+
process.on('SIGINT', quit);
|
|
125
|
+
process.on('SIGTERM', quit);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
129
|
+
if (invokedDirectly) {
|
|
130
|
+
main().catch((e) => { console.error(String(e?.message || e)); process.exit(1); });
|
|
131
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: omc-patch
|
|
3
|
+
description: "OMC 플러그인이 자동 업데이트된 뒤 사라진 훅 패치를 활성 버전에 다시 적용하고 OMC 마켓플레이스 자동 업데이트를 막음. 진단, 적용, 검증, 되돌리기. 'omc-patch'/'OMC 패치'/'OMC 업데이트 뒤 패치'/'훅 timed out'/'훅 프로세스 누적'/'PC 느려짐' 시 사용."
|
|
4
|
+
argument-hint: "[check | revert]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# omc-patch — OMC 훅 패치 재적용과 자동 업데이트 고정
|
|
8
|
+
|
|
9
|
+
OMC(oh-my-claudecode)가 새 버전으로 자동 업데이트되면 로컬 훅 패치가 사라진다. 그러면 도구 호출마다 훅 프로세스가 많이 생기고, Windows 에서는 커널 풀(paged pool)이 계속 늘어 PC 가 느려진다.
|
|
10
|
+
이 스킬은 같은 폴더의 `scripts/omc-patch.mjs` 로 패치를 다시 적용하고 결과를 검증한다. 답변은 한글.
|
|
11
|
+
|
|
12
|
+
패치 내용은 두 가지다.
|
|
13
|
+
- 훅 스크립트 최상위의 `await import(...)` 에 10초 시간 제한을 건다. 로드가 멈춰도 훅이 끝까지 가고 프로세스가 남지 않는다.
|
|
14
|
+
- `hooks.json` 의 `PostToolUse` 훅을 제거한다.
|
|
15
|
+
|
|
16
|
+
추가로 OMC 마켓플레이스 저장소의 `origin` 을 `omc-pinned://<원래 URL>` 로 바꿔 자동 업데이트를 막는다.
|
|
17
|
+
|
|
18
|
+
## 인자
|
|
19
|
+
|
|
20
|
+
| 인자 | 동작 |
|
|
21
|
+
|---|---|
|
|
22
|
+
| 없음 | 진단, 사용자 확인, 적용, 검증 |
|
|
23
|
+
| `check` | 진단만. 아무것도 바꾸지 않음 |
|
|
24
|
+
| `revert` | 패치와 고정을 되돌림. 사용자가 명시했을 때만 |
|
|
25
|
+
|
|
26
|
+
## 규칙
|
|
27
|
+
|
|
28
|
+
- 이 작업은 사용자 설정 폴더(`~/.claude`)의 플러그인 캐시를 고치는 설정 변경이다. 서브에이전트에 맡기지 않고 메인 세션에서 직접 실행하고 직접 검증한다.
|
|
29
|
+
- 적용은 `--no-update` 로만 실행한다. 인자 없는 apply 는 쓰지 않는다. 활성 OMC 를 도구의 `TARGET_VERSION`(5.3.0)으로 내리고, `revert` 로도 버전이 돌아오지 않는다.
|
|
30
|
+
- 도구가 출력하는 `인자 없이 다시 실행하십시오.` 문구는 따르지 않는다. 이 스킬에서는 항상 `--no-update` 를 붙인다.
|
|
31
|
+
- 패치 규칙은 OMC 훅 코드의 모양에 맞춰져 있다. 처음 보는 OMC 버전이면 반드시 `check` 를 먼저 실행한다.
|
|
32
|
+
- `실패 N건` 이 1건 이상이면 멈추고 출력을 그대로 보고한다. 문법 오류가 난 파일은 도구가 원본으로 자동 롤백한다.
|
|
33
|
+
- 판정은 출력 문구로 한다. 종료 코드 0 만으로 완료라고 보고하지 않는다.
|
|
34
|
+
- 캐시에는 정션이나 심볼릭 링크로 이어진 버전 폴더가 있을 수 있다(예: 옛 버전 폴더가 새 버전을 가리킴). 이때 한 번의 변경이 두 버전 폴더에 함께 보인다. 정상이다.
|
|
35
|
+
- 도구는 `os.homedir()/.claude` 를 쓴다. `CLAUDE_CONFIG_DIR` 로 설정 폴더를 옮겼다면 이 도구는 그 폴더를 보지 않는다.
|
|
36
|
+
- 출력의 origin 에 `https://사용자:토큰@...` 처럼 자격증명이 들어 있으면, 보고할 때 `//***@` 로 가린다. 출력을 그대로 보고하라는 단계에서도 이 가림은 지킨다. 그 URL 은 `PINNED` 에도 평문으로 남으니 사용자에게 알린다.
|
|
37
|
+
|
|
38
|
+
## 절차
|
|
39
|
+
|
|
40
|
+
스크립트 경로는 `<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs` 이다. Node 18 이상과 `git` 이 필요하다. 모든 명령은 OS 구분 없이 같다.
|
|
41
|
+
|
|
42
|
+
### 1. 진단
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --check --no-update
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `활성 버전: X (installed_plugins.json)` 줄로 실제 활성 버전을 확인한다.
|
|
49
|
+
- 그 줄이 `(추정: ...)` 로 끝나면 도구가 `installed_plugins.json` 에서 활성 버전을 찾지 못한 것이다. 적용하지 않고 출력을 그대로 보고하고 끝낸다. 추정한 폴더를 패치하면 실제 활성 버전은 그대로 남는데 `check` 는 적용된 것으로 보일 수 있다.
|
|
50
|
+
- `패치 필요 N건` 은 설치된 모든 버전 폴더를 합친 수다. 판정은 맨 아래 `결과:` 줄로 한다.
|
|
51
|
+
- `결과: 모두 적용된 상태입니다.` 이면 4단계 검증으로 건너뛴다.
|
|
52
|
+
- `참고: 비활성 버전 ...` 은 지금 쓰지 않는 버전이라 판정에 들어가지 않는다.
|
|
53
|
+
- 인자가 `check` 이면 여기서 결과만 보고하고 끝낸다.
|
|
54
|
+
|
|
55
|
+
### 2. 확인 (적용 전 필수)
|
|
56
|
+
|
|
57
|
+
`--check` 결과를 보여 주고 사용자에게 적용해도 되는지 묻는다. 묻기 전에 아래를 알린다.
|
|
58
|
+
- 활성 OMC 버전의 `scripts/*.mjs` 와 `hooks/hooks.json` 을 고친다. 원본은 `.omcbak` 로 남는다.
|
|
59
|
+
- OMC 마켓플레이스 `origin` 을 `omc-pinned://` 로 바꾼다. OMC 가 더 이상 자동 업데이트되지 않는다. 되돌리기는 `revert`.
|
|
60
|
+
- 승인이 없으면 적용하지 않는다.
|
|
61
|
+
|
|
62
|
+
### 3. 적용
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --no-update
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- `패치 N건 / 실패 0건`, `[3/4]` 의 PostToolUse 제거, `[4/4]` 의 고정 결과를 확인한다.
|
|
69
|
+
- 실패가 1건 이상이면 멈추고 출력 그대로 보고한다.
|
|
70
|
+
|
|
71
|
+
### 4. 검증 (세 가지 모두)
|
|
72
|
+
|
|
73
|
+
1. 다시 진단한다. `결과: 모두 적용된 상태입니다.` 가 나와야 한다.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --check --no-update
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
2. 패치된 훅의 문법을 확인한다. `*.omcbak` 와 짝인 파일마다 `node --check` 를 실행하고 종료 코드 0 을 확인한다.
|
|
80
|
+
|
|
81
|
+
bash:
|
|
82
|
+
```bash
|
|
83
|
+
R=$(node -e 'const j=require(require("path").join(require("os").homedir(),".claude/plugins/installed_plugins.json"));console.log(j.plugins["oh-my-claudecode@omc"][0].installPath)')
|
|
84
|
+
for b in "$R"/scripts/*.omcbak; do [ -e "$b" ] || continue; f="${b%.omcbak}"; node --check "$f"; rc=$?; echo "$(basename "$f") syntax=$rc"; done
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
PowerShell:
|
|
88
|
+
```powershell
|
|
89
|
+
$r = (Get-Content "$env:USERPROFILE\.claude\plugins\installed_plugins.json" -Raw | ConvertFrom-Json).plugins.'oh-my-claudecode@omc'[0].installPath
|
|
90
|
+
Get-ChildItem "$r\scripts\*.omcbak" | % { $f = $_.FullName -replace '\.omcbak$',''; node --check $f; "{0} syntax={1}" -f (Split-Path $f -Leaf), $LASTEXITCODE }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
3. 패치된 훅 하나를 플러그인의 `scripts/run.cjs` 로 실제 실행한다. 명령마다 새 셸일 수 있으니 경로를 다시 구한다.
|
|
94
|
+
`OMC_NOTIFY=0` 은 이 시험 실행이 사용자의 알림 채널로 `session-idle` 알림을 보내지 않게 한다.
|
|
95
|
+
|
|
96
|
+
bash:
|
|
97
|
+
```bash
|
|
98
|
+
R=$(node -e 'const j=require(require("path").join(require("os").homedir(),".claude/plugins/installed_plugins.json"));console.log(j.plugins["oh-my-claudecode@omc"][0].installPath)')
|
|
99
|
+
echo '{"session_id":"smoke-test","cwd":"/nonexistent","hook_event_name":"Stop"}' | CLAUDE_PLUGIN_ROOT="$R" OMC_NOTIFY=0 node "$R/scripts/run.cjs" "$R/scripts/persistent-mode.mjs"; echo "exit=$?"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
PowerShell:
|
|
103
|
+
```powershell
|
|
104
|
+
$r = (Get-Content "$env:USERPROFILE\.claude\plugins\installed_plugins.json" -Raw | ConvertFrom-Json).plugins.'oh-my-claudecode@omc'[0].installPath
|
|
105
|
+
$env:CLAUDE_PLUGIN_ROOT = $r; $env:OMC_NOTIFY = '0'
|
|
106
|
+
'{"session_id":"smoke-test","cwd":"C:\\nonexistent","hook_event_name":"Stop"}' | node "$r\scripts\run.cjs" "$r\scripts\persistent-mode.mjs"; "exit=$LASTEXITCODE"
|
|
107
|
+
Remove-Item Env:CLAUDE_PLUGIN_ROOT, Env:OMC_NOTIFY
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`{"continue":true,...}` 형태의 출력과 종료 코드 0 이면 정상. 훅 이름이 없으면 `*.omcbak` 와 짝인 다른 훅으로 바꾼다.
|
|
111
|
+
|
|
112
|
+
4. 마켓플레이스 고정을 확인한다. `omc-pinned://` 로 시작하면 고정된 것이다.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
git -C "$HOME/.claude/plugins/marketplaces/omc" remote get-url origin
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
PowerShell 에서는 `$HOME` 대신 `$env:USERPROFILE` 을 쓴다.
|
|
119
|
+
|
|
120
|
+
### 5. 보고
|
|
121
|
+
|
|
122
|
+
- 활성 버전, 패치 건수, PostToolUse 제거 목록, 고정 상태
|
|
123
|
+
- 훅 스크립트 패치는 바로 적용된다. 훅이 호출될 때마다 파일을 새로 읽기 때문이다.
|
|
124
|
+
- PostToolUse 제거는 `hooks.json` 변경이라 각 세션을 다시 시작해야 적용된다.
|
|
125
|
+
- Windows 에서 이미 쌓인 커널 풀은 재부팅으로만 회수된다. 재부팅은 사용자가 직접 한다.
|
|
126
|
+
|
|
127
|
+
## 되돌리기 (인자 `revert`)
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --revert
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- `.omcbak` 백업으로 훅 스크립트와 `hooks.json` 을 되돌리고 마켓플레이스 `origin` 을 원래 URL 로 복구한다.
|
|
134
|
+
- 되돌린 뒤 Claude Code 를 다시 시작해야 반영된다.
|
|
135
|
+
- `이 도구가 만들지 않은 수정이 남아 있습니다` 가 나오면 백업이 없는 손 수정이다. 출력에 적힌 복구 명령을 그대로 보고하고, 사용자 승인 없이 실행하지 않는다.
|
|
136
|
+
|
|
137
|
+
## 함정
|
|
138
|
+
|
|
139
|
+
- 도구의 `TARGET_VERSION`(5.3.0)은 소스에 고정돼 있다. 인자 없는 apply 만 이 값을 쓰고, 이 스킬은 그 apply 를 쓰지 않는다.
|
|
140
|
+
- OMC 가 훅 코드 구조를 바꾸면 패치 대상이 0건으로 나오거나 변환이 실패할 수 있다. 이때 `실패 N건` 과 `변환 실패` 를 그대로 보고한다.
|
|
141
|
+
- 고정을 풀지 않고 OMC 를 올리려면 먼저 `revert` 한다. 그렇지 않으면 마켓플레이스 fetch 가 계속 실패한다.
|
|
142
|
+
- 고정 기록(`PINNED`)은 `~/.claude/omc-local-patches/PINNED` 에 생긴다. 기록일 뿐이라 지워도 고정은 풀리지 않고, 이미 고정된 상태에서는 다시 만들어지지 않는다. `revert` 는 origin 값에서 원래 URL 을 되찾는다.
|
|
143
|
+
- 도구의 주석과 출력 문구는 한글이다.
|