@kaydash9999/banker-plugins 0.23.3 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +56 -0
- package/README.md +14 -6
- package/codex/manifest.json +6 -2
- package/codex/skills/teamwork/scripts/teamwork.mjs +3 -3
- package/codex/transform-matrix.md +6 -2
- package/package.json +1 -1
- package/skills/build-testbox/SKILL.md +213 -0
- package/skills/build-testbox/scripts/args.mjs +156 -0
- package/skills/build-testbox/scripts/browser-cell.mjs +181 -0
- package/skills/build-testbox/scripts/browsers.mjs +164 -0
- package/skills/build-testbox/scripts/containers.mjs +237 -0
- package/skills/build-testbox/scripts/detect.mjs +166 -0
- package/skills/build-testbox/scripts/proc.mjs +91 -0
- package/skills/build-testbox/scripts/report.mjs +109 -0
- package/skills/build-testbox/scripts/setup.mjs +144 -0
- package/skills/build-testbox/scripts/testbox.mjs +148 -0
- package/skills/git-init/SKILL.md +152 -0
- package/skills/git-init/scripts/git-init.mjs +440 -0
- package/skills/git-init/scripts/marker-block.mjs +321 -0
- package/skills/git-init/scripts/payload-scan.mjs +186 -0
- package/skills/init-dir/SKILL.md +172 -0
- package/skills/init-dir/scripts/init-dir.mjs +308 -0
- package/skills/init-dir/scripts/marker-block.mjs +321 -0
- package/skills/init-dir/scripts/templates.mjs +100 -0
- package/skills/init-dir/templates/KB/README.md +15 -0
- package/skills/init-dir/templates/docs/README.md +14 -0
- package/skills/init-dir/templates/logs/README.md +14 -0
- package/skills/init-dir/templates/logs/gitignore +3 -0
- package/skills/init-dir/templates/mockup/README.md +7 -0
- package/skills/init-dir/templates/scripts/README.md +39 -0
- package/skills/init-dir/templates/scripts/lib/service.mjs +596 -0
- package/skills/init-dir/templates/scripts/restart.cmd +13 -0
- package/skills/init-dir/templates/scripts/restart.sh +10 -0
- package/skills/init-dir/templates/scripts/service.conf +5 -0
- package/skills/init-dir/templates/scripts/start.cmd +13 -0
- package/skills/init-dir/templates/scripts/start.sh +10 -0
- package/skills/init-dir/templates/scripts/status.cmd +13 -0
- package/skills/init-dir/templates/scripts/status.sh +10 -0
- package/skills/init-dir/templates/scripts/stop.cmd +13 -0
- package/skills/init-dir/templates/scripts/stop.sh +10 -0
- package/skills/init-dir/templates/tests/README.md +15 -0
- package/skills/init-dir/templates/tests/run.cmd +13 -0
- package/skills/init-dir/templates/tests/run.mjs +160 -0
- package/skills/init-dir/templates/tests/run.sh +10 -0
- package/skills/init-dir/templates/tests/tests.conf +7 -0
- package/skills/init-dir/templates/work/README.md +8 -0
- package/skills/progress-mon/README.md +11 -8
- package/skills/progress-mon/SKILL.md +53 -16
- package/skills/progress-mon/assets/fixture/README.md +1 -1
- package/skills/progress-mon/assets/fixture/adr.json +35 -0
- package/skills/progress-mon/assets/fixture/arch.json +1 -1
- package/skills/progress-mon/assets/fixture/flow.json +2 -2
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +1 -1
- package/skills/progress-mon/assets/fixture/variants/legacy-init/rules.json +6 -0
- package/skills/progress-mon/assets/fixture/variants/legacy-init/state.json +5 -0
- package/skills/progress-mon/references/adr.md +184 -0
- package/skills/progress-mon/references/authoring.md +13 -5
- package/skills/progress-mon/scripts/pm-adr-view.mjs +101 -0
- package/skills/progress-mon/scripts/pm-adr.mjs +515 -0
- package/skills/progress-mon/scripts/pm-check.mjs +59 -22
- package/skills/progress-mon/scripts/pm-data.mjs +1 -1
- package/skills/progress-mon/scripts/pm-html.mjs +1 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +113 -28
- package/skills/progress-mon/scripts/pm-render.mjs +6 -4
- package/skills/progress-mon/scripts/pm-server.mjs +2 -1
- package/skills/progress-mon/scripts/pm-sources.mjs +10 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +3 -2
- package/skills/tune-tmux/SKILL.md +235 -0
- package/skills/tune-tmux/scripts/app-notes.mjs +38 -0
- package/skills/tune-tmux/scripts/marker-block.mjs +321 -0
- package/skills/tune-tmux/scripts/psmux-caps.json +71 -0
- package/skills/tune-tmux/scripts/tmux-config.mjs +251 -0
- package/skills/tune-tmux/scripts/tune-tmux.mjs +284 -0
- package/skills/ultra-chain/SKILL.md +52 -22
- package/skills/ultra-chain/references/chain-ledger.mjs +93 -11
- package/skills/ultra-chain/references/chain-report.mjs +13 -2
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Container OS cells (docker or podman) and the macOS cell (Tart on Apple Silicon) of build-testbox.
|
|
2
|
+
// A container cell: image inspect (no pull), create, cp a copy of the source to /src, start -a the command, rm -f.
|
|
3
|
+
// No bind mount (SELinux relabel, Windows paths). Containers are named banker-testbox-<random>-<os> and removed on
|
|
4
|
+
// success, failure, time-out and signal.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { copyFileSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
9
|
+
import { imageFor } from "./args.mjs";
|
|
10
|
+
import { pool } from "./browser-cell.mjs";
|
|
11
|
+
import { firstLine, runProc, which } from "./proc.mjs";
|
|
12
|
+
import { cellOf } from "./report.mjs";
|
|
13
|
+
|
|
14
|
+
export const NAME_PREFIX = "banker-testbox";
|
|
15
|
+
const iso = () => new Date().toISOString();
|
|
16
|
+
export const randomTag = () => Math.random().toString(16).slice(2, 10).padEnd(8, "0");
|
|
17
|
+
|
|
18
|
+
export async function detectEngines(env = process.env) {
|
|
19
|
+
const engines = {};
|
|
20
|
+
for (const name of ["docker", "podman"]) {
|
|
21
|
+
const path = which(name, env);
|
|
22
|
+
if (!path) { engines[name] = { present: false }; continue; }
|
|
23
|
+
const r = await runProc(path, ["--version"], { timeoutMs: 15000, env });
|
|
24
|
+
engines[name] = { present: true, path, version: firstLine(r.stdout) || firstLine(r.stderr) };
|
|
25
|
+
if (name === "docker") engines.docker.podmanEmulation = /podman/i.test(`${r.stdout}\n${r.stderr}`);
|
|
26
|
+
}
|
|
27
|
+
return engines;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// auto: a real docker first, then podman, then a docker that is podman's emulation.
|
|
31
|
+
function autoEngine(engines) {
|
|
32
|
+
if (engines.docker.present && !engines.docker.podmanEmulation) return "docker";
|
|
33
|
+
if (engines.podman.present) return "podman";
|
|
34
|
+
return engines.docker.present ? "docker" : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function selectEngine(pref, engines) {
|
|
38
|
+
const name = pref && pref !== "auto" ? pref : autoEngine(engines);
|
|
39
|
+
const e = name ? engines[name] : null;
|
|
40
|
+
if (!e || !e.present) return null;
|
|
41
|
+
return { name, bin: e.path, kind: name === "podman" || e.podmanEmulation ? "podman" : "docker" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Engine errors: a permission error (a sandbox, a socket without access) or an engine that cannot be reached.
|
|
45
|
+
export function engineReason(text) {
|
|
46
|
+
const t = String(text || "");
|
|
47
|
+
if (/permission denied|operation not permitted|\bEACCES\b|\bEPERM\b/i.test(t)) return "sandbox";
|
|
48
|
+
if (/cannot connect to|daemon running|connection refused/i.test(t)) return "needs-engine";
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function imageState(engine, image, env = process.env) {
|
|
53
|
+
const r = await runProc(engine.bin, ["image", "inspect", "--format", "{{.Id}}", image], { timeoutMs: 60000, env });
|
|
54
|
+
if (r.code === 0) return { present: true };
|
|
55
|
+
const text = `${r.stderr}\n${r.stdout}\n${r.error || ""}`;
|
|
56
|
+
return { present: false, reason: engineReason(text) || (r.timedOut ? "timeout" : "needs-image"), detail: firstLine(r.stderr) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const rmArgs = (engine, name) => (engine.kind === "podman" ? ["rm", "-f", "-t", "0", name] : ["rm", "-f", name]);
|
|
60
|
+
|
|
61
|
+
export function removeAllSync(engine, names) {
|
|
62
|
+
for (const name of names) spawnSync(engine.bin, rmArgs(engine, name), { stdio: "ignore", timeout: 30000 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── source copy ────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function gitFiles(src) {
|
|
68
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_/i.test(k)));
|
|
69
|
+
const r = spawnSync("git", ["-C", src, "ls-files", "-z", "-co", "--exclude-standard"], { encoding: "utf8", env, maxBuffer: 256 * 1024 * 1024 });
|
|
70
|
+
return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".testbox"]);
|
|
74
|
+
|
|
75
|
+
function walkFiles(root, rel = "", acc = []) {
|
|
76
|
+
for (const e of readdirSync(join(root, rel), { withFileTypes: true })) {
|
|
77
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
78
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
79
|
+
if (e.isDirectory()) walkFiles(root, r, acc);
|
|
80
|
+
else acc.push(r);
|
|
81
|
+
}
|
|
82
|
+
return acc;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function copyEntry(src, dest, rel) {
|
|
86
|
+
const from = join(src, rel);
|
|
87
|
+
const to = join(dest, rel);
|
|
88
|
+
let st;
|
|
89
|
+
try { st = lstatSync(from); } catch { return false; }
|
|
90
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
91
|
+
if (st.isSymbolicLink()) symlinkSync(readlinkSync(from), to);
|
|
92
|
+
else if (st.isFile()) copyFileSync(from, to);
|
|
93
|
+
else return false;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The files to put in /src: a git checkout gives its tracked and untracked-not-ignored files (working tree
|
|
98
|
+
// contents); any other folder gives everything but node_modules, .git and .testbox. The result folder is left out.
|
|
99
|
+
export function sourceFiles(src, out) {
|
|
100
|
+
const outRel = out ? relative(src, out).split(sep).join("/") : "";
|
|
101
|
+
const skipOut = outRel && !outRel.startsWith("..") && !isAbsolute(outRel);
|
|
102
|
+
const files = gitFiles(src) || walkFiles(src);
|
|
103
|
+
return files.filter((f) => !f.startsWith(".testbox/") && !(skipOut && (f === outRel || f.startsWith(`${outRel}/`))));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stageSource(src, out) {
|
|
107
|
+
const dir = mkdtempSync(join(tmpdir(), `${NAME_PREFIX}-src-`));
|
|
108
|
+
try {
|
|
109
|
+
let count = 0;
|
|
110
|
+
for (const rel of sourceFiles(src, out)) if (copyEntry(src, dir, rel)) count += 1;
|
|
111
|
+
return { dir, count };
|
|
112
|
+
} catch (e) {
|
|
113
|
+
rmSync(dir, { recursive: true, force: true });
|
|
114
|
+
return { error: firstLine(e.message) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── one container cell ─────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export function startOutcome(r) {
|
|
121
|
+
if (r.timedOut) return ["unverified", "timeout"];
|
|
122
|
+
if (r.code === 0) return ["pass", ""];
|
|
123
|
+
if (r.code === 127) return ["unverified", "needs-tool"];
|
|
124
|
+
if (r.code === null) return ["unverified", "launch-failed"];
|
|
125
|
+
if (r.code === 125 && engineReason(r.out)) return ["unverified", engineReason(r.out)];
|
|
126
|
+
return ["fail", `exit-${r.code}`];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function engineStep(ctx, args, timeoutMs) {
|
|
130
|
+
const r = await runProc(ctx.engine.bin, args, { timeoutMs, env: ctx.env });
|
|
131
|
+
if (r.code === 0) return { r };
|
|
132
|
+
const text = `${r.stderr}\n${r.stdout}\n${r.error || ""}`;
|
|
133
|
+
return { r, reason: engineReason(text) || (r.timedOut ? "timeout" : "launch-failed"), detail: firstLine(r.stderr) || r.error };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function networkMode(ctx, name) {
|
|
137
|
+
const r = await runProc(ctx.engine.bin, ["inspect", "--format", "{{.HostConfig.NetworkMode}}", name], { timeoutMs: 30000, env: ctx.env });
|
|
138
|
+
return (r.code === 0 && r.stdout.trim()) || ctx.o.network || "default";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function writeLog(file, header, body) {
|
|
142
|
+
writeFileSync(file, `${header.map((h) => `# ${h}`).join("\n")}\n${body}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function execContainer(ctx, base, stageDir) {
|
|
146
|
+
const { o, engine } = ctx;
|
|
147
|
+
const name = `${NAME_PREFIX}-${randomTag()}-${base.target}`;
|
|
148
|
+
ctx.tracker.containers.add(name);
|
|
149
|
+
try {
|
|
150
|
+
const net = o.network ? ["--network", o.network] : [];
|
|
151
|
+
const created = await engineStep(ctx, ["create", "--name", name, "-w", "/src", ...net, base.image, "sh", "-c", o.cmd], 120000);
|
|
152
|
+
if (created.reason) return cellOf(base, "unverified", created.reason, { detail: created.detail });
|
|
153
|
+
const network = await networkMode(ctx, name);
|
|
154
|
+
const copied = await engineStep(ctx, ["cp", `${stageDir}${sep}.`, `${name}:/src`], 600000);
|
|
155
|
+
if (copied.reason) return cellOf({ ...base, network }, "unverified", copied.reason, { detail: copied.detail });
|
|
156
|
+
const r = await runProc(engine.bin, ["start", "-a", name], { timeoutMs: o.timeout * 1000, env: ctx.env });
|
|
157
|
+
const log = join(ctx.out, `os-${base.target}.log`);
|
|
158
|
+
writeLog(log, [`image ${base.image}`, `engine ${engine.name}`, `network ${network}`, `cmd ${o.cmd}`, `exit ${r.timedOut ? "timeout" : r.code}`], r.out);
|
|
159
|
+
const [status, reason] = startOutcome(r);
|
|
160
|
+
return cellOf({ ...base, container: name, network, log, exitCode: r.code }, status, reason);
|
|
161
|
+
} finally {
|
|
162
|
+
await runProc(engine.bin, rmArgs(engine, name), { timeoutMs: 60000, env: ctx.env });
|
|
163
|
+
ctx.tracker.containers.delete(name);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function runContainerCell(ctx, os) {
|
|
168
|
+
const base = { id: `os:${os}`, group: "os", target: os, image: imageFor(ctx.o, os), engine: ctx.engine ? ctx.engine.name : null, startedAt: iso() };
|
|
169
|
+
if (!ctx.engine) return cellOf(base, "unverified", "needs-engine");
|
|
170
|
+
const img = await imageState(ctx.engine, base.image, ctx.env);
|
|
171
|
+
if (!img.present) return cellOf(base, "unverified", img.reason, { detail: img.detail });
|
|
172
|
+
const stage = await ctx.stage();
|
|
173
|
+
if (stage.error) return cellOf(base, "unverified", "launch-failed", { detail: `소스 사본 실패: ${stage.error}` });
|
|
174
|
+
return execContainer(ctx, base, stage.dir);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── macOS (Tart) ───────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
async function tartVms(tart, env) {
|
|
180
|
+
const r = await runProc(tart, ["list", "--format", "json"], { timeoutMs: 30000, env });
|
|
181
|
+
if (r.code !== 0) return [];
|
|
182
|
+
try {
|
|
183
|
+
return JSON.parse(r.stdout).map((v) => v.Name || v.name).filter(Boolean);
|
|
184
|
+
} catch {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Tart runs only on Apple Silicon Macs (the Apple SLA allows macOS VMs on Apple hardware). hw.model alone is not
|
|
190
|
+
// trusted: an x86_64 macOS VM on QEMU reports a Mac model too.
|
|
191
|
+
// The tart binary and VM to use: { tart, vm }, or { cell } that says why there is none.
|
|
192
|
+
async function macosVm(ctx, base) {
|
|
193
|
+
const { host, o, env } = ctx;
|
|
194
|
+
if (host.platform !== "darwin") return { cell: cellOf(base, "unsupported", "apple-sla") };
|
|
195
|
+
if (!host.appleSilicon) return { cell: cellOf(base, "unsupported", "tart-needs-apple-silicon") };
|
|
196
|
+
const tart = which("tart", env);
|
|
197
|
+
if (!tart) return { cell: cellOf(base, "unverified", "needs-tart") };
|
|
198
|
+
const vms = await tartVms(tart, env);
|
|
199
|
+
const vm = o.tartVm ? vms.find((v) => v === o.tartVm) : vms[0];
|
|
200
|
+
if (!vm) return { cell: cellOf(base, "unverified", "needs-vm", { detail: `로컬 VM: ${vms.join(", ") || "없음"}` }) };
|
|
201
|
+
return { tart, vm };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function macosCell(ctx) {
|
|
205
|
+
const { o, env } = ctx;
|
|
206
|
+
const base = { id: "os:macos", group: "os", target: "macos", engine: "tart", startedAt: iso() };
|
|
207
|
+
const found = await macosVm(ctx, base);
|
|
208
|
+
if (found.cell) return found.cell;
|
|
209
|
+
const { tart, vm } = found;
|
|
210
|
+
const help = await runProc(tart, ["exec", "--help"], { timeoutMs: 15000, env });
|
|
211
|
+
if (help.code !== 0) return cellOf({ ...base, vm }, "unverified", "tart-exec-unsupported", { detail: firstLine(help.stderr) });
|
|
212
|
+
const r = await runProc(tart, ["exec", vm, "--", "sh", "-c", o.cmd], { timeoutMs: o.timeout * 1000, env });
|
|
213
|
+
const log = join(ctx.out, "os-macos.log");
|
|
214
|
+
writeLog(log, [`tart vm ${vm}`, `cmd ${o.cmd}`, `exit ${r.timedOut ? "timeout" : r.code}`], r.out);
|
|
215
|
+
if (r.code !== 0 && !r.timedOut && /not running/i.test(r.out)) return cellOf({ ...base, vm, log }, "unverified", "needs-vm");
|
|
216
|
+
const [status, reason] = startOutcome(r);
|
|
217
|
+
return cellOf({ ...base, vm, log, exitCode: r.code }, status, reason);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// All requested OS cells, --jobs at a time. The source copy is made once, on first need, and removed at the end.
|
|
221
|
+
export async function runOsGroup(ctx) {
|
|
222
|
+
let staged = null;
|
|
223
|
+
const stage = async () => {
|
|
224
|
+
if (!staged) staged = stageSource(ctx.o.src, ctx.out);
|
|
225
|
+
if (staged.dir) ctx.tracker.temps.add(staged.dir);
|
|
226
|
+
return staged;
|
|
227
|
+
};
|
|
228
|
+
const c = { ...ctx, stage };
|
|
229
|
+
try {
|
|
230
|
+
return await pool(ctx.o.os, ctx.o.jobs, (os) => (os === "macos" ? macosCell(c) : runContainerCell(c, os)));
|
|
231
|
+
} finally {
|
|
232
|
+
if (staged && staged.dir) {
|
|
233
|
+
rmSync(staged.dir, { recursive: true, force: true });
|
|
234
|
+
ctx.tracker.temps.delete(staged.dir);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// detect: host, Playwright, browsers (a real headless start, not a file check), container engines, local images and
|
|
2
|
+
// Tart. Downloads nothing.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { homedir, release } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { BROWSERS, DEFAULT_IMAGES, imageFor } from "./args.mjs";
|
|
9
|
+
import { importPlaywright, probeBrowser } from "./browser-cell.mjs";
|
|
10
|
+
import { detectEngines, imageState, selectEngine } from "./containers.mjs";
|
|
11
|
+
import { firstLine, runProc, which } from "./proc.mjs";
|
|
12
|
+
|
|
13
|
+
function sysctl(key) {
|
|
14
|
+
const bin = existsSync("/usr/sbin/sysctl") ? "/usr/sbin/sysctl" : "sysctl";
|
|
15
|
+
const r = spawnSync(bin, ["-n", key], { encoding: "utf8", timeout: 10000 });
|
|
16
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// appleSilicon: an arm64 Mac (also when this Node runs under Rosetta). hw.model is reported but not trusted.
|
|
20
|
+
export function hostInfo() {
|
|
21
|
+
const host = { platform: process.platform, arch: process.arch, release: release(), node: process.version, appleSilicon: false, hwModel: null };
|
|
22
|
+
if (host.platform === "darwin") {
|
|
23
|
+
host.hwModel = sysctl("hw.model");
|
|
24
|
+
host.appleSilicon = host.arch === "arm64" || sysctl("hw.optional.arm64") === "1";
|
|
25
|
+
}
|
|
26
|
+
return host;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveFrom(base) {
|
|
30
|
+
try { return createRequire(join(base, "noop.js")).resolve("playwright"); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// npm's global root, asked from the home folder so a project's npm.cmd or .npmrc is not used.
|
|
34
|
+
function globalRoot() {
|
|
35
|
+
const opts = { encoding: "utf8", cwd: homedir(), timeout: 30000 };
|
|
36
|
+
const r = process.platform === "win32" ? spawnSync("npm.cmd root -g", { ...opts, shell: true }) : spawnSync("npm", ["root", "-g"], opts);
|
|
37
|
+
return String(r.stdout || "").trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function packageDir(entry, name) {
|
|
41
|
+
let dir = dirname(entry);
|
|
42
|
+
while (dir !== dirname(dir)) {
|
|
43
|
+
const pj = join(dir, "package.json");
|
|
44
|
+
if (existsSync(pj) && JSON.parse(readFileSync(pj, "utf8")).name === name) return dir;
|
|
45
|
+
dir = dirname(dir);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function coreDirOf(pwDir) {
|
|
51
|
+
try { return dirname(createRequire(join(pwDir, "package.json")).resolve("playwright-core/package.json")); } catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const PLAYWRIGHT_DIR_ENV = "BUILD_TESTBOX_PLAYWRIGHT_DIR";
|
|
55
|
+
|
|
56
|
+
// A folder given by --playwright-dir or BUILD_TESTBOX_PLAYWRIGHT_DIR (the folder that holds node_modules), or null.
|
|
57
|
+
const givenDir = (dir, env) => dir || env[PLAYWRIGHT_DIR_ENV] || null;
|
|
58
|
+
|
|
59
|
+
// { entry, from, base } or { missing } (where it looked). Order: a given folder (--playwright-dir, then
|
|
60
|
+
// BUILD_TESTBOX_PLAYWRIGHT_DIR; no fallback when it lacks Playwright), the project only with --playwright-from-project
|
|
61
|
+
// (loading it runs that project's code), then the global npm root. A bare import is not used.
|
|
62
|
+
function findEntry(fromProject, cwd, dir, env) {
|
|
63
|
+
const base = givenDir(dir, env);
|
|
64
|
+
if (base) {
|
|
65
|
+
const entry = resolveFrom(base);
|
|
66
|
+
return entry ? { entry, from: "dir", base } : { missing: `--playwright-dir 또는 ${PLAYWRIGHT_DIR_ENV}: ${base}` };
|
|
67
|
+
}
|
|
68
|
+
const projectEntry = fromProject ? resolveFrom(cwd) : null;
|
|
69
|
+
if (projectEntry) return { entry: projectEntry, from: "project", base: cwd };
|
|
70
|
+
const root = globalRoot();
|
|
71
|
+
const entry = root ? resolveFrom(root) : null;
|
|
72
|
+
return entry ? { entry, from: "global", base: null } : { missing: `${fromProject ? "프로젝트, " : ""}전역 npm root` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// { info: { entry, dir, coreDir, version, from, base } } or { error }. from is dir, project or global; base is the
|
|
76
|
+
// folder npx runs in for that Playwright (null for global).
|
|
77
|
+
export function locatePlaywright(fromProject = false, cwd = process.cwd(), dir = null, env = process.env) {
|
|
78
|
+
const found = findEntry(fromProject, cwd, dir, env);
|
|
79
|
+
if (found.missing) return { error: `playwright 패키지가 없습니다(${found.missing})` };
|
|
80
|
+
const pkg = packageDir(found.entry, "playwright");
|
|
81
|
+
const at = { from: found.from, base: found.base };
|
|
82
|
+
if (!pkg) return { info: { entry: found.entry, dir: null, coreDir: null, version: null, ...at } };
|
|
83
|
+
const version = JSON.parse(readFileSync(join(pkg, "package.json"), "utf8")).version;
|
|
84
|
+
const entry = existsSync(join(pkg, "index.mjs")) ? join(pkg, "index.mjs") : found.entry;
|
|
85
|
+
return { info: { entry, dir: pkg, coreDir: coreDirOf(pkg), version, ...at } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function loadPlaywright(fromProject = false, dir = null) {
|
|
89
|
+
const loc = locatePlaywright(fromProject, process.cwd(), dir);
|
|
90
|
+
if (loc.error) return loc;
|
|
91
|
+
try {
|
|
92
|
+
return { ...loc, pw: await importPlaywright(loc.info.entry) };
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { info: loc.info, error: `playwright 를 불러오지 못했습니다: ${firstLine(e.message)}` };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function browsersOf(pw) {
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const name of BROWSERS) out[name] = pw ? await probeBrowser(pw, name) : { runnable: false, reason: "needs-playwright", variants: {} };
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function imagesOf(o, engine, pwVersion) {
|
|
105
|
+
const names = [...new Set([...Object.keys(DEFAULT_IMAGES), ...(o.os || []).filter((x) => x !== "macos")])];
|
|
106
|
+
const targets = names.map((os) => [os, imageFor(o, os)]).filter(([, img]) => img);
|
|
107
|
+
if (pwVersion) targets.push(["playwright", o.images.playwright || playwrightImage(pwVersion)]);
|
|
108
|
+
const images = {};
|
|
109
|
+
for (const [key, image] of targets) images[key] = engine ? { image, ...(await imageState(engine, image)) } : { image, present: false, reason: "needs-engine" };
|
|
110
|
+
return images;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const playwrightImage = (version) => `mcr.microsoft.com/playwright:v${version}-noble`;
|
|
114
|
+
|
|
115
|
+
async function tartOf(host) {
|
|
116
|
+
if (host.platform !== "darwin") return { present: false, reason: "apple-sla" };
|
|
117
|
+
if (!host.appleSilicon) return { present: false, reason: "tart-needs-apple-silicon" };
|
|
118
|
+
const tart = which("tart");
|
|
119
|
+
if (!tart) return { present: false, reason: "needs-tart" };
|
|
120
|
+
const r = await runProc(tart, ["list", "--format", "json"], { timeoutMs: 30000 });
|
|
121
|
+
let vms = [];
|
|
122
|
+
try { vms = JSON.parse(r.stdout).map((v) => v.Name || v.name).filter(Boolean); } catch { vms = []; }
|
|
123
|
+
return { present: true, path: tart, vms };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function detect(o) {
|
|
127
|
+
const host = hostInfo();
|
|
128
|
+
const loaded = await loadPlaywright(o.fromProject, o.playwrightDir);
|
|
129
|
+
const engines = await detectEngines();
|
|
130
|
+
const engine = selectEngine(o.engine, engines);
|
|
131
|
+
const playwright = loaded.info ? { found: !loaded.error, ...loaded.info, error: loaded.error } : { found: false, error: loaded.error };
|
|
132
|
+
return {
|
|
133
|
+
tool: "build-testbox",
|
|
134
|
+
host,
|
|
135
|
+
playwright,
|
|
136
|
+
browsers: await browsersOf(loaded.pw),
|
|
137
|
+
engines,
|
|
138
|
+
engine: engine ? { name: engine.name, kind: engine.kind, path: engine.bin } : null,
|
|
139
|
+
images: await imagesOf(o, engine, loaded.pw ? loaded.info.version : null),
|
|
140
|
+
tart: await tartOf(host),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function browserLine(name, b) {
|
|
145
|
+
if (b.runnable) return `- ${name}: 실행 가능(${b.variant})`;
|
|
146
|
+
return `- ${name}: 실행 불가(${b.reason})${b.detail ? ` ${b.detail.slice(0, 160)}` : ""}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hostLine(h) {
|
|
150
|
+
const mac = h.platform === "darwin" ? `, Apple Silicon ${h.appleSilicon ? "예" : "아니오"}, hw.model ${h.hwModel}` : "";
|
|
151
|
+
return `호스트: ${h.platform} ${h.arch}${mac}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function playwrightLine(p) {
|
|
155
|
+
return p.found ? `Playwright: ${p.version} (${p.from}, ${p.dir})` : `Playwright: 없음 (${p.error})`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function renderDetect(d) {
|
|
159
|
+
const lines = [hostLine(d.host), playwrightLine(d.playwright)];
|
|
160
|
+
for (const name of BROWSERS) lines.push(browserLine(name, d.browsers[name]));
|
|
161
|
+
const emu = d.engines.docker.present && d.engines.docker.podmanEmulation ? ", docker 는 podman 에뮬레이션" : "";
|
|
162
|
+
lines.push(`엔진: ${d.engine ? `${d.engine.name}${emu}` : "없음"}`);
|
|
163
|
+
for (const [key, img] of Object.entries(d.images)) lines.push(`- 이미지 ${key}: ${img.image} ${img.present ? "있음" : `없음(${img.reason})`}`);
|
|
164
|
+
lines.push(`Tart: ${d.tart.present ? `VM ${d.tart.vms.join(", ") || "없음"}` : d.tart.reason}`);
|
|
165
|
+
return `${lines.join("\n")}\n`;
|
|
166
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Child processes for build-testbox: PATH lookup (PATHEXT on Windows), a promise-returning runner with a time limit
|
|
2
|
+
// and a set of live children, so a signal handler can stop them all. No shell: arguments go to the program as given.
|
|
3
|
+
// On Windows a .cmd or .bat goes through cmd.exe with each argument quoted (Node refuses to spawn them directly).
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
6
|
+
import { delimiter, extname, isAbsolute, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
const LIVE = new Set();
|
|
9
|
+
const MAX_OUT = 8 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
function isExecutable(file) {
|
|
12
|
+
try {
|
|
13
|
+
if (!statSync(file).isFile()) return false;
|
|
14
|
+
if (process.platform !== "win32") accessSync(file, constants.X_OK);
|
|
15
|
+
return true;
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// On Windows a name without extension also tries each PATHEXT entry.
|
|
22
|
+
function candidates(name, env) {
|
|
23
|
+
const dirs = String(env.PATH || env.Path || "").split(delimiter).filter(Boolean);
|
|
24
|
+
const exts = process.platform === "win32" && !extname(name) ? String(env.PATHEXT || ".EXE;.CMD;.BAT").split(";").filter(Boolean) : [""];
|
|
25
|
+
return dirs.flatMap((dir) => exts.flatMap((ext) => [...new Set([name + ext.toLowerCase(), name + ext])].map((n) => join(dir, n))));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The full path of a program on PATH, or null.
|
|
29
|
+
export function which(name, env = process.env) {
|
|
30
|
+
if (isAbsolute(name)) return isExecutable(name) ? name : null;
|
|
31
|
+
return candidates(name, env).find(isExecutable) || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const quoteCmd = (a) => `"${String(a).replace(/"/g, '""')}"`;
|
|
35
|
+
|
|
36
|
+
function spawnArgs(file, args) {
|
|
37
|
+
if (process.platform === "win32" && /\.(cmd|bat)$/i.test(file)) {
|
|
38
|
+
const line = [file, ...args].map(quoteCmd).join(" ");
|
|
39
|
+
return [process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"${line}"`], { windowsVerbatimArguments: true }];
|
|
40
|
+
}
|
|
41
|
+
return [file, args, {}];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function collector() {
|
|
45
|
+
const parts = { stdout: "", stderr: "", out: "" };
|
|
46
|
+
const add = (key) => (chunk) => {
|
|
47
|
+
const s = chunk.toString("utf8");
|
|
48
|
+
if (parts.out.length < MAX_OUT) parts.out += s;
|
|
49
|
+
if (parts[key].length < MAX_OUT) parts[key] += s;
|
|
50
|
+
};
|
|
51
|
+
return { parts, add };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Runs a program and resolves { code, signal, stdout, stderr, out, timedOut, error }. Never rejects: a missing
|
|
55
|
+
// program is { code: null, error: "ENOENT" }. out holds stdout and stderr in arrival order.
|
|
56
|
+
export function runProc(cmd, args = [], opts = {}) {
|
|
57
|
+
const file = which(cmd, opts.env || process.env) || cmd;
|
|
58
|
+
const [bin, argv, extra] = spawnArgs(file, args);
|
|
59
|
+
const { parts, add } = collector();
|
|
60
|
+
return new Promise((resolveRun) => {
|
|
61
|
+
let timedOut = false;
|
|
62
|
+
let child;
|
|
63
|
+
try {
|
|
64
|
+
child = spawn(bin, argv, { cwd: opts.cwd, env: opts.env || process.env, stdio: ["ignore", "pipe", "pipe"], ...extra });
|
|
65
|
+
} catch (e) {
|
|
66
|
+
resolveRun({ code: null, signal: null, ...parts, timedOut, error: e.code || String(e.message) });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
LIVE.add(child);
|
|
70
|
+
const timer = opts.timeoutMs ? setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, opts.timeoutMs) : null;
|
|
71
|
+
child.stdout.on("data", add("stdout"));
|
|
72
|
+
child.stderr.on("data", add("stderr"));
|
|
73
|
+
let error = null;
|
|
74
|
+
child.on("error", (e) => { error = e.code || String(e.message); });
|
|
75
|
+
child.on("close", (code, signal) => {
|
|
76
|
+
if (timer) clearTimeout(timer);
|
|
77
|
+
LIVE.delete(child);
|
|
78
|
+
resolveRun({ code, signal, ...parts, timedOut, error });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Stops every child still running (used by the SIGINT and SIGTERM handlers).
|
|
84
|
+
export function killAll() {
|
|
85
|
+
for (const child of LIVE) {
|
|
86
|
+
try { child.kill("SIGKILL"); } catch { /* already gone */ }
|
|
87
|
+
}
|
|
88
|
+
LIVE.clear();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const firstLine = (s) => String(s || "").split(/\r?\n/).map((l) => l.trim()).find(Boolean) || "";
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Cell states, reasons, hints, exit codes and the written results (results.json, summary.md) for build-testbox.
|
|
2
|
+
// The screen table and summary.md come from one function, so they hold the same rows.
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const STATUSES = ["pass", "fail", "unverified", "unsupported"];
|
|
7
|
+
|
|
8
|
+
// Every reason a cell can end with. fail reasons are patterns (exit-<n>, http-<n>, page-error).
|
|
9
|
+
export const UNVERIFIED_REASONS = [
|
|
10
|
+
"needs-playwright", "needs-browser", "needs-image", "needs-engine", "needs-network", "needs-tool",
|
|
11
|
+
"needs-tart", "needs-vm", "tart-exec-unsupported", "host-deps", "launch-failed", "unknown-device", "timeout", "sandbox",
|
|
12
|
+
];
|
|
13
|
+
export const UNSUPPORTED_REASONS = ["apple-sla", "tart-needs-apple-silicon"];
|
|
14
|
+
export const FAIL_REASON = /^(exit-\d+|http-\d+|page-error)$/;
|
|
15
|
+
|
|
16
|
+
const SANDBOX_HINT = "권한 오류. Codex 는 컨테이너, 브라우저 실행을 sandbox_permissions: \"require_escalated\" 로 승인 요청한 뒤 다시 실행";
|
|
17
|
+
|
|
18
|
+
const HINTS = {
|
|
19
|
+
"needs-playwright": () => "Playwright 패키지 없음. setup-playwright 스킬로 설치",
|
|
20
|
+
"needs-browser": (c) => `브라우저 미설치. build-testbox setup --browsers ${c.browser || "<이름>"}`,
|
|
21
|
+
"needs-image": (c) => (c.group === "browser"
|
|
22
|
+
? "Playwright 공식 이미지가 로컬에 없음. build-testbox setup --playwright-image"
|
|
23
|
+
: `로컬 이미지 없음. build-testbox setup --images ${c.target}`),
|
|
24
|
+
"needs-engine": () => "docker, podman 없음(또는 엔진에 연결 못 함)",
|
|
25
|
+
"needs-network": () => "setup 원천에 닿지 못함",
|
|
26
|
+
"needs-tool": () => "컨테이너 안 명령 없음(종료 127). 이미지에 도구를 넣거나 --cmd 를 바꿈",
|
|
27
|
+
"needs-tart": () => "Tart 없음(Apple Silicon 의 macOS 13 이상, https://tart.run)",
|
|
28
|
+
"needs-vm": () => "Tart VM 없음이나 꺼짐. tart clone 으로 받기(약 25GB) 뒤 tart run, --tart-vm <이름>",
|
|
29
|
+
"tart-exec-unsupported": () => "이 Tart 판에 tart exec 없음. Tart 를 올림",
|
|
30
|
+
"host-deps": () => "브라우저 실행에 필요한 OS 라이브러리 없음. --browser-in-container 사용",
|
|
31
|
+
"launch-failed": () => "실행 실패. 로그 확인",
|
|
32
|
+
"unknown-device": () => "이 Playwright 판에 없는 기기 이름",
|
|
33
|
+
timeout: () => "시간 초과. --timeout 을 늘림",
|
|
34
|
+
sandbox: () => SANDBOX_HINT,
|
|
35
|
+
"apple-sla": () => "Apple SLA 상 macOS 는 Apple 하드웨어에서만. 비 Apple 호스트는 지원 안 함",
|
|
36
|
+
"tart-needs-apple-silicon": () => "Tart 는 Apple Silicon(arm64) 전용. 이 macOS 는 Intel 이거나 가상 머신",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function hintOf(cell) {
|
|
40
|
+
const h = HINTS[cell.reason];
|
|
41
|
+
if (h) return h(cell);
|
|
42
|
+
return cell.status === "fail" ? "로그 확인" : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function countOf(cells) {
|
|
46
|
+
const counts = { pass: 0, fail: 0, unverified: 0, unsupported: 0 };
|
|
47
|
+
for (const c of cells) counts[c.status] += 1;
|
|
48
|
+
return counts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 0 every requested cell passed, 1 a fail, 4 no fail but an unverified or unsupported cell.
|
|
52
|
+
export function exitCodeOf(cells) {
|
|
53
|
+
const n = countOf(cells);
|
|
54
|
+
if (n.fail) return 1;
|
|
55
|
+
if (n.unverified || n.unsupported) return 4;
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const summaryLine = (n) => `pass ${n.pass}, fail ${n.fail}, unverified ${n.unverified}, unsupported ${n.unsupported}`;
|
|
60
|
+
|
|
61
|
+
const cellText = (v) => String(v ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
62
|
+
|
|
63
|
+
function seconds(c) {
|
|
64
|
+
const ms = Date.parse(c.endedAt) - Date.parse(c.startedAt);
|
|
65
|
+
return Number.isFinite(ms) ? (ms / 1000).toFixed(1) : "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function tableLines(cells) {
|
|
69
|
+
const lines = ["| 칸 | 상태 | 사유 | 초 | 안내 |", "|---|---|---|---|---|"];
|
|
70
|
+
for (const c of cells) lines.push(`| ${cellText(c.id)} | ${c.status} | ${cellText(c.reason)} | ${seconds(c)} | ${cellText(c.hint)} |`);
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The text printed on screen and written to summary.md: summary line, notes, table.
|
|
75
|
+
export function renderSummary(results) {
|
|
76
|
+
const lines = [summaryLine(results.counts), ""];
|
|
77
|
+
for (const note of results.notes) lines.push(`- ${note}`);
|
|
78
|
+
if (results.notes.length) lines.push("");
|
|
79
|
+
return [...lines, ...tableLines(results.cells), ""].join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function buildResults({ cells, notes, host, startedAt, out }) {
|
|
83
|
+
const done = cells.map((c) => ({ ...c, hint: c.hint ?? hintOf(c) }));
|
|
84
|
+
return {
|
|
85
|
+
tool: "build-testbox",
|
|
86
|
+
schema: 1,
|
|
87
|
+
startedAt,
|
|
88
|
+
endedAt: new Date().toISOString(),
|
|
89
|
+
out,
|
|
90
|
+
host,
|
|
91
|
+
counts: countOf(done),
|
|
92
|
+
exitCode: exitCodeOf(done),
|
|
93
|
+
notes,
|
|
94
|
+
cells: done,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Throws on a write failure; the caller turns that into exit 3.
|
|
99
|
+
export function writeResults(out, results) {
|
|
100
|
+
mkdirSync(out, { recursive: true });
|
|
101
|
+
writeFileSync(join(out, "results.json"), `${JSON.stringify(results, null, 2)}\n`);
|
|
102
|
+
writeFileSync(join(out, "summary.md"), renderSummary(results));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A finished cell record. Times are ISO strings.
|
|
106
|
+
export function cellOf(base, status, reason, extra = {}) {
|
|
107
|
+
const now = new Date().toISOString();
|
|
108
|
+
return { startedAt: now, ...base, status, reason: reason || "", endedAt: extra.endedAt || base.endedAt || now, ...extra };
|
|
109
|
+
}
|