explainmyrepo 0.1.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.
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ // build-kb.mjs — Station 1 (UNDERSTAND): build the REAL RVF KB + structured extraction.
3
+ //
4
+ // Thin wrapper over the existing, already-working kb/ engine (ADR-0005 D3). It runs, in order:
5
+ // 1. kb/build-kb.mjs --target <slug> -> the real RVF store (HNSW, local 384-dim embeds)
6
+ // 2. kb/extract-symbols.mjs <slug> -> <slug>-symbols.json (public API surface)
7
+ // 3. kb/dep-graph.mjs <slug> -> <slug>-dep-graph.json (component/dep graph)
8
+ // 4. kb/entrypoints.mjs <slug> -> <slug>-entrypoints.json (build/test/run commands)
9
+ // (2–4 feed the INV-18 architecture + flow diagrams and the Station-6 pack. The authored primer
10
+ // and its index-primer step are a later, brain-owned deliverable — NOT this tool's job.)
11
+ //
12
+ // Uniform tool convention (tools/CONTRACT.md §b): `node tools/build-kb.mjs <build-dir>`.
13
+ // reads : <build-dir>/build.json -> repo.slug, repo.clonePath, repo.name
14
+ // writes : the `understanding` + `kb` slots; the real store files under kb/stores/<slug>/
15
+ // stdout : exactly ONE JSON result object; child engine output is routed to stderr; exit 0 iff ok.
16
+ //
17
+ // PURE: reads only its declared build.json slice + its own freshly-produced outputs. FAIL LOUD: a
18
+ // failed RVF build, a missing/empty store, or a non-canonical .small.rvf (a missing `embed` block)
19
+ // all stop non-zero with a clear reason — NEVER a JSON-only fallback (INV-06) and never a placeholder.
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { execFileSync } from 'node:child_process';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // tools/
27
+ const REPO_ROOT = path.resolve(__dirname, '..'); // Ruv-Explainer/
28
+ const KB_DIR = path.join(REPO_ROOT, 'kb');
29
+
30
+ // ---------- uniform result protocol ----------
31
+ function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
32
+ function fail(error) { console.error(`[build-kb] FAIL: ${error}`); emit({ ok: false, outputs: {}, error }); process.exit(1); }
33
+ function done(outputs) { emit({ ok: true, outputs, error: null }); process.exit(0); }
34
+
35
+ // Run a kb/ engine script; child stdout + stderr are routed to OUR stderr (fd 2) so our stdout
36
+ // stays a single clean JSON object. A non-zero child exit throws -> we fail loud.
37
+ // extraEnv is merged into process.env — used to forward KB_REPO_DIR so all scripts index the
38
+ // correct build-dir clone instead of the hardcoded repoDir in kb/kb.config.mjs (Blocker-1 fix).
39
+ function runKbScript(script, args, timeout, extraEnv = {}) {
40
+ const scriptPath = path.join(KB_DIR, script);
41
+ if (!fs.existsSync(scriptPath)) fail(`kb engine script missing: ${path.relative(REPO_ROOT, scriptPath)}`);
42
+ console.error(`[build-kb] run: node kb/${script} ${args.join(' ')}`);
43
+ try {
44
+ execFileSync(process.execPath, [scriptPath, ...args], {
45
+ cwd: REPO_ROOT, env: { ...process.env, ...extraEnv }, stdio: ['ignore', 2, 2], timeout, maxBuffer: 64 * 1024 * 1024,
46
+ });
47
+ } catch (e) {
48
+ const why = e && e.signal ? `timed out / killed (${e.signal})` : `exit ${e && e.status}`;
49
+ fail(`kb/${script} failed for target "${args[args.length - 1]}" (${why}) — is the target registered in kb/kb.config.mjs with an \`embed\` block? (repoDir forwarded via KB_REPO_DIR: ${extraEnv.KB_REPO_DIR || 'not set'}) (${e && e.message ? e.message.split('\n')[0] : ''})`);
50
+ }
51
+ }
52
+
53
+ function countJsonlLines(file) {
54
+ let n = 0;
55
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) if (line.trim()) n++;
56
+ return n;
57
+ }
58
+
59
+ function readJsonSafe(file) {
60
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
61
+ }
62
+
63
+ function main() {
64
+ const buildDir = process.argv[2];
65
+ if (!buildDir) fail('usage: node tools/build-kb.mjs <build-dir> (missing build dir argument)');
66
+ const buildJsonPath = path.join(path.resolve(buildDir), 'build.json');
67
+ if (!fs.existsSync(buildJsonPath)) fail(`build.json not found at ${buildJsonPath} — run clone-repo first`);
68
+
69
+ let ctx;
70
+ try { ctx = JSON.parse(fs.readFileSync(buildJsonPath, 'utf8')); }
71
+ catch (e) { fail(`build.json is not valid JSON: ${e.message}`); }
72
+
73
+ const slug = ctx?.repo?.slug;
74
+ const clonePath = ctx?.repo?.clonePath;
75
+ const repoName = ctx?.repo?.name || slug;
76
+ if (!slug) fail('build.json has no repo.slug — run clone-repo first');
77
+ if (!clonePath || !fs.existsSync(clonePath) || !fs.statSync(clonePath).isDirectory()) {
78
+ fail(`repo.clonePath is missing or not a directory: ${clonePath} — run clone-repo first`);
79
+ }
80
+
81
+ // ---- run the real kb/ engine (build first, then the three structured extractors) ----
82
+ // Forward clonePath via KB_REPO_DIR so all four scripts index THIS build dir's clone,
83
+ // not the hardcoded repoDir in kb/kb.config.mjs (CONTRACT §b build-dir guarantee, Blocker-1 fix).
84
+ const kbEnv = { KB_REPO_DIR: clonePath };
85
+ runKbScript('build-kb.mjs', ['--target', slug], 1_200_000, kbEnv); // embeds every chunk; allow time
86
+ runKbScript('extract-symbols.mjs', [slug], 600_000, kbEnv); // rustdoc-json can be slow
87
+ runKbScript('dep-graph.mjs', [slug], 300_000, kbEnv);
88
+ runKbScript('entrypoints.mjs', [slug], 300_000, kbEnv);
89
+
90
+ // ---- verify the real outputs exist (no fake KB, no silent partial) ----
91
+ const storeDir = path.join(KB_DIR, 'stores', slug);
92
+ const f = {
93
+ rvf: path.join(storeDir, `${slug}-kb.rvf`),
94
+ smallRvf: path.join(storeDir, `${slug}-kb.small.rvf`),
95
+ passages: path.join(storeDir, `${slug}-kb.passages.jsonl`),
96
+ ids: path.join(storeDir, `${slug}-kb.ids.json`),
97
+ symbols: path.join(storeDir, `${slug}-symbols.json`),
98
+ depGraph: path.join(storeDir, `${slug}-dep-graph.json`),
99
+ entrypoints: path.join(storeDir, `${slug}-entrypoints.json`),
100
+ };
101
+
102
+ if (!fs.existsSync(f.rvf)) {
103
+ if (fs.existsSync(f.smallRvf)) {
104
+ fail(`build-kb wrote ${slug}-kb.small.rvf instead of the canonical ${slug}-kb.rvf — the target "${slug}" is missing an \`embed\` block in kb/kb.config.mjs (ADR-0005 D3). make-pack globs <slug>-kb.rvf and cannot find a .small.rvf store.`);
105
+ }
106
+ fail(`RVF build produced no store at ${path.relative(REPO_ROOT, f.rvf)} — the real KB build failed (INV-06: no JSON fallback)`);
107
+ }
108
+ for (const [key, file] of [['passages', f.passages], ['ids', f.ids], ['symbols', f.symbols], ['dep-graph', f.depGraph], ['entrypoints', f.entrypoints]]) {
109
+ if (!fs.existsSync(file)) fail(`expected ${key} output missing: ${path.relative(REPO_ROOT, file)} — structured extraction did not complete`);
110
+ }
111
+
112
+ // ---- passageCount > 0 (INV-06) ----
113
+ let passageCount;
114
+ try { passageCount = countJsonlLines(f.passages); }
115
+ catch (e) { fail(`could not read passages file ${path.relative(REPO_ROOT, f.passages)}: ${e.message}`); }
116
+ if (!(passageCount > 0)) fail(`KB has ${passageCount} passages — an empty corpus is not a real KB (INV-06)`);
117
+
118
+ // ---- embed model: read the sidecar the engine wrote next to the canonical .rvf ----
119
+ const embedCfg = readJsonSafe(`${f.rvf}.embed.json`);
120
+ const embedModel = (embedCfg && embedCfg.model) || 'Xenova/bge-small-en-v1.5';
121
+ if (!embedCfg) console.error(`[build-kb] warning: no ${slug}-kb.rvf.embed.json sidecar; reporting default embedModel ${embedModel}`);
122
+
123
+ // ---- mechanical (non-judgment) facts for the understanding slot ----
124
+ const dep = readJsonSafe(f.depGraph) || {};
125
+ const sym = readJsonSafe(f.symbols) || {};
126
+ const ent = readJsonSafe(f.entrypoints) || {};
127
+ const ecosystems = Array.isArray(dep.ecosystems) && dep.ecosystems.length ? dep.ecosystems.join('+') : 'unknown';
128
+ const componentCount = typeof dep.componentCount === 'number' ? dep.componentCount : (Array.isArray(dep.nodes) ? dep.nodes.length : 0);
129
+ const symbolCount = typeof sym.count === 'number' ? sym.count : (Array.isArray(sym.symbols) ? sym.symbols.length : 0);
130
+ const commandCount = Array.isArray(ent.commands) ? ent.commands.length : 0;
131
+ const summary = `${repoName} — ${ecosystems} repository indexed into a 384-dim RVF knowledge base: `
132
+ + `${passageCount} passages, ${componentCount} components, ${symbolCount} public symbols, ${commandCount} entrypoint commands.`;
133
+
134
+ // ---- assemble the two slots; paths are repo-root-relative (matching CONTRACT §a) ----
135
+ const rel = (p) => path.relative(REPO_ROOT, p);
136
+ const understanding = { repoName, summary, passageCount };
137
+ const kb = {
138
+ slug,
139
+ storeDir: rel(storeDir),
140
+ rvfPath: rel(f.rvf),
141
+ passagesPath: rel(f.passages),
142
+ idsPath: rel(f.ids),
143
+ embedModel,
144
+ primerPath: rel(path.join(storeDir, `${slug}-primer.md`)), // authored later by the brain (S1)
145
+ symbolsPath: rel(f.symbols),
146
+ depGraphPath: rel(f.depGraph),
147
+ entrypointsPath: rel(f.entrypoints),
148
+ };
149
+
150
+ ctx.understanding = { ...(ctx.understanding || {}), ...understanding };
151
+ ctx.kb = { ...(ctx.kb || {}), ...kb };
152
+ try { fs.writeFileSync(buildJsonPath, JSON.stringify(ctx, null, 2) + '\n'); }
153
+ catch (e) { fail(`could not write build.json: ${e.message}`); }
154
+
155
+ console.error(`[build-kb] OK ${slug}: ${passageCount} passages, ${symbolCount} symbols, ${componentCount} components (${ecosystems})`);
156
+ done({ slot: ['understanding', 'kb'], understanding, kb, passageCount });
157
+ }
158
+
159
+ main();
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ // clone-repo.mjs — Station 0–1 (VALIDATE + CLONE).
3
+ //
4
+ // Validate that the target repo URL is reachable, then clone it into <build-dir>/repo. Supports
5
+ // PUBLIC repos and PRIVATE / owner repos via a GitHub token supplied with the top-level
6
+ // `git -c http.extraheader=...` option — which is process-scoped and is NEVER written into the
7
+ // cloned repo's config (so no credentials are baked into the saved remote).
8
+ //
9
+ // Uniform tool convention (tools/CONTRACT.md §b): `node tools/clone-repo.mjs <build-dir>`.
10
+ // reads : <build-dir>/build.json -> repo.url (+ GITHUB_TOKEN / GH_TOKEN from env for private)
11
+ // writes : the `repo` slot (owner/name/slug/private/defaultBranch/clonePath/reachable) and the
12
+ // top-level buildId (set first, here); the working tree at <build-dir>/repo/
13
+ // stdout : exactly ONE JSON result object; all diagnostics go to stderr; exit 0 iff ok:true.
14
+ //
15
+ // PURE: reads only repo.url (its declared slice) + the env token; writes only the repo slot +
16
+ // buildId + its own working tree. FAIL LOUD: any failure exits non-zero with a clear reason and
17
+ // never writes a placeholder / partial clone past an error (tools/CONTRACT.md §b·6, INV-04).
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { execFileSync } from 'node:child_process';
22
+ import { randomUUID } from 'node:crypto';
23
+
24
+ // ---------- uniform result protocol ----------
25
+ function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
26
+ function fail(error) { console.error(`[clone-repo] FAIL: ${error}`); emit({ ok: false, outputs: {}, error }); process.exit(1); }
27
+ function done(outputs) { emit({ ok: true, outputs, error: null }); process.exit(0); }
28
+
29
+ // ---------- url parsing (https | scp-like git@host:owner/name) ----------
30
+ function parseRepoUrl(raw) {
31
+ let s = String(raw || '').trim();
32
+ if (!s) return null;
33
+ const scp = s.match(/^git@([^:]+):(.+)$/); // git@github.com:owner/name(.git)
34
+ if (scp) s = `https://${scp[1]}/${scp[2]}`;
35
+ if (!/^[a-z]+:\/\//i.test(s)) s = `https://${s}`; // bare github.com/owner/name
36
+ s = s.replace(/\/+$/, '');
37
+ let u;
38
+ try { u = new URL(s); } catch { return null; }
39
+ const parts = u.pathname.replace(/^\/+/, '').split('/').filter(Boolean);
40
+ if (parts.length < 2) return null;
41
+ const owner = parts[0];
42
+ const name = parts[1].replace(/\.git$/i, '');
43
+ if (!owner || !name) return null;
44
+ return { host: u.host, owner, name, cloneUrl: `https://${u.host}/${owner}/${name}.git` };
45
+ }
46
+
47
+ // ---------- git helpers (token never logged) ----------
48
+ function runGit(args, { capture = true, timeout = 120000 } = {}) {
49
+ try {
50
+ const out = execFileSync('git', args, {
51
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, // never block on an interactive prompt
52
+ stdio: ['ignore', capture ? 'pipe' : 2, 'pipe'], // non-captured stdout -> our stderr (fd 2)
53
+ timeout, maxBuffer: 32 * 1024 * 1024,
54
+ });
55
+ return { ok: true, stdout: capture && out ? out.toString() : '', stderr: '' };
56
+ } catch (e) {
57
+ if (e && e.code === 'ENOENT') return { ok: false, stdout: '', stderr: 'git executable not found on PATH' };
58
+ return { ok: false, stdout: e.stdout ? e.stdout.toString() : '', stderr: e.stderr ? e.stderr.toString().trim() : (e.message || 'git failed'), code: e.status };
59
+ }
60
+ }
61
+
62
+ function defaultBranchFromSymref(lsRemoteStdout) {
63
+ const m = (lsRemoteStdout || '').match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD/m);
64
+ return m ? m[1] : null;
65
+ }
66
+
67
+ function main() {
68
+ const buildDir = process.argv[2];
69
+ if (!buildDir) fail('usage: node tools/clone-repo.mjs <build-dir> (missing build dir argument)');
70
+ const buildDirAbs = path.resolve(buildDir);
71
+ const buildJsonPath = path.join(buildDirAbs, 'build.json');
72
+ if (!fs.existsSync(buildJsonPath)) fail(`build.json not found at ${buildJsonPath} — the brain must create the build dir + build.json (with repo.url) first`);
73
+
74
+ let ctx;
75
+ try { ctx = JSON.parse(fs.readFileSync(buildJsonPath, 'utf8')); }
76
+ catch (e) { fail(`build.json is not valid JSON: ${e.message}`); }
77
+
78
+ const url = ctx?.repo?.url;
79
+ if (!url) fail('build.json has no repo.url — clone-repo requires repo.url as its declared input');
80
+
81
+ const parsed = parseRepoUrl(url);
82
+ if (!parsed) fail(`could not parse owner/name from repo.url "${url}" (expected https://host/owner/name or git@host:owner/name)`);
83
+ const { host, owner, name, cloneUrl } = parsed;
84
+
85
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '';
86
+ const base = `https://${host}/`;
87
+ const authArgs = token
88
+ ? ['-c', `http.${base}.extraheader=AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`]
89
+ : [];
90
+ // Probe args neutralise any ambient credential helper so "public vs private" is an HONEST signal.
91
+ const noAuthProbe = ['-c', 'credential.helper=', 'ls-remote', '--symref', cloneUrl, 'HEAD'];
92
+ const authProbe = [...authArgs, '-c', 'credential.helper=', 'ls-remote', '--symref', cloneUrl, 'HEAD'];
93
+
94
+ // ---- Station 0: reachability + public/private detection ----
95
+ console.error(`[clone-repo] probing ${owner}/${name} on ${host} (unauthenticated)`);
96
+ let isPrivate;
97
+ let symref;
98
+ const pub = runGit(noAuthProbe);
99
+ if (pub.ok) {
100
+ isPrivate = false;
101
+ symref = pub.stdout;
102
+ } else if (token) {
103
+ console.error('[clone-repo] unauthenticated probe failed — retrying with token');
104
+ const prv = runGit(authProbe);
105
+ if (!prv.ok) fail(`repo not reachable even with a token: ${owner}/${name} — check the URL and that the token can access it (git: ${prv.stderr || `exit ${prv.code}`})`);
106
+ isPrivate = true;
107
+ symref = prv.stdout;
108
+ } else {
109
+ fail(`repo not reachable unauthenticated: ${owner}/${name} — if it is PRIVATE set GITHUB_TOKEN or GH_TOKEN; if PUBLIC check the URL (git: ${pub.stderr || `exit ${pub.code}`})`);
110
+ }
111
+
112
+ // ---- Station 1: clone into <build-dir>/repo (idempotent: replace any prior tree) ----
113
+ const dest = path.join(buildDirAbs, 'repo');
114
+ try { fs.rmSync(dest, { recursive: true, force: true }); }
115
+ catch (e) { fail(`could not clear prior clone at ${dest}: ${e.message}`); }
116
+ fs.mkdirSync(buildDirAbs, { recursive: true });
117
+
118
+ // Top-level `git -c ...` (BEFORE the `clone` subcommand) applies the auth header to THIS process
119
+ // only; it is NOT persisted into <dest>/.git/config, so the saved remote stays credential-free.
120
+ const cloneArgs = [...authArgs, 'clone', '--depth', '1', '--no-tags', '--single-branch', cloneUrl, dest];
121
+ console.error(`[clone-repo] cloning ${cloneUrl} -> ${dest} (private=${isPrivate})`);
122
+ const cloned = runGit(cloneArgs, { capture: false, timeout: 600000 });
123
+ if (!cloned.ok) fail(`git clone failed for ${owner}/${name}: ${cloned.stderr || `exit ${cloned.code}`}`);
124
+
125
+ // Guard: never bake creds into the saved remote (defence-in-depth on the §1 invariant).
126
+ try {
127
+ const savedCfg = fs.readFileSync(path.join(dest, '.git', 'config'), 'utf8');
128
+ if (/extraheader/i.test(savedCfg)) fail('refusing to finish: an http.extraheader leaked into the cloned repo config (credentials would be baked into the remote)');
129
+ } catch { /* no .git/config readable — handled by the working-tree check below */ }
130
+
131
+ if (!fs.existsSync(path.join(dest, '.git'))) fail(`clone produced no .git directory at ${dest}`);
132
+
133
+ // defaultBranch: prefer the remote symref; fall back to the checked-out HEAD.
134
+ let defaultBranch = defaultBranchFromSymref(symref);
135
+ if (!defaultBranch) {
136
+ const head = runGit(['-C', dest, 'rev-parse', '--abbrev-ref', 'HEAD']);
137
+ defaultBranch = head.ok ? head.stdout.trim() : null;
138
+ }
139
+ if (!defaultBranch) fail('cloned successfully but could not determine the default branch');
140
+
141
+ // ---- merge ONLY the repo slot (+ buildId, set first here) ----
142
+ const repoSlot = {
143
+ url,
144
+ owner,
145
+ name,
146
+ slug: name,
147
+ private: isPrivate,
148
+ defaultBranch,
149
+ clonePath: dest,
150
+ reachable: true,
151
+ };
152
+ ctx.buildId = ctx.buildId || randomUUID(); // correlation + idempotency key; set first (clone-repo)
153
+ ctx.repo = { ...(ctx.repo || {}), ...repoSlot };
154
+ try { fs.writeFileSync(buildJsonPath, JSON.stringify(ctx, null, 2) + '\n'); }
155
+ catch (e) { fail(`could not write build.json: ${e.message}`); }
156
+
157
+ console.error(`[clone-repo] OK ${owner}/${name} (${defaultBranch}${isPrivate ? ', private' : ', public'}) -> ${dest}`);
158
+ done({ slot: 'repo', buildId: ctx.buildId, repo: repoSlot, clonePath: dest });
159
+ }
160
+
161
+ main();
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // deploy.mjs — Station 8 tool #10: deploy the already-passed page to its own per-build URL.
3
+ //
4
+ // CONTRACT (tools/CONTRACT.md): node tools/deploy.mjs <build-dir>
5
+ // Reads (declared inputs): page.dir, repo.slug (+ deploy-provider token from env)
6
+ // Writes (own slot only): publish.liveUrl, publish.http200
7
+ // stdout = ONE JSON result object; diagnostics → stderr; exit 0 iff ok:true, else non-zero.
8
+ //
9
+ // Provider-agnostic adapter, DEFAULT NETLIFY (clean {slug}-explainer.netlify.app subdomain, zero
10
+ // DNS work). Vercel is a one-line swap-in via the ADAPTERS map (DEPLOY_PROVIDER=vercel). The deploy
11
+ // is a direct, atomic, immutable per-build upload — the owner can later git-connect the published
12
+ // repo for auto-redeploy; that is a post-publish owner action, not this station's job.
13
+ //
14
+ // FAIL LOUD: a missing token, a failed deploy, or a liveUrl that does not return 200 unauthenticated
15
+ // is a non-zero exit with a clear message — never a placeholder URL, never a silent green.
16
+
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { execFileSync } from 'node:child_process';
21
+
22
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
23
+ const sanitize = (s) => String(s).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
24
+
25
+ // ---- BuildContext I/O (only the declared slice in, only the owned slot out) ----
26
+ function readContext(buildDir) {
27
+ const p = path.join(buildDir, 'build.json');
28
+ let raw;
29
+ try { raw = fs.readFileSync(p, 'utf8'); }
30
+ catch { throw new Error(`build.json not found at ${p} (run earlier stations first)`); }
31
+ try { return JSON.parse(raw); }
32
+ catch (e) { throw new Error(`build.json is not valid JSON: ${e.message}`); }
33
+ }
34
+ function mergeSlot(buildDir, slot, partial) {
35
+ const p = path.join(buildDir, 'build.json');
36
+ const obj = JSON.parse(fs.readFileSync(p, 'utf8')); // re-read fresh, merge ONLY this slot's keys
37
+ obj[slot] = { ...(obj[slot] || {}), ...partial };
38
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
39
+ }
40
+
41
+ // ---- shared helpers ----
42
+ async function api(url, opts, label) {
43
+ const res = await fetch(url, opts);
44
+ const text = await res.text();
45
+ if (!res.ok) throw new Error(`${label} failed: HTTP ${res.status} ${res.statusText} — ${text.slice(0, 300)}`);
46
+ return text ? JSON.parse(text) : {};
47
+ }
48
+ function zipDir(dir, zipPath) {
49
+ try {
50
+ fs.rmSync(zipPath, { force: true });
51
+ execFileSync('zip', ['-r', '-X', zipPath, '.'], { cwd: dir, stdio: ['ignore', 'ignore', 'inherit'] });
52
+ } catch (e) {
53
+ throw new Error(`zip of site dir failed (is the system 'zip' installed?): ${e.message}`);
54
+ }
55
+ }
56
+ function collectFiles(dir) {
57
+ const out = [];
58
+ const walk = (d, base) => {
59
+ for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
60
+ const abs = path.join(d, e.name);
61
+ const rel = base ? `${base}/${e.name}` : e.name;
62
+ if (e.isDirectory()) walk(abs, rel);
63
+ else if (e.isFile()) out.push({ rel, abs });
64
+ }
65
+ };
66
+ walk(dir, '');
67
+ return out;
68
+ }
69
+ async function verify200(url, tries = 12) {
70
+ for (let i = 0; i < tries; i++) {
71
+ try { const r = await fetch(url, { redirect: 'follow' }); if (r.status === 200) return true; }
72
+ catch { /* propagation lag — retry */ }
73
+ await sleep(3000);
74
+ }
75
+ return false;
76
+ }
77
+
78
+ // ---- adapter: Netlify (DEFAULT) ----
79
+ async function deployNetlify({ pageDir, slug }) {
80
+ const token = process.env.NETLIFY_AUTH_TOKEN;
81
+ if (!token) throw new Error('NETLIFY_AUTH_TOKEN not set in environment (deploy-provider token required)');
82
+ const auth = { Authorization: `Bearer ${token}` };
83
+ const name = `${sanitize(slug)}-explainer`;
84
+
85
+ const sites = await api(`https://api.netlify.com/api/v1/sites?name=${encodeURIComponent(name)}&filter=all`,
86
+ { headers: auth }, 'netlify list sites');
87
+ let site = (Array.isArray(sites) ? sites : []).find((s) => s.name === name) || null;
88
+ if (!site) {
89
+ site = await api('https://api.netlify.com/api/v1/sites',
90
+ { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) },
91
+ 'netlify create site');
92
+ }
93
+ console.error(`[deploy] netlify site '${name}' (id ${site.id})`);
94
+
95
+ const zipPath = path.join(os.tmpdir(), `deploy-${name}-${Date.now()}.zip`);
96
+ zipDir(pageDir, zipPath);
97
+ const zipBuf = fs.readFileSync(zipPath);
98
+ const deploy = await api(`https://api.netlify.com/api/v1/sites/${site.id}/deploys`,
99
+ { method: 'POST', headers: { ...auth, 'Content-Type': 'application/zip' }, body: zipBuf },
100
+ 'netlify deploy');
101
+ fs.rmSync(zipPath, { force: true });
102
+
103
+ for (let i = 0; i < 80; i++) {
104
+ const d = await api(`https://api.netlify.com/api/v1/sites/${site.id}/deploys/${deploy.id}`,
105
+ { headers: auth }, 'netlify deploy status');
106
+ if (d.state === 'ready') { console.error('[deploy] netlify deploy ready'); break; }
107
+ if (d.state === 'error') throw new Error(`netlify deploy errored: ${d.error_message || 'unknown'}`);
108
+ if (i === 79) throw new Error('netlify deploy did not reach state=ready within timeout');
109
+ await sleep(3000);
110
+ }
111
+ return { liveUrl: site.ssl_url || `https://${name}.netlify.app`, provider: 'netlify' };
112
+ }
113
+
114
+ // ---- Vercel adapters DELETED 2026-06-30 (at the owner's instruction) ----
115
+ // A Vercel auto-fallback once deployed a demo into a shared personal-Vercel "site" project and overwrote
116
+ // an unrelated LIVE site (warrior-nation). ALL Vercel deploy code (REST adapter + CLI adapter) was removed.
117
+ // Deploys go to NETLIFY ONLY — each explainer to its OWN {slug}-explainer.netlify.app site. Do NOT
118
+ // reintroduce Vercel or any silent provider fallback; if another provider is ever truly needed, add a
119
+ // new, ISOLATED, opt-in adapter deliberately and review it for the shared-target failure mode.
120
+ const ADAPTERS = { netlify: deployNetlify };
121
+
122
+ // Netlify is the ONLY target. If its token is missing or invalid we FAIL LOUD with exactly how to refresh
123
+ // it — never a guess, never a different provider, never another account.
124
+ async function resolveProvider() {
125
+ if (!process.env.NETLIFY_AUTH_TOKEN) {
126
+ throw new Error('NETLIFY_AUTH_TOKEN is not set. Create a Netlify personal access token at https://app.netlify.com/user/applications#personal-access-tokens and put NETLIFY_AUTH_TOKEN=… in .env, then retry. Deploys go to Netlify only.');
127
+ }
128
+ const r = await fetch('https://api.netlify.com/api/v1/user', { headers: { Authorization: `Bearer ${process.env.NETLIFY_AUTH_TOKEN}` } }).catch(() => null);
129
+ if (!r || !r.ok) throw new Error(`NETLIFY_AUTH_TOKEN is set but not valid (HTTP ${r ? r.status : 'network error'}). Refresh it at https://app.netlify.com/user/applications#personal-access-tokens and update NETLIFY_AUTH_TOKEN in .env. Deploys go to Netlify only — no fallback.`);
130
+ return 'netlify';
131
+ }
132
+
133
+ async function main() {
134
+ if (typeof fetch !== 'function') throw new Error('global fetch unavailable — Node 18+ required');
135
+ const buildDir = process.argv[2];
136
+ if (!buildDir) throw new Error('usage: node tools/deploy.mjs <build-dir>');
137
+
138
+ const bc = readContext(buildDir);
139
+ const slug = bc.repo?.slug;
140
+ const pageDir = path.resolve(bc.page?.dir || '');
141
+ if (!slug) throw new Error('repo.slug missing in build.json (run clone-repo first)');
142
+ if (!bc.page?.dir) throw new Error('page.dir missing in build.json (run assemble-page first)');
143
+ if (!fs.existsSync(path.join(pageDir, 'index.html'))) throw new Error(`page.dir has no index.html: ${pageDir}`);
144
+
145
+ const provider = await resolveProvider();
146
+ const adapter = ADAPTERS[provider];
147
+ if (!adapter) throw new Error(`unknown DEPLOY_PROVIDER '${provider}' (supported: ${Object.keys(ADAPTERS).join(', ')})`);
148
+
149
+ const { liveUrl } = await adapter({ pageDir, slug });
150
+ console.error(`[deploy] ${provider} → ${liveUrl} (verifying 200 unauthenticated)`);
151
+ const http200 = await verify200(liveUrl);
152
+ if (!http200) throw new Error(`deployed to ${liveUrl} but it did not return 200 unauthenticated within timeout`);
153
+
154
+ mergeSlot(buildDir, 'publish', { liveUrl, http200: true });
155
+ return { liveUrl, http200: true, provider, slot: 'publish' };
156
+ }
157
+
158
+ main()
159
+ .then((outputs) => { process.stdout.write(JSON.stringify({ ok: true, outputs, error: null }) + '\n'); process.exit(0); })
160
+ .catch((e) => { process.stdout.write(JSON.stringify({ ok: false, outputs: {}, error: e.message || String(e) }) + '\n'); process.exit(1); });