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.
- package/README.md +165 -0
- package/assets/design-system/design-system.css +833 -0
- package/assets/design-system/theme-example.css +83 -0
- package/bin/explainmyrepo.mjs +115 -0
- package/kb/ask-kb.mjs +1487 -0
- package/kb/build-kb.mjs +353 -0
- package/kb/corpus-rules.mjs +341 -0
- package/kb/dep-graph.mjs +184 -0
- package/kb/entrypoints.mjs +207 -0
- package/kb/extract-symbols.mjs +322 -0
- package/kb/index-primer.mjs +255 -0
- package/kb/kb-mcp-server.mjs +186 -0
- package/kb/kb.config.mjs +1362 -0
- package/kb/make-dropin.mjs +224 -0
- package/kb/resolve-deps.mjs +126 -0
- package/package.json +52 -0
- package/src/brain.mjs +298 -0
- package/src/build-context.mjs +66 -0
- package/src/claude.mjs +97 -0
- package/src/env.mjs +77 -0
- package/src/orchestrator.mjs +419 -0
- package/src/run-tool.mjs +49 -0
- package/tools/CONTRACT.md +301 -0
- package/tools/assemble-page.mjs +631 -0
- package/tools/build-kb.mjs +159 -0
- package/tools/clone-repo.mjs +161 -0
- package/tools/deploy.mjs +160 -0
- package/tools/generate-image.mjs +280 -0
- package/tools/make-diagrams.mjs +835 -0
- package/tools/make-favicon.mjs +145 -0
- package/tools/make-pack.mjs +295 -0
- package/tools/make-social-card.mjs +198 -0
- package/tools/notify.mjs +327 -0
- package/tools/publish-repo.mjs +156 -0
- package/tools/quality-grade.mjs +746 -0
- package/tools/readme-enhance.mjs +310 -0
- package/tools/repo-seo.mjs +143 -0
package/tools/notify.mjs
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// notify.mjs — Station 9, tool #14 of tools/CONTRACT.md (the terminal step).
|
|
3
|
+
//
|
|
4
|
+
// JOB (one mechanical thing): email the owner the SCORECARD + BOTH SCREENSHOTS + ALL LINKS (live URL,
|
|
5
|
+
// explainer repo + collaborator-invite status, knowledge pack, and any optional README PR /
|
|
6
|
+
// source-repo SEO suggestions); also return the same summary inline. Pure SMTP over implicit TLS,
|
|
7
|
+
// zero npm dependencies — it absorbs the old `scripts/phase9-send-email.mjs` and adds multipart MIME
|
|
8
|
+
// so the two screenshots ride along as attachments.
|
|
9
|
+
//
|
|
10
|
+
// FAIL-LOUD: a genuine failure (no creds, no recipient, nothing meaningful to notify, SMTP refused)
|
|
11
|
+
// exits NON-ZERO with a clear reason (per CONTRACT (b)·6) — it never writes a placeholder/partial
|
|
12
|
+
// notify slot. Per ADR-0005 Station 9 / INV-04 the BRAIN treats that non-zero as a NON-BLOCKING
|
|
13
|
+
// WARNING: "a notify failure degrades to a warning — it never inverts a live, graded, deployed
|
|
14
|
+
// build." Notify failure never inverts a good build; it only ever fails to announce it.
|
|
15
|
+
//
|
|
16
|
+
// Uniform invocation: node tools/notify.mjs <build-dir>
|
|
17
|
+
//
|
|
18
|
+
// Reads (declared inputs only — CONTRACT roster row 14):
|
|
19
|
+
// build.json: publish { liveUrl, http200, explainerRepoUrl, ownerInvited, repoTopics,
|
|
20
|
+
// repoDescription, sourceRepoSeoSuggested },
|
|
21
|
+
// quality { scorecard[], passed } (+ the two screenshot paths recorded in that slot),
|
|
22
|
+
// pack.zipPath,
|
|
23
|
+
// readmePr { prUrl, svgsShared[] }
|
|
24
|
+
// env (SMTP creds + recipient — never from build.json):
|
|
25
|
+
// EMAIL_TO | NOTIFY_TO | OWNER_EMAIL recipient (required)
|
|
26
|
+
// SMTP_USER | GMAIL_USER authenticated sender (required)
|
|
27
|
+
// SMTP_PASS | GMAIL_APP_PASSWORD app password (required)
|
|
28
|
+
// SMTP_HOST (default smtp.gmail.com), SMTP_PORT (default 465, implicit TLS), EMAIL_FROM_NAME
|
|
29
|
+
// Writes (its own slot only):
|
|
30
|
+
// build.json: notify { emailSent, smtp250, inlineReturned }
|
|
31
|
+
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import tls from 'node:tls';
|
|
35
|
+
|
|
36
|
+
const TOOL = 'notify';
|
|
37
|
+
|
|
38
|
+
// stdout carries ONLY the single JSON result object; all diagnostics go to stderr.
|
|
39
|
+
function emit(result) {
|
|
40
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
41
|
+
}
|
|
42
|
+
function log(msg) {
|
|
43
|
+
process.stderr.write(`[${TOOL}] ${msg}\n`);
|
|
44
|
+
}
|
|
45
|
+
function fail(message) {
|
|
46
|
+
log(message);
|
|
47
|
+
emit({ ok: false, outputs: {}, error: message });
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveIn(buildDir, p) {
|
|
52
|
+
return path.isAbsolute(p) ? p : path.resolve(buildDir, p);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Minimal HTML escaping for values interpolated into the email body.
|
|
56
|
+
function esc(v) {
|
|
57
|
+
return String(v ?? '')
|
|
58
|
+
.replace(/&/g, '&')
|
|
59
|
+
.replace(/</g, '<')
|
|
60
|
+
.replace(/>/g, '>')
|
|
61
|
+
.replace(/"/g, '"');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Wrap a base64 string to 76-char lines (RFC 2045).
|
|
65
|
+
function b64wrap(buf) {
|
|
66
|
+
return buf.toString('base64').replace(/(.{76})/g, '$1\r\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const buildDir = process.argv[2];
|
|
70
|
+
if (!buildDir) fail('usage: node tools/notify.mjs <build-dir>');
|
|
71
|
+
|
|
72
|
+
const buildJsonPath = path.join(buildDir, 'build.json');
|
|
73
|
+
let ctx;
|
|
74
|
+
try {
|
|
75
|
+
ctx = JSON.parse(fs.readFileSync(buildJsonPath, 'utf8'));
|
|
76
|
+
} catch (err) {
|
|
77
|
+
fail(`cannot read ${buildJsonPath}: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Credentials + recipient (env only) ────────────────────────────────────────────────────────────
|
|
81
|
+
const to = (process.env.EMAIL_TO || process.env.NOTIFY_TO || process.env.OWNER_EMAIL || '').trim();
|
|
82
|
+
const user = (process.env.SMTP_USER || process.env.GMAIL_USER || '').trim();
|
|
83
|
+
const pass = (process.env.SMTP_PASS || process.env.GMAIL_APP_PASSWORD || '').replace(/\s+/g, '');
|
|
84
|
+
const host = (process.env.SMTP_HOST || 'smtp.gmail.com').trim();
|
|
85
|
+
const port = Number(process.env.SMTP_PORT || 465);
|
|
86
|
+
const fromName = (process.env.EMAIL_FROM_NAME || 'explainmyrepo').trim();
|
|
87
|
+
|
|
88
|
+
if (!to) fail('no recipient — set EMAIL_TO (or NOTIFY_TO / OWNER_EMAIL).');
|
|
89
|
+
if (!user) fail('no SMTP user — set SMTP_USER (or GMAIL_USER).');
|
|
90
|
+
if (!pass) fail('no SMTP password — set SMTP_PASS (or GMAIL_APP_PASSWORD).');
|
|
91
|
+
if (!Number.isInteger(port) || port <= 0) fail(`invalid SMTP_PORT: ${process.env.SMTP_PORT}`);
|
|
92
|
+
|
|
93
|
+
// ── Declared slice: publish + quality (+ pack, readmePr) ──────────────────────────────────────────
|
|
94
|
+
const publish = ctx.publish || {};
|
|
95
|
+
const quality = ctx.quality || {};
|
|
96
|
+
const scorecard = Array.isArray(quality.scorecard) ? quality.scorecard : [];
|
|
97
|
+
if (!publish.liveUrl && scorecard.length === 0) {
|
|
98
|
+
fail('nothing meaningful to notify — neither publish.liveUrl nor a quality.scorecard is present.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Screenshot paths come ONLY from the quality slot (never guessed from a sibling tool's files).
|
|
102
|
+
// Liberal field detection; each declared path MUST exist on disk or it is a loud stop.
|
|
103
|
+
function collectScreenshots(q) {
|
|
104
|
+
const raw = [];
|
|
105
|
+
const push = (p) => { if (typeof p === 'string' && p.trim()) raw.push(p.trim()); };
|
|
106
|
+
if (Array.isArray(q.screenshots)) q.screenshots.forEach(push);
|
|
107
|
+
if (q.screenshot && typeof q.screenshot === 'object') Object.values(q.screenshot).forEach(push);
|
|
108
|
+
for (const entry of Array.isArray(q.scorecard) ? q.scorecard : []) {
|
|
109
|
+
push(entry?.screenshot);
|
|
110
|
+
push(entry?.screenshotPath);
|
|
111
|
+
if (entry?.screenshots) {
|
|
112
|
+
const list = Array.isArray(entry.screenshots) ? entry.screenshots : Object.values(entry.screenshots);
|
|
113
|
+
list.forEach(push);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [...new Set(raw)];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const declaredShots = collectScreenshots(quality);
|
|
120
|
+
const attachments = [];
|
|
121
|
+
for (const rel of declaredShots) {
|
|
122
|
+
const abs = resolveIn(buildDir, rel);
|
|
123
|
+
if (!fs.existsSync(abs)) {
|
|
124
|
+
fail(`declared screenshot not found on disk: ${abs} (quality slot referenced it).`);
|
|
125
|
+
}
|
|
126
|
+
attachments.push({ path: abs, name: path.basename(abs) });
|
|
127
|
+
}
|
|
128
|
+
if (attachments.length === 0) {
|
|
129
|
+
log('no screenshot paths recorded in the quality slot — sending links + scorecard only.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Compose the email body ────────────────────────────────────────────────────────────────────────
|
|
133
|
+
const overallPassed = quality.passed === true;
|
|
134
|
+
const scoreRows = scorecard.map((e) => {
|
|
135
|
+
const device = esc(e.device || 'unknown');
|
|
136
|
+
const score = e.headlineScore ?? '—';
|
|
137
|
+
const passed = e.passed === true ? '✅' : '❌';
|
|
138
|
+
return `<tr><td style="padding:4px 12px 4px 0">${device}</td>`
|
|
139
|
+
+ `<td style="padding:4px 12px 4px 0;text-align:right"><b>${esc(score)}</b></td>`
|
|
140
|
+
+ `<td style="padding:4px 0">${passed}</td></tr>`;
|
|
141
|
+
}).join('');
|
|
142
|
+
|
|
143
|
+
const links = [];
|
|
144
|
+
if (publish.liveUrl) {
|
|
145
|
+
links.push(`<li><b>Live explainer:</b> <a href="${esc(publish.liveUrl)}">${esc(publish.liveUrl)}</a>`
|
|
146
|
+
+ `${publish.http200 === true ? ' (200 ✓)' : ''}</li>`);
|
|
147
|
+
}
|
|
148
|
+
if (publish.explainerRepoUrl) {
|
|
149
|
+
links.push(`<li><b>Explainer repo:</b> <a href="${esc(publish.explainerRepoUrl)}">${esc(publish.explainerRepoUrl)}</a>`
|
|
150
|
+
+ `${publish.ownerInvited === true ? ' — you were invited as a collaborator ✓' : ' — collaborator invite not confirmed'}</li>`);
|
|
151
|
+
}
|
|
152
|
+
if (ctx.pack?.zipPath) {
|
|
153
|
+
links.push(`<li><b>AI knowledge pack:</b> ${esc(path.basename(ctx.pack.zipPath))} (ships with the explainer site)</li>`);
|
|
154
|
+
}
|
|
155
|
+
const prUrl = ctx.readmePr?.prUrl;
|
|
156
|
+
if (prUrl && prUrl !== 'declined') {
|
|
157
|
+
links.push(`<li><b>README pull request (optional):</b> <a href="${esc(prUrl)}">${esc(prUrl)}</a></li>`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const seo = publish.sourceRepoSeoSuggested;
|
|
161
|
+
let seoBlock = '';
|
|
162
|
+
if (seo && (seo.topics?.length || seo.description)) {
|
|
163
|
+
const topics = Array.isArray(seo.topics) ? seo.topics.map(esc).join(', ') : '';
|
|
164
|
+
seoBlock = `<h3 style="margin:20px 0 6px">Suggested SEO for your source repo (optional)</h3>`
|
|
165
|
+
+ `${topics ? `<p style="margin:4px 0"><b>Topics:</b> ${topics}</p>` : ''}`
|
|
166
|
+
+ `${seo.description ? `<p style="margin:4px 0"><b>Description:</b> ${esc(seo.description)}</p>` : ''}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const html = `<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;line-height:1.5">
|
|
170
|
+
<h2 style="margin:0 0 8px">Your repo explainer is ready ${overallPassed ? '✅' : '⚠️'}</h2>
|
|
171
|
+
<p style="margin:0 0 16px;color:#555">${overallPassed ? 'It passed the dual gate on both devices.' : 'See the scorecard below for any line flagged honestly under 95.'}</p>
|
|
172
|
+
${scoreRows ? `<h3 style="margin:16px 0 6px">Scorecard</h3>
|
|
173
|
+
<table style="border-collapse:collapse;font-size:14px"><thead><tr>
|
|
174
|
+
<th style="text-align:left;padding:4px 12px 4px 0">Device</th>
|
|
175
|
+
<th style="text-align:right;padding:4px 12px 4px 0">Headline (MIN)</th>
|
|
176
|
+
<th style="text-align:left;padding:4px 0">Pass</th></tr></thead>
|
|
177
|
+
<tbody>${scoreRows}</tbody></table>` : ''}
|
|
178
|
+
${links.length ? `<h3 style="margin:20px 0 6px">Links</h3><ul style="margin:4px 0;padding-left:20px">${links.join('')}</ul>` : ''}
|
|
179
|
+
${seoBlock}
|
|
180
|
+
${attachments.length ? `<p style="margin:16px 0 0;color:#555">Mobile + desktop screenshots are attached.</p>` : ''}
|
|
181
|
+
<p style="margin:24px 0 0;color:#999;font-size:12px">Sent by the explainmyrepo recipe (Station 9).</p>
|
|
182
|
+
</body></html>`;
|
|
183
|
+
|
|
184
|
+
const subject = `Your repo explainer is ready${ctx.repo?.name ? ` — ${ctx.repo.name}` : ''}`;
|
|
185
|
+
|
|
186
|
+
// ── Build the MIME message (multipart/mixed when there are attachments) ───────────────────────────
|
|
187
|
+
const CRLF = '\r\n';
|
|
188
|
+
const date = new Date().toUTCString();
|
|
189
|
+
const headerLines = [
|
|
190
|
+
`From: ${fromName} <${user}>`,
|
|
191
|
+
`To: ${to}`,
|
|
192
|
+
`Subject: ${subject}`,
|
|
193
|
+
`Date: ${date}`,
|
|
194
|
+
'MIME-Version: 1.0',
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
let message;
|
|
198
|
+
if (attachments.length === 0) {
|
|
199
|
+
message = [
|
|
200
|
+
...headerLines,
|
|
201
|
+
'Content-Type: text/html; charset=utf-8',
|
|
202
|
+
'Content-Transfer-Encoding: base64',
|
|
203
|
+
'',
|
|
204
|
+
b64wrap(Buffer.from(html, 'utf8')),
|
|
205
|
+
].join(CRLF);
|
|
206
|
+
} else {
|
|
207
|
+
const boundary = `=_rx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
|
208
|
+
const parts = [
|
|
209
|
+
`--${boundary}`,
|
|
210
|
+
'Content-Type: text/html; charset=utf-8',
|
|
211
|
+
'Content-Transfer-Encoding: base64',
|
|
212
|
+
'',
|
|
213
|
+
b64wrap(Buffer.from(html, 'utf8')),
|
|
214
|
+
];
|
|
215
|
+
for (const att of attachments) {
|
|
216
|
+
const data = fs.readFileSync(att.path);
|
|
217
|
+
parts.push(
|
|
218
|
+
`--${boundary}`,
|
|
219
|
+
`Content-Type: image/png; name="${att.name}"`,
|
|
220
|
+
'Content-Transfer-Encoding: base64',
|
|
221
|
+
`Content-Disposition: attachment; filename="${att.name}"`,
|
|
222
|
+
'',
|
|
223
|
+
b64wrap(data),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
parts.push(`--${boundary}--`);
|
|
227
|
+
message = [
|
|
228
|
+
...headerLines,
|
|
229
|
+
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
230
|
+
'',
|
|
231
|
+
...parts,
|
|
232
|
+
].join(CRLF);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// SMTP body dot-stuffing: a line that is just "." would terminate DATA early.
|
|
236
|
+
const dotStuffed = message.replace(/\r\n\./g, '\r\n..');
|
|
237
|
+
|
|
238
|
+
// ── Ordered SMTP conversation over implicit TLS (absorbed from phase9-send-email.mjs) ─────────────
|
|
239
|
+
function sendMail() {
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
const socket = tls.connect({ host, port, servername: host });
|
|
242
|
+
socket.setEncoding('utf8');
|
|
243
|
+
socket.setTimeout(60000, () => {
|
|
244
|
+
reject(new Error(`SMTP connection to ${host}:${port} timed out.`));
|
|
245
|
+
socket.destroy();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const steps = [
|
|
249
|
+
{ expect: '220', send: null }, // server greeting
|
|
250
|
+
{ expect: '250', send: 'EHLO explainmyrepo' },
|
|
251
|
+
{ expect: '334', send: 'AUTH LOGIN' },
|
|
252
|
+
{ expect: '334', send: Buffer.from(user).toString('base64') },
|
|
253
|
+
{ expect: '235', send: Buffer.from(pass).toString('base64') },
|
|
254
|
+
{ expect: '250', send: `MAIL FROM:<${user}>` },
|
|
255
|
+
{ expect: '250', send: `RCPT TO:<${to}>` },
|
|
256
|
+
{ expect: '354', send: 'DATA' },
|
|
257
|
+
{ expect: '250', send: `${dotStuffed}${CRLF}.` },
|
|
258
|
+
{ expect: '221', send: 'QUIT' },
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
let i = 0;
|
|
262
|
+
let buffer = '';
|
|
263
|
+
|
|
264
|
+
function pump() {
|
|
265
|
+
// Multi-line replies use "250-" for continuations and "250 " for the final line.
|
|
266
|
+
const lines = buffer.split(CRLF).filter(Boolean);
|
|
267
|
+
const last = lines[lines.length - 1];
|
|
268
|
+
if (!last || !/^\d{3} /.test(last)) return; // wait for the final line
|
|
269
|
+
buffer = '';
|
|
270
|
+
|
|
271
|
+
const step = steps[i];
|
|
272
|
+
const code = last.slice(0, 3);
|
|
273
|
+
if (code !== step.expect) {
|
|
274
|
+
reject(new Error(`SMTP expected ${step.expect} but got "${last}" (step ${i}).`));
|
|
275
|
+
socket.end();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
i += 1;
|
|
280
|
+
if (i >= steps.length) {
|
|
281
|
+
resolve();
|
|
282
|
+
socket.end();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const next = steps[i];
|
|
286
|
+
if (next.send !== null) socket.write(next.send + CRLF);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
socket.on('data', (chunk) => {
|
|
290
|
+
buffer += chunk;
|
|
291
|
+
pump();
|
|
292
|
+
});
|
|
293
|
+
socket.on('error', reject);
|
|
294
|
+
socket.on('end', () => {
|
|
295
|
+
if (i < steps.length) reject(new Error('SMTP connection closed before completion.'));
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await sendMail();
|
|
302
|
+
} catch (err) {
|
|
303
|
+
fail(err.message);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Merge ONLY the notify slot back; leave every other slot untouched ─────────────────────────────
|
|
307
|
+
ctx.notify = { emailSent: true, smtp250: true, inlineReturned: true };
|
|
308
|
+
fs.writeFileSync(buildJsonPath, JSON.stringify(ctx, null, 2) + '\n');
|
|
309
|
+
|
|
310
|
+
log(`notification sent to ${to} (${attachments.length} screenshot attachment(s)).`);
|
|
311
|
+
emit({
|
|
312
|
+
ok: true,
|
|
313
|
+
outputs: {
|
|
314
|
+
notify: ctx.notify,
|
|
315
|
+
to,
|
|
316
|
+
subject,
|
|
317
|
+
attachments: attachments.map((a) => a.name),
|
|
318
|
+
links: {
|
|
319
|
+
liveUrl: publish.liveUrl || null,
|
|
320
|
+
explainerRepoUrl: publish.explainerRepoUrl || null,
|
|
321
|
+
ownerInvited: publish.ownerInvited === true,
|
|
322
|
+
readmePr: prUrl && prUrl !== 'declined' ? prUrl : null,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
error: null,
|
|
326
|
+
});
|
|
327
|
+
process.exit(0);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// publish-repo.mjs — Station 8 tool #11: create the dedicated explainer GitHub repo + ship the site.
|
|
3
|
+
//
|
|
4
|
+
// CONTRACT (tools/CONTRACT.md): node tools/publish-repo.mjs <build-dir>
|
|
5
|
+
// Reads (declared inputs): repo.owner, repo.name, repo.slug, page.dir (+ GitHub token from env)
|
|
6
|
+
// Writes (own slot only): publish.explainerRepoUrl, publish.ownerInvited
|
|
7
|
+
// stdout = ONE JSON result object; diagnostics → stderr; exit 0 iff ok:true, else non-zero.
|
|
8
|
+
//
|
|
9
|
+
// Creates stuinfla/{slug}-explainer (public; org overridable via GITHUB_EXPLAINER_OWNER) via `gh`,
|
|
10
|
+
// pushes the assembled site to it, then invites the SOURCE repo owner as a collaborator (best-effort
|
|
11
|
+
// per CONTRACT) and surfaces the invite link in stderr + outputs.
|
|
12
|
+
//
|
|
13
|
+
// FAIL LOUD: the core job (create repo + push site) fails non-zero with a clear message on any error
|
|
14
|
+
// — never a placeholder URL. The collaborator invite is best-effort: a failure is a WARNING that sets
|
|
15
|
+
// ownerInvited:false, it never inverts a successfully-published repo.
|
|
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 sanitize = (s) => String(s).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
23
|
+
|
|
24
|
+
function readContext(buildDir) {
|
|
25
|
+
const p = path.join(buildDir, 'build.json');
|
|
26
|
+
let raw;
|
|
27
|
+
try { raw = fs.readFileSync(p, 'utf8'); }
|
|
28
|
+
catch { throw new Error(`build.json not found at ${p} (run earlier stations first)`); }
|
|
29
|
+
try { return JSON.parse(raw); }
|
|
30
|
+
catch (e) { throw new Error(`build.json is not valid JSON: ${e.message}`); }
|
|
31
|
+
}
|
|
32
|
+
function mergeSlot(buildDir, slot, partial) {
|
|
33
|
+
const p = path.join(buildDir, 'build.json');
|
|
34
|
+
const obj = JSON.parse(fs.readFileSync(p, 'utf8')); // re-read fresh, merge ONLY this slot's keys
|
|
35
|
+
obj[slot] = { ...(obj[slot] || {}), ...partial };
|
|
36
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
|
|
37
|
+
}
|
|
38
|
+
const errText = (e) => (e.stderr ? e.stderr.toString() : '') || e.message || String(e);
|
|
39
|
+
|
|
40
|
+
function requireGh() {
|
|
41
|
+
try { execFileSync('gh', ['--version'], { stdio: ['ignore', 'ignore', 'ignore'] }); }
|
|
42
|
+
catch { throw new Error("GitHub CLI 'gh' not found in PATH (required to publish the explainer repo)"); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ghRepoExists(full) {
|
|
46
|
+
try { execFileSync('gh', ['api', `repos/${full}`], { stdio: ['ignore', 'ignore', 'pipe'] }); return true; }
|
|
47
|
+
catch (e) {
|
|
48
|
+
const msg = errText(e);
|
|
49
|
+
if (/Not Found|HTTP 404|\b404\b/.test(msg)) return false;
|
|
50
|
+
throw new Error(`gh api repos/${full} failed: ${msg.slice(0, 200)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function ghCreateRepo(full, description) {
|
|
54
|
+
try { execFileSync('gh', ['repo', 'create', full, '--public', '--description', description], { stdio: ['ignore', 'pipe', 'pipe'] }); }
|
|
55
|
+
catch (e) { throw new Error(`gh repo create ${full} failed: ${errText(e).slice(0, 300)}`); }
|
|
56
|
+
}
|
|
57
|
+
function pushSite(pageDir, full, token) {
|
|
58
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'explainer-push-'));
|
|
59
|
+
try {
|
|
60
|
+
fs.cpSync(pageDir, stage, { recursive: true });
|
|
61
|
+
const remote = `https://x-access-token:${token}@github.com/${full}.git`;
|
|
62
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
63
|
+
const run = (args) => execFileSync('git', args, { cwd: stage, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
64
|
+
run(['init', '-q']);
|
|
65
|
+
run(['config', 'user.email', 'explainer-bot@users.noreply.github.com']);
|
|
66
|
+
run(['config', 'user.name', 'Explainer Bot']);
|
|
67
|
+
run(['add', '-A']);
|
|
68
|
+
run(['commit', '-q', '-m', 'Publish explainer site']);
|
|
69
|
+
run(['branch', '-M', 'main']);
|
|
70
|
+
run(['remote', 'add', 'origin', remote]);
|
|
71
|
+
run(['push', '-f', '-u', 'origin', 'main']);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
throw new Error(`git push to ${full} failed: ${errText(e).replace(token, '***').slice(0, 300)}`);
|
|
74
|
+
} finally {
|
|
75
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function ghInvite(full, username) {
|
|
79
|
+
// PUT collaborators → 201 with an invitation body (html_url) for a non-member, or 204 (empty) if
|
|
80
|
+
// the user is already a collaborator. Either is a success.
|
|
81
|
+
try {
|
|
82
|
+
const out = execFileSync('gh', ['api', '-X', 'PUT', `repos/${full}/collaborators/${username}`, '-f', 'permission=push'],
|
|
83
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
84
|
+
return out && out.trim() ? JSON.parse(out) : {};
|
|
85
|
+
} catch (e) {
|
|
86
|
+
throw new Error(`invite ${username} to ${full} failed: ${errText(e).slice(0, 200)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function main() {
|
|
91
|
+
const buildDir = process.argv[2];
|
|
92
|
+
if (!buildDir) throw new Error('usage: node tools/publish-repo.mjs <build-dir>');
|
|
93
|
+
|
|
94
|
+
const bc = readContext(buildDir);
|
|
95
|
+
const owner = bc.repo?.owner; // SOURCE repo owner — the person to invite
|
|
96
|
+
const name = bc.repo?.name;
|
|
97
|
+
const slug = bc.repo?.slug;
|
|
98
|
+
const pageDir = path.resolve(bc.page?.dir || '');
|
|
99
|
+
if (!owner) throw new Error('repo.owner missing in build.json (run clone-repo first)');
|
|
100
|
+
if (!slug) throw new Error('repo.slug missing in build.json (run clone-repo first)');
|
|
101
|
+
if (!bc.page?.dir) throw new Error('page.dir missing in build.json (run assemble-page first)');
|
|
102
|
+
if (!fs.existsSync(path.join(pageDir, 'index.html'))) throw new Error(`page.dir has no index.html: ${pageDir}`);
|
|
103
|
+
|
|
104
|
+
requireGh();
|
|
105
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
106
|
+
if (!token) throw new Error('GITHUB_TOKEN (or GH_TOKEN) not set in environment (required to create + push the explainer repo)');
|
|
107
|
+
|
|
108
|
+
const explainerOwner = process.env.GITHUB_EXPLAINER_OWNER || 'stuinfla';
|
|
109
|
+
// COLLISION-PROOF BY CONSTRUCTION: encode BOTH the source owner AND name. Two different users'
|
|
110
|
+
// same-named repos (e.g. two "helix" repos) would previously BOTH map to "{name}-explainer" and
|
|
111
|
+
// the second build's force-push would silently overwrite the first. "{owner}-{name}-explainer" is
|
|
112
|
+
// globally unique per source repo, so a re-run updates the SAME repo (force-push is safe) and no
|
|
113
|
+
// two distinct sources can ever clobber each other. (See deploy-safety-incident.)
|
|
114
|
+
const repoBase = `${sanitize(owner)}-${sanitize(name || slug)}`;
|
|
115
|
+
const full = `${explainerOwner}/${repoBase}-explainer`;
|
|
116
|
+
const explainerRepoUrl = `https://github.com/${full}`;
|
|
117
|
+
|
|
118
|
+
// Guard: if a repo of this exact name already exists, confirm it is genuinely OUR explainer repo for
|
|
119
|
+
// THIS source (description marker) before force-pushing — never overwrite an unrelated repo.
|
|
120
|
+
if (ghRepoExists(full)) {
|
|
121
|
+
const expectMarker = `Explainer site for ${owner}/${name || slug}`;
|
|
122
|
+
let desc = '';
|
|
123
|
+
try { desc = JSON.parse(execFileSync('gh', ['api', `repos/${full}`, '--jq', '.description'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) || '""'); } catch { desc = ''; }
|
|
124
|
+
if (desc && desc !== expectMarker) {
|
|
125
|
+
throw new Error(`refusing to publish: ${full} already exists but is NOT the explainer repo for ${owner}/${name || slug} (its description is "${desc}"). This would overwrite an unrelated repo. Set GITHUB_EXPLAINER_OWNER or rename to resolve.`);
|
|
126
|
+
}
|
|
127
|
+
console.error(`[publish-repo] repo ${full} already exists (ours) — pushing latest site`);
|
|
128
|
+
} else { ghCreateRepo(full, `Explainer site for ${owner}/${name || slug}`); console.error(`[publish-repo] created ${full} (public)`); }
|
|
129
|
+
|
|
130
|
+
pushSite(pageDir, full, token);
|
|
131
|
+
console.error(`[publish-repo] pushed site → ${explainerRepoUrl}`);
|
|
132
|
+
|
|
133
|
+
// The collaborator invite emails a real person, so it is a DELIBERATE opt-out: set
|
|
134
|
+
// EXPLAINER_SKIP_INVITE=1 to publish the hub repo without contacting the source owner.
|
|
135
|
+
let ownerInvited = false;
|
|
136
|
+
let inviteUrl = null;
|
|
137
|
+
if (process.env.EXPLAINER_SKIP_INVITE) {
|
|
138
|
+
console.error(`[publish-repo] collaborator invite SKIPPED (EXPLAINER_SKIP_INVITE set) — ${owner} not contacted`);
|
|
139
|
+
} else {
|
|
140
|
+
try {
|
|
141
|
+
const inv = ghInvite(full, owner);
|
|
142
|
+
ownerInvited = true;
|
|
143
|
+
inviteUrl = inv?.html_url || `https://github.com/${full}/invitations`;
|
|
144
|
+
console.error(`[publish-repo] invited ${owner} as collaborator — invite: ${inviteUrl}`);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
console.error(`[publish-repo] WARN: collaborator invite failed (best-effort, build continues): ${e.message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
mergeSlot(buildDir, 'publish', { explainerRepoUrl, ownerInvited });
|
|
151
|
+
return { explainerRepoUrl, ownerInvited, inviteUrl, slot: 'publish' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
main()
|
|
155
|
+
.then((outputs) => { process.stdout.write(JSON.stringify({ ok: true, outputs, error: null }) + '\n'); process.exit(0); })
|
|
156
|
+
.catch((e) => { process.stdout.write(JSON.stringify({ ok: false, outputs: {}, error: e.message || String(e) }) + '\n'); process.exit(1); });
|