pagecast 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.
package/src/server.js ADDED
@@ -0,0 +1,3918 @@
1
+ import { createServer } from "node:http";
2
+ import { spawn } from "node:child_process";
3
+ import { createReadStream, watch as fsWatch } from "node:fs";
4
+ import { promises as fs } from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { randomBytes } from "node:crypto";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+
10
+ import { markdownToHtml } from "./markdown.js";
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+ const PROJECT_ROOT = path.resolve(__dirname, "..");
15
+
16
+ export const DEFAULT_HOST = "127.0.0.1";
17
+ export const DEFAULT_ADMIN_PORT = 4173;
18
+ export const DEFAULT_PUBLIC_PORT = 4174;
19
+ export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
20
+ export const MAX_FOLDER_UPLOAD_BYTES = 100 * 1024 * 1024;
21
+ export const MAX_FOLDER_UPLOAD_FILES = 1000;
22
+ export const MAX_FOLDER_UPLOAD_FILE_BYTES = 25 * 1024 * 1024;
23
+ export const DEFAULT_PAGES_PROJECT_NAME = "pagecast";
24
+ export const DEFAULT_PAGES_BRANCH = "main";
25
+ export const DEFAULT_CLOUDFLARE_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
26
+ export const DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS = 60 * 1000;
27
+ export const CLOUDFLARE_OAUTH_SCOPES = ["account:read", "user:read", "pages:write"];
28
+
29
+ const MIME_TYPES = new Map([
30
+ [".html", "text/html; charset=utf-8"],
31
+ [".htm", "text/html; charset=utf-8"],
32
+ [".css", "text/css; charset=utf-8"],
33
+ [".js", "text/javascript; charset=utf-8"],
34
+ [".json", "application/json; charset=utf-8"],
35
+ [".svg", "image/svg+xml"],
36
+ [".png", "image/png"],
37
+ [".jpg", "image/jpeg"],
38
+ [".jpeg", "image/jpeg"],
39
+ [".gif", "image/gif"],
40
+ [".webp", "image/webp"],
41
+ [".ico", "image/x-icon"],
42
+ [".txt", "text/plain; charset=utf-8"],
43
+ [".csv", "text/csv; charset=utf-8"],
44
+ [".pdf", "application/pdf"],
45
+ [".woff2", "font/woff2"],
46
+ [".woff", "font/woff"],
47
+ [".map", "application/json; charset=utf-8"]
48
+ ]);
49
+
50
+ export function appError(message, statusCode = 400) {
51
+ const error = new Error(message);
52
+ error.statusCode = statusCode;
53
+ error.expose = true;
54
+ return error;
55
+ }
56
+
57
+ function contentTypeFor(filePath) {
58
+ return MIME_TYPES.get(path.extname(filePath).toLowerCase()) || "application/octet-stream";
59
+ }
60
+
61
+ function nowIso() {
62
+ return new Date().toISOString();
63
+ }
64
+
65
+ function stripTrailingSlash(value) {
66
+ return value.replace(/\/+$/, "");
67
+ }
68
+
69
+ function joinUrl(baseUrl, suffix) {
70
+ return `${stripTrailingSlash(baseUrl)}${suffix.startsWith("/") ? suffix : `/${suffix}`}`;
71
+ }
72
+
73
+ function safeJsonParse(value, fallback) {
74
+ try {
75
+ return JSON.parse(value);
76
+ } catch {
77
+ return fallback;
78
+ }
79
+ }
80
+
81
+ async function pathExists(filePath) {
82
+ try {
83
+ await fs.access(filePath);
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ function isHtmlFileName(fileName) {
91
+ const ext = path.extname(fileName).toLowerCase();
92
+ return ext === ".html" || ext === ".htm";
93
+ }
94
+
95
+ function isMarkdownFileName(fileName) {
96
+ const ext = path.extname(fileName).toLowerCase();
97
+ return ext === ".md" || ext === ".markdown";
98
+ }
99
+
100
+ // Any file type pagecast can turn into a published page: HTML as-is, or Markdown
101
+ // rendered to HTML at publish/preview time.
102
+ function isPublishableFileName(fileName) {
103
+ return isHtmlFileName(fileName) || isMarkdownFileName(fileName);
104
+ }
105
+
106
+ function isIndexFileName(fileName) {
107
+ const base = path.basename(fileName).toLowerCase();
108
+ return base === "index.html" || base === "index.htm" || base === "index.md" || base === "index.markdown";
109
+ }
110
+
111
+ function slugifyReportName(fileName) {
112
+ const baseName = path.basename(fileName, path.extname(fileName));
113
+ const slug = baseName
114
+ .toLowerCase()
115
+ .replace(/[^a-z0-9]+/g, "-")
116
+ .replace(/^-+|-+$/g, "");
117
+ return slug || "report";
118
+ }
119
+
120
+ const RESERVED_SLUGS = new Set(["p", "index", "404", ""]);
121
+
122
+ // Validate a user-supplied vanity slug for the /p/<slug>/ URL path. Enforces a
123
+ // DNS-label-like shape (lowercase, hyphen-separated, 1-63 chars) and rejects the
124
+ // reserved path segments that would collide with the staged site structure.
125
+ function normalizeCustomSlug(value) {
126
+ const slug = String(value || "").trim().toLowerCase();
127
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(slug)) {
128
+ throw appError("Custom URL must be 1-63 lowercase letters, numbers, or hyphens.", 400);
129
+ }
130
+ if (RESERVED_SLUGS.has(slug)) {
131
+ throw appError("That custom URL is reserved. Choose another.", 400);
132
+ }
133
+ return slug;
134
+ }
135
+
136
+ function createReportId(fileName) {
137
+ return `${slugifyReportName(fileName)}-${randomBytes(4).toString("hex")}`;
138
+ }
139
+
140
+ function createPublicToken(label) {
141
+ return `${slugifyReportName(label)}-${randomBytes(8).toString("hex")}`;
142
+ }
143
+
144
+ function isPathInside(rootDir, targetPath) {
145
+ const relative = path.relative(rootDir, targetPath);
146
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
147
+ }
148
+
149
+ function normalizePagesProjectName(value) {
150
+ const projectName = String(value || "").trim().toLowerCase();
151
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(projectName)) {
152
+ throw appError("Cloudflare Pages project name must be a valid lowercase slug.", 400);
153
+ }
154
+ return projectName;
155
+ }
156
+
157
+ function normalizeAccountId(value) {
158
+ const accountId = String(value || "").trim();
159
+ if (!accountId) {
160
+ return "";
161
+ }
162
+
163
+ if (!/^[a-fA-F0-9]{32}$/.test(accountId)) {
164
+ throw appError("Cloudflare account ID must be 32 hex characters.", 400);
165
+ }
166
+ return accountId;
167
+ }
168
+
169
+ function normalizePagesBranch(value = DEFAULT_PAGES_BRANCH) {
170
+ const branch = String(value || DEFAULT_PAGES_BRANCH).trim();
171
+ if (
172
+ !branch ||
173
+ branch.length > 128 ||
174
+ branch.startsWith("-") ||
175
+ branch.startsWith("/") ||
176
+ branch.endsWith("/") ||
177
+ branch.includes("..") ||
178
+ !/^[A-Za-z0-9._/-]+$/.test(branch)
179
+ ) {
180
+ throw appError("Cloudflare Pages branch must be a valid branch name.", 400);
181
+ }
182
+ return branch;
183
+ }
184
+
185
+ function normalizeAccountName(value) {
186
+ const accountName = stripAnsi(value).trim();
187
+ if (!accountName || isRedactedAccountName(accountName)) {
188
+ return "";
189
+ }
190
+ return accountName;
191
+ }
192
+
193
+ function pagesBaseUrl(projectName) {
194
+ return `https://${projectName}.pages.dev`;
195
+ }
196
+
197
+ // Derive the REAL production base URL from a `wrangler pages deploy` output.
198
+ // Cloudflare Pages subdomains are globally unique, so a project named "pagecast"
199
+ // whose subdomain is taken gets e.g. "pagecast-6cv.pages.dev" — the subdomain is
200
+ // NOT always the project name. Wrangler prints the deployment URL as
201
+ // `https://<deploy-hash>.<project-subdomain>.pages.dev`; strip the leading hash
202
+ // label to get the production host. Falls back to `<projectName>.pages.dev`.
203
+ function pagesBaseUrlFromDeployOutput(output, projectName) {
204
+ const text = stripAnsi(output || "");
205
+ const match = text.match(/https:\/\/[0-9a-f]{6,12}\.([a-z0-9-]+\.pages\.dev)/i);
206
+ if (match) {
207
+ return `https://${match[1].toLowerCase()}`;
208
+ }
209
+ return pagesBaseUrl(projectName);
210
+ }
211
+
212
+ function pagesDeploymentUrlFromDeployOutput(output, fallbackUrl = "") {
213
+ const text = stripAnsi(output || "");
214
+ const match = text.match(/https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.pages\.dev(?:\/[^\s"'<>)]*)?/i);
215
+ if (match) {
216
+ return match[0].replace(/[),.;]+$/g, "");
217
+ }
218
+ return fallbackUrl;
219
+ }
220
+
221
+ function stripAnsi(value) {
222
+ return String(value || "").replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
223
+ }
224
+
225
+ function cleanCommandOutput(output) {
226
+ return stripAnsi(output)
227
+ .replace(/\n{3,}/g, "\n\n")
228
+ .trim();
229
+ }
230
+
231
+ function normalizeConfig(config = {}) {
232
+ const projectName = normalizePagesProjectName(
233
+ config.pages?.projectName || DEFAULT_PAGES_PROJECT_NAME
234
+ );
235
+ const accountId = normalizeAccountId(config.pages?.accountId || "");
236
+ const accountName = accountId ? normalizeAccountName(config.pages?.accountName || "") : "";
237
+
238
+ return {
239
+ pages: {
240
+ projectName,
241
+ accountId,
242
+ accountName,
243
+ branch: DEFAULT_PAGES_BRANCH,
244
+ baseUrl: pagesBaseUrl(projectName)
245
+ }
246
+ };
247
+ }
248
+
249
+ export function cloudflareCredentialStatus(env = process.env) {
250
+ const tokenConfigured = Boolean(String(env.CLOUDFLARE_API_TOKEN || "").trim());
251
+ const rawAccountId = String(env.CLOUDFLARE_ACCOUNT_ID || "").trim();
252
+ let accountIdConfigured = false;
253
+ let accountId = "";
254
+
255
+ if (rawAccountId) {
256
+ try {
257
+ accountId = normalizeAccountId(rawAccountId);
258
+ accountIdConfigured = true;
259
+ } catch {
260
+ accountIdConfigured = false;
261
+ }
262
+ }
263
+
264
+ return {
265
+ authMode: tokenConfigured ? "api-token" : "scoped-oauth",
266
+ tokenConfigured,
267
+ accountIdConfigured,
268
+ accountId,
269
+ scopedOauthAvailable: true,
270
+ oauthScopes: CLOUDFLARE_OAUTH_SCOPES
271
+ };
272
+ }
273
+
274
+ function parseJsonFromCommandOutput(output) {
275
+ const text = String(output || "").trim();
276
+ if (!text) {
277
+ throw appError("Wrangler did not return JSON output.", 502);
278
+ }
279
+
280
+ try {
281
+ return JSON.parse(text);
282
+ } catch {
283
+ const firstObject = text.indexOf("{");
284
+ const firstArray = text.indexOf("[");
285
+ const starts = [firstObject, firstArray].filter((index) => index >= 0);
286
+ const start = starts.length > 0 ? Math.min(...starts) : -1;
287
+ const end = Math.max(text.lastIndexOf("}"), text.lastIndexOf("]"));
288
+ if (start >= 0 && end > start) {
289
+ try {
290
+ return JSON.parse(text.slice(start, end + 1));
291
+ } catch {
292
+ // Fall through to the public error below.
293
+ }
294
+ }
295
+ }
296
+
297
+ throw appError("Wrangler project list output was not valid JSON.", 502);
298
+ }
299
+
300
+ function firstString(...values) {
301
+ for (const value of values) {
302
+ if (typeof value === "string" && value.trim()) {
303
+ return value.trim();
304
+ }
305
+ }
306
+ return "";
307
+ }
308
+
309
+ function isRedactedAccountName(value) {
310
+ return /^\(?redacted\)?$/i.test(String(value || "").trim());
311
+ }
312
+
313
+ function extractProjectCandidates(parsed) {
314
+ if (Array.isArray(parsed)) {
315
+ return parsed;
316
+ }
317
+
318
+ if (Array.isArray(parsed?.projects)) {
319
+ return parsed.projects;
320
+ }
321
+
322
+ if (Array.isArray(parsed?.items)) {
323
+ return parsed.items;
324
+ }
325
+
326
+ if (Array.isArray(parsed?.result)) {
327
+ return parsed.result;
328
+ }
329
+
330
+ if (Array.isArray(parsed?.result?.projects)) {
331
+ return parsed.result.projects;
332
+ }
333
+
334
+ return [];
335
+ }
336
+
337
+ function parseWranglerPagesProjectTable(output) {
338
+ const text = stripAnsi(output);
339
+ const rows = text
340
+ .split(/\r?\n/)
341
+ .filter((line) => line.includes("│"))
342
+ .map((line) => line.split("│").slice(1, -1).map((column) => column.trim()))
343
+ .filter((columns) => columns.some(Boolean));
344
+
345
+ if (rows.length === 0) {
346
+ return [];
347
+ }
348
+
349
+ const headerIndex = rows.findIndex((columns) =>
350
+ columns.some((column) => /^(project\s+)?name$/i.test(column))
351
+ );
352
+ if (headerIndex === -1) {
353
+ return [];
354
+ }
355
+
356
+ const headers = rows[headerIndex].map((column) => column.toLowerCase());
357
+ const nameIndex = headers.findIndex((header) => header === "name" || header === "project name");
358
+ const branchIndex = headers.findIndex((header) => header.includes("branch"));
359
+ const accountIdIndex = headers.findIndex((header) => header === "account id");
360
+ const accountNameIndex = headers.findIndex((header) => header === "account");
361
+
362
+ return rows.slice(headerIndex + 1)
363
+ .map((columns) => {
364
+ const name = columns[nameIndex] || "";
365
+ if (!name || /^(name|project name)$/i.test(name)) {
366
+ return null;
367
+ }
368
+
369
+ return {
370
+ name,
371
+ account_id: accountIdIndex >= 0 ? columns[accountIdIndex] : "",
372
+ account_name: accountNameIndex >= 0 ? columns[accountNameIndex] : "",
373
+ production_branch: branchIndex >= 0 ? columns[branchIndex] : ""
374
+ };
375
+ })
376
+ .filter(Boolean);
377
+ }
378
+
379
+ export function parseWranglerPagesProjects(output) {
380
+ let parsed;
381
+ try {
382
+ parsed = parseJsonFromCommandOutput(output);
383
+ } catch {
384
+ parsed = parseWranglerPagesProjectTable(output);
385
+ }
386
+
387
+ return extractProjectCandidates(parsed)
388
+ .map((project) => {
389
+ const name = firstString(project?.name, project?.projectName, project?.project_name);
390
+ if (!name) {
391
+ return null;
392
+ }
393
+
394
+ let projectName;
395
+ try {
396
+ projectName = normalizePagesProjectName(name);
397
+ } catch {
398
+ return null;
399
+ }
400
+
401
+ let accountId = "";
402
+ try {
403
+ accountId = normalizeAccountId(
404
+ firstString(
405
+ project?.accountId,
406
+ project?.account_id,
407
+ project?.account?.id,
408
+ project?.account?.account_id
409
+ )
410
+ );
411
+ } catch {
412
+ accountId = "";
413
+ }
414
+
415
+ return {
416
+ name: projectName,
417
+ accountId,
418
+ accountName: firstString(project?.accountName, project?.account_name, project?.account?.name),
419
+ productionBranch: firstString(
420
+ project?.productionBranch,
421
+ project?.production_branch,
422
+ project?.deployment_configs?.production?.branch
423
+ ),
424
+ baseUrl: pagesBaseUrl(projectName)
425
+ };
426
+ })
427
+ .filter(Boolean)
428
+ .sort((a, b) => a.name.localeCompare(b.name));
429
+ }
430
+
431
+ export function chooseWranglerPagesProject(projects, pagesConfig = {}) {
432
+ if (!Array.isArray(projects) || projects.length === 0) {
433
+ return null;
434
+ }
435
+
436
+ const preferredName = String(pagesConfig.projectName || "").toLowerCase();
437
+ // Only adopt a project that is actually requested. If no explicit preference
438
+ // is available, fall back to Pagecast's default project.
439
+ if (preferredName) {
440
+ return projects.find((project) => project.name === preferredName) || null;
441
+ }
442
+ return projects.find((project) => project.name === DEFAULT_PAGES_PROJECT_NAME) || null;
443
+ }
444
+
445
+ function normalizeAccountIdSafe(value) {
446
+ try {
447
+ return normalizeAccountId(value || "");
448
+ } catch {
449
+ return "";
450
+ }
451
+ }
452
+
453
+ function parseWranglerWhoamiTable(output) {
454
+ const text = stripAnsi(output);
455
+ const rows = text
456
+ .split(/\r?\n/)
457
+ .filter((line) => line.includes("│"))
458
+ .map((line) => line.split("│").slice(1, -1).map((column) => column.trim()))
459
+ .filter((columns) => columns.some(Boolean));
460
+
461
+ if (rows.length === 0) {
462
+ return [];
463
+ }
464
+
465
+ const headerIndex = rows.findIndex((columns) =>
466
+ columns.some((column) => /account\s*id/i.test(column))
467
+ );
468
+ if (headerIndex === -1) {
469
+ return [];
470
+ }
471
+
472
+ const headers = rows[headerIndex].map((column) => column.toLowerCase());
473
+ const idIndex = headers.findIndex((header) => /account\s*id/.test(header));
474
+ const nameIndex = headers.findIndex(
475
+ (header) => /account\s*name/.test(header) || header === "account" || header === "name"
476
+ );
477
+
478
+ return rows.slice(headerIndex + 1)
479
+ .map((columns) => {
480
+ const id = normalizeAccountIdSafe(idIndex >= 0 ? columns[idIndex] : "");
481
+ const name = nameIndex >= 0 ? columns[nameIndex] || "" : "";
482
+ return id ? { id, name } : null;
483
+ })
484
+ .filter(Boolean);
485
+ }
486
+
487
+ export function parseWranglerWhoamiAccounts(output) {
488
+ let parsed = null;
489
+ try {
490
+ parsed = parseJsonFromCommandOutput(output);
491
+ } catch {
492
+ parsed = null;
493
+ }
494
+
495
+ if (parsed) {
496
+ const candidates = Array.isArray(parsed)
497
+ ? parsed
498
+ : Array.isArray(parsed.accounts)
499
+ ? parsed.accounts
500
+ : Array.isArray(parsed.result?.accounts)
501
+ ? parsed.result.accounts
502
+ : Array.isArray(parsed.result)
503
+ ? parsed.result
504
+ : [];
505
+
506
+ const accounts = candidates
507
+ .map((account) => {
508
+ const id = normalizeAccountIdSafe(
509
+ firstString(account?.id, account?.account_id, account?.accountId, account?.account?.id)
510
+ );
511
+ const name = firstString(
512
+ account?.name,
513
+ account?.account_name,
514
+ account?.accountName,
515
+ account?.account?.name
516
+ );
517
+ return id ? { id, name } : null;
518
+ })
519
+ .filter(Boolean);
520
+
521
+ if (accounts.length > 0) {
522
+ return accounts;
523
+ }
524
+ }
525
+
526
+ return parseWranglerWhoamiTable(output);
527
+ }
528
+
529
+ async function copyPublicTree(sourceRoot, destinationRoot) {
530
+ await fs.rm(destinationRoot, { recursive: true, force: true });
531
+ await fs.mkdir(destinationRoot, { recursive: true });
532
+
533
+ async function copyDirectory(currentSource, currentRelative = "") {
534
+ const entries = await fs.readdir(currentSource, { withFileTypes: true });
535
+ for (const entry of entries) {
536
+ if (entry.name.startsWith(".")) {
537
+ continue;
538
+ }
539
+
540
+ const sourcePath = path.join(currentSource, entry.name);
541
+ const relativePath = path.join(currentRelative, entry.name);
542
+ const destinationPath = path.join(destinationRoot, relativePath);
543
+
544
+ if (entry.isSymbolicLink()) {
545
+ continue;
546
+ }
547
+
548
+ if (entry.isDirectory()) {
549
+ await copyDirectory(sourcePath, relativePath);
550
+ continue;
551
+ }
552
+
553
+ if (!entry.isFile()) {
554
+ continue;
555
+ }
556
+
557
+ await fs.mkdir(path.dirname(destinationPath), { recursive: true });
558
+ await fs.copyFile(sourcePath, destinationPath);
559
+ }
560
+ }
561
+
562
+ await copyDirectory(sourceRoot);
563
+ }
564
+
565
+ async function runSpawnCommand({
566
+ spawnImpl,
567
+ command,
568
+ args,
569
+ timeoutMs,
570
+ cwd = PROJECT_ROOT,
571
+ env = process.env
572
+ }) {
573
+ return new Promise((resolve, reject) => {
574
+ let settled = false;
575
+ let child;
576
+ let output = "";
577
+
578
+ const finish = (result) => {
579
+ if (settled) {
580
+ return;
581
+ }
582
+ settled = true;
583
+ clearTimeout(timer);
584
+ resolve(result);
585
+ };
586
+
587
+ const fail = (error) => {
588
+ if (settled) {
589
+ return;
590
+ }
591
+ settled = true;
592
+ clearTimeout(timer);
593
+ terminateChild(child);
594
+ reject(error);
595
+ };
596
+
597
+ const timer = setTimeout(() => {
598
+ fail(appError(`${command} did not finish within ${timeoutMs}ms.\n${output.trim()}`, 504));
599
+ }, timeoutMs);
600
+ timer.unref?.();
601
+
602
+ try {
603
+ child = spawnImpl(command, args, {
604
+ cwd,
605
+ stdio: ["ignore", "pipe", "pipe"],
606
+ env
607
+ });
608
+ } catch (error) {
609
+ fail(appError(`${command} could not start.`, 502));
610
+ return;
611
+ }
612
+
613
+ const recordOutput = (chunk) => {
614
+ output += chunk.toString();
615
+ };
616
+
617
+ child.stdout?.on("data", recordOutput);
618
+ child.stderr?.on("data", recordOutput);
619
+ child.on("error", () => fail(appError(`${command} could not start.`, 502)));
620
+ child.on("exit", (code, signal) => finish({ code, signal, output }));
621
+ });
622
+ }
623
+
624
+ export function trimPastedLocalPathInput(inputPath) {
625
+ if (typeof inputPath !== "string") {
626
+ return "";
627
+ }
628
+
629
+ let value = inputPath.trim();
630
+ const wrappers = [
631
+ ['"', '"'],
632
+ ["'", "'"],
633
+ ["`", "`"],
634
+ ["<", ">"]
635
+ ];
636
+
637
+ let changed = true;
638
+ while (changed && value.length >= 2) {
639
+ changed = false;
640
+ for (const [open, close] of wrappers) {
641
+ if (value.startsWith(open) && value.endsWith(close)) {
642
+ value = value.slice(1, -1).trim();
643
+ changed = true;
644
+ }
645
+ }
646
+ }
647
+
648
+ return value;
649
+ }
650
+
651
+ function coercePastedValueToLocalPath(value) {
652
+ const schemeMatch = /^([a-zA-Z][a-zA-Z\d+.-]*):/.exec(value);
653
+ if (!schemeMatch) {
654
+ return value.replace(/^~(?=$|\/)/, os.homedir());
655
+ }
656
+
657
+ if (schemeMatch[1].toLowerCase() !== "file") {
658
+ throw appError("Only local file paths or file:// URLs can be shared.", 400);
659
+ }
660
+
661
+ try {
662
+ return fileURLToPath(value);
663
+ } catch {
664
+ throw appError("File URL could not be converted to a local path.", 400);
665
+ }
666
+ }
667
+
668
+ export function localHtmlPathCandidates(inputPath) {
669
+ const trimmedValue = trimPastedLocalPathInput(inputPath);
670
+ const trailingTrimmedValue = trimmedValue.replace(/[),.;]+$/g, "");
671
+ const values = [trimmedValue, trailingTrimmedValue].filter(
672
+ (value, index, allValues) => value && allValues.indexOf(value) === index
673
+ );
674
+
675
+ return values.map(coercePastedValueToLocalPath);
676
+ }
677
+
678
+ export function normalizeAssetRequestPath(rawPath) {
679
+ const trimmed = rawPath.replace(/^\/+/, "");
680
+ if (trimmed === "") {
681
+ return "";
682
+ }
683
+
684
+ let decoded;
685
+ try {
686
+ decoded = decodeURIComponent(trimmed);
687
+ } catch {
688
+ return null;
689
+ }
690
+
691
+ if (decoded.includes("\0")) {
692
+ return null;
693
+ }
694
+
695
+ const segments = decoded.split("/");
696
+ if (
697
+ segments.some(
698
+ (segment) => segment === "" || segment === "." || segment === ".." || segment.startsWith(".")
699
+ )
700
+ ) {
701
+ return null;
702
+ }
703
+
704
+ return segments.join(path.sep);
705
+ }
706
+
707
+ export async function normalizeLocalHtmlPath(inputPath) {
708
+ if (typeof inputPath !== "string" || trimPastedLocalPathInput(inputPath) === "") {
709
+ throw appError("Provide an absolute path to an HTML file.", 400);
710
+ }
711
+
712
+ const candidates = localHtmlPathCandidates(inputPath);
713
+ let missingError = null;
714
+
715
+ for (const [index, candidate] of candidates.entries()) {
716
+ try {
717
+ return await normalizeLocalHtmlPathCandidate(candidate);
718
+ } catch (error) {
719
+ const hasFallbackCandidate = index < candidates.length - 1;
720
+ if (
721
+ error.statusCode === 404 ||
722
+ (hasFallbackCandidate && error.statusCode === 400 && /Only \.html/.test(error.message))
723
+ ) {
724
+ missingError = error;
725
+ continue;
726
+ }
727
+ throw error;
728
+ }
729
+ }
730
+
731
+ throw missingError || appError("HTML file was not found.", 404);
732
+ }
733
+
734
+ export async function normalizeLocalFolderPath(inputPath) {
735
+ if (typeof inputPath !== "string" || trimPastedLocalPathInput(inputPath) === "") {
736
+ throw appError("Provide an absolute path to a folder.", 400);
737
+ }
738
+
739
+ const candidates = localHtmlPathCandidates(inputPath);
740
+ let missingError = null;
741
+
742
+ for (const candidate of candidates) {
743
+ const resolvedPath = path.resolve(candidate);
744
+ if (!path.isAbsolute(candidate)) {
745
+ throw appError("Folder path must be absolute.", 400);
746
+ }
747
+ let stat;
748
+ try {
749
+ stat = await fs.stat(resolvedPath);
750
+ } catch (error) {
751
+ if (error.code === "ENOENT") {
752
+ missingError = appError("Folder was not found.", 404);
753
+ continue;
754
+ }
755
+ throw error;
756
+ }
757
+ if (!stat.isDirectory()) {
758
+ throw appError("Folder path must point to a directory.", 400);
759
+ }
760
+ if (path.basename(resolvedPath).startsWith(".")) {
761
+ throw appError("Hidden folders are not served.", 400);
762
+ }
763
+ return resolvedPath;
764
+ }
765
+
766
+ throw missingError || appError("Folder was not found.", 404);
767
+ }
768
+
769
+ async function findFolderEntry(rootDir, preferredEntry = "") {
770
+ const candidates = [
771
+ preferredEntry,
772
+ "index.html",
773
+ "index.htm",
774
+ "index.md",
775
+ "index.markdown"
776
+ ].filter(Boolean);
777
+
778
+ for (const candidate of candidates) {
779
+ const normalized = normalizeAssetRequestPath(candidate);
780
+ if (!normalized || normalized !== candidate.split("/").join(path.sep)) {
781
+ continue;
782
+ }
783
+ const candidatePath = path.resolve(rootDir, normalized);
784
+ if (!isPathInside(rootDir, candidatePath) || !isIndexFileName(candidatePath)) {
785
+ continue;
786
+ }
787
+ try {
788
+ const stat = await fs.stat(candidatePath);
789
+ if (stat.isFile()) {
790
+ return normalized;
791
+ }
792
+ } catch {
793
+ // Try the next conventional entry candidate.
794
+ }
795
+ }
796
+
797
+ throw appError("Folder must contain index.html, index.htm, index.md, or index.markdown.", 400);
798
+ }
799
+
800
+ async function detectBuildOutputDir(sourceRoot, preferredOutput = "") {
801
+ const candidates = [preferredOutput, "dist", "build", "out", "site", "public"]
802
+ .filter(Boolean)
803
+ .map((candidate) => normalizeAssetRequestPath(candidate))
804
+ .filter(Boolean);
805
+
806
+ for (const candidate of candidates) {
807
+ const outputRoot = path.resolve(sourceRoot, candidate);
808
+ if (!isPathInside(sourceRoot, outputRoot)) {
809
+ continue;
810
+ }
811
+ try {
812
+ const stat = await fs.stat(outputRoot);
813
+ if (stat.isDirectory()) {
814
+ const entryFile = await findFolderEntry(outputRoot);
815
+ return { outputRoot, outputDir: candidate, entryFile };
816
+ }
817
+ } catch {
818
+ // Try the next conventional output candidate.
819
+ }
820
+ }
821
+
822
+ throw appError("Build finished, but no deployable output folder was found. Set an output directory such as dist, build, out, site, or public.", 400);
823
+ }
824
+
825
+ async function normalizeLocalHtmlPathCandidate(candidatePath) {
826
+ const expandedPath = candidatePath;
827
+ if (!path.isAbsolute(expandedPath)) {
828
+ throw appError("Report path must be absolute.", 400);
829
+ }
830
+
831
+ const resolvedPath = path.resolve(expandedPath);
832
+ if (!isPublishableFileName(resolvedPath)) {
833
+ throw appError("Only .html, .htm, .md, and .markdown files can be shared.", 400);
834
+ }
835
+
836
+ if (path.basename(resolvedPath).startsWith(".")) {
837
+ throw appError("Hidden files are not served.", 400);
838
+ }
839
+
840
+ let stat;
841
+ try {
842
+ stat = await fs.stat(resolvedPath);
843
+ } catch (error) {
844
+ if (error.code === "ENOENT") {
845
+ throw appError("HTML file was not found.", 404);
846
+ }
847
+ throw error;
848
+ }
849
+
850
+ if (!stat.isFile()) {
851
+ throw appError("Report path must point to a file.", 400);
852
+ }
853
+
854
+ return resolvedPath;
855
+ }
856
+
857
+ export function createConfigStore({ dataDir = path.join(PROJECT_ROOT, ".pagecast") } = {}) {
858
+ const configPath = path.join(dataDir, "config.json");
859
+ let config = normalizeConfig();
860
+
861
+ async function save() {
862
+ await fs.mkdir(dataDir, { recursive: true });
863
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
864
+ }
865
+
866
+ async function init() {
867
+ if (!(await pathExists(configPath))) {
868
+ await save();
869
+ return;
870
+ }
871
+
872
+ const parsed = safeJsonParse(await fs.readFile(configPath, "utf8"), {});
873
+ config = normalizeConfig(parsed);
874
+ await save();
875
+ }
876
+
877
+ function get() {
878
+ return structuredClone(config);
879
+ }
880
+
881
+ async function updatePages({ projectName, accountId, accountName } = {}) {
882
+ const nextAccountId = accountId === undefined ? config.pages.accountId : accountId;
883
+ const nextAccountName =
884
+ accountName === undefined && nextAccountId === config.pages.accountId
885
+ ? config.pages.accountName
886
+ : accountName;
887
+ config = normalizeConfig({
888
+ pages: {
889
+ projectName: projectName === undefined ? config.pages.projectName : projectName,
890
+ accountId: nextAccountId,
891
+ accountName: nextAccountName
892
+ }
893
+ });
894
+ await save();
895
+ return get();
896
+ }
897
+
898
+ return {
899
+ init,
900
+ get,
901
+ updatePages,
902
+ configPath
903
+ };
904
+ }
905
+
906
+ // Serializes wrangler deploys so only one runs at a time. Each task is appended
907
+ // to a single promise chain; a failing task rejects to its own caller but does
908
+ // NOT wedge the chain (the internal chain always recovers to a resolved state so
909
+ // later tasks still run).
910
+ export function createDeployQueue() {
911
+ let chain = Promise.resolve();
912
+
913
+ function enqueue(taskFn) {
914
+ const result = chain.then(() => taskFn());
915
+ // Keep the internal chain alive regardless of this task's outcome.
916
+ chain = result.then(
917
+ () => undefined,
918
+ () => undefined
919
+ );
920
+ return result;
921
+ }
922
+
923
+ return { enqueue };
924
+ }
925
+
926
+ export function createCloudflarePagesPublisher({
927
+ dataDir = path.join(PROJECT_ROOT, ".pagecast"),
928
+ spawnImpl = spawn,
929
+ timeoutMs = 180000,
930
+ getRedirects = () => []
931
+ } = {}) {
932
+ const siteRoot = path.join(dataDir, "pages-site");
933
+ const deployRoot = path.join(dataDir, "pages-deploy");
934
+
935
+ function publicationDir(slug) {
936
+ return path.join(siteRoot, "p", slug);
937
+ }
938
+
939
+ // Returns the directory whose contents should be published for a report: the
940
+ // working copy when the report has been detached/edited in-app, otherwise the
941
+ // original source directory.
942
+ function publishSourceFor(report) {
943
+ return report.workingDir || report.buildOutputRoot || report.rootDir;
944
+ }
945
+
946
+ async function ensureSiteRoot() {
947
+ await fs.mkdir(siteRoot, { recursive: true });
948
+ await fs.rm(path.join(siteRoot, "index.html"), { force: true });
949
+ await fs.writeFile(path.join(siteRoot, "404.html"), "<!doctype html><title>Not found</title>", "utf8");
950
+ await fs.writeFile(
951
+ path.join(siteRoot, "_headers"),
952
+ "/*\n Cache-Control: no-store\n X-Content-Type-Options: nosniff\n",
953
+ "utf8"
954
+ );
955
+
956
+ const redirects = getRedirects() || [];
957
+ const redirectsPath = path.join(siteRoot, "_redirects");
958
+ if (redirects.length > 0) {
959
+ const lines = redirects
960
+ .map((entry) => `${stripTrailingSlash(entry.from)}/* ${stripTrailingSlash(entry.to)}/:splat 301`)
961
+ .join("\n");
962
+ await fs.writeFile(redirectsPath, `${lines}\n`, "utf8");
963
+ } else {
964
+ await fs.rm(redirectsPath, { force: true });
965
+ }
966
+ }
967
+
968
+ async function stagePublication(report, publication) {
969
+ const destinationRoot = publicationDir(publication.slug || publication.token);
970
+ const sourceRoot = publishSourceFor(report);
971
+ await copyPublicTree(sourceRoot, destinationRoot);
972
+ if (isMarkdownFileName(report.entryFile)) {
973
+ // Render the raw markdown entry to real HTML so the published Cloudflare
974
+ // site serves a proper document; sibling assets were copied above.
975
+ const markdown = await fs.readFile(path.join(sourceRoot, report.entryFile), "utf8");
976
+ const html = markdownToHtml(markdown, { title: report.name });
977
+ await fs.writeFile(path.join(destinationRoot, "index.html"), html, "utf8");
978
+ } else {
979
+ await fs.copyFile(path.join(sourceRoot, report.entryFile), path.join(destinationRoot, "index.html"));
980
+ }
981
+ }
982
+
983
+ async function removePublication(slug) {
984
+ await fs.rm(publicationDir(slug), { recursive: true, force: true });
985
+ }
986
+
987
+ async function runPagesDeploy(rootDir, pagesConfig, branch = DEFAULT_PAGES_BRANCH) {
988
+ const projectName = normalizePagesProjectName(pagesConfig.projectName);
989
+ const accountId = normalizeAccountId(pagesConfig.accountId || "");
990
+ const deployBranch = normalizePagesBranch(branch);
991
+
992
+ const args = [
993
+ "--yes",
994
+ "wrangler",
995
+ "pages",
996
+ "deploy",
997
+ rootDir,
998
+ "--project-name",
999
+ projectName,
1000
+ "--branch",
1001
+ deployBranch
1002
+ ];
1003
+
1004
+ // `wrangler pages deploy` does not accept an `--account-id` flag (it errors
1005
+ // with "Unknown arguments: account-id" on e.g. 4.63.0). The account is
1006
+ // selected via the CLOUDFLARE_ACCOUNT_ID environment variable instead.
1007
+ const result = await runSpawnCommand({
1008
+ spawnImpl,
1009
+ command: "npx",
1010
+ args,
1011
+ timeoutMs,
1012
+ env: accountId ? { ...process.env, CLOUDFLARE_ACCOUNT_ID: accountId } : process.env
1013
+ });
1014
+
1015
+ if (result.code !== 0) {
1016
+ throw appError(
1017
+ `Cloudflare Pages deploy failed (${result.signal || result.code}).\n${cleanCommandOutput(result.output)}`,
1018
+ 502
1019
+ );
1020
+ }
1021
+
1022
+ // Use the real subdomain Cloudflare actually assigned (may differ from the
1023
+ // project name on a global subdomain collision), not an assumed one.
1024
+ const baseUrl = pagesBaseUrlFromDeployOutput(result.output, projectName);
1025
+ return {
1026
+ baseUrl,
1027
+ deploymentUrl: pagesDeploymentUrlFromDeployOutput(result.output, baseUrl),
1028
+ output: result.output
1029
+ };
1030
+ }
1031
+
1032
+ async function deploy(pagesConfig) {
1033
+ await ensureSiteRoot();
1034
+ const result = await runPagesDeploy(siteRoot, pagesConfig, DEFAULT_PAGES_BRANCH);
1035
+ return result.baseUrl;
1036
+ }
1037
+
1038
+ async function deploySite({ sourceDir, pagesConfig, branch = DEFAULT_PAGES_BRANCH } = {}) {
1039
+ const normalizedSourceDir = await normalizeLocalFolderPath(sourceDir);
1040
+ const projectName = normalizePagesProjectName(pagesConfig.projectName);
1041
+ if (isPathInside(deployRoot, normalizedSourceDir)) {
1042
+ throw appError("Cannot deploy Pagecast's internal deploy staging folder.", 400);
1043
+ }
1044
+ const stagingRoot = path.join(deployRoot, projectName);
1045
+ await copyPublicTree(normalizedSourceDir, stagingRoot);
1046
+ const result = await runPagesDeploy(stagingRoot, pagesConfig, branch);
1047
+ return {
1048
+ ...result,
1049
+ sourceDir: normalizedSourceDir,
1050
+ stagingRoot,
1051
+ projectName,
1052
+ branch: normalizePagesBranch(branch)
1053
+ };
1054
+ }
1055
+
1056
+ async function publish({ report, publication, pagesConfig }) {
1057
+ const slug = publication.slug || publication.token;
1058
+ await ensureSiteRoot();
1059
+ await stagePublication(report, publication);
1060
+ try {
1061
+ const baseUrl = await deploy(pagesConfig);
1062
+ return joinUrl(baseUrl, `/p/${encodeURIComponent(slug)}/`);
1063
+ } catch (error) {
1064
+ await removePublication(slug);
1065
+ throw error;
1066
+ }
1067
+ }
1068
+
1069
+ // Re-stage and redeploy the SAME slug folder so the public URL updates in
1070
+ // place. Unlike publish(), this never removes the staged folder on failure so
1071
+ // the last known-good content stays live.
1072
+ async function syncPublication({ report, publication, pagesConfig }) {
1073
+ const slug = publication.slug || publication.token;
1074
+ await ensureSiteRoot();
1075
+ await stagePublication(report, publication);
1076
+ const baseUrl = await deploy(pagesConfig);
1077
+ return joinUrl(baseUrl, `/p/${encodeURIComponent(slug)}/`);
1078
+ }
1079
+
1080
+ // Move a publication's staged content from oldSlug to newSlug and redeploy,
1081
+ // returning the new public URL.
1082
+ async function renamePublication({ oldSlug, newSlug, report, publication, pagesConfig }) {
1083
+ await ensureSiteRoot();
1084
+ await stagePublication(report, { ...publication, slug: newSlug });
1085
+ if (oldSlug && oldSlug !== newSlug) {
1086
+ await removePublication(oldSlug);
1087
+ }
1088
+ const baseUrl = await deploy(pagesConfig);
1089
+ return joinUrl(baseUrl, `/p/${encodeURIComponent(newSlug)}/`);
1090
+ }
1091
+
1092
+ async function revoke(slugs, pagesConfig) {
1093
+ await ensureSiteRoot();
1094
+ for (const slug of slugs) {
1095
+ await removePublication(slug);
1096
+ }
1097
+ return deploy(pagesConfig);
1098
+ }
1099
+
1100
+ return {
1101
+ siteRoot,
1102
+ deployRoot,
1103
+ publish,
1104
+ syncPublication,
1105
+ renamePublication,
1106
+ revoke,
1107
+ deploySite,
1108
+ publicationDir,
1109
+ publishSourceFor
1110
+ };
1111
+ }
1112
+
1113
+ // Watches the source directories of auto-sync path reports and re-publishes
1114
+ // (same URL, in place) each active snapshot when the entry file changes. All
1115
+ // deploys go through the shared deploy queue so they never overlap. Failures are
1116
+ // swallowed (last-good content stays live) and the queue is never wedged.
1117
+ export function createWatchManager({
1118
+ store,
1119
+ pagesPublisher,
1120
+ configStore,
1121
+ deployQueue,
1122
+ debounceMs = 1000,
1123
+ onError = () => {}
1124
+ } = {}) {
1125
+ const watchers = new Map();
1126
+ const timers = new Map();
1127
+
1128
+ function clearTimer(reportId) {
1129
+ const timer = timers.get(reportId);
1130
+ if (timer) {
1131
+ clearTimeout(timer);
1132
+ timers.delete(reportId);
1133
+ }
1134
+ }
1135
+
1136
+ async function syncReportSnapshots(reportId) {
1137
+ const report = store.get(reportId);
1138
+ if (!report) {
1139
+ return;
1140
+ }
1141
+ const snapshots = store.activeSnapshotPublications(report);
1142
+ if (snapshots.length === 0) {
1143
+ return;
1144
+ }
1145
+ for (const publication of snapshots) {
1146
+ try {
1147
+ await pagesPublisher.syncPublication({
1148
+ report,
1149
+ publication,
1150
+ pagesConfig: configStore.get().pages
1151
+ });
1152
+ await store.syncSnapshot(publication.token);
1153
+ } catch (error) {
1154
+ onError(error);
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ function schedule(reportId) {
1160
+ clearTimer(reportId);
1161
+ const timer = setTimeout(() => {
1162
+ timers.delete(reportId);
1163
+ // Run the actual deploy work inside the shared queue so concurrent
1164
+ // watchers never deploy at the same time. Recover so the chain survives.
1165
+ deployQueue
1166
+ .enqueue(() => syncReportSnapshots(reportId))
1167
+ .catch((error) => onError(error));
1168
+ }, debounceMs);
1169
+ timer.unref?.();
1170
+ timers.set(reportId, timer);
1171
+ }
1172
+
1173
+ function register(reportId) {
1174
+ const report = store.get(reportId);
1175
+ if (!report || report.kind !== "path" || !report.autoSync || report.workingDir) {
1176
+ return;
1177
+ }
1178
+ if (watchers.has(reportId)) {
1179
+ return;
1180
+ }
1181
+
1182
+ try {
1183
+ const watcher = fsWatch(
1184
+ report.rootDir,
1185
+ { persistent: false },
1186
+ (eventType, filename) => {
1187
+ // macOS often reports a null filename; treat that as "something
1188
+ // changed" and let the per-report debounce coalesce the burst.
1189
+ if (filename === null || filename === report.entryFile) {
1190
+ schedule(reportId);
1191
+ }
1192
+ }
1193
+ );
1194
+ watcher.on("error", (error) => {
1195
+ // A deleted-then-recreated source dir surfaces here; never crash.
1196
+ onError(error);
1197
+ });
1198
+ watchers.set(reportId, watcher);
1199
+ } catch (error) {
1200
+ onError(error);
1201
+ }
1202
+ }
1203
+
1204
+ function unregister(reportId) {
1205
+ clearTimer(reportId);
1206
+ const watcher = watchers.get(reportId);
1207
+ if (watcher) {
1208
+ try {
1209
+ watcher.close();
1210
+ } catch {
1211
+ // ignore
1212
+ }
1213
+ watchers.delete(reportId);
1214
+ }
1215
+ }
1216
+
1217
+ function closeAll() {
1218
+ for (const reportId of Array.from(watchers.keys())) {
1219
+ unregister(reportId);
1220
+ }
1221
+ for (const reportId of Array.from(timers.keys())) {
1222
+ clearTimer(reportId);
1223
+ }
1224
+ }
1225
+
1226
+ return { register, unregister, closeAll };
1227
+ }
1228
+
1229
+ export function createCloudflareAuthManager({
1230
+ spawnImpl = spawn,
1231
+ loginTimeoutMs = DEFAULT_CLOUDFLARE_LOGIN_TIMEOUT_MS,
1232
+ listTimeoutMs = DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS
1233
+ } = {}) {
1234
+ async function runWrangler(args, timeoutMs, env = {}) {
1235
+ const result = await runSpawnCommand({
1236
+ spawnImpl,
1237
+ command: "npx",
1238
+ args: ["--yes", "wrangler", ...args],
1239
+ timeoutMs,
1240
+ env: {
1241
+ ...process.env,
1242
+ ...env
1243
+ }
1244
+ });
1245
+
1246
+ if (result.code !== 0) {
1247
+ throw appError(
1248
+ `Wrangler failed (${result.signal || result.code}).\n${cleanCommandOutput(result.output)}`,
1249
+ 502
1250
+ );
1251
+ }
1252
+
1253
+ return result.output;
1254
+ }
1255
+
1256
+ // Cached view of the current Wrangler OAuth session so /api/status can report
1257
+ // "logged in" without spawning Wrangler on every poll. The probe runs at
1258
+ // connect/refresh time; the cache is invalidated whenever login state changes.
1259
+ let sessionCache = null;
1260
+
1261
+ async function login() {
1262
+ const scopedArgs = CLOUDFLARE_OAUTH_SCOPES.flatMap((scope) => ["--scopes", scope]);
1263
+ await runWrangler(["login", ...scopedArgs], loginTimeoutMs);
1264
+ sessionCache = null;
1265
+ }
1266
+
1267
+ async function logout() {
1268
+ await runWrangler(["logout"], listTimeoutMs);
1269
+ sessionCache = null;
1270
+ }
1271
+
1272
+ async function listProjects({ accountId = "" } = {}) {
1273
+ const env = accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {};
1274
+ // Try the JSON form, but fall back to the plain-text listing on ANY failure:
1275
+ // some Wrangler versions (e.g. 4.63.0) don't support `--json` and exit 1 with
1276
+ // a help screen rather than a clean "Unknown argument: json" message.
1277
+ try {
1278
+ const output = await runWrangler(["pages", "project", "list", "--json"], listTimeoutMs, env);
1279
+ const projects = parseWranglerPagesProjects(output);
1280
+ if (projects.length > 0) {
1281
+ return projects;
1282
+ }
1283
+ } catch {
1284
+ // fall through to the text listing
1285
+ }
1286
+ const output = await runWrangler(["pages", "project", "list"], listTimeoutMs, env);
1287
+ return parseWranglerPagesProjects(output);
1288
+ }
1289
+
1290
+ async function loginAndListProjects(options = {}) {
1291
+ await login();
1292
+ return listProjects(options);
1293
+ }
1294
+
1295
+ // Returns the Cloudflare accounts visible to the current OAuth session.
1296
+ // An empty array means "not logged in". Used to auto-detect the account so
1297
+ // the user never has to paste an account ID for the single-account case.
1298
+ async function whoami() {
1299
+ // Prefer the JSON form on newer Wrangler, but fall back to the stable text
1300
+ // table whenever `--json` is unsupported or yields nothing. Wrangler 4.63.0
1301
+ // exits 1 and prints a help screen for `whoami --json` — that must NOT be
1302
+ // read as "logged out", or the app will trigger a needless re-login.
1303
+ try {
1304
+ const output = await runWrangler(["whoami", "--json"], listTimeoutMs);
1305
+ const accounts = parseWranglerWhoamiAccounts(output);
1306
+ if (accounts.length > 0) {
1307
+ return accounts;
1308
+ }
1309
+ } catch {
1310
+ // fall through to the text whoami
1311
+ }
1312
+ try {
1313
+ const output = await runWrangler(["whoami"], listTimeoutMs);
1314
+ return parseWranglerWhoamiAccounts(output);
1315
+ } catch (error) {
1316
+ const message = stripAnsi(error.message || "");
1317
+ if (/not authenticated|not logged in|wrangler login|run `?wrangler login/i.test(message)) {
1318
+ return [];
1319
+ }
1320
+ throw error;
1321
+ }
1322
+ }
1323
+
1324
+ // Idempotently ensures a Pages project exists so a first-time user can publish
1325
+ // without manually creating one in the Cloudflare dashboard.
1326
+ async function ensureProject({ projectName, accountId = "", branch = DEFAULT_PAGES_BRANCH } = {}) {
1327
+ const name = normalizePagesProjectName(projectName);
1328
+ const env = accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {};
1329
+ try {
1330
+ await runWrangler(
1331
+ ["pages", "project", "create", name, "--production-branch", branch],
1332
+ listTimeoutMs,
1333
+ env
1334
+ );
1335
+ } catch (error) {
1336
+ const message = stripAnsi(error.message);
1337
+ if (/already exists|already taken|name is taken|project with.*name/i.test(message)) {
1338
+ return name;
1339
+ }
1340
+ throw error;
1341
+ }
1342
+ return name;
1343
+ }
1344
+
1345
+ function cachedSession() {
1346
+ return sessionCache ? sessionCache.value : { loggedIn: false, accounts: [] };
1347
+ }
1348
+
1349
+ async function refreshSession() {
1350
+ let accounts = [];
1351
+ try {
1352
+ accounts = await whoami();
1353
+ } catch {
1354
+ accounts = [];
1355
+ }
1356
+ const value = { loggedIn: accounts.length > 0, accounts };
1357
+ sessionCache = { value };
1358
+ return value;
1359
+ }
1360
+
1361
+ function invalidateSession() {
1362
+ sessionCache = null;
1363
+ }
1364
+
1365
+ return {
1366
+ login,
1367
+ logout,
1368
+ listProjects,
1369
+ loginAndListProjects,
1370
+ whoami,
1371
+ ensureProject,
1372
+ cachedSession,
1373
+ refreshSession,
1374
+ invalidateSession
1375
+ };
1376
+ }
1377
+
1378
+ export function createReportStore({
1379
+ dataDir = path.join(PROJECT_ROOT, ".pagecast"),
1380
+ buildSpawnImpl = spawn,
1381
+ buildTimeoutMs = 5 * 60 * 1000
1382
+ } = {}) {
1383
+ const statePath = path.join(dataDir, "reports.json");
1384
+ const uploadRoot = path.join(dataDir, "uploads");
1385
+ const workingRoot = path.join(dataDir, "working");
1386
+ const reports = new Map();
1387
+ let redirects = [];
1388
+
1389
+ function normalizePublication(publication) {
1390
+ const kind = publication.kind || "snapshot";
1391
+ // Legacy (version-2) publications had no slug; the token doubled as the
1392
+ // staged-folder/URL-path key, so backfill slug from token.
1393
+ const slug = publication.slug || publication.token;
1394
+ return {
1395
+ ...publication,
1396
+ kind,
1397
+ slug,
1398
+ publicUrl: kind === "snapshot" ? publication.publicUrl || null : null,
1399
+ revokedAt: publication.revokedAt || null,
1400
+ updatedAt: publication.updatedAt || publication.createdAt
1401
+ };
1402
+ }
1403
+
1404
+ function normalizeReport(report) {
1405
+ const kind = report.kind;
1406
+ const defaultSourceMode = kind === "upload" ? "edited-in-pagecast" : "source-tracked";
1407
+ return {
1408
+ ...report,
1409
+ order: typeof report.order === "number" ? report.order : Number.MAX_SAFE_INTEGER,
1410
+ autoSync: report.autoSync === true,
1411
+ workingDir: typeof report.workingDir === "string" ? report.workingDir : null,
1412
+ buildCommand: typeof report.buildCommand === "string" ? report.buildCommand : "",
1413
+ buildOutputDir: typeof report.buildOutputDir === "string" ? report.buildOutputDir : "",
1414
+ buildOutputRoot: typeof report.buildOutputRoot === "string" ? report.buildOutputRoot : null,
1415
+ buildStatus: report.buildStatus || "idle",
1416
+ buildError: report.buildError || "",
1417
+ lastBuildAt: report.lastBuildAt || null,
1418
+ sourceMode: report.sourceMode || defaultSourceMode,
1419
+ publications: Array.isArray(report.publications)
1420
+ ? report.publications.map(normalizePublication)
1421
+ : []
1422
+ };
1423
+ }
1424
+
1425
+ function reportSourceRoot(report) {
1426
+ return path.resolve(report.workingDir || report.buildOutputRoot || report.rootDir);
1427
+ }
1428
+
1429
+ async function save() {
1430
+ await fs.mkdir(dataDir, { recursive: true });
1431
+ const state = {
1432
+ version: 3,
1433
+ reports: Array.from(reports.values()),
1434
+ redirects
1435
+ };
1436
+ await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
1437
+ }
1438
+
1439
+ async function init() {
1440
+ await fs.mkdir(uploadRoot, { recursive: true });
1441
+ await fs.mkdir(workingRoot, { recursive: true });
1442
+ if (!(await pathExists(statePath))) {
1443
+ await save();
1444
+ return;
1445
+ }
1446
+
1447
+ const rawState = await fs.readFile(statePath, "utf8");
1448
+ const parsed = safeJsonParse(rawState, { reports: [] });
1449
+ redirects = Array.isArray(parsed.redirects)
1450
+ ? parsed.redirects
1451
+ .filter((entry) => entry && typeof entry.from === "string" && typeof entry.to === "string")
1452
+ .map((entry) => ({ from: entry.from, to: entry.to }))
1453
+ : [];
1454
+ for (const report of parsed.reports || []) {
1455
+ if (typeof report?.id === "string" && typeof report?.kind === "string") {
1456
+ reports.set(report.id, normalizeReport(report));
1457
+ }
1458
+ }
1459
+ }
1460
+
1461
+ function listRedirects() {
1462
+ return redirects.map((entry) => ({ ...entry }));
1463
+ }
1464
+
1465
+ // Add a 301 redirect, collapsing chains: if an existing entry pointed at the
1466
+ // slug we are now renaming away from, rewrite its target to the new
1467
+ // destination so we never need a multi-hop redirect. Dedupes on `from`.
1468
+ function addRedirect(from, to) {
1469
+ if (!from || !to || from === to) {
1470
+ return;
1471
+ }
1472
+ for (const entry of redirects) {
1473
+ if (entry.to === from) {
1474
+ entry.to = to;
1475
+ }
1476
+ }
1477
+ const existing = redirects.find((entry) => entry.from === from);
1478
+ if (existing) {
1479
+ existing.to = to;
1480
+ } else {
1481
+ redirects.push({ from, to });
1482
+ }
1483
+ // Drop any self-referential entries produced by collapsing.
1484
+ redirects = redirects.filter((entry) => entry.from !== entry.to);
1485
+ }
1486
+
1487
+ function formatPublication(publication, { localPublicBaseUrl } = {}) {
1488
+ const slug = publication.slug || publication.token;
1489
+ const suffix = `/p/${encodeURIComponent(slug)}/`;
1490
+ const active = !publication.revokedAt;
1491
+ const kind = publication.kind || "snapshot";
1492
+ return {
1493
+ token: publication.token,
1494
+ slug,
1495
+ label: publication.label,
1496
+ kind,
1497
+ active,
1498
+ createdAt: publication.createdAt,
1499
+ updatedAt: publication.updatedAt || publication.createdAt,
1500
+ revokedAt: publication.revokedAt || null,
1501
+ localUrl: active && localPublicBaseUrl ? joinUrl(localPublicBaseUrl, suffix) : null,
1502
+ publicUrl: active && kind === "snapshot" ? publication.publicUrl : null
1503
+ };
1504
+ }
1505
+
1506
+ function formatReport(report, { adminBaseUrl, localPublicBaseUrl } = {}) {
1507
+ const previewSuffix = `/preview/${encodeURIComponent(report.id)}/`;
1508
+ const publications = (report.publications || [])
1509
+ .slice()
1510
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
1511
+ .map((publication) => formatPublication(publication, { localPublicBaseUrl }));
1512
+ const latestActivePublication = publications.find((publication) => publication.active) || null;
1513
+ return {
1514
+ id: report.id,
1515
+ name: report.name,
1516
+ kind: report.kind,
1517
+ sourcePath: report.kind === "path" || report.kind === "folder" ? report.sourcePath : null,
1518
+ order: typeof report.order === "number" ? report.order : Number.MAX_SAFE_INTEGER,
1519
+ autoSync: report.autoSync === true,
1520
+ sourceMode: report.sourceMode || (report.kind === "upload" ? "edited-in-pagecast" : "source-tracked"),
1521
+ buildCommand: report.buildCommand || "",
1522
+ buildOutputDir: report.buildOutputDir || "",
1523
+ buildStatus: report.buildStatus || "idle",
1524
+ buildError: report.buildError || "",
1525
+ lastBuildAt: report.lastBuildAt || null,
1526
+ createdAt: report.createdAt,
1527
+ updatedAt: report.updatedAt,
1528
+ localUrl: adminBaseUrl ? joinUrl(adminBaseUrl, previewSuffix) : null,
1529
+ publicUrl: latestActivePublication?.publicUrl || null,
1530
+ publications
1531
+ };
1532
+ }
1533
+
1534
+ function list(options = {}) {
1535
+ return Array.from(reports.values())
1536
+ .sort((a, b) => {
1537
+ const orderA = typeof a.order === "number" ? a.order : Number.MAX_SAFE_INTEGER;
1538
+ const orderB = typeof b.order === "number" ? b.order : Number.MAX_SAFE_INTEGER;
1539
+ if (orderA !== orderB) {
1540
+ return orderA - orderB;
1541
+ }
1542
+ return a.createdAt.localeCompare(b.createdAt);
1543
+ })
1544
+ .map((report) => formatReport(report, options));
1545
+ }
1546
+
1547
+ async function addPath(sourcePath) {
1548
+ const normalizedPath = await normalizeLocalHtmlPath(sourcePath);
1549
+ const createdAt = nowIso();
1550
+ const report = {
1551
+ id: createReportId(normalizedPath),
1552
+ kind: "path",
1553
+ name: path.basename(normalizedPath),
1554
+ sourcePath: normalizedPath,
1555
+ rootDir: path.dirname(normalizedPath),
1556
+ entryFile: path.basename(normalizedPath),
1557
+ order: reports.size,
1558
+ autoSync: false,
1559
+ workingDir: null,
1560
+ sourceMode: "source-tracked",
1561
+ createdAt,
1562
+ updatedAt: createdAt,
1563
+ publications: []
1564
+ };
1565
+
1566
+ reports.set(report.id, report);
1567
+ await save();
1568
+ return report;
1569
+ }
1570
+
1571
+ async function addFolder({
1572
+ folderPath,
1573
+ entryFile = "",
1574
+ buildCommand = "",
1575
+ buildOutputDir = "",
1576
+ name = ""
1577
+ } = {}) {
1578
+ const normalizedPath = await normalizeLocalFolderPath(folderPath);
1579
+ const normalizedBuildOutput = buildOutputDir
1580
+ ? normalizeAssetRequestPath(buildOutputDir)
1581
+ : "";
1582
+ if (buildOutputDir && !normalizedBuildOutput) {
1583
+ throw appError("Build output directory is not allowed.", 400);
1584
+ }
1585
+ const normalizedEntry = buildCommand
1586
+ ? ""
1587
+ : await findFolderEntry(normalizedPath, entryFile);
1588
+ const createdAt = nowIso();
1589
+ const report = {
1590
+ id: createReportId(name || normalizedPath),
1591
+ kind: "folder",
1592
+ name: name || path.basename(normalizedPath),
1593
+ sourcePath: normalizedPath,
1594
+ rootDir: normalizedPath,
1595
+ entryFile: normalizedEntry || "index.html",
1596
+ order: reports.size,
1597
+ autoSync: false,
1598
+ workingDir: null,
1599
+ sourceMode: "source-tracked",
1600
+ buildCommand: String(buildCommand || "").trim(),
1601
+ buildOutputDir: normalizedBuildOutput || "",
1602
+ buildOutputRoot: null,
1603
+ buildStatus: buildCommand ? "idle" : "ready",
1604
+ buildError: "",
1605
+ lastBuildAt: null,
1606
+ createdAt,
1607
+ updatedAt: createdAt,
1608
+ publications: []
1609
+ };
1610
+
1611
+ reports.set(report.id, report);
1612
+ await save();
1613
+ return report;
1614
+ }
1615
+
1616
+ async function addUpload({ filename, content }) {
1617
+ const safeName = path.basename(filename || "report.html");
1618
+ if (!isPublishableFileName(safeName)) {
1619
+ throw appError("Uploaded file must be .html, .htm, .md, or .markdown.", 400);
1620
+ }
1621
+
1622
+ if (safeName.startsWith(".")) {
1623
+ throw appError("Hidden files are not served.", 400);
1624
+ }
1625
+
1626
+ const createdAt = nowIso();
1627
+ const id = createReportId(safeName);
1628
+ const reportDir = path.join(uploadRoot, id);
1629
+ // Markdown uploads keep their raw .md source so the entry extension drives
1630
+ // rendering at preview/publish time; HTML uploads are stored as index.html.
1631
+ const entryFile = isMarkdownFileName(safeName) ? "index.md" : "index.html";
1632
+
1633
+ await fs.mkdir(reportDir, { recursive: true });
1634
+ await fs.writeFile(path.join(reportDir, entryFile), content);
1635
+
1636
+ const report = {
1637
+ id,
1638
+ kind: "upload",
1639
+ name: safeName,
1640
+ rootDir: reportDir,
1641
+ entryFile,
1642
+ order: reports.size,
1643
+ autoSync: false,
1644
+ workingDir: null,
1645
+ sourceMode: "edited-in-pagecast",
1646
+ createdAt,
1647
+ updatedAt: createdAt,
1648
+ publications: []
1649
+ };
1650
+
1651
+ reports.set(report.id, report);
1652
+ await save();
1653
+ return report;
1654
+ }
1655
+
1656
+ async function addFolderUpload({ files, name = "" }) {
1657
+ if (!Array.isArray(files) || files.length === 0) {
1658
+ throw appError("Folder upload did not include any files.", 400);
1659
+ }
1660
+ if (files.length > MAX_FOLDER_UPLOAD_FILES) {
1661
+ throw appError(`Folder upload can include at most ${MAX_FOLDER_UPLOAD_FILES} files.`, 413);
1662
+ }
1663
+
1664
+ const createdAt = nowIso();
1665
+ const id = createReportId(name || files[0].filename || "folder");
1666
+ const reportDir = path.join(uploadRoot, id);
1667
+ let totalBytes = 0;
1668
+
1669
+ await fs.mkdir(reportDir, { recursive: true });
1670
+ for (const file of files) {
1671
+ const relativePath = normalizeAssetRequestPath(file.filename || "");
1672
+ if (!relativePath) {
1673
+ throw appError("Folder upload includes an unsafe file path.", 400);
1674
+ }
1675
+ if (file.content.length > MAX_FOLDER_UPLOAD_FILE_BYTES) {
1676
+ throw appError("Folder upload includes a file that is too large.", 413);
1677
+ }
1678
+ totalBytes += file.content.length;
1679
+ if (totalBytes > MAX_FOLDER_UPLOAD_BYTES) {
1680
+ throw appError("Folder upload is too large.", 413);
1681
+ }
1682
+ const destinationPath = path.resolve(reportDir, relativePath);
1683
+ if (!isPathInside(reportDir, destinationPath)) {
1684
+ throw appError("Folder upload includes an unsafe file path.", 400);
1685
+ }
1686
+ await fs.mkdir(path.dirname(destinationPath), { recursive: true });
1687
+ await fs.writeFile(destinationPath, file.content);
1688
+ }
1689
+
1690
+ let publishRoot = reportDir;
1691
+ let entryFile;
1692
+ try {
1693
+ entryFile = await findFolderEntry(publishRoot);
1694
+ } catch (error) {
1695
+ const roots = new Set(
1696
+ files
1697
+ .map((file) => normalizeAssetRequestPath(file.filename || ""))
1698
+ .filter(Boolean)
1699
+ .map((relativePath) => relativePath.split(path.sep)[0])
1700
+ );
1701
+ if (roots.size !== 1) {
1702
+ throw error;
1703
+ }
1704
+ publishRoot = path.join(reportDir, Array.from(roots)[0]);
1705
+ entryFile = await findFolderEntry(publishRoot);
1706
+ }
1707
+ const report = {
1708
+ id,
1709
+ kind: "folder",
1710
+ name: name || path.basename(id),
1711
+ sourcePath: null,
1712
+ rootDir: publishRoot,
1713
+ entryFile,
1714
+ order: reports.size,
1715
+ autoSync: false,
1716
+ workingDir: null,
1717
+ sourceMode: "edited-in-pagecast",
1718
+ buildCommand: "",
1719
+ buildOutputDir: "",
1720
+ buildOutputRoot: null,
1721
+ buildStatus: "ready",
1722
+ buildError: "",
1723
+ lastBuildAt: null,
1724
+ createdAt,
1725
+ updatedAt: createdAt,
1726
+ publications: []
1727
+ };
1728
+
1729
+ reports.set(report.id, report);
1730
+ await save();
1731
+ return report;
1732
+ }
1733
+
1734
+ async function buildReport(id) {
1735
+ const report = reports.get(id);
1736
+ if (!report) {
1737
+ throw appError("Report was not found.", 404);
1738
+ }
1739
+ if (report.kind !== "folder") {
1740
+ throw appError("Only folder reports can be built.", 400);
1741
+ }
1742
+ if (!report.buildCommand) {
1743
+ const entryFile = await findFolderEntry(path.resolve(report.rootDir), report.entryFile);
1744
+ report.entryFile = entryFile;
1745
+ report.buildOutputRoot = null;
1746
+ report.buildStatus = "ready";
1747
+ report.buildError = "";
1748
+ report.lastBuildAt = nowIso();
1749
+ report.updatedAt = report.lastBuildAt;
1750
+ await save();
1751
+ return report;
1752
+ }
1753
+
1754
+ report.buildStatus = "building";
1755
+ report.buildError = "";
1756
+ report.updatedAt = nowIso();
1757
+ await save();
1758
+
1759
+ const result = await runSpawnCommand({
1760
+ spawnImpl: buildSpawnImpl,
1761
+ command: "sh",
1762
+ args: ["-lc", report.buildCommand],
1763
+ cwd: report.rootDir,
1764
+ timeoutMs: buildTimeoutMs
1765
+ });
1766
+
1767
+ if (result.code !== 0) {
1768
+ report.buildStatus = "failed";
1769
+ report.buildError = cleanCommandOutput(result.output) || `Build failed (${result.signal || result.code}).`;
1770
+ report.lastBuildAt = nowIso();
1771
+ report.updatedAt = report.lastBuildAt;
1772
+ await save();
1773
+ throw appError(`Build failed.\n${report.buildError}`, 502);
1774
+ }
1775
+
1776
+ const output = await detectBuildOutputDir(report.rootDir, report.buildOutputDir);
1777
+ report.buildOutputDir = output.outputDir;
1778
+ report.buildOutputRoot = output.outputRoot;
1779
+ report.entryFile = output.entryFile;
1780
+ report.buildStatus = "ready";
1781
+ report.buildError = "";
1782
+ report.lastBuildAt = nowIso();
1783
+ report.updatedAt = report.lastBuildAt;
1784
+ await save();
1785
+ return report;
1786
+ }
1787
+
1788
+ async function remove(id) {
1789
+ const report = reports.get(id);
1790
+ if (!report) {
1791
+ return false;
1792
+ }
1793
+
1794
+ reports.delete(id);
1795
+ if (report.kind === "upload" || (report.kind === "folder" && isPathInside(uploadRoot, report.rootDir))) {
1796
+ await fs.rm(report.rootDir, { recursive: true, force: true });
1797
+ }
1798
+ if (report.workingDir && isPathInside(workingRoot, report.workingDir)) {
1799
+ await fs.rm(report.workingDir, { recursive: true, force: true });
1800
+ }
1801
+ await save();
1802
+ return true;
1803
+ }
1804
+
1805
+ function nextPublicationLabel(report) {
1806
+ return `v${(report.publications || []).length + 1}`;
1807
+ }
1808
+
1809
+ function draftPublication(id, { label, kind = "snapshot", publicUrl = null } = {}) {
1810
+ const report = reports.get(id);
1811
+ if (!report) {
1812
+ throw appError("Report was not found.", 404);
1813
+ }
1814
+
1815
+ const createdAt = nowIso();
1816
+ const cleanLabel = slugifyReportName(label || nextPublicationLabel(report));
1817
+ const token = createPublicToken(cleanLabel);
1818
+ const publication = {
1819
+ token,
1820
+ slug: token,
1821
+ label: cleanLabel,
1822
+ kind,
1823
+ publicUrl: kind === "snapshot" ? publicUrl : null,
1824
+ createdAt,
1825
+ updatedAt: createdAt,
1826
+ revokedAt: null
1827
+ };
1828
+
1829
+ return { report, publication };
1830
+ }
1831
+
1832
+ async function commitPublication(id, publication) {
1833
+ const report = reports.get(id);
1834
+ if (!report) {
1835
+ throw appError("Report was not found.", 404);
1836
+ }
1837
+
1838
+ report.publications = [...(report.publications || []), publication];
1839
+ report.updatedAt = publication.createdAt;
1840
+ await save();
1841
+ return { report, publication };
1842
+ }
1843
+
1844
+ async function publish(id, { label } = {}) {
1845
+ const { publication } = draftPublication(id, { label, kind: "snapshot" });
1846
+ return commitPublication(id, publication);
1847
+ }
1848
+
1849
+ function get(id) {
1850
+ return reports.get(id) || null;
1851
+ }
1852
+
1853
+ function findPublication(token) {
1854
+ for (const report of reports.values()) {
1855
+ const publication = (report.publications || []).find((item) => item.token === token);
1856
+ if (publication) {
1857
+ return { report, publication };
1858
+ }
1859
+ }
1860
+
1861
+ return null;
1862
+ }
1863
+
1864
+ async function revokePublication(token) {
1865
+ const revokedAt = nowIso();
1866
+ const match = findPublication(token);
1867
+ if (!match) {
1868
+ throw appError("Published link was not found.", 404);
1869
+ }
1870
+
1871
+ if (!match.publication.revokedAt) {
1872
+ match.publication.revokedAt = revokedAt;
1873
+ match.report.updatedAt = revokedAt;
1874
+ await save();
1875
+ }
1876
+ return match;
1877
+ }
1878
+
1879
+ async function revokeAll(id) {
1880
+ const report = reports.get(id);
1881
+ if (!report) {
1882
+ throw appError("Report was not found.", 404);
1883
+ }
1884
+
1885
+ const revokedAt = nowIso();
1886
+ let revokedCount = 0;
1887
+ for (const publication of report.publications || []) {
1888
+ if (!publication.revokedAt) {
1889
+ publication.revokedAt = revokedAt;
1890
+ revokedCount += 1;
1891
+ }
1892
+ }
1893
+
1894
+ if (revokedCount > 0) {
1895
+ report.updatedAt = revokedAt;
1896
+ await save();
1897
+ }
1898
+
1899
+ return { report, revokedCount };
1900
+ }
1901
+
1902
+ function findActivePublication(token) {
1903
+ for (const report of reports.values()) {
1904
+ const publication = (report.publications || []).find((item) => item.token === token);
1905
+ if (publication && !publication.revokedAt) {
1906
+ return { report, publication };
1907
+ }
1908
+ }
1909
+
1910
+ return null;
1911
+ }
1912
+
1913
+ function findActivePublicationBySlug(slug) {
1914
+ for (const report of reports.values()) {
1915
+ const publication = (report.publications || []).find(
1916
+ (item) => (item.slug || item.token) === slug && !item.revokedAt
1917
+ );
1918
+ if (publication) {
1919
+ return { report, publication };
1920
+ }
1921
+ }
1922
+
1923
+ return null;
1924
+ }
1925
+
1926
+ function activeSnapshotPublications(report) {
1927
+ return (report.publications || []).filter(
1928
+ (publication) => !publication.revokedAt && publication.kind === "snapshot"
1929
+ );
1930
+ }
1931
+
1932
+ // Bump a snapshot's updatedAt (and its report's) after a successful same-URL
1933
+ // sync. Token is the stable identity; the slug/URL is unchanged.
1934
+ async function syncSnapshot(token) {
1935
+ const match = findActivePublication(token);
1936
+ if (!match) {
1937
+ throw appError("Published link was not found.", 404);
1938
+ }
1939
+ if (match.publication.kind !== "snapshot") {
1940
+ throw appError("Only snapshot publications can be synced.", 400);
1941
+ }
1942
+ const updatedAt = nowIso();
1943
+ match.publication.updatedAt = updatedAt;
1944
+ match.report.updatedAt = updatedAt;
1945
+ await save();
1946
+ return match;
1947
+ }
1948
+
1949
+ // Returns the set of slugs currently in use (non-revoked publications) plus
1950
+ // existing redirect sources, so callers can enforce slug uniqueness.
1951
+ function usedSlugs() {
1952
+ const used = new Set();
1953
+ for (const report of reports.values()) {
1954
+ for (const publication of report.publications || []) {
1955
+ if (!publication.revokedAt) {
1956
+ used.add(publication.slug || publication.token);
1957
+ }
1958
+ }
1959
+ }
1960
+ for (const entry of redirects) {
1961
+ const fromMatch = /^\/p\/([^/]+)\/?$/.exec(entry.from);
1962
+ if (fromMatch) {
1963
+ used.add(decodeURIComponent(fromMatch[1]));
1964
+ }
1965
+ }
1966
+ return used;
1967
+ }
1968
+
1969
+ // Rename a publication's slug: validates and enforces uniqueness, rewrites the
1970
+ // publicUrl to the new /p/<slug>/ path, records a 301 redirect from the old
1971
+ // slug, and bumps updatedAt. Token stays the stable identity.
1972
+ async function renameSlug(token, rawSlug) {
1973
+ const match = findActivePublication(token);
1974
+ if (!match) {
1975
+ throw appError("Published link was not found.", 404);
1976
+ }
1977
+ const newSlug = normalizeCustomSlug(rawSlug);
1978
+ const oldSlug = match.publication.slug || match.publication.token;
1979
+ if (newSlug === oldSlug) {
1980
+ return { ...match, oldSlug, newSlug };
1981
+ }
1982
+ if (usedSlugs().has(newSlug)) {
1983
+ throw appError("That custom URL is already in use.", 409);
1984
+ }
1985
+
1986
+ const updatedAt = nowIso();
1987
+ match.publication.slug = newSlug;
1988
+ if (match.publication.kind === "snapshot" && match.publication.publicUrl) {
1989
+ const base = match.publication.publicUrl.replace(/\/p\/[^/]+\/?$/, "");
1990
+ match.publication.publicUrl = joinUrl(base, `/p/${encodeURIComponent(newSlug)}/`);
1991
+ }
1992
+ match.publication.updatedAt = updatedAt;
1993
+ match.report.updatedAt = updatedAt;
1994
+ addRedirect(`/p/${oldSlug}/`, `/p/${newSlug}/`);
1995
+ await save();
1996
+ return { ...match, oldSlug, newSlug };
1997
+ }
1998
+
1999
+ async function resolveAsset(id, rawAssetPath = "") {
2000
+ const report = reports.get(id);
2001
+ if (!report) {
2002
+ return { statusCode: 404, message: "Report was not found." };
2003
+ }
2004
+
2005
+ const relativeAssetPath = normalizeAssetRequestPath(rawAssetPath);
2006
+ if (relativeAssetPath === null) {
2007
+ return { statusCode: 403, message: "Asset path is not allowed." };
2008
+ }
2009
+
2010
+ const rootDir = reportSourceRoot(report);
2011
+ const targetPath =
2012
+ relativeAssetPath === ""
2013
+ ? path.resolve(rootDir, report.entryFile)
2014
+ : path.resolve(rootDir, relativeAssetPath);
2015
+
2016
+ if (!isPathInside(rootDir, targetPath)) {
2017
+ return { statusCode: 403, message: "Asset path is not allowed." };
2018
+ }
2019
+
2020
+ let stat;
2021
+ try {
2022
+ stat = await fs.stat(targetPath);
2023
+ } catch (error) {
2024
+ if (error.code === "ENOENT") {
2025
+ return {
2026
+ statusCode: report.kind === "path" && relativeAssetPath === "" ? 410 : 404,
2027
+ message: "Report asset was not found."
2028
+ };
2029
+ }
2030
+ throw error;
2031
+ }
2032
+
2033
+ if (!stat.isFile()) {
2034
+ return { statusCode: 404, message: "Report asset was not found." };
2035
+ }
2036
+
2037
+ // When the requested asset IS a markdown entry, render it to HTML in memory
2038
+ // so the local preview serves a real document. Sibling assets (images, css)
2039
+ // continue to resolve as files. Published snapshots are rendered on disk by
2040
+ // staging, so only this preview path needs the in-memory render.
2041
+ if (relativeAssetPath === "" && isMarkdownFileName(report.entryFile)) {
2042
+ const markdown = await fs.readFile(targetPath, "utf8");
2043
+ const body = markdownToHtml(markdown, { title: report.name });
2044
+ return {
2045
+ statusCode: 200,
2046
+ filePath: targetPath,
2047
+ contentType: "text/html; charset=utf-8",
2048
+ body,
2049
+ size: Buffer.byteLength(body, "utf8"),
2050
+ mtime: stat.mtime
2051
+ };
2052
+ }
2053
+
2054
+ return {
2055
+ statusCode: 200,
2056
+ filePath: targetPath,
2057
+ contentType: contentTypeFor(targetPath),
2058
+ size: stat.size,
2059
+ mtime: stat.mtime
2060
+ };
2061
+ }
2062
+
2063
+ async function resolvePublishedAsset(slug, rawAssetPath = "") {
2064
+ const match = findActivePublicationBySlug(slug);
2065
+ if (!match) {
2066
+ // No active publication at this slug: if a redirect points away from it,
2067
+ // surface a local 301 so the legacy/old URL still lands on the new one.
2068
+ const redirect = redirects.find((entry) => {
2069
+ const fromMatch = /^\/p\/([^/]+)\/?$/.exec(entry.from);
2070
+ return fromMatch && decodeURIComponent(fromMatch[1]) === slug;
2071
+ });
2072
+ if (redirect) {
2073
+ return { statusCode: 301, location: redirect.to };
2074
+ }
2075
+ return { statusCode: 404, message: "Published link was not found." };
2076
+ }
2077
+
2078
+ return resolveAsset(match.report.id, rawAssetPath);
2079
+ }
2080
+
2081
+ // Ensure a report has an editable working copy. Uploads are already private
2082
+ // copies; path reports are copied from their source dir into working/<id>/ the
2083
+ // first time they are edited, after which they are "edited-in-pagecast" and no
2084
+ // longer track their original source file.
2085
+ async function detachToWorkingCopy(id) {
2086
+ const report = reports.get(id);
2087
+ if (!report) {
2088
+ throw appError("Report was not found.", 404);
2089
+ }
2090
+
2091
+ if (report.kind === "upload") {
2092
+ let changed = false;
2093
+ if (report.sourceMode !== "edited-in-pagecast") {
2094
+ report.sourceMode = "edited-in-pagecast";
2095
+ changed = true;
2096
+ }
2097
+ if (report.autoSync !== false) {
2098
+ report.autoSync = false;
2099
+ changed = true;
2100
+ }
2101
+ if (changed) {
2102
+ await save();
2103
+ }
2104
+ return report;
2105
+ }
2106
+
2107
+ if (report.workingDir) {
2108
+ return report;
2109
+ }
2110
+
2111
+ const workingDir = path.join(workingRoot, report.id);
2112
+ // Markdown reports keep editing their raw .md working copy (republish
2113
+ // re-renders via staging); HTML reports normalize their entry to index.html.
2114
+ const workingEntry = isMarkdownFileName(report.entryFile) ? "index.md" : "index.html";
2115
+ const sourceRoot = reportSourceRoot(report);
2116
+ await copyPublicTree(sourceRoot, workingDir);
2117
+ await fs.copyFile(
2118
+ path.join(sourceRoot, report.entryFile),
2119
+ path.join(workingDir, workingEntry)
2120
+ );
2121
+ report.workingDir = workingDir;
2122
+ report.entryFile = workingEntry;
2123
+ report.sourceMode = "edited-in-pagecast";
2124
+ report.autoSync = false;
2125
+ report.updatedAt = nowIso();
2126
+ await save();
2127
+ return report;
2128
+ }
2129
+
2130
+ // Read the current HTML content of a report's entry document, from the working
2131
+ // copy when detached, otherwise from the original source file.
2132
+ async function readContent(id) {
2133
+ const report = reports.get(id);
2134
+ if (!report) {
2135
+ throw appError("Report was not found.", 404);
2136
+ }
2137
+ const rootDir = reportSourceRoot(report);
2138
+ const targetPath = path.resolve(rootDir, report.entryFile);
2139
+ if (!isPathInside(rootDir, targetPath)) {
2140
+ throw appError("Report content path is not allowed.", 403);
2141
+ }
2142
+ const html = await fs.readFile(targetPath, "utf8");
2143
+ return { html };
2144
+ }
2145
+
2146
+ // Persist edited HTML to a report's working copy (creating it if needed). The
2147
+ // original source file is never touched for path reports.
2148
+ async function writeContent(id, html) {
2149
+ if (typeof html !== "string" || html.length === 0) {
2150
+ throw appError("Report content must be a non-empty string.", 400);
2151
+ }
2152
+ if (Buffer.byteLength(html, "utf8") > MAX_UPLOAD_BYTES) {
2153
+ throw appError("Report content is too large.", 413);
2154
+ }
2155
+
2156
+ const report = await detachToWorkingCopy(id);
2157
+ // Write back to the report's entry file. For markdown reports this stays the
2158
+ // raw .md working copy (republish re-renders via staging); HTML reports keep
2159
+ // their index.html entry.
2160
+ const editRoot = reportSourceRoot(report);
2161
+ const targetPath = path.resolve(editRoot, report.entryFile);
2162
+ if (!isPathInside(editRoot, targetPath)) {
2163
+ throw appError("Report content path is not allowed.", 403);
2164
+ }
2165
+ await fs.writeFile(targetPath, html, "utf8");
2166
+ report.updatedAt = nowIso();
2167
+ await save();
2168
+ return report;
2169
+ }
2170
+
2171
+ // Toggle auto-sync for a source-tracked path report (only valid before it has
2172
+ // been detached into a working copy).
2173
+ async function setAutoSync(id, enabled) {
2174
+ const report = reports.get(id);
2175
+ if (!report) {
2176
+ throw appError("Report was not found.", 404);
2177
+ }
2178
+ if (report.kind !== "path" || report.workingDir) {
2179
+ throw appError("Auto-sync is only available for source-tracked path reports.", 400);
2180
+ }
2181
+ report.autoSync = enabled === true;
2182
+ report.sourceMode = "source-tracked";
2183
+ report.updatedAt = nowIso();
2184
+ await save();
2185
+ return report;
2186
+ }
2187
+
2188
+ // Reassign explicit order indices to the listed ids (in the given order). Ids
2189
+ // not listed keep their relative order after the listed ones. Unknown ids are
2190
+ // rejected so the caller can surface a 400.
2191
+ async function reorder(orderedIds) {
2192
+ if (!Array.isArray(orderedIds)) {
2193
+ throw appError("Reorder requires an array of report ids.", 400);
2194
+ }
2195
+ for (const id of orderedIds) {
2196
+ if (!reports.has(id)) {
2197
+ throw appError(`Unknown report id: ${id}`, 400);
2198
+ }
2199
+ }
2200
+ const seen = new Set(orderedIds);
2201
+ const remaining = Array.from(reports.values())
2202
+ .filter((report) => !seen.has(report.id))
2203
+ .sort((a, b) => {
2204
+ const orderA = typeof a.order === "number" ? a.order : Number.MAX_SAFE_INTEGER;
2205
+ const orderB = typeof b.order === "number" ? b.order : Number.MAX_SAFE_INTEGER;
2206
+ if (orderA !== orderB) {
2207
+ return orderA - orderB;
2208
+ }
2209
+ return a.createdAt.localeCompare(b.createdAt);
2210
+ })
2211
+ .map((report) => report.id);
2212
+
2213
+ const finalOrder = [...orderedIds, ...remaining];
2214
+ finalOrder.forEach((id, index) => {
2215
+ const report = reports.get(id);
2216
+ if (report) {
2217
+ report.order = index;
2218
+ }
2219
+ });
2220
+ await save();
2221
+ return list();
2222
+ }
2223
+
2224
+ function listAutoSyncReports() {
2225
+ return Array.from(reports.values()).filter(
2226
+ (report) => report.kind === "path" && report.autoSync && !report.workingDir
2227
+ );
2228
+ }
2229
+
2230
+ return {
2231
+ init,
2232
+ list,
2233
+ get,
2234
+ addPath,
2235
+ addFolder,
2236
+ addUpload,
2237
+ addFolderUpload,
2238
+ buildReport,
2239
+ remove,
2240
+ draftPublication,
2241
+ commitPublication,
2242
+ publish,
2243
+ findPublication,
2244
+ findActivePublication,
2245
+ findActivePublicationBySlug,
2246
+ activeSnapshotPublications,
2247
+ revokePublication,
2248
+ revokeAll,
2249
+ syncSnapshot,
2250
+ renameSlug,
2251
+ detachToWorkingCopy,
2252
+ readContent,
2253
+ writeContent,
2254
+ setAutoSync,
2255
+ reorder,
2256
+ listAutoSyncReports,
2257
+ listRedirects,
2258
+ addRedirect,
2259
+ resolveAsset,
2260
+ resolvePublishedAsset,
2261
+ formatReport,
2262
+ formatPublication,
2263
+ workingRoot,
2264
+ dataDir
2265
+ };
2266
+ }
2267
+
2268
+ export function extractPublicUrl(text) {
2269
+ const urls = String(text).match(/https:\/\/[^\s"'<>]+/g) || [];
2270
+ const cleanedUrls = urls.map((url) => url.replace(/[),.]+$/g, ""));
2271
+ return cleanedUrls.find((url) => /\.ts\.net/i.test(url)) || null;
2272
+ }
2273
+
2274
+ function tunnelCommandFor(provider, localUrl) {
2275
+ if (provider === "tailscale") {
2276
+ return {
2277
+ command: "tailscale",
2278
+ args: ["funnel", "--bg", "--yes", "--https=443", localUrl],
2279
+ stopArgs: ["funnel", "--https=443", "off"],
2280
+ startupHint: "Start Tailscale and make sure Funnel is enabled for this tailnet."
2281
+ };
2282
+ }
2283
+
2284
+ throw appError("Pagecast is configured for Tailscale Funnel only.", 400);
2285
+ }
2286
+
2287
+ function hasTailscaleFunnelCapability(capabilities) {
2288
+ return capabilities.some(
2289
+ (capability) =>
2290
+ capability === "funnel" ||
2291
+ capability === "https://tailscale.com/cap/funnel" ||
2292
+ capability.startsWith("https://tailscale.com/cap/funnel-ports")
2293
+ );
2294
+ }
2295
+
2296
+ function terminateChild(child) {
2297
+ if (!child) {
2298
+ return;
2299
+ }
2300
+
2301
+ const hasExited =
2302
+ (child.exitCode !== null && child.exitCode !== undefined) ||
2303
+ (child.signalCode !== null && child.signalCode !== undefined);
2304
+ if (hasExited) {
2305
+ return;
2306
+ }
2307
+
2308
+ child.kill("SIGTERM");
2309
+ const timer = setTimeout(() => {
2310
+ const stillRunning = child.exitCode === null || child.exitCode === undefined;
2311
+ if (stillRunning) {
2312
+ child.kill("SIGKILL");
2313
+ }
2314
+ }, 1000);
2315
+ timer.unref?.();
2316
+ }
2317
+
2318
+ export class TunnelManager {
2319
+ constructor({ localUrl, spawnImpl = spawn, timeoutMs = 30000 } = {}) {
2320
+ this.localUrl = localUrl;
2321
+ this.spawnImpl = spawnImpl;
2322
+ this.timeoutMs = timeoutMs;
2323
+ this.child = null;
2324
+ this.provider = null;
2325
+ this.publicUrl = null;
2326
+ this.startedAt = null;
2327
+ this.logs = [];
2328
+ }
2329
+
2330
+ status() {
2331
+ return {
2332
+ running: Boolean(this.child || this.publicUrl),
2333
+ provider: this.provider,
2334
+ publicUrl: this.publicUrl,
2335
+ localUrl: this.localUrl,
2336
+ startedAt: this.startedAt,
2337
+ logs: this.logs.slice(-20)
2338
+ };
2339
+ }
2340
+
2341
+ async start(provider = "tailscale") {
2342
+ if (this.publicUrl) {
2343
+ return this.status();
2344
+ }
2345
+
2346
+ const providers = [provider === "auto" ? "tailscale" : provider];
2347
+ const errors = [];
2348
+
2349
+ for (const candidate of providers) {
2350
+ try {
2351
+ return await this.startProvider(candidate);
2352
+ } catch (error) {
2353
+ errors.push(`${candidate}: ${error.message}`);
2354
+ }
2355
+ }
2356
+
2357
+ throw appError(`Could not start a public tunnel. ${errors.join(" ")}`, 502);
2358
+ }
2359
+
2360
+ async startProvider(provider) {
2361
+ const config = tunnelCommandFor(provider, this.localUrl);
2362
+ this.logs = [];
2363
+ await this.preflightProvider(provider);
2364
+ const result = await this.runCommand(config);
2365
+
2366
+ if (result.code !== 0) {
2367
+ throw appError(
2368
+ this.withRecentOutput(`${provider} exited before returning a public URL (${result.signal || result.code}).`),
2369
+ 502
2370
+ );
2371
+ }
2372
+
2373
+ const publicUrl = extractPublicUrl(result.output);
2374
+ if (!publicUrl) {
2375
+ throw appError(this.withRecentOutput(`${provider} did not return a public URL.`), 502);
2376
+ }
2377
+
2378
+ this.child = null;
2379
+ this.provider = provider;
2380
+ this.publicUrl = stripTrailingSlash(publicUrl);
2381
+ this.startedAt = nowIso();
2382
+ return this.status();
2383
+ }
2384
+
2385
+ async preflightProvider(provider) {
2386
+ if (provider !== "tailscale") {
2387
+ return;
2388
+ }
2389
+
2390
+ const result = await this.runCommand(
2391
+ {
2392
+ command: "tailscale",
2393
+ args: ["status", "--json"],
2394
+ startupHint: "Start Tailscale before starting a public URL."
2395
+ },
2396
+ { timeoutMs: 10000, recordLogs: false }
2397
+ );
2398
+
2399
+ if (result.code !== 0) {
2400
+ throw appError(`Tailscale is not running.\n${result.output.trim()}`, 502);
2401
+ }
2402
+
2403
+ const status = safeJsonParse(result.output, null);
2404
+ if (!status?.Self?.ID) {
2405
+ throw appError("Tailscale status did not include this device ID.", 502);
2406
+ }
2407
+
2408
+ const capabilities = status.Self.Capabilities || [];
2409
+ if (!hasTailscaleFunnelCapability(capabilities)) {
2410
+ const nodeId = encodeURIComponent(status.Self.ID);
2411
+ throw appError(
2412
+ `Tailscale Funnel is not enabled on this tailnet. Enable it here:\nhttps://login.tailscale.com/f/funnel?node=${nodeId}`,
2413
+ 502
2414
+ );
2415
+ }
2416
+ }
2417
+
2418
+ withRecentOutput(message) {
2419
+ const recent = this.logs.slice(-3).join("\n").trim();
2420
+ return recent ? `${message}\n${recent}` : message;
2421
+ }
2422
+
2423
+ runCommand(config, { timeoutMs = this.timeoutMs, recordLogs = true } = {}) {
2424
+ return new Promise((resolve, reject) => {
2425
+ let settled = false;
2426
+ let child;
2427
+ let output = "";
2428
+
2429
+ const fail = (error) => {
2430
+ if (settled) {
2431
+ return;
2432
+ }
2433
+ settled = true;
2434
+ clearTimeout(timer);
2435
+ terminateChild(child);
2436
+ reject(error);
2437
+ };
2438
+
2439
+ const finish = (result) => {
2440
+ if (settled) {
2441
+ return;
2442
+ }
2443
+ settled = true;
2444
+ clearTimeout(timer);
2445
+ resolve(result);
2446
+ };
2447
+
2448
+ const recordOutput = (chunk) => {
2449
+ const text = chunk.toString();
2450
+ output += text;
2451
+ if (recordLogs) {
2452
+ this.logs.push(text.trim());
2453
+ this.logs = this.logs.filter(Boolean).slice(-50);
2454
+ }
2455
+ };
2456
+
2457
+ const timer = setTimeout(() => {
2458
+ fail(
2459
+ appError(
2460
+ this.withRecentOutput(`${config.command} did not finish within ${timeoutMs}ms.`),
2461
+ 504
2462
+ )
2463
+ );
2464
+ }, timeoutMs);
2465
+ timer.unref?.();
2466
+
2467
+ try {
2468
+ child = this.spawnImpl(config.command, config.args, {
2469
+ stdio: ["ignore", "pipe", "pipe"],
2470
+ env: process.env
2471
+ });
2472
+ } catch (error) {
2473
+ fail(appError(`${config.command} could not start. ${config.startupHint}`, 502));
2474
+ return;
2475
+ }
2476
+
2477
+ child.stdout?.on("data", recordOutput);
2478
+ child.stderr?.on("data", recordOutput);
2479
+ child.on("error", () => {
2480
+ fail(appError(`${config.command} could not start. ${config.startupHint}`, 502));
2481
+ });
2482
+ child.on("exit", (code, signal) => {
2483
+ finish({ code, signal, output });
2484
+ });
2485
+ });
2486
+ }
2487
+
2488
+ async stop() {
2489
+ if (!this.child && !this.publicUrl) {
2490
+ this.provider = null;
2491
+ this.publicUrl = null;
2492
+ this.startedAt = null;
2493
+ return this.status();
2494
+ }
2495
+
2496
+ const provider = this.provider;
2497
+ const child = this.child;
2498
+ if (child) {
2499
+ terminateChild(child);
2500
+ }
2501
+ if (provider) {
2502
+ const config = tunnelCommandFor(provider, this.localUrl);
2503
+ this.logs = [];
2504
+ const result = await this.runCommand(
2505
+ { ...config, args: config.stopArgs || config.args },
2506
+ { timeoutMs: 10000 }
2507
+ );
2508
+ if (result.code !== 0) {
2509
+ throw appError(
2510
+ this.withRecentOutput(`${provider} did not stop cleanly (${result.signal || result.code}).`),
2511
+ 502
2512
+ );
2513
+ }
2514
+ }
2515
+
2516
+ this.child = null;
2517
+ this.provider = null;
2518
+ this.publicUrl = null;
2519
+ this.startedAt = null;
2520
+ return this.status();
2521
+ }
2522
+
2523
+ async rotate(provider = "tailscale") {
2524
+ await this.stop();
2525
+ return this.start(provider);
2526
+ }
2527
+ }
2528
+
2529
+ async function readRequestBody(req, maxBytes = MAX_UPLOAD_BYTES) {
2530
+ const chunks = [];
2531
+ let totalBytes = 0;
2532
+ let tooLarge = false;
2533
+
2534
+ for await (const chunk of req) {
2535
+ totalBytes += chunk.length;
2536
+ if (totalBytes > maxBytes) {
2537
+ tooLarge = true;
2538
+ } else {
2539
+ chunks.push(chunk);
2540
+ }
2541
+ }
2542
+
2543
+ if (tooLarge) {
2544
+ throw appError("Request body is too large.", 413);
2545
+ }
2546
+
2547
+ return Buffer.concat(chunks);
2548
+ }
2549
+
2550
+ async function readJsonBody(req) {
2551
+ const body = await readRequestBody(req, 1024 * 1024);
2552
+ try {
2553
+ return JSON.parse(body.toString("utf8"));
2554
+ } catch {
2555
+ throw appError("Request body must be valid JSON.", 400);
2556
+ }
2557
+ }
2558
+
2559
+ export function parseMultipartUpload(body, contentType) {
2560
+ const boundaryMatch = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || "");
2561
+ const boundaryValue = boundaryMatch?.[1] || boundaryMatch?.[2];
2562
+ if (!boundaryValue) {
2563
+ throw appError("Upload request is missing a multipart boundary.", 400);
2564
+ }
2565
+
2566
+ const boundary = `--${boundaryValue}`;
2567
+ const rawBody = body.toString("latin1");
2568
+ const parts = rawBody.split(boundary).slice(1, -1);
2569
+
2570
+ for (const rawPart of parts) {
2571
+ const part = rawPart.startsWith("\r\n") ? rawPart.slice(2) : rawPart;
2572
+ const headerEnd = part.indexOf("\r\n\r\n");
2573
+ if (headerEnd === -1) {
2574
+ continue;
2575
+ }
2576
+
2577
+ const headerText = part.slice(0, headerEnd);
2578
+ let contentText = part.slice(headerEnd + 4);
2579
+ if (contentText.endsWith("\r\n")) {
2580
+ contentText = contentText.slice(0, -2);
2581
+ }
2582
+
2583
+ const disposition = headerText
2584
+ .split("\r\n")
2585
+ .find((line) => line.toLowerCase().startsWith("content-disposition:"));
2586
+ if (!disposition) {
2587
+ continue;
2588
+ }
2589
+
2590
+ const name = /name="([^"]+)"/i.exec(disposition)?.[1] || "";
2591
+ const filename = /filename="([^"]*)"/i.exec(disposition)?.[1] || "";
2592
+ if (filename) {
2593
+ return {
2594
+ fieldName: name,
2595
+ filename,
2596
+ content: Buffer.from(contentText, "latin1")
2597
+ };
2598
+ }
2599
+ }
2600
+
2601
+ throw appError("Upload request did not include an HTML file.", 400);
2602
+ }
2603
+
2604
+ export function parseMultipartFiles(body, contentType) {
2605
+ const boundaryMatch = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || "");
2606
+ const boundaryValue = boundaryMatch?.[1] || boundaryMatch?.[2];
2607
+ if (!boundaryValue) {
2608
+ throw appError("Upload request is missing a multipart boundary.", 400);
2609
+ }
2610
+
2611
+ const boundary = `--${boundaryValue}`;
2612
+ const rawBody = body.toString("latin1");
2613
+ const parts = rawBody.split(boundary).slice(1, -1);
2614
+ const files = [];
2615
+
2616
+ for (const rawPart of parts) {
2617
+ const part = rawPart.startsWith("\r\n") ? rawPart.slice(2) : rawPart;
2618
+ const headerEnd = part.indexOf("\r\n\r\n");
2619
+ if (headerEnd === -1) {
2620
+ continue;
2621
+ }
2622
+
2623
+ const headerText = part.slice(0, headerEnd);
2624
+ let contentText = part.slice(headerEnd + 4);
2625
+ if (contentText.endsWith("\r\n")) {
2626
+ contentText = contentText.slice(0, -2);
2627
+ }
2628
+
2629
+ const disposition = headerText
2630
+ .split("\r\n")
2631
+ .find((line) => line.toLowerCase().startsWith("content-disposition:"));
2632
+ if (!disposition) {
2633
+ continue;
2634
+ }
2635
+
2636
+ const filename = /filename="([^"]*)"/i.exec(disposition)?.[1] || "";
2637
+ if (filename) {
2638
+ files.push({
2639
+ filename,
2640
+ content: Buffer.from(contentText, "latin1")
2641
+ });
2642
+ }
2643
+ }
2644
+
2645
+ if (files.length === 0) {
2646
+ throw appError("Upload request did not include any files.", 400);
2647
+ }
2648
+ return files;
2649
+ }
2650
+
2651
+ function sendJson(res, statusCode, payload) {
2652
+ res.writeHead(statusCode, {
2653
+ "Content-Type": "application/json; charset=utf-8",
2654
+ "Cache-Control": "no-store"
2655
+ });
2656
+ res.end(`${JSON.stringify(payload)}\n`);
2657
+ }
2658
+
2659
+ function sendText(res, statusCode, message) {
2660
+ res.writeHead(statusCode, {
2661
+ "Content-Type": "text/plain; charset=utf-8",
2662
+ "Cache-Control": "no-store"
2663
+ });
2664
+ res.end(`${message}\n`);
2665
+ }
2666
+
2667
+ function sendError(res, error) {
2668
+ const statusCode = error.statusCode || 500;
2669
+ sendJson(res, statusCode, {
2670
+ error: {
2671
+ message: error.expose ? error.message : "Internal server error.",
2672
+ statusCode
2673
+ }
2674
+ });
2675
+ }
2676
+
2677
+ // Send an in-memory HTML body (used for the markdown preview render, where
2678
+ // there is no file on disk to stream).
2679
+ function sendHtmlBody(req, res, file) {
2680
+ const buffer = Buffer.isBuffer(file.body) ? file.body : Buffer.from(String(file.body), "utf8");
2681
+ res.writeHead(200, {
2682
+ "Content-Type": file.contentType || "text/html; charset=utf-8",
2683
+ "Content-Length": buffer.length,
2684
+ "Cache-Control": "no-store",
2685
+ "X-Content-Type-Options": "nosniff"
2686
+ });
2687
+
2688
+ if (req.method === "HEAD") {
2689
+ res.end();
2690
+ return;
2691
+ }
2692
+
2693
+ res.end(buffer);
2694
+ }
2695
+
2696
+ async function sendFile(req, res, file) {
2697
+ res.writeHead(200, {
2698
+ "Content-Type": file.contentType || contentTypeFor(file.filePath),
2699
+ "Content-Length": file.size,
2700
+ "Cache-Control": "no-store",
2701
+ "X-Content-Type-Options": "nosniff"
2702
+ });
2703
+
2704
+ if (req.method === "HEAD") {
2705
+ res.end();
2706
+ return;
2707
+ }
2708
+
2709
+ await new Promise((resolve, reject) => {
2710
+ const stream = createReadStream(file.filePath);
2711
+ stream.on("error", reject);
2712
+ stream.on("end", resolve);
2713
+ stream.pipe(res);
2714
+ });
2715
+ }
2716
+
2717
+ async function serveStatic(req, res, staticDir, pathname) {
2718
+ const relativePath = pathname === "/" ? "index.html" : pathname.slice(1);
2719
+ const normalizedPath = normalizeAssetRequestPath(relativePath);
2720
+ if (normalizedPath === null) {
2721
+ sendText(res, 403, "Static path is not allowed.");
2722
+ return;
2723
+ }
2724
+
2725
+ const filePath = path.resolve(staticDir, normalizedPath);
2726
+ if (!isPathInside(staticDir, filePath)) {
2727
+ sendText(res, 403, "Static path is not allowed.");
2728
+ return;
2729
+ }
2730
+
2731
+ try {
2732
+ const stat = await fs.stat(filePath);
2733
+ if (!stat.isFile()) {
2734
+ sendText(res, 404, "Not found.");
2735
+ return;
2736
+ }
2737
+ await sendFile(req, res, {
2738
+ filePath,
2739
+ contentType: contentTypeFor(filePath),
2740
+ size: stat.size
2741
+ });
2742
+ } catch (error) {
2743
+ if (error.code === "ENOENT") {
2744
+ sendText(res, 404, "Not found.");
2745
+ return;
2746
+ }
2747
+ throw error;
2748
+ }
2749
+ }
2750
+
2751
+ function reportOptions({ getAdminBaseUrl, getLocalPublicBaseUrl }) {
2752
+ return {
2753
+ adminBaseUrl: getAdminBaseUrl(),
2754
+ localPublicBaseUrl: getLocalPublicBaseUrl()
2755
+ };
2756
+ }
2757
+
2758
+ function activeSnapshotSlugs(report) {
2759
+ return (report.publications || [])
2760
+ .filter((publication) => !publication.revokedAt && publication.kind === "snapshot")
2761
+ .map((publication) => publication.slug || publication.token);
2762
+ }
2763
+
2764
+ async function detectAndPersistCloudflareProjects({ cloudflareAuth, configStore }) {
2765
+ const currentConfig = configStore.get();
2766
+ const projects = await cloudflareAuth.listProjects({
2767
+ accountId: currentConfig.pages.accountId
2768
+ });
2769
+ const selectedProject = chooseWranglerPagesProject(projects, currentConfig.pages);
2770
+ const config = selectedProject
2771
+ ? await configStore.updatePages({
2772
+ projectName: selectedProject.name,
2773
+ accountId: selectedProject.accountId || currentConfig.pages.accountId,
2774
+ accountName: selectedProject.accountName || currentConfig.pages.accountName
2775
+ })
2776
+ : currentConfig;
2777
+
2778
+ return {
2779
+ config,
2780
+ cloudflare: {
2781
+ authenticated: true,
2782
+ projects,
2783
+ selectedProject,
2784
+ projectCount: projects.length
2785
+ }
2786
+ };
2787
+ }
2788
+
2789
+ // Resolve the Cloudflare account automatically (no manual account ID for the
2790
+ // single-account case) and ensure a publishable Pages project exists, creating
2791
+ // the default one when none is found. This is the seamless one-shot target used
2792
+ // by /api/cloudflare/connect and by snapshot self-provisioning.
2793
+ async function ensureCloudflarePagesTarget({
2794
+ cloudflareAuth,
2795
+ configStore,
2796
+ autoCreate = true,
2797
+ branch = DEFAULT_PAGES_BRANCH
2798
+ }) {
2799
+ const currentConfig = configStore.get();
2800
+ const session = await cloudflareAuth.refreshSession();
2801
+ const accounts = session.accounts;
2802
+ const productionBranch = normalizePagesBranch(branch);
2803
+
2804
+ if (!session.loggedIn) {
2805
+ return {
2806
+ config: currentConfig,
2807
+ cloudflare: {
2808
+ authenticated: false,
2809
+ needsAccountChoice: false,
2810
+ accounts: [],
2811
+ account: null,
2812
+ projects: [],
2813
+ selectedProject: null,
2814
+ projectCount: 0,
2815
+ autoCreated: false
2816
+ }
2817
+ };
2818
+ }
2819
+
2820
+ const envAccountId = normalizeAccountIdSafe(process.env.CLOUDFLARE_ACCOUNT_ID);
2821
+ let account = null;
2822
+ if (envAccountId) {
2823
+ account = accounts.find((item) => item.id === envAccountId) || { id: envAccountId, name: "" };
2824
+ } else if (currentConfig.pages.accountId) {
2825
+ account = accounts.find((item) => item.id === currentConfig.pages.accountId) || null;
2826
+ }
2827
+ if (!account && accounts.length === 1) {
2828
+ account = accounts[0];
2829
+ }
2830
+
2831
+ const needsAccountChoice = !account && accounts.length > 1;
2832
+ const accountId = account?.id || "";
2833
+ const accountName = normalizeAccountName(account?.name || currentConfig.pages.accountName || "");
2834
+
2835
+ if (needsAccountChoice) {
2836
+ return {
2837
+ config: currentConfig,
2838
+ cloudflare: {
2839
+ authenticated: true,
2840
+ needsAccountChoice: true,
2841
+ accounts,
2842
+ account: null,
2843
+ projects: [],
2844
+ selectedProject: null,
2845
+ projectCount: 0,
2846
+ autoCreated: false
2847
+ }
2848
+ };
2849
+ }
2850
+
2851
+ let projects = await cloudflareAuth.listProjects({ accountId });
2852
+ let selectedProject = chooseWranglerPagesProject(projects, currentConfig.pages);
2853
+ let autoCreated = false;
2854
+
2855
+ if (!selectedProject && autoCreate) {
2856
+ const projectName = currentConfig.pages.projectName || DEFAULT_PAGES_PROJECT_NAME;
2857
+ await cloudflareAuth.ensureProject({
2858
+ projectName,
2859
+ accountId,
2860
+ branch: productionBranch
2861
+ });
2862
+ autoCreated = true;
2863
+ projects = await cloudflareAuth.listProjects({ accountId });
2864
+ selectedProject =
2865
+ chooseWranglerPagesProject(projects, { projectName }) || {
2866
+ name: normalizePagesProjectName(projectName),
2867
+ accountId,
2868
+ accountName,
2869
+ productionBranch,
2870
+ baseUrl: pagesBaseUrl(projectName)
2871
+ };
2872
+ }
2873
+
2874
+ let config = currentConfig;
2875
+ if (selectedProject) {
2876
+ config = await configStore.updatePages({
2877
+ projectName: selectedProject.name,
2878
+ accountId: selectedProject.accountId || accountId,
2879
+ accountName: accountName || selectedProject.accountName
2880
+ });
2881
+ } else if (accountId) {
2882
+ config = await configStore.updatePages({
2883
+ projectName: currentConfig.pages.projectName,
2884
+ accountId,
2885
+ accountName
2886
+ });
2887
+ }
2888
+
2889
+ return {
2890
+ config,
2891
+ cloudflare: {
2892
+ authenticated: true,
2893
+ needsAccountChoice: false,
2894
+ accounts,
2895
+ account: account
2896
+ ? {
2897
+ id: accountId,
2898
+ name: accountName || normalizeAccountName(selectedProject?.accountName || "")
2899
+ }
2900
+ : null,
2901
+ projects,
2902
+ selectedProject,
2903
+ projectCount: projects.length,
2904
+ autoCreated
2905
+ }
2906
+ };
2907
+ }
2908
+
2909
+ export function createPublicHandler({ store }) {
2910
+ return async function publicHandler(req, res) {
2911
+ try {
2912
+ if (req.method !== "GET" && req.method !== "HEAD") {
2913
+ sendText(res, 405, "Method not allowed.");
2914
+ return;
2915
+ }
2916
+
2917
+ const url = new URL(req.url, `http://${req.headers.host || DEFAULT_HOST}`);
2918
+ if (url.pathname === "/healthz") {
2919
+ sendText(res, 200, "ok");
2920
+ return;
2921
+ }
2922
+
2923
+ const match = /^\/p\/([^/]+)(\/.*)?$/.exec(url.pathname);
2924
+ if (!match) {
2925
+ sendText(res, 404, "Not found.");
2926
+ return;
2927
+ }
2928
+
2929
+ const slug = decodeURIComponent(match[1]);
2930
+ const tail = match[2] || "";
2931
+ if (tail === "") {
2932
+ res.writeHead(302, { Location: `/p/${encodeURIComponent(slug)}/` });
2933
+ res.end();
2934
+ return;
2935
+ }
2936
+
2937
+ const rawAssetPath = tail === "/" ? "" : tail.slice(1);
2938
+ const resolvedAsset = await store.resolvePublishedAsset(slug, rawAssetPath);
2939
+ if (resolvedAsset.statusCode === 301) {
2940
+ res.writeHead(301, { Location: resolvedAsset.location });
2941
+ res.end();
2942
+ return;
2943
+ }
2944
+ if (resolvedAsset.statusCode !== 200) {
2945
+ sendText(res, resolvedAsset.statusCode, resolvedAsset.message);
2946
+ return;
2947
+ }
2948
+
2949
+ // A markdown entry resolves with an in-memory rendered HTML body; serve it
2950
+ // directly rather than streaming the raw .md source.
2951
+ if (resolvedAsset.body !== undefined) {
2952
+ sendHtmlBody(req, res, resolvedAsset);
2953
+ return;
2954
+ }
2955
+
2956
+ await sendFile(req, res, resolvedAsset);
2957
+ } catch (error) {
2958
+ sendError(res, error);
2959
+ }
2960
+ };
2961
+ }
2962
+
2963
+ export function createAdminHandler({
2964
+ store,
2965
+ configStore,
2966
+ cloudflareAuth,
2967
+ pagesPublisher,
2968
+ staticDir,
2969
+ getAdminBaseUrl,
2970
+ getLocalPublicBaseUrl,
2971
+ tunnelManager,
2972
+ deployQueue,
2973
+ watchManager
2974
+ }) {
2975
+ return async function adminHandler(req, res) {
2976
+ try {
2977
+ const url = new URL(req.url, `http://${req.headers.host || DEFAULT_HOST}`);
2978
+
2979
+ if (url.pathname.startsWith("/api/")) {
2980
+ await handleApi(req, res, url, {
2981
+ store,
2982
+ configStore,
2983
+ cloudflareAuth,
2984
+ pagesPublisher,
2985
+ getAdminBaseUrl,
2986
+ getLocalPublicBaseUrl,
2987
+ tunnelManager,
2988
+ deployQueue,
2989
+ watchManager
2990
+ });
2991
+ return;
2992
+ }
2993
+
2994
+ if (req.method !== "GET" && req.method !== "HEAD") {
2995
+ sendText(res, 405, "Method not allowed.");
2996
+ return;
2997
+ }
2998
+
2999
+ const previewMatch = /^\/preview\/([^/]+)(\/.*)?$/.exec(url.pathname);
3000
+ if (previewMatch) {
3001
+ const id = decodeURIComponent(previewMatch[1]);
3002
+ const tail = previewMatch[2] || "";
3003
+ if (tail === "") {
3004
+ res.writeHead(302, { Location: `/preview/${encodeURIComponent(id)}/` });
3005
+ res.end();
3006
+ return;
3007
+ }
3008
+
3009
+ const rawAssetPath = tail === "/" ? "" : tail.slice(1);
3010
+ const resolvedAsset = await store.resolveAsset(id, rawAssetPath);
3011
+ if (resolvedAsset.statusCode !== 200) {
3012
+ sendText(res, resolvedAsset.statusCode, resolvedAsset.message);
3013
+ return;
3014
+ }
3015
+
3016
+ // Markdown entries resolve with an in-memory rendered HTML body; serve it
3017
+ // directly instead of streaming the raw .md file.
3018
+ if (resolvedAsset.body !== undefined) {
3019
+ sendHtmlBody(req, res, resolvedAsset);
3020
+ return;
3021
+ }
3022
+
3023
+ await sendFile(req, res, resolvedAsset);
3024
+ return;
3025
+ }
3026
+
3027
+ await serveStatic(req, res, staticDir, url.pathname);
3028
+ } catch (error) {
3029
+ sendError(res, error);
3030
+ }
3031
+ };
3032
+ }
3033
+
3034
+ async function handleApi(
3035
+ req,
3036
+ res,
3037
+ url,
3038
+ {
3039
+ store,
3040
+ configStore,
3041
+ cloudflareAuth,
3042
+ pagesPublisher,
3043
+ getAdminBaseUrl,
3044
+ getLocalPublicBaseUrl,
3045
+ tunnelManager,
3046
+ deployQueue,
3047
+ watchManager
3048
+ }
3049
+ ) {
3050
+ const options = reportOptions({ getAdminBaseUrl, getLocalPublicBaseUrl });
3051
+
3052
+ if (url.pathname === "/api/status" && req.method === "GET") {
3053
+ const credential = cloudflareCredentialStatus();
3054
+ const session = credential.tokenConfigured
3055
+ ? { loggedIn: credential.accountIdConfigured, accounts: [] }
3056
+ : cloudflareAuth.cachedSession();
3057
+ const pages = configStore.get().pages;
3058
+ const activeAccount =
3059
+ session.accounts.find((account) => account.id === pages.accountId) ||
3060
+ session.accounts[0] ||
3061
+ null;
3062
+ const accountName =
3063
+ normalizeAccountName(activeAccount?.name || "") || normalizeAccountName(pages.accountName || "");
3064
+ sendJson(res, 200, {
3065
+ admin: { ok: true },
3066
+ public: { localBaseUrl: getLocalPublicBaseUrl() },
3067
+ cloudflare: {
3068
+ ...credential,
3069
+ loggedIn: session.loggedIn,
3070
+ accounts: session.accounts,
3071
+ accountName,
3072
+ accountId: pages.accountId || activeAccount?.id || "",
3073
+ projectName: pages.projectName,
3074
+ baseUrl: pages.baseUrl
3075
+ },
3076
+ config: configStore.get()
3077
+ });
3078
+ return;
3079
+ }
3080
+
3081
+ if (url.pathname === "/api/config" && req.method === "GET") {
3082
+ sendJson(res, 200, { config: configStore.get() });
3083
+ return;
3084
+ }
3085
+
3086
+ if (url.pathname === "/api/config/pages" && req.method === "POST") {
3087
+ const body = await readJsonBody(req);
3088
+ const config = await configStore.updatePages({
3089
+ projectName: body.projectName,
3090
+ accountId: body.accountId,
3091
+ accountName: body.accountName
3092
+ });
3093
+ sendJson(res, 200, { config });
3094
+ return;
3095
+ }
3096
+
3097
+ if (url.pathname === "/api/cloudflare/login" && req.method === "POST") {
3098
+ await readJsonBody(req);
3099
+ await cloudflareAuth.login();
3100
+ sendJson(res, 200, await detectAndPersistCloudflareProjects({ cloudflareAuth, configStore }));
3101
+ return;
3102
+ }
3103
+
3104
+ if (url.pathname === "/api/cloudflare/projects" && req.method === "POST") {
3105
+ await readJsonBody(req);
3106
+ sendJson(res, 200, await detectAndPersistCloudflareProjects({ cloudflareAuth, configStore }));
3107
+ return;
3108
+ }
3109
+
3110
+ // Seamless one-shot: log in only if needed (reusing an existing OAuth session
3111
+ // on disk when present), auto-detect the account, auto-create the Pages project
3112
+ // when none exists, and return the connected state.
3113
+ if (url.pathname === "/api/cloudflare/connect" && req.method === "POST") {
3114
+ await readJsonBody(req);
3115
+ const credential = cloudflareCredentialStatus();
3116
+ if (!credential.tokenConfigured) {
3117
+ const session = await cloudflareAuth.refreshSession();
3118
+ if (!session.loggedIn) {
3119
+ await cloudflareAuth.login();
3120
+ }
3121
+ }
3122
+ sendJson(res, 200, await ensureCloudflarePagesTarget({ cloudflareAuth, configStore }));
3123
+ return;
3124
+ }
3125
+
3126
+ // Used only when whoami reports multiple accounts: persist the chosen account
3127
+ // and finish provisioning. Single-account users never reach this route.
3128
+ if (url.pathname === "/api/cloudflare/account" && req.method === "POST") {
3129
+ const body = await readJsonBody(req);
3130
+ const accountId = normalizeAccountId(body.accountId || "");
3131
+ const current = configStore.get();
3132
+ const session = cloudflareAuth.cachedSession();
3133
+ const account = session.accounts.find((item) => item.id === accountId) || null;
3134
+ await configStore.updatePages({
3135
+ projectName: current.pages.projectName,
3136
+ accountId,
3137
+ accountName: normalizeAccountName(account?.name || "")
3138
+ });
3139
+ sendJson(res, 200, await ensureCloudflarePagesTarget({ cloudflareAuth, configStore }));
3140
+ return;
3141
+ }
3142
+
3143
+ if (url.pathname === "/api/cloudflare/logout" && req.method === "POST") {
3144
+ await readJsonBody(req);
3145
+ const credential = cloudflareCredentialStatus();
3146
+ if (credential.tokenConfigured) {
3147
+ throw appError("Token-based Cloudflare auth is configured through environment variables.", 400);
3148
+ }
3149
+ await cloudflareAuth.logout();
3150
+ const current = configStore.get();
3151
+ await configStore.updatePages({
3152
+ projectName: current.pages.projectName,
3153
+ accountId: "",
3154
+ accountName: ""
3155
+ });
3156
+ sendJson(res, 200, {
3157
+ cloudflare: { loggedOut: true },
3158
+ config: configStore.get()
3159
+ });
3160
+ return;
3161
+ }
3162
+
3163
+ if (url.pathname === "/api/reports" && req.method === "GET") {
3164
+ sendJson(res, 200, { reports: store.list(options) });
3165
+ return;
3166
+ }
3167
+
3168
+ if (url.pathname === "/api/reports/path" && req.method === "POST") {
3169
+ const body = await readJsonBody(req);
3170
+ const report = await store.addPath(body.path);
3171
+ sendJson(res, 201, { report: store.formatReport(report, options) });
3172
+ return;
3173
+ }
3174
+
3175
+ if (url.pathname === "/api/reports/folder" && req.method === "POST") {
3176
+ const body = await readJsonBody(req);
3177
+ const report = await store.addFolder({
3178
+ folderPath: body.path,
3179
+ entryFile: body.entryFile,
3180
+ buildCommand: body.buildCommand,
3181
+ buildOutputDir: body.buildOutputDir,
3182
+ name: body.name
3183
+ });
3184
+ sendJson(res, 201, { report: store.formatReport(report, options) });
3185
+ return;
3186
+ }
3187
+
3188
+ if (url.pathname === "/api/reports/upload" && req.method === "POST") {
3189
+ const body = await readRequestBody(req);
3190
+ const upload = parseMultipartUpload(body, req.headers["content-type"]);
3191
+ const report = await store.addUpload(upload);
3192
+ sendJson(res, 201, { report: store.formatReport(report, options) });
3193
+ return;
3194
+ }
3195
+
3196
+ if (url.pathname === "/api/reports/folder-upload" && req.method === "POST") {
3197
+ const body = await readRequestBody(req, MAX_FOLDER_UPLOAD_BYTES);
3198
+ const files = parseMultipartFiles(body, req.headers["content-type"]);
3199
+ const report = await store.addFolderUpload({ files });
3200
+ sendJson(res, 201, { report: store.formatReport(report, options) });
3201
+ return;
3202
+ }
3203
+
3204
+ const buildMatch = /^\/api\/reports\/([^/]+)\/build$/.exec(url.pathname);
3205
+ if (buildMatch && req.method === "POST") {
3206
+ await readJsonBody(req);
3207
+ const report = await store.buildReport(decodeURIComponent(buildMatch[1]));
3208
+ sendJson(res, 200, { report: store.formatReport(report, options) });
3209
+ return;
3210
+ }
3211
+
3212
+ const publishMatch = /^\/api\/reports\/([^/]+)\/publish$/.exec(url.pathname);
3213
+ if (publishMatch && req.method === "POST") {
3214
+ await readJsonBody(req);
3215
+ sendJson(res, 410, {
3216
+ error: {
3217
+ message: "Local live publishing has been removed. Use Cloudflare Pages snapshots.",
3218
+ statusCode: 410
3219
+ }
3220
+ });
3221
+ return;
3222
+ }
3223
+
3224
+ const snapshotPublishMatch = /^\/api\/reports\/([^/]+)\/publish-snapshot$/.exec(url.pathname);
3225
+ if (snapshotPublishMatch && req.method === "POST") {
3226
+ const body = await readJsonBody(req);
3227
+ const id = decodeURIComponent(snapshotPublishMatch[1]);
3228
+ const sourceReport = store.get(id);
3229
+ if (sourceReport?.kind === "folder" && sourceReport.buildCommand) {
3230
+ await store.buildReport(id);
3231
+ }
3232
+ const draft = store.draftPublication(id, {
3233
+ label: body.label,
3234
+ kind: "snapshot"
3235
+ });
3236
+ await deployQueue.enqueue(async () => {
3237
+ try {
3238
+ draft.publication.publicUrl = await pagesPublisher.publish({
3239
+ report: draft.report,
3240
+ publication: draft.publication,
3241
+ pagesConfig: configStore.get().pages
3242
+ });
3243
+ } catch (error) {
3244
+ // Self-provision and retry once when the failure is a missing Pages
3245
+ // project or account, so a snapshot can publish without first visiting
3246
+ // the Cloudflare panel.
3247
+ const message = stripAnsi(error.message || "");
3248
+ const provisionable =
3249
+ /project.*not.*found|could not find.*project|does not exist|no such project|account|select an account/i.test(
3250
+ message
3251
+ );
3252
+ if (!provisionable) {
3253
+ throw error;
3254
+ }
3255
+ await ensureCloudflarePagesTarget({ cloudflareAuth, configStore });
3256
+ draft.publication.publicUrl = await pagesPublisher.publish({
3257
+ report: draft.report,
3258
+ publication: draft.publication,
3259
+ pagesConfig: configStore.get().pages
3260
+ });
3261
+ }
3262
+ });
3263
+ const { report, publication } = await store.commitPublication(id, draft.publication);
3264
+ sendJson(res, 201, {
3265
+ report: store.formatReport(report, options),
3266
+ publication: store.formatPublication(publication, options)
3267
+ });
3268
+ return;
3269
+ }
3270
+
3271
+ const snapshotSyncMatch = /^\/api\/publications\/([^/]+)\/sync$/.exec(url.pathname);
3272
+ if (snapshotSyncMatch && req.method === "POST") {
3273
+ const token = decodeURIComponent(snapshotSyncMatch[1]);
3274
+ const existing = store.findActivePublication(token);
3275
+ if (!existing) {
3276
+ throw appError("Published link was not found.", 404);
3277
+ }
3278
+ if (existing.publication.kind !== "snapshot") {
3279
+ throw appError("Only snapshot publications can be synced.", 400);
3280
+ }
3281
+ if (existing.report.kind === "folder" && existing.report.buildCommand) {
3282
+ await store.buildReport(existing.report.id);
3283
+ }
3284
+ await deployQueue.enqueue(async () => {
3285
+ try {
3286
+ await pagesPublisher.syncPublication({
3287
+ report: existing.report,
3288
+ publication: existing.publication,
3289
+ pagesConfig: configStore.get().pages
3290
+ });
3291
+ } catch (error) {
3292
+ const message = stripAnsi(error.message || "");
3293
+ const provisionable =
3294
+ /project.*not.*found|could not find.*project|does not exist|no such project|account|select an account/i.test(
3295
+ message
3296
+ );
3297
+ if (!provisionable) {
3298
+ throw error;
3299
+ }
3300
+ await ensureCloudflarePagesTarget({ cloudflareAuth, configStore });
3301
+ await pagesPublisher.syncPublication({
3302
+ report: existing.report,
3303
+ publication: existing.publication,
3304
+ pagesConfig: configStore.get().pages
3305
+ });
3306
+ }
3307
+ });
3308
+ const { report, publication } = await store.syncSnapshot(token);
3309
+ sendJson(res, 200, {
3310
+ report: store.formatReport(report, options),
3311
+ publication: store.formatPublication(publication, options)
3312
+ });
3313
+ return;
3314
+ }
3315
+
3316
+ const slugRenameMatch = /^\/api\/publications\/([^/]+)\/slug$/.exec(url.pathname);
3317
+ if (slugRenameMatch && req.method === "PUT") {
3318
+ const body = await readJsonBody(req);
3319
+ const token = decodeURIComponent(slugRenameMatch[1]);
3320
+ const existing = store.findActivePublication(token);
3321
+ if (!existing) {
3322
+ throw appError("Published link was not found.", 404);
3323
+ }
3324
+ // Validate + reserve the slug (throws 400/409) before any deploy work.
3325
+ const { oldSlug, newSlug } = await store.renameSlug(token, body.slug);
3326
+ if (oldSlug !== newSlug && existing.publication.kind === "snapshot") {
3327
+ await deployQueue.enqueue(() =>
3328
+ pagesPublisher.renamePublication({
3329
+ oldSlug,
3330
+ newSlug,
3331
+ report: existing.report,
3332
+ publication: existing.publication,
3333
+ pagesConfig: configStore.get().pages
3334
+ })
3335
+ );
3336
+ }
3337
+ const refreshed = store.findPublication(token);
3338
+ sendJson(res, 200, {
3339
+ report: store.formatReport(refreshed.report, options),
3340
+ publication: store.formatPublication(refreshed.publication, options)
3341
+ });
3342
+ return;
3343
+ }
3344
+
3345
+ const revokeAllMatch = /^\/api\/reports\/([^/]+)\/revoke-all$/.exec(url.pathname);
3346
+ if (revokeAllMatch && req.method === "POST") {
3347
+ const id = decodeURIComponent(revokeAllMatch[1]);
3348
+ const reportBeforeRevoke = store.get(id);
3349
+ if (!reportBeforeRevoke) {
3350
+ throw appError("Report was not found.", 404);
3351
+ }
3352
+ const snapshotSlugs = activeSnapshotSlugs(reportBeforeRevoke);
3353
+ if (snapshotSlugs.length > 0) {
3354
+ await deployQueue.enqueue(() =>
3355
+ pagesPublisher.revoke(snapshotSlugs, configStore.get().pages)
3356
+ );
3357
+ }
3358
+ const { report, revokedCount } = await store.revokeAll(id);
3359
+ sendJson(res, 200, {
3360
+ revokedCount,
3361
+ report: store.formatReport(report, options)
3362
+ });
3363
+ return;
3364
+ }
3365
+
3366
+ const deleteMatch = /^\/api\/reports\/([^/]+)$/.exec(url.pathname);
3367
+ if (deleteMatch && req.method === "DELETE") {
3368
+ const id = decodeURIComponent(deleteMatch[1]);
3369
+ const reportBeforeDelete = store.get(id);
3370
+ if (reportBeforeDelete) {
3371
+ if (watchManager) {
3372
+ watchManager.unregister(id);
3373
+ }
3374
+ const snapshotSlugs = activeSnapshotSlugs(reportBeforeDelete);
3375
+ if (snapshotSlugs.length > 0) {
3376
+ await deployQueue.enqueue(() =>
3377
+ pagesPublisher.revoke(snapshotSlugs, configStore.get().pages)
3378
+ );
3379
+ }
3380
+ }
3381
+ const removed = await store.remove(id);
3382
+ sendJson(res, removed ? 200 : 404, { removed });
3383
+ return;
3384
+ }
3385
+
3386
+ const revokePublicationMatch = /^\/api\/publications\/([^/]+)\/revoke$/.exec(url.pathname);
3387
+ if (revokePublicationMatch && req.method === "POST") {
3388
+ const token = decodeURIComponent(revokePublicationMatch[1]);
3389
+ const existing = store.findPublication(token);
3390
+ if (!existing) {
3391
+ throw appError("Published link was not found.", 404);
3392
+ }
3393
+ if (!existing.publication.revokedAt && existing.publication.kind === "snapshot") {
3394
+ const slug = existing.publication.slug || existing.publication.token;
3395
+ await deployQueue.enqueue(() =>
3396
+ pagesPublisher.revoke([slug], configStore.get().pages)
3397
+ );
3398
+ }
3399
+ const { report, publication } = await store.revokePublication(token);
3400
+ sendJson(res, 200, {
3401
+ report: store.formatReport(report, options),
3402
+ publication: store.formatPublication(publication, options)
3403
+ });
3404
+ return;
3405
+ }
3406
+
3407
+ if (url.pathname === "/api/reports/reorder" && req.method === "POST") {
3408
+ const body = await readJsonBody(req);
3409
+ await store.reorder(body.ids);
3410
+ sendJson(res, 200, { reports: store.list(options) });
3411
+ return;
3412
+ }
3413
+
3414
+ const contentMatch = /^\/api\/reports\/([^/]+)\/content$/.exec(url.pathname);
3415
+ if (contentMatch && req.method === "GET") {
3416
+ const id = decodeURIComponent(contentMatch[1]);
3417
+ const { html } = await store.readContent(id);
3418
+ sendJson(res, 200, { html });
3419
+ return;
3420
+ }
3421
+
3422
+ if (contentMatch && req.method === "PUT") {
3423
+ const body = await readJsonBody(req);
3424
+ const id = decodeURIComponent(contentMatch[1]);
3425
+ const report = await store.writeContent(id, body.html);
3426
+ // Push the edit live to every active snapshot of this report (same URL).
3427
+ const snapshots = store.activeSnapshotPublications(report);
3428
+ for (const publication of snapshots) {
3429
+ await deployQueue.enqueue(async () => {
3430
+ await pagesPublisher.syncPublication({
3431
+ report,
3432
+ publication,
3433
+ pagesConfig: configStore.get().pages
3434
+ });
3435
+ await store.syncSnapshot(publication.token);
3436
+ });
3437
+ }
3438
+ sendJson(res, 200, { report: store.formatReport(store.get(id), options) });
3439
+ return;
3440
+ }
3441
+
3442
+ const autoSyncMatch = /^\/api\/reports\/([^/]+)\/auto-sync$/.exec(url.pathname);
3443
+ if (autoSyncMatch && req.method === "POST") {
3444
+ const body = await readJsonBody(req);
3445
+ const id = decodeURIComponent(autoSyncMatch[1]);
3446
+ const report = await store.setAutoSync(id, body.enabled === true);
3447
+ if (watchManager) {
3448
+ if (report.autoSync) {
3449
+ watchManager.register(id);
3450
+ } else {
3451
+ watchManager.unregister(id);
3452
+ }
3453
+ }
3454
+ sendJson(res, 200, { report: store.formatReport(report, options) });
3455
+ return;
3456
+ }
3457
+
3458
+ if (url.pathname.startsWith("/api/tunnel/")) {
3459
+ sendJson(res, 410, {
3460
+ error: {
3461
+ message: "Live tunnel publishing has been removed. Use Cloudflare Pages publishing.",
3462
+ statusCode: 410
3463
+ }
3464
+ });
3465
+ return;
3466
+ }
3467
+
3468
+ sendJson(res, 404, {
3469
+ error: {
3470
+ message: "API route was not found.",
3471
+ statusCode: 404
3472
+ }
3473
+ });
3474
+ }
3475
+
3476
+ function listen(server, { host, port }) {
3477
+ return new Promise((resolve, reject) => {
3478
+ server.once("error", reject);
3479
+ server.listen(port, host, () => {
3480
+ server.off("error", reject);
3481
+ resolve(server.address());
3482
+ });
3483
+ });
3484
+ }
3485
+
3486
+ function closeServer(server) {
3487
+ return new Promise((resolve, reject) => {
3488
+ server.close((error) => {
3489
+ if (error) {
3490
+ reject(error);
3491
+ } else {
3492
+ resolve();
3493
+ }
3494
+ });
3495
+ });
3496
+ }
3497
+
3498
+ export async function startServers({
3499
+ host = DEFAULT_HOST,
3500
+ adminPort = Number(process.env.PORT || DEFAULT_ADMIN_PORT),
3501
+ publicPort = Number(process.env.PUBLIC_PORT || DEFAULT_PUBLIC_PORT),
3502
+ dataDir = path.join(PROJECT_ROOT, ".pagecast"),
3503
+ staticDir = path.join(PROJECT_ROOT, "public"),
3504
+ spawnImpl = spawn,
3505
+ tunnelTimeoutMs = 30000,
3506
+ cloudflareAuthSpawnImpl = spawn,
3507
+ cloudflareLoginTimeoutMs = DEFAULT_CLOUDFLARE_LOGIN_TIMEOUT_MS,
3508
+ cloudflareListTimeoutMs = DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS,
3509
+ pagesDeploySpawnImpl = spawn,
3510
+ pagesDeployTimeoutMs = 180000
3511
+ } = {}) {
3512
+ const store = createReportStore({ dataDir });
3513
+ await store.init();
3514
+ const configStore = createConfigStore({ dataDir });
3515
+ await configStore.init();
3516
+ const cloudflareAuth = createCloudflareAuthManager({
3517
+ spawnImpl: cloudflareAuthSpawnImpl,
3518
+ loginTimeoutMs: cloudflareLoginTimeoutMs,
3519
+ listTimeoutMs: cloudflareListTimeoutMs
3520
+ });
3521
+ const pagesPublisher = createCloudflarePagesPublisher({
3522
+ dataDir,
3523
+ spawnImpl: pagesDeploySpawnImpl,
3524
+ timeoutMs: pagesDeployTimeoutMs,
3525
+ getRedirects: () => store.listRedirects()
3526
+ });
3527
+ const deployQueue = createDeployQueue();
3528
+ const watchManager = createWatchManager({
3529
+ store,
3530
+ pagesPublisher,
3531
+ configStore,
3532
+ deployQueue
3533
+ });
3534
+ for (const report of store.listAutoSyncReports()) {
3535
+ watchManager.register(report.id);
3536
+ }
3537
+
3538
+ const publicServer = createServer(createPublicHandler({ store }));
3539
+ await listen(publicServer, { host, port: publicPort });
3540
+ const actualPublicPort = publicServer.address().port;
3541
+ const localPublicBaseUrl = `http://${host}:${actualPublicPort}`;
3542
+ let adminBaseUrl = null;
3543
+ const tunnelManager = new TunnelManager({
3544
+ localUrl: localPublicBaseUrl,
3545
+ spawnImpl,
3546
+ timeoutMs: tunnelTimeoutMs
3547
+ });
3548
+
3549
+ const adminServer = createServer(
3550
+ createAdminHandler({
3551
+ store,
3552
+ configStore,
3553
+ cloudflareAuth,
3554
+ pagesPublisher,
3555
+ staticDir,
3556
+ getAdminBaseUrl: () => adminBaseUrl,
3557
+ getLocalPublicBaseUrl: () => localPublicBaseUrl,
3558
+ tunnelManager,
3559
+ deployQueue,
3560
+ watchManager
3561
+ })
3562
+ );
3563
+
3564
+ try {
3565
+ await listen(adminServer, { host, port: adminPort });
3566
+ } catch (error) {
3567
+ await closeServer(publicServer);
3568
+ throw error;
3569
+ }
3570
+
3571
+ const actualAdminPort = adminServer.address().port;
3572
+ const adminUrl = `http://${host}:${actualAdminPort}`;
3573
+ adminBaseUrl = adminUrl;
3574
+
3575
+ return {
3576
+ adminServer,
3577
+ publicServer,
3578
+ store,
3579
+ configStore,
3580
+ cloudflareAuth,
3581
+ pagesPublisher,
3582
+ tunnelManager,
3583
+ deployQueue,
3584
+ watchManager,
3585
+ adminUrl,
3586
+ publicUrl: localPublicBaseUrl,
3587
+ async close() {
3588
+ watchManager.closeAll();
3589
+ await tunnelManager.stop();
3590
+ await Promise.all([closeServer(adminServer), closeServer(publicServer)]);
3591
+ }
3592
+ };
3593
+ }
3594
+
3595
+ async function createHeadlessCloudflareContext({
3596
+ dataDir = path.join(PROJECT_ROOT, ".pagecast"),
3597
+ cloudflareAuthSpawnImpl = spawn,
3598
+ pagesDeploySpawnImpl = spawn,
3599
+ cloudflareListTimeoutMs = DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS,
3600
+ pagesDeployTimeoutMs = 180000
3601
+ } = {}) {
3602
+ const configStore = createConfigStore({ dataDir });
3603
+ await configStore.init();
3604
+ const cloudflareAuth = createCloudflareAuthManager({
3605
+ spawnImpl: cloudflareAuthSpawnImpl,
3606
+ listTimeoutMs: cloudflareListTimeoutMs
3607
+ });
3608
+ const pagesPublisher = createCloudflarePagesPublisher({
3609
+ dataDir,
3610
+ spawnImpl: pagesDeploySpawnImpl,
3611
+ timeoutMs: pagesDeployTimeoutMs
3612
+ });
3613
+ return { configStore, cloudflareAuth, pagesPublisher };
3614
+ }
3615
+
3616
+ async function applyPagesSelection({ configStore, projectName, accountId }) {
3617
+ const current = configStore.get();
3618
+ const selectedProjectName = projectName ? normalizePagesProjectName(projectName) : current.pages.projectName;
3619
+ const selectedAccountId = accountId ? normalizeAccountId(accountId) : current.pages.accountId;
3620
+ if (projectName || accountId) {
3621
+ await configStore.updatePages({
3622
+ projectName: selectedProjectName,
3623
+ accountId: selectedAccountId,
3624
+ accountName: accountId ? "" : current.pages.accountName
3625
+ });
3626
+ }
3627
+ return configStore.get();
3628
+ }
3629
+
3630
+ function cloudflareAuthRequiredMessage() {
3631
+ return "Not signed in to Cloudflare. Run `npx pagecast pages setup` once, then retry.";
3632
+ }
3633
+
3634
+ async function ensureHeadlessPagesTarget({
3635
+ configStore,
3636
+ cloudflareAuth,
3637
+ projectName,
3638
+ accountId,
3639
+ branch = DEFAULT_PAGES_BRANCH,
3640
+ autoCreate = true,
3641
+ loginIfNeeded = false
3642
+ } = {}) {
3643
+ await applyPagesSelection({ configStore, projectName, accountId });
3644
+
3645
+ const credential = cloudflareCredentialStatus();
3646
+ if (!credential.tokenConfigured) {
3647
+ const session = await cloudflareAuth.refreshSession();
3648
+ if (!session.loggedIn) {
3649
+ if (!loginIfNeeded) {
3650
+ throw appError(cloudflareAuthRequiredMessage(), 401);
3651
+ }
3652
+ await cloudflareAuth.login();
3653
+ }
3654
+ }
3655
+
3656
+ const target = await ensureCloudflarePagesTarget({
3657
+ cloudflareAuth,
3658
+ configStore,
3659
+ autoCreate,
3660
+ branch
3661
+ });
3662
+
3663
+ if (!target.cloudflare.authenticated) {
3664
+ throw appError(cloudflareAuthRequiredMessage(), 401);
3665
+ }
3666
+ if (target.cloudflare.needsAccountChoice) {
3667
+ throw appError(
3668
+ "Multiple Cloudflare accounts found. Run `npx pagecast pages setup --account <account-id>` once, then retry.",
3669
+ 409
3670
+ );
3671
+ }
3672
+
3673
+ return target;
3674
+ }
3675
+
3676
+ export async function setupCloudflarePages({
3677
+ projectName,
3678
+ accountId,
3679
+ branch = DEFAULT_PAGES_BRANCH,
3680
+ dataDir = path.join(PROJECT_ROOT, ".pagecast"),
3681
+ cloudflareAuthSpawnImpl = spawn,
3682
+ cloudflareListTimeoutMs = DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS
3683
+ } = {}) {
3684
+ const { configStore, cloudflareAuth } = await createHeadlessCloudflareContext({
3685
+ dataDir,
3686
+ cloudflareAuthSpawnImpl,
3687
+ cloudflareListTimeoutMs
3688
+ });
3689
+ const target = await ensureHeadlessPagesTarget({
3690
+ configStore,
3691
+ cloudflareAuth,
3692
+ projectName,
3693
+ accountId,
3694
+ branch,
3695
+ autoCreate: true,
3696
+ loginIfNeeded: true
3697
+ });
3698
+ return {
3699
+ config: configStore.get(),
3700
+ cloudflare: target.cloudflare
3701
+ };
3702
+ }
3703
+
3704
+ export async function getCloudflarePagesStatus({
3705
+ dataDir = path.join(PROJECT_ROOT, ".pagecast"),
3706
+ cloudflareAuthSpawnImpl = spawn,
3707
+ cloudflareListTimeoutMs = DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS
3708
+ } = {}) {
3709
+ const { configStore, cloudflareAuth } = await createHeadlessCloudflareContext({
3710
+ dataDir,
3711
+ cloudflareAuthSpawnImpl,
3712
+ cloudflareListTimeoutMs
3713
+ });
3714
+ const credential = cloudflareCredentialStatus();
3715
+ const session = credential.tokenConfigured
3716
+ ? { loggedIn: credential.accountIdConfigured, accounts: [] }
3717
+ : await cloudflareAuth.refreshSession();
3718
+ const pages = configStore.get().pages;
3719
+ const activeAccount =
3720
+ session.accounts.find((account) => account.id === pages.accountId) ||
3721
+ session.accounts[0] ||
3722
+ null;
3723
+ const accountName =
3724
+ normalizeAccountName(activeAccount?.name || "") || normalizeAccountName(pages.accountName || "");
3725
+
3726
+ return {
3727
+ config: configStore.get(),
3728
+ cloudflare: {
3729
+ ...credential,
3730
+ loggedIn: session.loggedIn,
3731
+ accounts: session.accounts,
3732
+ accountName,
3733
+ accountId: pages.accountId || activeAccount?.id || "",
3734
+ projectName: pages.projectName,
3735
+ baseUrl: pages.baseUrl
3736
+ }
3737
+ };
3738
+ }
3739
+
3740
+ export async function listCloudflarePagesProjects({
3741
+ accountId,
3742
+ dataDir = path.join(PROJECT_ROOT, ".pagecast"),
3743
+ cloudflareAuthSpawnImpl = spawn,
3744
+ cloudflareListTimeoutMs = DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS
3745
+ } = {}) {
3746
+ const { configStore, cloudflareAuth } = await createHeadlessCloudflareContext({
3747
+ dataDir,
3748
+ cloudflareAuthSpawnImpl,
3749
+ cloudflareListTimeoutMs
3750
+ });
3751
+ const current = await applyPagesSelection({ configStore, accountId });
3752
+ const credential = cloudflareCredentialStatus();
3753
+ const envAccountId = normalizeAccountIdSafe(process.env.CLOUDFLARE_ACCOUNT_ID);
3754
+ let selectedAccountId = normalizeAccountIdSafe(accountId || envAccountId || current.pages.accountId);
3755
+
3756
+ if (!credential.tokenConfigured) {
3757
+ const session = await cloudflareAuth.refreshSession();
3758
+ if (!session.loggedIn) {
3759
+ throw appError(cloudflareAuthRequiredMessage(), 401);
3760
+ }
3761
+ if (!selectedAccountId && session.accounts.length === 1) {
3762
+ selectedAccountId = session.accounts[0].id;
3763
+ }
3764
+ if (!selectedAccountId && session.accounts.length > 1) {
3765
+ throw appError(
3766
+ "Multiple Cloudflare accounts found. Re-run with `--account <account-id>`.",
3767
+ 409
3768
+ );
3769
+ }
3770
+ }
3771
+
3772
+ if (credential.tokenConfigured && !selectedAccountId) {
3773
+ throw appError("Cloudflare API token mode requires CLOUDFLARE_ACCOUNT_ID or --account.", 401);
3774
+ }
3775
+
3776
+ const projects = await cloudflareAuth.listProjects({ accountId: selectedAccountId });
3777
+ return {
3778
+ projects,
3779
+ accountId: selectedAccountId,
3780
+ selectedProject: chooseWranglerPagesProject(projects, configStore.get().pages)
3781
+ };
3782
+ }
3783
+
3784
+ export async function deployCloudflarePagesSite({
3785
+ sourceDir,
3786
+ projectName,
3787
+ accountId,
3788
+ branch = DEFAULT_PAGES_BRANCH,
3789
+ dataDir = path.join(PROJECT_ROOT, ".pagecast"),
3790
+ cloudflareAuthSpawnImpl = spawn,
3791
+ pagesDeploySpawnImpl = spawn,
3792
+ cloudflareListTimeoutMs = DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS,
3793
+ pagesDeployTimeoutMs = 180000
3794
+ } = {}) {
3795
+ if (!projectName) {
3796
+ throw appError("Provide --project for direct Pages site deploys.", 400);
3797
+ }
3798
+ const normalizedBranch = normalizePagesBranch(branch);
3799
+ const normalizedSourceDir = await normalizeLocalFolderPath(sourceDir);
3800
+ const { configStore, cloudflareAuth, pagesPublisher } = await createHeadlessCloudflareContext({
3801
+ dataDir,
3802
+ cloudflareAuthSpawnImpl,
3803
+ pagesDeploySpawnImpl,
3804
+ cloudflareListTimeoutMs,
3805
+ pagesDeployTimeoutMs
3806
+ });
3807
+ await ensureHeadlessPagesTarget({
3808
+ configStore,
3809
+ cloudflareAuth,
3810
+ projectName,
3811
+ accountId,
3812
+ branch: normalizedBranch,
3813
+ autoCreate: true,
3814
+ loginIfNeeded: false
3815
+ });
3816
+ const pagesConfig = configStore.get().pages;
3817
+ const deployment = await pagesPublisher.deploySite({
3818
+ sourceDir: normalizedSourceDir,
3819
+ pagesConfig,
3820
+ branch: normalizedBranch
3821
+ });
3822
+
3823
+ return {
3824
+ url: deployment.baseUrl,
3825
+ deploymentUrl: deployment.deploymentUrl,
3826
+ projectName: pagesConfig.projectName,
3827
+ accountId: pagesConfig.accountId,
3828
+ accountName: pagesConfig.accountName,
3829
+ branch: deployment.branch,
3830
+ sourceDir: normalizedSourceDir
3831
+ };
3832
+ }
3833
+
3834
+ // Headless one-shot snapshot publish for the CLI / agent skill. Reuses the same
3835
+ // store, config, auth, and publisher wiring as the server, auto-provisioning the
3836
+ // Cloudflare account and Pages project, and returns the public URL. Throws a
3837
+ // structured (statusCode-bearing) error when the user is not signed in, so the
3838
+ // caller can turn it into clear guidance instead of a stack trace.
3839
+ export async function publishReportSnapshot({
3840
+ path: reportPath,
3841
+ label,
3842
+ dataDir = path.join(PROJECT_ROOT, ".pagecast"),
3843
+ cloudflareAuthSpawnImpl = spawn,
3844
+ pagesDeploySpawnImpl = spawn,
3845
+ cloudflareListTimeoutMs = DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS,
3846
+ pagesDeployTimeoutMs = 180000
3847
+ } = {}) {
3848
+ if (!reportPath) {
3849
+ throw appError("Provide a path to an HTML report to publish.", 400);
3850
+ }
3851
+
3852
+ const store = createReportStore({ dataDir });
3853
+ await store.init();
3854
+ const { configStore, cloudflareAuth, pagesPublisher } = await createHeadlessCloudflareContext({
3855
+ dataDir,
3856
+ cloudflareAuthSpawnImpl,
3857
+ pagesDeploySpawnImpl,
3858
+ cloudflareListTimeoutMs,
3859
+ pagesDeployTimeoutMs
3860
+ });
3861
+
3862
+ const credential = cloudflareCredentialStatus();
3863
+ if (!credential.tokenConfigured) {
3864
+ const session = await cloudflareAuth.refreshSession();
3865
+ if (!session.loggedIn) {
3866
+ throw appError(
3867
+ "Not signed in to Cloudflare. Run `npx pagecast` once, click Connect Cloudflare, then retry.",
3868
+ 401
3869
+ );
3870
+ }
3871
+ }
3872
+
3873
+ const target = await ensureCloudflarePagesTarget({ cloudflareAuth, configStore });
3874
+ if (target.cloudflare.needsAccountChoice) {
3875
+ throw appError(
3876
+ "Multiple Cloudflare accounts found. Run `npx pagecast` to choose one, then retry.",
3877
+ 409
3878
+ );
3879
+ }
3880
+
3881
+ const report = await store.addPath(reportPath);
3882
+ const draft = store.draftPublication(report.id, { label, kind: "snapshot" });
3883
+ draft.publication.publicUrl = await pagesPublisher.publish({
3884
+ report: draft.report,
3885
+ publication: draft.publication,
3886
+ pagesConfig: configStore.get().pages
3887
+ });
3888
+ await store.commitPublication(report.id, draft.publication);
3889
+
3890
+ return {
3891
+ url: draft.publication.publicUrl,
3892
+ token: draft.publication.token,
3893
+ label: draft.publication.label,
3894
+ projectName: configStore.get().pages.projectName,
3895
+ reportId: report.id
3896
+ };
3897
+ }
3898
+
3899
+ async function main() {
3900
+ const runtime = await startServers();
3901
+ console.log(`Pagecast admin: ${runtime.adminUrl}`);
3902
+ console.log(`Local published-page server: ${runtime.publicUrl}`);
3903
+ console.log("Press Ctrl-C to stop.");
3904
+
3905
+ const shutdown = async () => {
3906
+ await runtime.close();
3907
+ process.exit(0);
3908
+ };
3909
+ process.once("SIGINT", shutdown);
3910
+ process.once("SIGTERM", shutdown);
3911
+ }
3912
+
3913
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
3914
+ main().catch((error) => {
3915
+ console.error(error);
3916
+ process.exit(1);
3917
+ });
3918
+ }