@charlescms/astro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -0
  3. package/SECURITY.md +77 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/connector/worker.js +505 -0
  6. package/connector/wrangler.toml +15 -0
  7. package/package.json +92 -0
  8. package/scripts/check-licenses.js +45 -0
  9. package/scripts/check-package.js +62 -0
  10. package/scripts/setup.js +719 -0
  11. package/scripts/update-vendored-site.js +71 -0
  12. package/src/admin.astro +314 -0
  13. package/src/analyzer.js +639 -0
  14. package/src/asset-images.js +130 -0
  15. package/src/astro-frontmatter.js +17 -0
  16. package/src/boot.js +35 -0
  17. package/src/client.js +347 -0
  18. package/src/connector-client.js +185 -0
  19. package/src/content-bridge.js +162 -0
  20. package/src/content-panel.js +440 -0
  21. package/src/data-analyzer.js +304 -0
  22. package/src/edit-affordance.js +463 -0
  23. package/src/editor-styles.js +243 -0
  24. package/src/element-editor.js +355 -0
  25. package/src/fields.js +6 -0
  26. package/src/frontmatter.js +153 -0
  27. package/src/ids.js +20 -0
  28. package/src/index.js +681 -0
  29. package/src/js-ast.js +140 -0
  30. package/src/markdown-analyzer.js +95 -0
  31. package/src/media-preview.js +58 -0
  32. package/src/panel-manager.js +133 -0
  33. package/src/publishing.js +457 -0
  34. package/src/rich-text-editor.js +209 -0
  35. package/src/routes.js +21 -0
  36. package/src/runtime-controller.js +206 -0
  37. package/src/sanitize.js +150 -0
  38. package/src/section-editor.js +437 -0
  39. package/src/source-edit.js +310 -0
  40. package/src/source-map-runtime.js +184 -0
  41. package/src/staged-panel.js +145 -0
  42. package/src/toolbar.js +128 -0
  43. package/src/versions-panel.js +112 -0
@@ -0,0 +1,719 @@
1
+ #!/usr/bin/env node
2
+ import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import { spawn } from "node:child_process";
6
+ import { createPrivateKey, randomUUID } from "node:crypto";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import readline from "node:readline";
10
+
11
+ const RESET = "\x1b[0m";
12
+ const BOLD = "\x1b[1m";
13
+ const DIM = "\x1b[2m";
14
+ const CYAN = "\x1b[36m";
15
+ const GREEN = "\x1b[32m";
16
+ const RED = "\x1b[31m";
17
+
18
+ const args = process.argv.slice(2);
19
+ const command = args.find((arg) => !arg.startsWith("-")) || "setup";
20
+ const deployRequested = args.includes("--deploy") || process.env.CHARLESCMS_SETUP_DEPLOY === "1";
21
+ const openBrowserRequested = args.includes("--open") || process.env.CHARLESCMS_SETUP_OPEN === "1";
22
+
23
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
24
+ const projectRoot = process.cwd();
25
+ const connectorDir = join(projectRoot, ".charlescms", "connector");
26
+ const statePath = join(connectorDir, "setup-state.json");
27
+
28
+ if (args.includes("--help") || args.includes("-h") || command === "help") {
29
+ printHelp();
30
+ process.exit(0);
31
+ }
32
+ if (args.includes("--version") || args.includes("-v")) {
33
+ console.log(readPackageVersion());
34
+ process.exit(0);
35
+ }
36
+ if (!["setup", "doctor"].includes(command)) {
37
+ console.error(`Unknown command "${command}". Run \`charlescms --help\` to see usage.`);
38
+ process.exit(1);
39
+ }
40
+
41
+ main().catch((error) => {
42
+ console.error(error?.message || String(error));
43
+ process.exit(1);
44
+ });
45
+
46
+ async function main() {
47
+ if (command === "doctor") {
48
+ await doctor();
49
+ } else {
50
+ await setup();
51
+ }
52
+ }
53
+
54
+ function readPackageVersion() {
55
+ try {
56
+ return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version || "unknown";
57
+ } catch {
58
+ return "unknown";
59
+ }
60
+ }
61
+
62
+ function printHelp() {
63
+ console.log(`${BOLD}charlescms${RESET} ${DIM}v${readPackageVersion()}${RESET} — connect an Astro site to GitHub for visual editing
64
+
65
+ ${BOLD}Usage${RESET}
66
+ npx charlescms <command> [options]
67
+
68
+ ${BOLD}Commands${RESET}
69
+ ${CYAN}setup${RESET} Answer a few questions and scaffold the connector
70
+ (.charlescms/connector: worker.js, wrangler.toml, state).
71
+ ${CYAN}doctor${RESET} [url] Check the connector: files, reachability, and a live
72
+ /api/verify (repo access, origin, branch). Pass the Worker
73
+ URL, or omit it to reuse the last one.
74
+
75
+ ${BOLD}Options${RESET}
76
+ --deploy After scaffolding, create the GitHub App, store the Worker
77
+ secrets, and run \`wrangler deploy\`.
78
+ --open Open the GitHub App setup page in your browser automatically.
79
+ -h, --help Show this help.
80
+ -v, --version Print the version.
81
+
82
+ ${BOLD}Examples${RESET}
83
+ npx charlescms setup --deploy --open
84
+ npx charlescms doctor https://my-connector.workers.dev
85
+
86
+ ${DIM}Tip: the editor password is the Worker secret AUTH_SECRET; editors enter it on /cms.${RESET}`);
87
+ }
88
+
89
+ async function setup() {
90
+ const existing = await readSetupState();
91
+ if (!process.stdin.isTTY) {
92
+ const repo = process.env.CHARLESCMS_SETUP_REPO || existing.repo || guessRepo();
93
+ const config = validateSetupConfig({
94
+ repo,
95
+ origin: process.env.CHARLESCMS_SETUP_ORIGIN || existing.origin || "http://localhost:4321",
96
+ workerName: process.env.CHARLESCMS_SETUP_WORKER || existing.workerName || safeWorkerName(repo),
97
+ // Detect the repo's real default branch so edits never target a missing
98
+ // "main" on a "master" repo (the #1 silent publish failure).
99
+ branch: process.env.CHARLESCMS_SETUP_BRANCH || existing.branch || (await detectDefaultBranch(repo)) || "main"
100
+ });
101
+ await writeScaffold(config);
102
+ if (deployRequested) {
103
+ const authSecret = process.env.CHARLESCMS_SETUP_AUTH_SECRET || "";
104
+ if (!authSecret) {
105
+ throw new Error("CHARLESCMS_SETUP_AUTH_SECRET is required for non-interactive deployment.");
106
+ }
107
+ await deployConnector(config, { authSecret });
108
+ }
109
+ return;
110
+ }
111
+
112
+ console.log(`\n${BOLD}CharlesCMS connector setup${RESET}`);
113
+ console.log(`${DIM}You need a GitHub repository and a Cloudflare account. The Worker fits Cloudflare's free tier for typical small sites.${RESET}`);
114
+ console.log(`${DIM}This setup creates the connector files; --deploy also creates the GitHub App, stores secrets, and publishes the Worker.${RESET}`);
115
+ console.log(`${DIM}Suggested answers are filled in — press Enter to accept, or type to change. (Lists: ↑/↓ then Enter.)${RESET}`);
116
+ if (existing.updatedAt) {
117
+ console.log(`${DIM}Your previous answers are pre-filled too.${RESET}`);
118
+ }
119
+
120
+ const repo = await promptText({
121
+ title: "Your GitHub repository",
122
+ hint: "Format: owner/repo · example: your-name/your-site",
123
+ defaultValue: validRepoPath(existing.repo) ? existing.repo : "",
124
+ validate: validRepoPath,
125
+ error: "Enter the full owner/repo path, e.g. your-name/your-site."
126
+ });
127
+
128
+ const origin = await promptOrigin(existing);
129
+
130
+ const workerName = await promptText({
131
+ title: "Name for the connector (a Cloudflare Worker)",
132
+ hint: "Lowercase letters, numbers and hyphens. Becomes part of its address: <name>.workers.dev",
133
+ defaultValue: validWorkerName(existing.workerName) ? existing.workerName : safeWorkerName(repo),
134
+ validate: validWorkerName,
135
+ error: "Use only lowercase letters, numbers, and hyphens — e.g. acme-cms."
136
+ });
137
+
138
+ // Look up the repo's real default branch so the answer is correct by default
139
+ // and nobody has to know whether their repo uses main or master.
140
+ const detectedBranch = await detectDefaultBranch(repo);
141
+ const branch = await promptText({
142
+ title: "Which branch should edits be published to?",
143
+ hint: detectedBranch
144
+ ? `Detected your repository's default branch: ${detectedBranch}. Press Enter to use it.`
145
+ : "Usually main. Older projects sometimes use master.",
146
+ defaultValue: validBranch(existing.branch) ? existing.branch : (detectedBranch || "main"),
147
+ validate: validBranch,
148
+ error: "Enter a branch name, for example main."
149
+ });
150
+
151
+ const authSecret = await resolveEditorPassword();
152
+
153
+ const config = validateSetupConfig({ repo, origin, workerName, branch });
154
+ printSummary(config, { protected: Boolean(authSecret) });
155
+ await writeScaffold(config);
156
+ if (deployRequested) {
157
+ await deployConnector(config, { authSecret });
158
+ } else if (authSecret) {
159
+ console.log(`\n${DIM}Run \`npx charlescms setup --deploy\` (or the wrangler command above) to store the editor password.${RESET}`);
160
+ }
161
+ }
162
+
163
+ // The editor password (Worker secret AUTH_SECRET) is mandatory because the
164
+ // connector deliberately fails closed without it.
165
+ // Kept out of setup-state.json on disk; it is only ever sent to `wrangler secret`.
166
+ async function resolveEditorPassword() {
167
+ if (process.env.CHARLESCMS_SETUP_AUTH_SECRET) return process.env.CHARLESCMS_SETUP_AUTH_SECRET;
168
+ if (!process.stdin.isTTY) return "";
169
+ const choice = await promptSelect({
170
+ title: "Create the required editor password",
171
+ hint: "Editors enter this on /cms before they can publish. It is stored only as the Worker's AUTH_SECRET.",
172
+ options: [
173
+ { label: "Generate one for me", value: "generate", hint: "a strong random password" },
174
+ { label: "Type my own", value: "custom", hint: "pick your own" }
175
+ ],
176
+ defaultValue: "generate"
177
+ });
178
+ if (choice === "custom") {
179
+ return promptText({
180
+ title: "Choose an editor password",
181
+ hint: "At least 8 characters. Editors type this on /cms.",
182
+ validate: (value) => value.trim().length >= 8,
183
+ error: "Use at least 8 characters."
184
+ });
185
+ }
186
+ const secret = randomUUID();
187
+ console.log(`\n ${BOLD}Editor password:${RESET} ${GREEN}${secret}${RESET}`);
188
+ console.log(` ${DIM}Save this now. Editors type it on /cms. Change it later with: npx wrangler secret put AUTH_SECRET${RESET}`);
189
+ return secret;
190
+ }
191
+
192
+ async function promptOrigin(existing) {
193
+ // Local development (http://localhost:4321) is always allowed, so the only
194
+ // thing to ask is the public website address. It's optional: you can add it
195
+ // now or later (re-running setup keeps localhost and merges in this URL), and
196
+ // the connector accepts edits from both.
197
+ const existingDomain = parseOrigins(existing.origin).find((origin) => !isLocalOrigin(origin)) || "";
198
+ return promptText({
199
+ title: "Your live website address (optional)",
200
+ hint: "The public URL where you'll open the editor in production, e.g. https://www.yoursite.com. Leave blank for now — local development always works and you can add this later.",
201
+ defaultValue: existingDomain,
202
+ validate: (value) => value === "" || validHttpUrl(value),
203
+ error: "Enter a full address including https://, e.g. https://www.yoursite.com — or leave it blank."
204
+ });
205
+ }
206
+
207
+ function printSummary(config, options = {}) {
208
+ console.log(`\n${BOLD}Configuration summary${RESET}`);
209
+ console.log(` ${DIM}Repository${RESET} ${config.repo}`);
210
+ console.log(` ${DIM}Branch${RESET} ${config.branch}`);
211
+ console.log(` ${DIM}Editor at${RESET} ${config.origin}`);
212
+ console.log(` ${DIM}Connector${RESET} ${config.workerName}.workers.dev`);
213
+ console.log(` ${DIM}Password${RESET} ${options.protected ? "set (editors enter it on /cms)" : "set during deployment"}`);
214
+ }
215
+
216
+ async function writeScaffold({ repo, origin, workerName, branch }) {
217
+ const config = { repo, origin, workerName, branch };
218
+ await mkdir(connectorDir, { recursive: true });
219
+ await copyFile(join(packageRoot, "connector", "worker.js"), join(connectorDir, "worker.js"));
220
+ const wrangler = await renderWrangler({ workerName, origin, repo, branch });
221
+ await writeFile(join(connectorDir, "wrangler.toml"), wrangler);
222
+ await writeSetupState(config);
223
+
224
+ console.log("\nConnector files created. Your site source was not changed:");
225
+ console.log(` ${join(connectorDir, "worker.js")}`);
226
+ console.log(` ${join(connectorDir, "wrangler.toml")}`);
227
+ console.log(` ${statePath}`);
228
+ console.log("\nTo deploy the connector yourself:");
229
+ console.log(` cd ${connectorDir}`);
230
+ console.log(" npx wrangler login");
231
+ console.log(" npx wrangler secret put GITHUB_APP_ID");
232
+ console.log(" npx wrangler secret put GITHUB_PRIVATE_KEY");
233
+ console.log(" npx wrangler secret put AUTH_SECRET # required editor password");
234
+ console.log(" npx wrangler deploy");
235
+ console.log("\nEasiest next step: run `npx charlescms setup --deploy --open`.");
236
+ console.log("It creates the GitHub App, stores the secrets, deploys the Worker, and prints the connector URL for /cms.");
237
+ }
238
+
239
+ async function deployConnector(config, { authSecret = "" } = {}) {
240
+ console.log("\nDeploying the connector…");
241
+ const app = process.env.CHARLESCMS_SETUP_GITHUB_APP_ID && process.env.CHARLESCMS_SETUP_GITHUB_PRIVATE_KEY
242
+ ? {
243
+ id: process.env.CHARLESCMS_SETUP_GITHUB_APP_ID,
244
+ client_id: process.env.CHARLESCMS_SETUP_GITHUB_CLIENT_ID || "",
245
+ pem: process.env.CHARLESCMS_SETUP_GITHUB_PRIVATE_KEY,
246
+ slug: process.env.CHARLESCMS_SETUP_GITHUB_APP_SLUG || ""
247
+ }
248
+ : await runGitHubManifestFlow(config);
249
+ await putWranglerSecret("GITHUB_APP_ID", String(app.id));
250
+ if (app.client_id) await putWranglerSecret("GITHUB_CLIENT_ID", String(app.client_id));
251
+ await putWranglerSecret("GITHUB_PRIVATE_KEY", normalizeGitHubPrivateKey(app.pem));
252
+ await writeSetupState({ ...config, githubAppId: String(app.id), githubClientId: String(app.client_id || ""), githubAppSlug: app.slug || "" });
253
+ if (app.html_url) console.log(` GitHub App created: ${app.html_url}`);
254
+
255
+ if (authSecret) await putWranglerSecret("AUTH_SECRET", authSecret);
256
+
257
+ await runWrangler(["deploy"], "deploy the Cloudflare Worker");
258
+
259
+ // The GitHub App is created but not yet installed on the repo — this is the
260
+ // step users miss most, so surface it loudly and open it for them.
261
+ const installUrl = app.slug ? `https://github.com/apps/${app.slug}/installations/new` : "";
262
+ if (installUrl) {
263
+ console.log(`\n${BOLD}Last step — install the GitHub App on your repository:${RESET}`);
264
+ console.log(` ${CYAN}${installUrl}${RESET}`);
265
+ console.log(` ${DIM}Choose "${config.repo}" → Install. Without this the connector cannot read or commit.${RESET}`);
266
+ if (process.stdin.isTTY) openBrowser(installUrl).catch(() => {});
267
+ }
268
+
269
+ console.log("\nThen verify:");
270
+ console.log(` npx charlescms doctor https://${config.workerName}.<your-workers-subdomain>.workers.dev`);
271
+ }
272
+
273
+ async function runGitHubManifestFlow(config) {
274
+ const state = randomUUID();
275
+ const server = createServer();
276
+ const callback = await new Promise((resolve, reject) => {
277
+ server.on("request", (req, res) => {
278
+ const url = new URL(req.url, "http://127.0.0.1");
279
+ if (url.pathname === "/") {
280
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
281
+ res.end(renderGitHubManifestPage({ config, state, redirectUrl: callbackUrl(server) }));
282
+ return;
283
+ }
284
+ if (url.pathname === "/callback") {
285
+ if (url.searchParams.get("state") !== state) {
286
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
287
+ res.end("CharlesCMS setup state did not match. Return to the terminal and try again.");
288
+ reject(new Error("GitHub App manifest state mismatch."));
289
+ server.close();
290
+ return;
291
+ }
292
+ const code = url.searchParams.get("code");
293
+ res.writeHead(code ? 200 : 400, { "content-type": "text/plain; charset=utf-8" });
294
+ res.end(code ? "CharlesCMS received the GitHub App code. You can return to the terminal." : "GitHub did not send a code.");
295
+ if (code) resolve(code);
296
+ else reject(new Error("GitHub did not return a manifest code."));
297
+ server.close();
298
+ return;
299
+ }
300
+ res.writeHead(404).end();
301
+ });
302
+ server.on("error", reject);
303
+ server.listen(Number(process.env.CHARLESCMS_SETUP_CALLBACK_PORT || 0), "127.0.0.1", () => {
304
+ const url = callbackUrl(server);
305
+ console.log(` Open this local setup page: ${url}`);
306
+ console.log(" It submits a GitHub App manifest and waits for GitHub to redirect back here.");
307
+ if (openBrowserRequested) openBrowser(url).catch((error) => console.log(` Could not open browser automatically: ${error.message}`));
308
+ });
309
+ });
310
+ return convertGitHubManifest(callback);
311
+ }
312
+
313
+ function callbackUrl(server) {
314
+ const address = server.address();
315
+ return `http://127.0.0.1:${address.port}`;
316
+ }
317
+
318
+ function renderGitHubManifestPage({ config, state, redirectUrl }) {
319
+ const appName = config.workerName.slice(0, 34);
320
+ const appOwner = process.env.CHARLESCMS_SETUP_GITHUB_APP_OWNER || "user";
321
+ const repoOwner = config.repo.split("/")[0];
322
+ const manifest = {
323
+ name: appName,
324
+ url: config.origin,
325
+ redirect_url: `${redirectUrl}/callback`,
326
+ callback_urls: [`${redirectUrl}/callback`],
327
+ public: false,
328
+ default_events: [],
329
+ default_permissions: {
330
+ contents: "write",
331
+ metadata: "read"
332
+ },
333
+ description: `CharlesCMS connector for ${config.repo}`
334
+ };
335
+ const action = appOwner === "org"
336
+ ? `https://github.com/organizations/${encodeURIComponent(repoOwner)}/settings/apps/new?state=${encodeURIComponent(state)}`
337
+ : `https://github.com/settings/apps/new?state=${encodeURIComponent(state)}`;
338
+ return `<!doctype html>
339
+ <html lang="en">
340
+ <meta charset="utf-8">
341
+ <title>CharlesCMS GitHub App setup</title>
342
+ <body>
343
+ <p>Creating a GitHub App for CharlesCMS...</p>
344
+ <form action="${escapeHtml(action)}" method="post">
345
+ <input type="hidden" name="manifest" value="${escapeHtml(JSON.stringify(manifest))}">
346
+ <button type="submit">Continue to GitHub</button>
347
+ </form>
348
+ <script>document.querySelector("form").submit();</script>
349
+ </body>
350
+ </html>`;
351
+ }
352
+
353
+ async function convertGitHubManifest(code) {
354
+ const res = await fetch(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, {
355
+ method: "POST",
356
+ headers: {
357
+ accept: "application/vnd.github+json",
358
+ "user-agent": "charlescms-setup",
359
+ "x-github-api-version": "2022-11-28"
360
+ }
361
+ });
362
+ const data = await res.json().catch(() => ({}));
363
+ if (!res.ok) throw new Error(`GitHub App manifest conversion failed (${res.status}): ${data.message || "request failed"}`);
364
+ if (!data.id || !data.pem) throw new Error("GitHub App manifest conversion did not return an app id and private key.");
365
+ return data;
366
+ }
367
+
368
+ async function putWranglerSecret(name, value) {
369
+ console.log(` Storing Worker secret ${name}`);
370
+ await runWrangler(["secret", "put", name], `store ${name}`, value);
371
+ }
372
+
373
+ function runWrangler(args, label, stdinValue = null) {
374
+ return new Promise((resolve, reject) => {
375
+ const child = spawn("npx", ["wrangler", ...args], {
376
+ cwd: connectorDir,
377
+ stdio: ["pipe", "inherit", "pipe"],
378
+ // On Windows, npm/npx are .cmd batch files that only resolve via a shell.
379
+ shell: process.platform === "win32"
380
+ });
381
+ let stderr = "";
382
+ child.stderr.on("data", (chunk) => {
383
+ stderr += chunk;
384
+ });
385
+ child.on("error", reject);
386
+ child.on("close", (code) => {
387
+ if (code === 0) resolve();
388
+ else reject(new Error(`Could not ${label}. Wrangler exited with ${code}: ${stderr.trim()}`));
389
+ });
390
+ if (stdinValue) child.stdin.end(`${stdinValue}\n`);
391
+ else child.stdin.end();
392
+ });
393
+ }
394
+
395
+ function openBrowser(url) {
396
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
397
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
398
+ return new Promise((resolve, reject) => {
399
+ const child = spawn(command, args, { stdio: "ignore" });
400
+ child.on("error", reject);
401
+ child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${command} exited with ${code}`)));
402
+ });
403
+ }
404
+
405
+ async function doctor() {
406
+ let ok = true;
407
+
408
+ console.log("Connector files:");
409
+ for (const file of ["worker.js", "wrangler.toml", "setup-state.json"]) {
410
+ const path = join(connectorDir, file);
411
+ const exists = existsSync(path);
412
+ console.log(` ${exists ? "OK " : "MISSING"} ${path}`);
413
+ ok &&= exists;
414
+ }
415
+
416
+ const state = await readSetupState();
417
+ const url = String(process.argv[3] || process.env.CHARLESCMS_CONNECTOR_URL || state.connectorUrl || "")
418
+ .trim()
419
+ .replace(/\/+$/, "");
420
+
421
+ if (!url) {
422
+ console.log("\nNo connector URL yet — skipping the live check.");
423
+ console.log("After you deploy, run: charlescms doctor https://<your-worker>.workers.dev");
424
+ process.exit(ok ? 0 : 1);
425
+ }
426
+
427
+ // Remember the URL so future runs can just be `charlescms doctor`.
428
+ if (url !== state.connectorUrl) await writeSetupState({ ...state, connectorUrl: url });
429
+
430
+ // setup-state stores origins as a comma-separated allow-list, but an HTTP
431
+ // Origin header is a single value — send just one (the first), or the check
432
+ // 403s with "Origin is not allowed" even when configured correctly.
433
+ const origin = parseOrigins(state.origin)[0] || "http://localhost:4321";
434
+ const repo = state.repo || "";
435
+ const branch = state.branch || "main";
436
+
437
+ console.log(`\nLive connector check (${url}):`);
438
+
439
+ // 1) Is the Worker deployed and reachable? (403 still means it answered.)
440
+ try {
441
+ const res = await fetch(url, { headers: { origin } });
442
+ const up = res.ok || res.status === 403;
443
+ console.log(` ${up ? "OK " : "FAIL "} reachable (HTTP ${res.status})`);
444
+ if (!up) process.exit(1);
445
+ } catch (error) {
446
+ console.log(` FAIL not reachable — is the Worker deployed? (${error.message})`);
447
+ process.exit(1);
448
+ }
449
+
450
+ // 2) One /api/verify call exercises the whole chain: origin allow-list, repo
451
+ // allow-list, secrets, and the GitHub App actually reaching the repo.
452
+ // If the connector enforces an editor password (AUTH_SECRET), every /api/* call —
453
+ // including verify — needs the x-charlescms-auth header, so send it here too.
454
+ const verify = (secret) => fetch(`${url}/api/verify`, {
455
+ method: "POST",
456
+ headers: { "content-type": "application/json", origin, ...(secret ? { "x-charlescms-auth": secret } : {}) },
457
+ body: JSON.stringify({ repo, branch })
458
+ });
459
+ try {
460
+ let res = await verify(process.env.CHARLESCMS_AUTH_SECRET || "");
461
+ if (res.status === 401 && !process.env.CHARLESCMS_AUTH_SECRET && process.stdin.isTTY) {
462
+ const secret = await promptText({
463
+ title: "This connector requires an editor password",
464
+ hint: "Enter it to finish the check (or set CHARLESCMS_AUTH_SECRET to skip this prompt).",
465
+ validate: (value) => value.length > 0,
466
+ error: "Enter the editor password."
467
+ });
468
+ res = await verify(secret);
469
+ }
470
+ const data = await res.json().catch(() => ({}));
471
+ if (res.ok) {
472
+ console.log(` OK repo access: ${repo} (default branch "${data.defaultBranch}", ${data.private ? "private" : "public"})`);
473
+ // The #1 silent failure: edits target a branch that does not exist (e.g.
474
+ // the config says "main" but the repo's default is "master"). verify still
475
+ // succeeds because it reads repo metadata, so flag the mismatch loudly.
476
+ if (data.defaultBranch && branch && branch !== data.defaultBranch) {
477
+ console.log(` WARN you publish to "${branch}", but the repository's default branch is "${data.defaultBranch}".`);
478
+ console.log(` → If "${branch}" doesn't exist, every publish fails with 404. Set the branch to "${data.defaultBranch}"`);
479
+ console.log(` (re-run setup, or reconnect on /cms and leave the branch field blank to use the default).`);
480
+ process.exit(1);
481
+ }
482
+ console.log("\nConnector is configured correctly. ✅");
483
+ process.exit(ok ? 0 : 1);
484
+ }
485
+ console.log(` FAIL verify (HTTP ${res.status}): ${data.error || "request failed"}`);
486
+ console.log(diagnoseVerify(res.status, origin, repo, data.error));
487
+ if (res.status === 404 && state.githubAppSlug) {
488
+ console.log(` Install it here: https://github.com/apps/${state.githubAppSlug}/installations/new`);
489
+ }
490
+ process.exit(1);
491
+ } catch (error) {
492
+ console.log(` FAIL verify request failed (${error.message})`);
493
+ process.exit(1);
494
+ }
495
+ }
496
+
497
+ function diagnoseVerify(status, origin, repo, error = "") {
498
+ // The connector's own allow-list rejections say "not allowed"; a 403 without
499
+ // that phrasing came from GitHub downstream (often a missing User-Agent header
500
+ // or revoked credentials), not from ALLOWED_ORIGINS/ALLOWED_REPOS.
501
+ if (status === 401) {
502
+ return " → The connector requires an editor password. Set CHARLESCMS_AUTH_SECRET (or enter it when prompted) to the same value as the Worker's AUTH_SECRET secret.";
503
+ }
504
+ if (status === 403 && /not allowed/i.test(error)) {
505
+ return ` → The connector rejected origin "${origin}" or repo "${repo}".\n Check ALLOWED_ORIGINS and ALLOWED_REPOS in .charlescms/connector/wrangler.toml, then redeploy.`;
506
+ }
507
+ if (status === 403) {
508
+ return ` → GitHub returned 403 for "${repo}". The GitHub App may lack access, or the deployed Worker is outdated — redeploy it with \`npx wrangler deploy\`.`;
509
+ }
510
+ if (status === 404) {
511
+ return ` → Repo "${repo}" not found, or the GitHub App isn't installed on it. Install the app on the repo.`;
512
+ }
513
+ if (status >= 500) {
514
+ return " → Connector error — usually missing/invalid secrets. Re-run `npx wrangler secret put GITHUB_APP_ID` and `GITHUB_PRIVATE_KEY`.";
515
+ }
516
+ return "";
517
+ }
518
+
519
+ async function readSetupState() {
520
+ try {
521
+ return JSON.parse(await readFile(statePath, "utf8"));
522
+ } catch {
523
+ return {};
524
+ }
525
+ }
526
+
527
+ async function writeSetupState(config) {
528
+ await writeFile(statePath, `${JSON.stringify({ ...config, updatedAt: new Date().toISOString() }, null, 2)}\n`);
529
+ }
530
+
531
+ async function renderWrangler({ workerName, origin, repo, branch }) {
532
+ const template = await readFile(join(packageRoot, "connector", "wrangler.toml"), "utf8");
533
+ return template
534
+ .replace(/^name = .+$/m, `name = ${JSON.stringify(workerName)}`)
535
+ .replace(/^ALLOWED_ORIGINS = .+$/m, `ALLOWED_ORIGINS = ${JSON.stringify(origin)}`)
536
+ .replace(/^ALLOWED_REPOS = .+$/m, `ALLOWED_REPOS = ${JSON.stringify(repo)}`)
537
+ .replace(/^DEFAULT_BRANCH = .+$/m, `DEFAULT_BRANCH = ${JSON.stringify(branch)}`);
538
+ }
539
+
540
+ // Arrow-key single-select. Falls back to the default when stdin isn't a TTY
541
+ // (piped input, CI) so non-interactive runs never hang.
542
+ function promptSelect({ title, hint, options, defaultValue }) {
543
+ return new Promise((resolve) => {
544
+ let index = Math.max(0, options.findIndex((option) => option.value === defaultValue));
545
+ if (title) console.log(`\n ${BOLD}${title}${RESET}`);
546
+ if (hint) console.log(` ${DIM}${hint}${RESET}`);
547
+ if (!process.stdin.isTTY) {
548
+ resolve(options[index].value);
549
+ return;
550
+ }
551
+
552
+ const stdin = process.stdin;
553
+ readline.emitKeypressEvents(stdin);
554
+ stdin.setRawMode(true);
555
+ stdin.resume();
556
+ console.log(` ${DIM}(use ↑/↓ or 1-${options.length}, then Enter)${RESET}`);
557
+
558
+ let drawn = false;
559
+ const draw = () => {
560
+ if (drawn) process.stdout.write(`\x1b[${options.length}A`);
561
+ drawn = true;
562
+ options.forEach((option, i) => {
563
+ const active = i === index;
564
+ const pointer = active ? `${CYAN}❯${RESET}` : " ";
565
+ const label = active ? `${CYAN}${option.label}${RESET}` : option.label;
566
+ const tail = option.hint ? ` ${DIM}${option.hint}${RESET}` : "";
567
+ process.stdout.write(`\x1b[2K ${pointer} ${label}${tail}\n`);
568
+ });
569
+ };
570
+ draw();
571
+
572
+ const onKey = (str, key) => {
573
+ if (!key) return;
574
+ if (key.name === "up") index = (index - 1 + options.length) % options.length;
575
+ else if (key.name === "down") index = (index + 1) % options.length;
576
+ else if (/^[1-9]$/.test(str || "") && Number(str) <= options.length) index = Number(str) - 1;
577
+ else if (key.name === "return") return finish();
578
+ else if (key.ctrl && key.name === "c") { finish(); console.log(); process.exit(130); }
579
+ else return;
580
+ draw();
581
+ };
582
+ const finish = () => {
583
+ stdin.removeListener("keypress", onKey);
584
+ stdin.setRawMode(false);
585
+ stdin.pause();
586
+ process.stdout.write(`\x1b[${options.length}A`);
587
+ options.forEach((option, i) => {
588
+ const chosen = i === index ? `${GREEN}✔${RESET} ${option.label}` : `${DIM} ${option.label}${RESET}`;
589
+ process.stdout.write(`\x1b[2K ${chosen}\n`);
590
+ });
591
+ resolve(options[index].value);
592
+ };
593
+ stdin.on("keypress", onKey);
594
+ });
595
+ }
596
+
597
+ // Free-text prompt with a title + dimmed hint and inline default. Re-asks until
598
+ // the value validates.
599
+ function promptText({ title, hint, defaultValue = "", validate, error }) {
600
+ return new Promise((resolve) => {
601
+ if (title) console.log(`\n ${BOLD}${title}${RESET}`);
602
+ if (hint) console.log(` ${DIM}${hint}${RESET}`);
603
+ const ask = () => {
604
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
605
+ rl.question(` ${CYAN}❯${RESET} `, (answer) => {
606
+ rl.close();
607
+ const value = (answer || "").trim() || defaultValue;
608
+ if (!validate || validate(value)) {
609
+ resolve(value);
610
+ return;
611
+ }
612
+ console.log(` ${RED}${error || "Please try again."}${RESET}`);
613
+ ask();
614
+ });
615
+ // Pre-fill the suggested answer as editable text so it's obvious that
616
+ // pressing Enter accepts it (and you can just edit/clear it to change it).
617
+ if (defaultValue) rl.write(defaultValue);
618
+ };
619
+ ask();
620
+ });
621
+ }
622
+
623
+ function validateSetupConfig(config) {
624
+ const errors = [];
625
+ if (!validRepoPath(config.repo)) errors.push("repo must be a full owner/repo path");
626
+ // The origin is optional (localhost is always added). Only reject a value that
627
+ // was provided but isn't a usable http(s) URL.
628
+ if (String(config.origin || "").trim() && !parseOrigins(config.origin).length) {
629
+ errors.push("origin must be a full http:// or https:// URL");
630
+ }
631
+ if (!validWorkerName(config.workerName)) errors.push("worker name must contain only letters, numbers, and hyphens");
632
+ if (!validBranch(config.branch)) errors.push("branch is required");
633
+ if (errors.length) throw new Error(`Invalid CharlesCMS setup: ${errors.join("; ")}.`);
634
+ return {
635
+ ...config,
636
+ repo: String(config.repo).trim().replace(/^\/+|\/+$/g, ""),
637
+ origin: mergeOrigins(config.origin),
638
+ workerName: String(config.workerName).trim(),
639
+ branch: String(config.branch).trim()
640
+ };
641
+ }
642
+
643
+ function validRepoPath(value) {
644
+ const repo = String(value || "").trim().replace(/^\/+|\/+$/g, "");
645
+ return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo);
646
+ }
647
+
648
+ function validHttpUrl(value) {
649
+ try {
650
+ const url = new URL(String(value || "").trim());
651
+ return ["http:", "https:"].includes(url.protocol) && Boolean(url.hostname);
652
+ } catch {
653
+ return false;
654
+ }
655
+ }
656
+
657
+ function parseOrigins(value) {
658
+ return String(value || "")
659
+ .split(",")
660
+ .map(normalizeUrl)
661
+ .filter(Boolean)
662
+ .filter(validHttpUrl);
663
+ }
664
+
665
+ function mergeOrigins(value) {
666
+ return [...new Set(["http://localhost:4321", ...parseOrigins(value)])].join(",");
667
+ }
668
+
669
+ function isLocalOrigin(value) {
670
+ return /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/i.test(String(value || ""));
671
+ }
672
+
673
+ function validWorkerName(value) {
674
+ return /^[a-z0-9][a-z0-9-]{0,62}$/i.test(String(value || "").trim());
675
+ }
676
+
677
+ function validBranch(value) {
678
+ return Boolean(String(value || "").trim());
679
+ }
680
+
681
+ function normalizeUrl(value) {
682
+ return String(value || "").trim().replace(/\/+$/, "");
683
+ }
684
+
685
+ // Ask GitHub for the repository's default branch (public repos need no auth).
686
+ // Returns "" on any failure so setup falls back to the user's answer / "main".
687
+ async function detectDefaultBranch(repo) {
688
+ if (!validRepoPath(repo)) return "";
689
+ try {
690
+ const res = await fetch(`https://api.github.com/repos/${repo}`, {
691
+ headers: { accept: "application/vnd.github+json", "user-agent": "charlescms-setup", "x-github-api-version": "2022-11-28" }
692
+ });
693
+ if (!res.ok) return "";
694
+ const data = await res.json();
695
+ return typeof data.default_branch === "string" ? data.default_branch : "";
696
+ } catch {
697
+ return "";
698
+ }
699
+ }
700
+
701
+ function guessRepo() {
702
+ return "owner/site";
703
+ }
704
+
705
+ function safeWorkerName(repo) {
706
+ return `charlescms-${String(repo || "site").replace(/[^a-z0-9-]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase()}`;
707
+ }
708
+
709
+ function normalizeGitHubPrivateKey(pem) {
710
+ return createPrivateKey(String(pem)).export({ type: "pkcs8", format: "pem" });
711
+ }
712
+
713
+ function escapeHtml(value) {
714
+ return String(value)
715
+ .replace(/&/g, "&amp;")
716
+ .replace(/</g, "&lt;")
717
+ .replace(/>/g, "&gt;")
718
+ .replace(/"/g, "&quot;");
719
+ }