pagecast 0.1.1 → 0.1.2

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 CHANGED
@@ -26,6 +26,17 @@ export const DEFAULT_CLOUDFLARE_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
26
26
  export const DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS = 60 * 1000;
27
27
  export const CLOUDFLARE_OAUTH_SCOPES = ["account:read", "user:read", "pages:write"];
28
28
 
29
+ // Feedback provisioning deploys a Worker + KV, which the base publishing scopes
30
+ // don't permit. These elevate the grant only when the user opts into feedback,
31
+ // so publishing never has to request Workers/KV access up front.
32
+ export const FEEDBACK_OAUTH_SCOPES = [
33
+ "account:read",
34
+ "user:read",
35
+ "pages:write",
36
+ "workers_scripts:write",
37
+ "workers_kv:write"
38
+ ];
39
+
29
40
  const MIME_TYPES = new Map([
30
41
  [".html", "text/html; charset=utf-8"],
31
42
  [".htm", "text/html; charset=utf-8"],
@@ -228,6 +239,62 @@ function cleanCommandOutput(output) {
228
239
  .trim();
229
240
  }
230
241
 
242
+ // --- Feedback Worker provisioning: parse wrangler outputs --------------------
243
+
244
+ // A KV namespace id is a 32-char hex string. `wrangler kv namespace create`
245
+ // prints a TOML/JSON snippet containing `id = "<hex>"` / `"id": "<hex>"`.
246
+ export function parseKvNamespaceId(output) {
247
+ const text = stripAnsi(output || "");
248
+ const match = text.match(/(?:id\s*=\s*|"id"\s*:\s*)"([0-9a-f]{32})"/i);
249
+ return match ? match[1].toLowerCase() : "";
250
+ }
251
+
252
+ // `wrangler kv namespace list` prints a JSON array of { id, title }. Reuse an
253
+ // existing namespace by title so re-running setup is idempotent.
254
+ export function findKvNamespaceId(output, title) {
255
+ const text = stripAnsi(output || "");
256
+ try {
257
+ const start = text.indexOf("[");
258
+ const end = text.lastIndexOf("]");
259
+ if (start >= 0 && end > start) {
260
+ const list = JSON.parse(text.slice(start, end + 1));
261
+ const hit = list.find((entry) => entry && entry.title === title);
262
+ if (hit && /^[0-9a-f]{32}$/i.test(hit.id || "")) {
263
+ return String(hit.id).toLowerCase();
264
+ }
265
+ }
266
+ } catch {
267
+ // Non-JSON or unexpected shape — treat as "not found" and create one.
268
+ }
269
+ return "";
270
+ }
271
+
272
+ // The deployed Worker's public origin, e.g. https://name.sub.workers.dev.
273
+ export function parseWorkerDevUrl(output) {
274
+ const text = stripAnsi(output || "");
275
+ const match = text.match(/https:\/\/[a-z0-9-]+\.[a-z0-9-]+\.workers\.dev/i);
276
+ return match ? match[0].toLowerCase() : "";
277
+ }
278
+
279
+ // Normalize the persisted feedback (reactions + analytics) settings. Returns
280
+ // null until the feedback Worker has been provisioned, so callers can treat the
281
+ // whole feature as off by checking for a truthy `feedback`.
282
+ function normalizeFeedback(feedback) {
283
+ if (!feedback || typeof feedback !== "object") {
284
+ return null;
285
+ }
286
+ const url = String(feedback.url || "").trim().replace(/\/+$/, "");
287
+ if (!/^https:\/\/[^\s/]+/i.test(url)) {
288
+ return null;
289
+ }
290
+ return {
291
+ url,
292
+ statsToken: String(feedback.statsToken || ""),
293
+ workerName: String(feedback.workerName || ""),
294
+ kvId: String(feedback.kvId || "")
295
+ };
296
+ }
297
+
231
298
  function normalizeConfig(config = {}) {
232
299
  const projectName = normalizePagesProjectName(
233
300
  config.pages?.projectName || DEFAULT_PAGES_PROJECT_NAME
@@ -242,7 +309,8 @@ function normalizeConfig(config = {}) {
242
309
  accountName,
243
310
  branch: DEFAULT_PAGES_BRANCH,
244
311
  baseUrl: pagesBaseUrl(projectName)
245
- }
312
+ },
313
+ feedback: normalizeFeedback(config.feedback)
246
314
  };
247
315
  }
248
316
 
@@ -889,7 +957,19 @@ export function createConfigStore({ dataDir = path.join(PROJECT_ROOT, ".pagecast
889
957
  projectName: projectName === undefined ? config.pages.projectName : projectName,
890
958
  accountId: nextAccountId,
891
959
  accountName: nextAccountName
892
- }
960
+ },
961
+ // Preserve feedback config — a pages update (e.g. persisting the account on
962
+ // publish) must not wipe an already-provisioned feedback Worker.
963
+ feedback: config.feedback
964
+ });
965
+ await save();
966
+ return get();
967
+ }
968
+
969
+ async function updateFeedback(feedback) {
970
+ config = normalizeConfig({
971
+ pages: config.pages,
972
+ feedback: feedback === null ? null : { ...(config.feedback || {}), ...feedback }
893
973
  });
894
974
  await save();
895
975
  return get();
@@ -899,6 +979,7 @@ export function createConfigStore({ dataDir = path.join(PROJECT_ROOT, ".pagecast
899
979
  init,
900
980
  get,
901
981
  updatePages,
982
+ updateFeedback,
902
983
  configPath
903
984
  };
904
985
  }
@@ -923,11 +1004,41 @@ export function createDeployQueue() {
923
1004
  return { enqueue };
924
1005
  }
925
1006
 
1007
+ // Insert the feedback widget into a published HTML document. The widget (served
1008
+ // by the user's feedback Worker) beacons a view and renders the reactions bar.
1009
+ // Injected just before </body> so it loads after page content. `url` is the
1010
+ // Worker origin and `slug` keys this page's stats. Returns the HTML unchanged
1011
+ // when feedback is not configured. Pure + exported for testing.
1012
+ export function injectFeedbackWidget(html, { url, slug } = {}) {
1013
+ const baseUrl = String(url || "").trim().replace(/\/+$/, "");
1014
+ const pageSlug = String(slug || "").trim();
1015
+ if (!baseUrl || !pageSlug) {
1016
+ return html;
1017
+ }
1018
+ const esc = (value) =>
1019
+ String(value)
1020
+ .replace(/&/g, "&amp;")
1021
+ .replace(/"/g, "&quot;")
1022
+ .replace(/</g, "&lt;")
1023
+ .replace(/>/g, "&gt;");
1024
+ const tag =
1025
+ `<script src="${esc(`${baseUrl}/widget.js`)}" data-slug="${esc(pageSlug)}" defer></script>`;
1026
+ // Avoid double-injecting if the document already carries the widget.
1027
+ if (html.includes(`data-slug="${esc(pageSlug)}"`) && html.includes("/widget.js")) {
1028
+ return html;
1029
+ }
1030
+ if (/<\/body>/i.test(html)) {
1031
+ return html.replace(/<\/body>/i, `${tag}\n</body>`);
1032
+ }
1033
+ return `${html}\n${tag}\n`;
1034
+ }
1035
+
926
1036
  export function createCloudflarePagesPublisher({
927
1037
  dataDir = path.join(PROJECT_ROOT, ".pagecast"),
928
1038
  spawnImpl = spawn,
929
1039
  timeoutMs = 180000,
930
- getRedirects = () => []
1040
+ getRedirects = () => [],
1041
+ getFeedback = () => null
931
1042
  } = {}) {
932
1043
  const siteRoot = path.join(dataDir, "pages-site");
933
1044
  const deployRoot = path.join(dataDir, "pages-deploy");
@@ -966,18 +1077,28 @@ export function createCloudflarePagesPublisher({
966
1077
  }
967
1078
 
968
1079
  async function stagePublication(report, publication) {
969
- const destinationRoot = publicationDir(publication.slug || publication.token);
1080
+ const slug = publication.slug || publication.token;
1081
+ const destinationRoot = publicationDir(slug);
970
1082
  const sourceRoot = publishSourceFor(report);
971
1083
  await copyPublicTree(sourceRoot, destinationRoot);
1084
+
1085
+ const indexPath = path.join(destinationRoot, "index.html");
1086
+ let html;
972
1087
  if (isMarkdownFileName(report.entryFile)) {
973
1088
  // Render the raw markdown entry to real HTML so the published Cloudflare
974
1089
  // site serves a proper document; sibling assets were copied above.
975
1090
  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");
1091
+ html = markdownToHtml(markdown, { title: report.name });
978
1092
  } else {
979
- await fs.copyFile(path.join(sourceRoot, report.entryFile), path.join(destinationRoot, "index.html"));
1093
+ html = await fs.readFile(path.join(sourceRoot, report.entryFile), "utf8");
980
1094
  }
1095
+
1096
+ // Inject the reactions + analytics widget when feedback is provisioned.
1097
+ const feedback = getFeedback();
1098
+ if (feedback?.url) {
1099
+ html = injectFeedbackWidget(html, { url: feedback.url, slug });
1100
+ }
1101
+ await fs.writeFile(indexPath, html, "utf8");
981
1102
  }
982
1103
 
983
1104
  async function removePublication(slug) {
@@ -1258,8 +1379,8 @@ export function createCloudflareAuthManager({
1258
1379
  // connect/refresh time; the cache is invalidated whenever login state changes.
1259
1380
  let sessionCache = null;
1260
1381
 
1261
- async function login() {
1262
- const scopedArgs = CLOUDFLARE_OAUTH_SCOPES.flatMap((scope) => ["--scopes", scope]);
1382
+ async function login(scopes = CLOUDFLARE_OAUTH_SCOPES) {
1383
+ const scopedArgs = scopes.flatMap((scope) => ["--scopes", scope]);
1263
1384
  await runWrangler(["login", ...scopedArgs], loginTimeoutMs);
1264
1385
  sessionCache = null;
1265
1386
  }
@@ -1342,6 +1463,98 @@ export function createCloudflareAuthManager({
1342
1463
  return name;
1343
1464
  }
1344
1465
 
1466
+ // Provision the feedback Worker: reuse-or-create a KV namespace, stage the
1467
+ // worker + a generated wrangler.toml, and deploy it to the account's
1468
+ // workers.dev. Returns { url, kvId, workerName, statsToken }. Side-effecting
1469
+ // (creates real Cloudflare resources) — only run on explicit user action.
1470
+ async function setupFeedback({
1471
+ accountId = "",
1472
+ workerName = "pagecast-feedback",
1473
+ workerSource = "",
1474
+ statsToken = "",
1475
+ deployDir,
1476
+ timeoutMs = 120000
1477
+ } = {}) {
1478
+ if (!workerSource) {
1479
+ throw appError("Feedback Worker source was not found in the package.", 500);
1480
+ }
1481
+ if (!deployDir) {
1482
+ throw appError("A deploy directory is required to set up feedback.", 500);
1483
+ }
1484
+ const env = accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {};
1485
+ const kvTitle = `${workerName}-store`;
1486
+
1487
+ const provision = async () => {
1488
+ // 1. Reuse or create the KV namespace.
1489
+ let kvId = "";
1490
+ try {
1491
+ const listOut = await runWrangler(["kv", "namespace", "list"], timeoutMs, env);
1492
+ kvId = findKvNamespaceId(listOut, kvTitle);
1493
+ } catch {
1494
+ // Listing isn't available/authorized — fall through to create.
1495
+ }
1496
+ if (!kvId) {
1497
+ const createOut = await runWrangler(
1498
+ ["kv", "namespace", "create", kvTitle],
1499
+ timeoutMs,
1500
+ env
1501
+ );
1502
+ kvId = parseKvNamespaceId(createOut);
1503
+ }
1504
+ if (!kvId) {
1505
+ throw appError("Could not create the feedback KV namespace.", 502);
1506
+ }
1507
+
1508
+ // 2. Stage worker.js + a generated wrangler.toml in a clean temp dir.
1509
+ await fs.rm(deployDir, { recursive: true, force: true });
1510
+ await fs.mkdir(deployDir, { recursive: true });
1511
+ await fs.writeFile(path.join(deployDir, "worker.js"), workerSource, "utf8");
1512
+ const toml = [
1513
+ `name = "${workerName}"`,
1514
+ `main = "worker.js"`,
1515
+ `compatibility_date = "2024-09-01"`,
1516
+ `workers_dev = true`,
1517
+ ``,
1518
+ `[[kv_namespaces]]`,
1519
+ `binding = "PAGECAST_FEEDBACK"`,
1520
+ `id = "${kvId}"`,
1521
+ ``,
1522
+ `[vars]`,
1523
+ `PAGECAST_STATS_TOKEN = "${statsToken}"`,
1524
+ ``
1525
+ ].join("\n");
1526
+ await fs.writeFile(path.join(deployDir, "wrangler.toml"), toml, "utf8");
1527
+
1528
+ // 3. Deploy. Wrangler resolves `main` relative to the config file's dir.
1529
+ const deployOut = await runWrangler(
1530
+ ["deploy", "--config", path.join(deployDir, "wrangler.toml")],
1531
+ timeoutMs,
1532
+ env
1533
+ );
1534
+ const url = parseWorkerDevUrl(deployOut);
1535
+ if (!url) {
1536
+ throw appError(
1537
+ "Feedback Worker deployed but no workers.dev URL was returned. Enable a workers.dev subdomain in your Cloudflare dashboard, then retry.",
1538
+ 502
1539
+ );
1540
+ }
1541
+ return { url, kvId, workerName, statsToken };
1542
+ };
1543
+
1544
+ try {
1545
+ return await provision();
1546
+ } catch (error) {
1547
+ // The base publishing OAuth lacks Workers/KV permission, surfacing as a
1548
+ // Cloudflare "Authentication error [code: 10000]". Elevate the grant to the
1549
+ // feedback scopes once, then retry. (No-op if a token is already broad.)
1550
+ if (/code:\s*10000|authentication error/i.test(stripAnsi(error.message || ""))) {
1551
+ await login(FEEDBACK_OAUTH_SCOPES);
1552
+ return await provision();
1553
+ }
1554
+ throw error;
1555
+ }
1556
+ }
1557
+
1345
1558
  function cachedSession() {
1346
1559
  return sessionCache ? sessionCache.value : { loggedIn: false, accounts: [] };
1347
1560
  }
@@ -1369,6 +1582,7 @@ export function createCloudflareAuthManager({
1369
1582
  loginAndListProjects,
1370
1583
  whoami,
1371
1584
  ensureProject,
1585
+ setupFeedback,
1372
1586
  cachedSession,
1373
1587
  refreshSession,
1374
1588
  invalidateSession
@@ -3165,6 +3379,60 @@ async function handleApi(
3165
3379
  return;
3166
3380
  }
3167
3381
 
3382
+ // Provision (or re-provision) the feedback Worker + KV on the user's account.
3383
+ // Creates real Cloudflare resources, so it only runs on this explicit action.
3384
+ if (url.pathname === "/api/feedback/setup" && req.method === "POST") {
3385
+ const body = await readJsonBody(req);
3386
+ const pages = configStore.get().pages;
3387
+ const accountId = normalizeAccountId(body.accountId || pages.accountId || "");
3388
+ const workerPath = path.join(PROJECT_ROOT, "feedback", "worker.js");
3389
+ let workerSource;
3390
+ try {
3391
+ workerSource = await fs.readFile(workerPath, "utf8");
3392
+ } catch {
3393
+ sendError(res, appError("Feedback Worker source not found in the package.", 500));
3394
+ return;
3395
+ }
3396
+ const existing = configStore.get().feedback;
3397
+ const statsToken = existing?.statsToken || randomBytes(24).toString("hex");
3398
+ const dataDir = path.dirname(configStore.configPath);
3399
+ try {
3400
+ const result = await cloudflareAuth.setupFeedback({
3401
+ accountId,
3402
+ workerSource,
3403
+ statsToken,
3404
+ deployDir: path.join(dataDir, "feedback-deploy")
3405
+ });
3406
+ const config = await configStore.updateFeedback(result);
3407
+ sendJson(res, 200, { config, feedback: config.feedback });
3408
+ } catch (error) {
3409
+ sendError(res, error);
3410
+ }
3411
+ return;
3412
+ }
3413
+
3414
+ // Read aggregate stats for a published page back from the feedback Worker.
3415
+ // Proxied through the local server so the stats token never reaches the UI.
3416
+ if (url.pathname === "/api/feedback/stats" && req.method === "GET") {
3417
+ const feedback = configStore.get().feedback;
3418
+ if (!feedback?.url) {
3419
+ sendJson(res, 200, { ok: true, configured: false, stats: null });
3420
+ return;
3421
+ }
3422
+ const slug = url.searchParams.get("slug") || "";
3423
+ const statsUrl =
3424
+ `${feedback.url}/api/v1/stats?slug=${encodeURIComponent(slug)}` +
3425
+ `&token=${encodeURIComponent(feedback.statsToken)}`;
3426
+ try {
3427
+ const response = await fetch(statsUrl);
3428
+ const data = await response.json().catch(() => ({}));
3429
+ sendJson(res, 200, { ok: response.ok, configured: true, ...data });
3430
+ } catch {
3431
+ sendError(res, appError("Could not reach the feedback service.", 502));
3432
+ }
3433
+ return;
3434
+ }
3435
+
3168
3436
  if (url.pathname === "/api/cloudflare/login" && req.method === "POST") {
3169
3437
  await readJsonBody(req);
3170
3438
  await cloudflareAuth.login();
@@ -3593,7 +3861,8 @@ export async function startServers({
3593
3861
  dataDir,
3594
3862
  spawnImpl: pagesDeploySpawnImpl,
3595
3863
  timeoutMs: pagesDeployTimeoutMs,
3596
- getRedirects: () => store.listRedirects()
3864
+ getRedirects: () => store.listRedirects(),
3865
+ getFeedback: () => configStore.get().feedback
3597
3866
  });
3598
3867
  const deployQueue = createDeployQueue();
3599
3868
  const watchManager = createWatchManager({
@@ -3685,7 +3954,10 @@ async function createHeadlessCloudflareContext({
3685
3954
  const pagesPublisher = createCloudflarePagesPublisher({
3686
3955
  dataDir,
3687
3956
  spawnImpl: pagesDeploySpawnImpl,
3688
- timeoutMs: pagesDeployTimeoutMs
3957
+ timeoutMs: pagesDeployTimeoutMs,
3958
+ // Headless/CLI publishes (incl. the agent skill's `npx pagecast publish`)
3959
+ // must inject the feedback widget too, not just the running app.
3960
+ getFeedback: () => configStore.get().feedback
3689
3961
  });
3690
3962
  return { configStore, cloudflareAuth, pagesPublisher };
3691
3963
  }
@@ -3778,6 +4050,45 @@ export async function setupCloudflarePages({
3778
4050
  };
3779
4051
  }
3780
4052
 
4053
+ // Provision the feedback Worker + KV on the user's account and persist the
4054
+ // resulting config. Reuses an existing stats token/namespace on re-run.
4055
+ export async function setupCloudflareFeedback({
4056
+ accountId,
4057
+ dataDir = path.join(PROJECT_ROOT, ".pagecast"),
4058
+ cloudflareAuthSpawnImpl = spawn,
4059
+ cloudflareListTimeoutMs = DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS,
4060
+ feedbackTimeoutMs = 120000
4061
+ } = {}) {
4062
+ const { configStore, cloudflareAuth } = await createHeadlessCloudflareContext({
4063
+ dataDir,
4064
+ cloudflareAuthSpawnImpl,
4065
+ cloudflareListTimeoutMs
4066
+ });
4067
+ const pages = configStore.get().pages;
4068
+ const resolvedAccountId = normalizeAccountId(accountId || pages.accountId || "");
4069
+
4070
+ const workerPath = path.join(PROJECT_ROOT, "feedback", "worker.js");
4071
+ let workerSource;
4072
+ try {
4073
+ workerSource = await fs.readFile(workerPath, "utf8");
4074
+ } catch {
4075
+ throw appError("Feedback Worker source not found in the package.", 500);
4076
+ }
4077
+
4078
+ const existing = configStore.get().feedback;
4079
+ const statsToken = existing?.statsToken || randomBytes(24).toString("hex");
4080
+
4081
+ const result = await cloudflareAuth.setupFeedback({
4082
+ accountId: resolvedAccountId,
4083
+ workerSource,
4084
+ statsToken,
4085
+ deployDir: path.join(dataDir, "feedback-deploy")
4086
+ });
4087
+
4088
+ const config = await configStore.updateFeedback(result);
4089
+ return { config, feedback: config.feedback };
4090
+ }
4091
+
3781
4092
  export async function getCloudflarePagesStatus({
3782
4093
  dataDir = path.join(PROJECT_ROOT, ".pagecast"),
3783
4094
  cloudflareAuthSpawnImpl = spawn,