@reddoorla/maintenance 0.91.0 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/{bump-deps-KGH5UYSF.js → bump-deps-GI7PHMOC.js} +6 -6
  2. package/dist/{chunk-A6T2R63Z.js → chunk-3HS6LPFX.js} +2 -2
  3. package/dist/{chunk-SJOXWJ5Y.js → chunk-4763KQ6O.js} +184 -9
  4. package/dist/chunk-4763KQ6O.js.map +1 -0
  5. package/dist/{chunk-UXJZU23T.js → chunk-4NUKMPOO.js} +9 -9
  6. package/dist/{chunk-BQRFQSGY.js → chunk-4R3YAQNV.js} +2 -2
  7. package/dist/{chunk-XIDUYLSR.js → chunk-7CJ3DVZW.js} +2 -2
  8. package/dist/{chunk-Z3KYFUN6.js → chunk-B6NUZ2ZX.js} +2 -2
  9. package/dist/{chunk-WVVT6SJK.js → chunk-BJQCIJOY.js} +169 -17
  10. package/dist/chunk-BJQCIJOY.js.map +1 -0
  11. package/dist/{chunk-Y53WOQIJ.js → chunk-CVJF2IC5.js} +2 -2
  12. package/dist/chunk-I7NHLVVI.js +89 -0
  13. package/dist/chunk-I7NHLVVI.js.map +1 -0
  14. package/dist/{chunk-QD427NIO.js → chunk-JB4Q3C3M.js} +7 -7
  15. package/dist/{chunk-IYSEC543.js → chunk-LS6QPANO.js} +2 -2
  16. package/dist/{chunk-7RJ2HMMJ.js → chunk-NTLSMJHW.js} +2 -2
  17. package/dist/{chunk-2NJQLSEU.js → chunk-PPIZHDJL.js} +2 -2
  18. package/dist/{chunk-JRHIFA5R.js → chunk-QF6IQQFP.js} +10 -4
  19. package/dist/chunk-QF6IQQFP.js.map +1 -0
  20. package/dist/{chunk-RQLXEMEE.js → chunk-T7ZUUOZ2.js} +2 -2
  21. package/dist/chunk-T7ZUUOZ2.js.map +1 -0
  22. package/dist/{chunk-TVL5MVUF.js → chunk-TCF6S7AD.js} +4 -4
  23. package/dist/{chunk-GSWTT2J6.js → chunk-WHWVYL7B.js} +4 -4
  24. package/dist/{chunk-3G25KIWW.js → chunk-ZTPPCHDB.js} +4 -4
  25. package/dist/cli/bin.js +17 -17
  26. package/dist/cli/commands/audit.js +10 -10
  27. package/dist/{convert-to-pnpm-NWB5FCQ2.js → convert-to-pnpm-ONFP3JKF.js} +6 -6
  28. package/dist/{db-IMMQSRAB.js → db-HSZ6D5DU.js} +3 -3
  29. package/dist/digest-EOA4JLTV.js +1 -1
  30. package/dist/digest-collectors-ZA3BTS3R.js +1 -1
  31. package/dist/{email-PLPDJUDH.js → email-I4BCETI4.js} +16 -65
  32. package/dist/email-I4BCETI4.js.map +1 -0
  33. package/dist/forms/index.d.ts +5 -1
  34. package/dist/forms/index.js +1 -1
  35. package/dist/forms/index.js.map +1 -1
  36. package/dist/forms/prismic.d.ts +92 -0
  37. package/dist/forms/prismic.js +105 -0
  38. package/dist/forms/prismic.js.map +1 -0
  39. package/dist/{forms-notify-target-YCZUAHTY.js → forms-notify-target-RMZPKT6S.js} +4 -3
  40. package/dist/{forms-notify-target-YCZUAHTY.js.map → forms-notify-target-RMZPKT6S.js.map} +1 -1
  41. package/dist/{health-endpoint-UVX6R5N5.js → health-endpoint-DHKJUPRM.js} +6 -6
  42. package/dist/index.js +30 -23
  43. package/dist/index.js.map +1 -1
  44. package/dist/{init-NXZ4NXTL.js → init-ZRKWYCKA.js} +15 -15
  45. package/dist/{launch-WPEQ3TY2.js → launch-6X5OXPF2.js} +11 -11
  46. package/dist/{notify-4HP4GZ2H.js → notify-IISTHNBR.js} +4 -3
  47. package/dist/{onboard-BKCYBAVW.js → onboard-6NHGCKGB.js} +6 -6
  48. package/dist/orchestrate-KMQIUP3H.js +1 -1
  49. package/dist/{pipeline-HQJYZ443.js → pipeline-AQBFG5WM.js} +5 -3
  50. package/dist/preflight-VPHQYWTY.js +1 -1
  51. package/dist/{prismic-ci-6YGRIOHT.js → prismic-ci-A2HI7NNJ.js} +8 -8
  52. package/dist/{prismic-models-FPDSUXDO.js → prismic-models-QTKIMFBC.js} +4 -4
  53. package/dist/prospect/types.d.ts +17 -0
  54. package/dist/prospect/types.js +1 -1
  55. package/dist/{prospect-audit-TFIXSTE5.js → prospect-audit-RKUVSTLN.js} +4 -4
  56. package/dist/recipes/sync-configs.js +3 -3
  57. package/dist/{chunk-H6BLTEFN.js → render-7G43BSK5.js} +3 -3
  58. package/dist/{chunk-H6BLTEFN.js.map → render-7G43BSK5.js.map} +1 -1
  59. package/dist/{replay-4S6ENYCH.js → replay-PCSQOGCZ.js} +3 -2
  60. package/dist/{replay-4S6ENYCH.js.map → replay-PCSQOGCZ.js.map} +1 -1
  61. package/dist/{self-updating-VQIW24RZ.js → self-updating-D5XOWS7V.js} +5 -5
  62. package/dist/selftest-IFGSJP4X.js +1 -1
  63. package/dist/{smoke-suite-4JFZZYVW.js → smoke-suite-GXC5JZPH.js} +6 -6
  64. package/dist/{svelte-codemods-QAPDZC6F.js → svelte-codemods-A3EARSRP.js} +6 -6
  65. package/dist/{sync-configs-PVRLZRKL.js → sync-configs-KSESOHFF.js} +6 -6
  66. package/dist/{upgrade-IR2OO3KP.js → upgrade-ZLNVEIEA.js} +6 -6
  67. package/dist/{webflow-624FEBFD.js → webflow-IRKOIT7S.js} +4 -4
  68. package/package.json +5 -1
  69. package/dist/chunk-JRHIFA5R.js.map +0 -1
  70. package/dist/chunk-RQLXEMEE.js.map +0 -1
  71. package/dist/chunk-SJOXWJ5Y.js.map +0 -1
  72. package/dist/chunk-WVVT6SJK.js.map +0 -1
  73. package/dist/email-PLPDJUDH.js.map +0 -1
  74. package/dist/render-3GYW2J34.js +0 -11
  75. package/dist/render-3GYW2J34.js.map +0 -1
  76. /package/dist/{bump-deps-KGH5UYSF.js.map → bump-deps-GI7PHMOC.js.map} +0 -0
  77. /package/dist/{chunk-A6T2R63Z.js.map → chunk-3HS6LPFX.js.map} +0 -0
  78. /package/dist/{chunk-UXJZU23T.js.map → chunk-4NUKMPOO.js.map} +0 -0
  79. /package/dist/{chunk-BQRFQSGY.js.map → chunk-4R3YAQNV.js.map} +0 -0
  80. /package/dist/{chunk-XIDUYLSR.js.map → chunk-7CJ3DVZW.js.map} +0 -0
  81. /package/dist/{chunk-Z3KYFUN6.js.map → chunk-B6NUZ2ZX.js.map} +0 -0
  82. /package/dist/{chunk-Y53WOQIJ.js.map → chunk-CVJF2IC5.js.map} +0 -0
  83. /package/dist/{chunk-QD427NIO.js.map → chunk-JB4Q3C3M.js.map} +0 -0
  84. /package/dist/{chunk-IYSEC543.js.map → chunk-LS6QPANO.js.map} +0 -0
  85. /package/dist/{chunk-7RJ2HMMJ.js.map → chunk-NTLSMJHW.js.map} +0 -0
  86. /package/dist/{chunk-2NJQLSEU.js.map → chunk-PPIZHDJL.js.map} +0 -0
  87. /package/dist/{chunk-TVL5MVUF.js.map → chunk-TCF6S7AD.js.map} +0 -0
  88. /package/dist/{chunk-GSWTT2J6.js.map → chunk-WHWVYL7B.js.map} +0 -0
  89. /package/dist/{chunk-3G25KIWW.js.map → chunk-ZTPPCHDB.js.map} +0 -0
  90. /package/dist/{convert-to-pnpm-NWB5FCQ2.js.map → convert-to-pnpm-ONFP3JKF.js.map} +0 -0
  91. /package/dist/{db-IMMQSRAB.js.map → db-HSZ6D5DU.js.map} +0 -0
  92. /package/dist/{health-endpoint-UVX6R5N5.js.map → health-endpoint-DHKJUPRM.js.map} +0 -0
  93. /package/dist/{init-NXZ4NXTL.js.map → init-ZRKWYCKA.js.map} +0 -0
  94. /package/dist/{launch-WPEQ3TY2.js.map → launch-6X5OXPF2.js.map} +0 -0
  95. /package/dist/{notify-4HP4GZ2H.js.map → notify-IISTHNBR.js.map} +0 -0
  96. /package/dist/{onboard-BKCYBAVW.js.map → onboard-6NHGCKGB.js.map} +0 -0
  97. /package/dist/{pipeline-HQJYZ443.js.map → pipeline-AQBFG5WM.js.map} +0 -0
  98. /package/dist/{prismic-ci-6YGRIOHT.js.map → prismic-ci-A2HI7NNJ.js.map} +0 -0
  99. /package/dist/{prismic-models-FPDSUXDO.js.map → prismic-models-QTKIMFBC.js.map} +0 -0
  100. /package/dist/{prospect-audit-TFIXSTE5.js.map → prospect-audit-RKUVSTLN.js.map} +0 -0
  101. /package/dist/{self-updating-VQIW24RZ.js.map → self-updating-D5XOWS7V.js.map} +0 -0
  102. /package/dist/{smoke-suite-4JFZZYVW.js.map → smoke-suite-GXC5JZPH.js.map} +0 -0
  103. /package/dist/{svelte-codemods-QAPDZC6F.js.map → svelte-codemods-A3EARSRP.js.map} +0 -0
  104. /package/dist/{sync-configs-PVRLZRKL.js.map → sync-configs-KSESOHFF.js.map} +0 -0
  105. /package/dist/{upgrade-IR2OO3KP.js.map → upgrade-ZLNVEIEA.js.map} +0 -0
  106. /package/dist/{webflow-624FEBFD.js.map → webflow-IRKOIT7S.js.map} +0 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Copy for the submitter's confirmation email, resolved by the SITE from its own
3
+ * CMS and forwarded in the reserved `_reply` envelope.
4
+ *
5
+ * Everything here arrives over an untrusted boundary twice — once off the wire,
6
+ * once back out of the persisted `extraFields` JSON — so `parseReplyCopy` is the
7
+ * single gate both paths go through. It drops field by field rather than
8
+ * rejecting whole: a usable subject should still improve the email when the
9
+ * calendar block is malformed.
10
+ */
11
+ type ReplyCalendar = {
12
+ title: string;
13
+ /** ISO 8601. Validated as parseable, not as any particular shape. */
14
+ start: string;
15
+ end?: string;
16
+ location?: string;
17
+ url?: string;
18
+ description?: string;
19
+ };
20
+ /** Inline formatting, as an offset range over a block's raw `text`. */
21
+ type ReplySpan = {
22
+ start: number;
23
+ end: number;
24
+ type: "strong" | "em" | "link";
25
+ /** Required for `link`; https: and mailto: only, enforced at render. */
26
+ url?: string;
27
+ };
28
+ declare const REPLY_BLOCK_TYPES: readonly ["paragraph", "heading2", "heading3", "list-item", "o-list-item"];
29
+ type ReplyBlockType = (typeof REPLY_BLOCK_TYPES)[number];
30
+ /**
31
+ * One block of body copy.
32
+ *
33
+ * Deliberately an AST and NOT an HTML string. The envelope crosses an untrusted
34
+ * boundary twice, so a renderer that can only emit a fixed set of tags is what
35
+ * keeps "no attacker text reaches an outbound email" true by construction
36
+ * rather than by everyone downstream remembering to escape. See rich-text.ts.
37
+ */
38
+ type ReplyBlock = {
39
+ type: ReplyBlockType;
40
+ text: string;
41
+ spans?: ReplySpan[];
42
+ };
43
+ type ReplyCopy = {
44
+ subject?: string;
45
+ body?: ReplyBlock[];
46
+ signature?: string;
47
+ calendar?: ReplyCalendar;
48
+ };
49
+
50
+ /**
51
+ * Resolve a submitter's confirmation copy from a Prismic repository.
52
+ *
53
+ * Published as its own subpath (`@reddoorla/maintenance/forms/prismic`) so the
54
+ * CMS-agnostic core stays that way: `./forms` must remain importable by a site
55
+ * that uses no CMS at all.
56
+ *
57
+ * The client is STRUCTURALLY typed rather than imported from
58
+ * `@prismicio/client`. The two reads below are the whole surface, every fleet
59
+ * site already constructs its own client, and duck-typing keeps this package's
60
+ * dependency list — and a consuming site's bundle — untouched. It also makes the
61
+ * tests a two-line object instead of a mocked SDK.
62
+ *
63
+ * Every read is wrapped: a CMS failure must cost the visitor nothing, because
64
+ * the submission itself is captured either way.
65
+ */
66
+
67
+ type PrismicReader = {
68
+ getSingle: (type: string) => Promise<unknown>;
69
+ getByUID: (type: string, uid: string) => Promise<unknown>;
70
+ };
71
+ type ResolveReplyCopyOptions = {
72
+ /** The submission's form type; selects the entry in the singleton. */
73
+ formType: string;
74
+ /** Present for event forms only. A miss is not an error. */
75
+ eventUid?: string;
76
+ /** Custom type holding events. Default "rsvp". */
77
+ eventType?: string;
78
+ /** Singleton holding site-level defaults. Default "form_replies". */
79
+ settingsType?: string;
80
+ /** Calendar location when the event names none — usually the venue address. */
81
+ defaultLocation?: string;
82
+ /** Canonical URL of the event page, attached to the calendar entry. */
83
+ eventUrl?: string;
84
+ };
85
+ /**
86
+ * Site defaults for `formType`, overridden field-by-field by the event document
87
+ * when `eventUid` names one. Undefined means "nothing authored" — the caller
88
+ * omits `_reply` entirely and the shared package's own fallbacks apply.
89
+ */
90
+ declare function resolveReplyCopy(client: PrismicReader, opts: ResolveReplyCopyOptions): Promise<ReplyCopy | undefined>;
91
+
92
+ export { type PrismicReader, type ResolveReplyCopyOptions, resolveReplyCopy };
@@ -0,0 +1,105 @@
1
+ import {
2
+ REPLY_BLOCK_TYPES,
3
+ parseReplyCopy
4
+ } from "../chunk-I7NHLVVI.js";
5
+
6
+ // src/forms/prismic.ts
7
+ var DEFAULT_DURATION_MS = 2 * 60 * 60 * 1e3;
8
+ function record(v) {
9
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
10
+ }
11
+ function text(v) {
12
+ if (typeof v !== "string") return void 0;
13
+ const t = v.trim();
14
+ return t === "" ? void 0 : t;
15
+ }
16
+ function blocks(v) {
17
+ if (!Array.isArray(v)) return void 0;
18
+ const out = [];
19
+ for (const raw of v) {
20
+ const b = record(raw);
21
+ const body = typeof b.text === "string" ? b.text : "";
22
+ if (body.trim() === "") continue;
23
+ const declared = typeof b.type === "string" ? b.type : "paragraph";
24
+ const type = REPLY_BLOCK_TYPES.includes(declared) ? declared : "paragraph";
25
+ const block = { type, text: body };
26
+ const spans = Array.isArray(b.spans) ? b.spans.map(record) : [];
27
+ const mapped = [];
28
+ for (const s of spans) {
29
+ const kind = s.type === "hyperlink" ? "link" : s.type;
30
+ if (kind !== "strong" && kind !== "em" && kind !== "link") continue;
31
+ const start = typeof s.start === "number" ? s.start : NaN;
32
+ const end = typeof s.end === "number" ? s.end : NaN;
33
+ if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
34
+ const span = { start, end, type: kind };
35
+ const url = text(record(s.data).url);
36
+ if (url) span.url = url;
37
+ mapped.push(span);
38
+ }
39
+ if (mapped.length > 0) block.spans = mapped;
40
+ out.push(block);
41
+ }
42
+ return out.length > 0 ? out : void 0;
43
+ }
44
+ function flatText(v) {
45
+ if (!Array.isArray(v)) return void 0;
46
+ const out = v.map((b) => text(record(b).text)).filter((t) => t !== void 0);
47
+ return out.length > 0 ? out.join(" ") : void 0;
48
+ }
49
+ function timestamp(v) {
50
+ const s = text(v);
51
+ if (!s) return void 0;
52
+ const ms = Date.parse(s);
53
+ return Number.isNaN(ms) ? void 0 : new Date(ms).toISOString();
54
+ }
55
+ async function attempt(run) {
56
+ try {
57
+ return await run();
58
+ } catch {
59
+ return void 0;
60
+ }
61
+ }
62
+ function calendarFrom(data, opts) {
63
+ const start = timestamp(data.start_time);
64
+ const title = text(data.name);
65
+ if (!start || !title) return void 0;
66
+ const cal = {
67
+ title,
68
+ start,
69
+ end: timestamp(data.end_time) ?? new Date(Date.parse(start) + DEFAULT_DURATION_MS).toISOString()
70
+ };
71
+ const location = text(data.location) ?? opts.defaultLocation;
72
+ if (location) cal.location = location;
73
+ if (opts.eventUrl) cal.url = opts.eventUrl;
74
+ return cal;
75
+ }
76
+ async function resolveReplyCopy(client, opts) {
77
+ const settings = record(
78
+ await attempt(() => client.getSingle(opts.settingsType ?? "form_replies"))
79
+ );
80
+ const settingsData = record(settings.data);
81
+ const entries = (Array.isArray(settingsData.replies) ? settingsData.replies : []).map(record);
82
+ const matches = entries.filter((e) => e.form_type === opts.formType);
83
+ const entry = matches.find((e) => text(e.subject) !== void 0 || blocks(e.body) !== void 0) ?? matches[0] ?? {};
84
+ const draft = {
85
+ subject: text(entry.subject),
86
+ body: blocks(entry.body),
87
+ signature: flatText(settingsData.signature)
88
+ };
89
+ if (opts.eventUid) {
90
+ const event = record(
91
+ await attempt(() => client.getByUID(opts.eventType ?? "rsvp", opts.eventUid))
92
+ );
93
+ const data = record(event.data);
94
+ if (Object.keys(data).length > 0) {
95
+ draft.subject = text(data.reply_subject) ?? draft.subject;
96
+ draft.body = blocks(data.reply_body) ?? draft.body;
97
+ draft.calendar = calendarFrom(data, opts);
98
+ }
99
+ }
100
+ return parseReplyCopy(draft);
101
+ }
102
+ export {
103
+ resolveReplyCopy
104
+ };
105
+ //# sourceMappingURL=prismic.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/forms/prismic.ts"],"sourcesContent":["/**\n * Resolve a submitter's confirmation copy from a Prismic repository.\n *\n * Published as its own subpath (`@reddoorla/maintenance/forms/prismic`) so the\n * CMS-agnostic core stays that way: `./forms` must remain importable by a site\n * that uses no CMS at all.\n *\n * The client is STRUCTURALLY typed rather than imported from\n * `@prismicio/client`. The two reads below are the whole surface, every fleet\n * site already constructs its own client, and duck-typing keeps this package's\n * dependency list — and a consuming site's bundle — untouched. It also makes the\n * tests a two-line object instead of a mocked SDK.\n *\n * Every read is wrapped: a CMS failure must cost the visitor nothing, because\n * the submission itself is captured either way.\n */\nimport {\n parseReplyCopy,\n REPLY_BLOCK_TYPES,\n type ReplyBlock,\n type ReplyCalendar,\n type ReplyCopy,\n type ReplySpan,\n} from \"./reply-copy.js\";\n\nexport type PrismicReader = {\n getSingle: (type: string) => Promise<unknown>;\n getByUID: (type: string, uid: string) => Promise<unknown>;\n};\n\nexport type ResolveReplyCopyOptions = {\n /** The submission's form type; selects the entry in the singleton. */\n formType: string;\n /** Present for event forms only. A miss is not an error. */\n eventUid?: string;\n /** Custom type holding events. Default \"rsvp\". */\n eventType?: string;\n /** Singleton holding site-level defaults. Default \"form_replies\". */\n settingsType?: string;\n /** Calendar location when the event names none — usually the venue address. */\n defaultLocation?: string;\n /** Canonical URL of the event page, attached to the calendar entry. */\n eventUrl?: string;\n};\n\nconst DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000;\n\nfunction record(v: unknown): Record<string, unknown> {\n return v && typeof v === \"object\" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};\n}\n\nfunction text(v: unknown): string | undefined {\n if (typeof v !== \"string\") return undefined;\n const t = v.trim();\n return t === \"\" ? undefined : t;\n}\n\n/** Prismic Rich Text is already a block/span AST, and `ReplyBlock` is modelled\n * on it — so this is close to a pass-through, mapping Prismic's `hyperlink`\n * onto our `link` and dropping any block or span kind the renderer does not\n * know. Nothing is converted to HTML here: the AST crosses the wire and the\n * shared renderer is the only thing that ever emits a tag. */\nfunction blocks(v: unknown): ReplyBlock[] | undefined {\n if (!Array.isArray(v)) return undefined;\n const out: ReplyBlock[] = [];\n for (const raw of v) {\n const b = record(raw);\n const body = typeof b.text === \"string\" ? b.text : \"\";\n if (body.trim() === \"\") continue;\n const declared = typeof b.type === \"string\" ? b.type : \"paragraph\";\n const type = (REPLY_BLOCK_TYPES as readonly string[]).includes(declared)\n ? (declared as ReplyBlock[\"type\"])\n : \"paragraph\";\n const block: ReplyBlock = { type, text: body };\n const spans = Array.isArray(b.spans) ? b.spans.map(record) : [];\n const mapped: ReplySpan[] = [];\n for (const s of spans) {\n const kind = s.type === \"hyperlink\" ? \"link\" : s.type;\n if (kind !== \"strong\" && kind !== \"em\" && kind !== \"link\") continue;\n const start = typeof s.start === \"number\" ? s.start : NaN;\n const end = typeof s.end === \"number\" ? s.end : NaN;\n if (!Number.isFinite(start) || !Number.isFinite(end)) continue;\n const span: ReplySpan = { start, end, type: kind };\n const url = text(record(s.data).url);\n if (url) span.url = url;\n mapped.push(span);\n }\n if (mapped.length > 0) block.spans = mapped;\n out.push(block);\n }\n return out.length > 0 ? out : undefined;\n}\n\n/** The signature is a short sign-off, so it stays flat text. */\nfunction flatText(v: unknown): string | undefined {\n if (!Array.isArray(v)) return undefined;\n const out = v.map((b) => text(record(b).text)).filter((t): t is string => t !== undefined);\n return out.length > 0 ? out.join(\" \") : undefined;\n}\n\n/** A Prismic Timestamp (\"2026-09-12T18:00:00+0000\") as an ISO string, or\n * undefined when the field is blank or unparseable. */\nfunction timestamp(v: unknown): string | undefined {\n const s = text(v);\n if (!s) return undefined;\n const ms = Date.parse(s);\n return Number.isNaN(ms) ? undefined : new Date(ms).toISOString();\n}\n\n/** Never let a CMS read fail the caller. */\nasync function attempt<T>(run: () => Promise<T>): Promise<T | undefined> {\n try {\n return await run();\n } catch {\n return undefined;\n }\n}\n\nfunction calendarFrom(\n data: Record<string, unknown>,\n opts: ResolveReplyCopyOptions,\n): ReplyCalendar | undefined {\n const start = timestamp(data.start_time);\n const title = text(data.name);\n // No start, no calendar — and deliberately no guess. An invite for the wrong\n // evening is worse for a guest than no invite at all.\n if (!start || !title) return undefined;\n const cal: ReplyCalendar = {\n title,\n start,\n end:\n timestamp(data.end_time) ?? new Date(Date.parse(start) + DEFAULT_DURATION_MS).toISOString(),\n };\n const location = text(data.location) ?? opts.defaultLocation;\n if (location) cal.location = location;\n if (opts.eventUrl) cal.url = opts.eventUrl;\n return cal;\n}\n\n/**\n * Site defaults for `formType`, overridden field-by-field by the event document\n * when `eventUid` names one. Undefined means \"nothing authored\" — the caller\n * omits `_reply` entirely and the shared package's own fallbacks apply.\n */\nexport async function resolveReplyCopy(\n client: PrismicReader,\n opts: ResolveReplyCopyOptions,\n): Promise<ReplyCopy | undefined> {\n const settings = record(\n await attempt(() => client.getSingle(opts.settingsType ?? \"form_replies\")),\n );\n const settingsData = record(settings.data);\n const entries = (Array.isArray(settingsData.replies) ? settingsData.replies : []).map(record);\n // Prefer the first row for this form type that actually SAYS something.\n //\n // A repeatable group hands the editor a new row with the Select unset or\n // defaulted, so a document part-way through being filled in genuinely does\n // contain several blank rows all claiming the same form type. Taking the\n // first match outright meant one stray blank row above the real copy\n // silently discarded it — the reply fell back to the built-in default and\n // looked, from the client's side, exactly like the feature not working.\n const matches = entries.filter((e) => e.form_type === opts.formType);\n const entry =\n matches.find((e) => text(e.subject) !== undefined || blocks(e.body) !== undefined) ??\n matches[0] ??\n {};\n\n const draft: Record<string, unknown> = {\n subject: text(entry.subject),\n body: blocks(entry.body),\n signature: flatText(settingsData.signature),\n };\n\n if (opts.eventUid) {\n const event = record(\n await attempt(() => client.getByUID(opts.eventType ?? \"rsvp\", opts.eventUid as string)),\n );\n const data = record(event.data);\n if (Object.keys(data).length > 0) {\n draft.subject = text(data.reply_subject) ?? draft.subject;\n draft.body = blocks(data.reply_body) ?? draft.body;\n draft.calendar = calendarFrom(data, opts);\n }\n }\n\n return parseReplyCopy(draft);\n}\n"],"mappings":";;;;;;AA6CA,IAAM,sBAAsB,IAAI,KAAK,KAAK;AAE1C,SAAS,OAAO,GAAqC;AACnD,SAAO,KAAK,OAAO,MAAM,YAAY,CAAC,MAAM,QAAQ,CAAC,IAAK,IAAgC,CAAC;AAC7F;AAEA,SAAS,KAAK,GAAgC;AAC5C,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,QAAM,IAAI,EAAE,KAAK;AACjB,SAAO,MAAM,KAAK,SAAY;AAChC;AAOA,SAAS,OAAO,GAAsC;AACpD,MAAI,CAAC,MAAM,QAAQ,CAAC,EAAG,QAAO;AAC9B,QAAM,MAAoB,CAAC;AAC3B,aAAW,OAAO,GAAG;AACnB,UAAM,IAAI,OAAO,GAAG;AACpB,UAAM,OAAO,OAAO,EAAE,SAAS,WAAW,EAAE,OAAO;AACnD,QAAI,KAAK,KAAK,MAAM,GAAI;AACxB,UAAM,WAAW,OAAO,EAAE,SAAS,WAAW,EAAE,OAAO;AACvD,UAAM,OAAQ,kBAAwC,SAAS,QAAQ,IAClE,WACD;AACJ,UAAM,QAAoB,EAAE,MAAM,MAAM,KAAK;AAC7C,UAAM,QAAQ,MAAM,QAAQ,EAAE,KAAK,IAAI,EAAE,MAAM,IAAI,MAAM,IAAI,CAAC;AAC9D,UAAM,SAAsB,CAAC;AAC7B,eAAW,KAAK,OAAO;AACrB,YAAM,OAAO,EAAE,SAAS,cAAc,SAAS,EAAE;AACjD,UAAI,SAAS,YAAY,SAAS,QAAQ,SAAS,OAAQ;AAC3D,YAAM,QAAQ,OAAO,EAAE,UAAU,WAAW,EAAE,QAAQ;AACtD,YAAM,MAAM,OAAO,EAAE,QAAQ,WAAW,EAAE,MAAM;AAChD,UAAI,CAAC,OAAO,SAAS,KAAK,KAAK,CAAC,OAAO,SAAS,GAAG,EAAG;AACtD,YAAM,OAAkB,EAAE,OAAO,KAAK,MAAM,KAAK;AACjD,YAAM,MAAM,KAAK,OAAO,EAAE,IAAI,EAAE,GAAG;AACnC,UAAI,IAAK,MAAK,MAAM;AACpB,aAAO,KAAK,IAAI;AAAA,IAClB;AACA,QAAI,OAAO,SAAS,EAAG,OAAM,QAAQ;AACrC,QAAI,KAAK,KAAK;AAAA,EAChB;AACA,SAAO,IAAI,SAAS,IAAI,MAAM;AAChC;AAGA,SAAS,SAAS,GAAgC;AAChD,MAAI,CAAC,MAAM,QAAQ,CAAC,EAAG,QAAO;AAC9B,QAAM,MAAM,EAAE,IAAI,CAAC,MAAM,KAAK,OAAO,CAAC,EAAE,IAAI,CAAC,EAAE,OAAO,CAAC,MAAmB,MAAM,MAAS;AACzF,SAAO,IAAI,SAAS,IAAI,IAAI,KAAK,GAAG,IAAI;AAC1C;AAIA,SAAS,UAAU,GAAgC;AACjD,QAAM,IAAI,KAAK,CAAC;AAChB,MAAI,CAAC,EAAG,QAAO;AACf,QAAM,KAAK,KAAK,MAAM,CAAC;AACvB,SAAO,OAAO,MAAM,EAAE,IAAI,SAAY,IAAI,KAAK,EAAE,EAAE,YAAY;AACjE;AAGA,eAAe,QAAW,KAA+C;AACvE,MAAI;AACF,WAAO,MAAM,IAAI;AAAA,EACnB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,aACP,MACA,MAC2B;AAC3B,QAAM,QAAQ,UAAU,KAAK,UAAU;AACvC,QAAM,QAAQ,KAAK,KAAK,IAAI;AAG5B,MAAI,CAAC,SAAS,CAAC,MAAO,QAAO;AAC7B,QAAM,MAAqB;AAAA,IACzB;AAAA,IACA;AAAA,IACA,KACE,UAAU,KAAK,QAAQ,KAAK,IAAI,KAAK,KAAK,MAAM,KAAK,IAAI,mBAAmB,EAAE,YAAY;AAAA,EAC9F;AACA,QAAM,WAAW,KAAK,KAAK,QAAQ,KAAK,KAAK;AAC7C,MAAI,SAAU,KAAI,WAAW;AAC7B,MAAI,KAAK,SAAU,KAAI,MAAM,KAAK;AAClC,SAAO;AACT;AAOA,eAAsB,iBACpB,QACA,MACgC;AAChC,QAAM,WAAW;AAAA,IACf,MAAM,QAAQ,MAAM,OAAO,UAAU,KAAK,gBAAgB,cAAc,CAAC;AAAA,EAC3E;AACA,QAAM,eAAe,OAAO,SAAS,IAAI;AACzC,QAAM,WAAW,MAAM,QAAQ,aAAa,OAAO,IAAI,aAAa,UAAU,CAAC,GAAG,IAAI,MAAM;AAS5F,QAAM,UAAU,QAAQ,OAAO,CAAC,MAAM,EAAE,cAAc,KAAK,QAAQ;AACnE,QAAM,QACJ,QAAQ,KAAK,CAAC,MAAM,KAAK,EAAE,OAAO,MAAM,UAAa,OAAO,EAAE,IAAI,MAAM,MAAS,KACjF,QAAQ,CAAC,KACT,CAAC;AAEH,QAAM,QAAiC;AAAA,IACrC,SAAS,KAAK,MAAM,OAAO;AAAA,IAC3B,MAAM,OAAO,MAAM,IAAI;AAAA,IACvB,WAAW,SAAS,aAAa,SAAS;AAAA,EAC5C;AAEA,MAAI,KAAK,UAAU;AACjB,UAAM,QAAQ;AAAA,MACZ,MAAM,QAAQ,MAAM,OAAO,SAAS,KAAK,aAAa,QAAQ,KAAK,QAAkB,CAAC;AAAA,IACxF;AACA,UAAM,OAAO,OAAO,MAAM,IAAI;AAC9B,QAAI,OAAO,KAAK,IAAI,EAAE,SAAS,GAAG;AAChC,YAAM,UAAU,KAAK,KAAK,aAAa,KAAK,MAAM;AAClD,YAAM,OAAO,OAAO,KAAK,UAAU,KAAK,MAAM;AAC9C,YAAM,WAAW,aAAa,MAAM,IAAI;AAAA,IAC1C;AAAA,EACF;AAEA,SAAO,eAAe,KAAK;AAC7B;","names":[]}
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  describeNotifyTarget
3
- } from "./chunk-SJOXWJ5Y.js";
4
- import "./chunk-JRHIFA5R.js";
3
+ } from "./chunk-4763KQ6O.js";
4
+ import "./chunk-QF6IQQFP.js";
5
5
  import "./chunk-5ML7FMBD.js";
6
6
  import "./chunk-NO74DXDP.js";
7
+ import "./chunk-I7NHLVVI.js";
7
8
  import {
8
9
  openBase,
9
10
  readAirtableConfig
@@ -163,4 +164,4 @@ export {
163
164
  formatNotifyTarget,
164
165
  runFormsNotifyTargetCommand
165
166
  };
166
- //# sourceMappingURL=forms-notify-target-YCZUAHTY.js.map
167
+ //# sourceMappingURL=forms-notify-target-RMZPKT6S.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/recipes/forms-notify-target.ts","../src/cli/commands/forms-notify-target.ts"],"sourcesContent":["import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { SiteMirror } from \"../db/site-mirror.js\";\nimport {\n listWebsites,\n siteSlug,\n updateSiteField,\n type Status,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport { canonicalizeStatus, toAirtableStatus } from \"../reports/airtable/site-status.js\";\nimport { describeNotifyTarget, type NotifyTarget } from \"../forms/notify.js\";\n\n/** The Airtable column the pre-launch guard actually lives in. */\nexport const STATUS_COLUMN = \"Status\";\n\n/** The two ends of the verify flip. Deliberately the ONLY transition this\n * performs: a site in any other status is already guarded (or is deliberately\n * something else, like \"hosted-only\"), and silently rewriting that would be the\n * same class of unseen change as the incident. */\nexport const LIVE_STATUS: Status = \"maintained\";\nexport const VERIFY_STATUS: Status = \"launching\";\n\nexport type FormsNotifyTargetDeps = {\n base?: AirtableBase;\n /** Site slug or the Airtable Websites NAME (both accepted). */\n site: string;\n /** `on` routes notifications to the operator; `off` restores. Omit to read. */\n set?: \"on\" | \"off\";\n /** Status to restore with `--set off`. Required, never inferred. */\n restore?: string;\n /** #539 Phase 5: Turso write-through for the Status cell this flips. Injected\n * at the CLI root, never defaulted — the unit suite calls this with a fake\n * base and must not open a real libSQL handle. */\n siteMirror?: SiteMirror;\n};\n\nexport type FormsNotifyTargetResult = {\n site: string;\n status: Status | null;\n target: NotifyTarget;\n /** Present only when a flip was attempted. `confirmed` is the whole point:\n * it comes from RE-READING the row, not from the write call returning. */\n flip?: { from: Status | null; to: Status; confirmed: boolean };\n};\n\n/**\n * The exact Airtable cell to write for an operator-supplied `--restore` value:\n * the operator's own string, verbatim.\n *\n * This is the ONE non-revertible surface in the #539 Phase 4 stage-1 rename —\n * every other change is code, and `git revert` undoes code. It cannot undo a\n * rewritten Airtable cell. So the rule here is stricter than everywhere else:\n * substitute only when the substitution is provably lossless, i.e. when\n * canonicalizing and mapping back ROUND-TRIPS to the operator's own string.\n *\n * That condition is, today, never false in a way that changes the answer — which\n * is the point. `toAirtableStatus(canonicalizeStatus(raw))` either equals `raw`\n * (so writing it is writing `raw`) or it does not (so we must write `raw`). The\n * function therefore reduces to \"write raw\", and it is written this way so the\n * reduction is visible rather than assumed. The case that made it matter:\n * `--restore legacy` canonicalizes to `archived`, which maps back to\n * \"deprecated\" — a different, real, operator-visible Airtable option that nobody\n * asked for. `hosting` → `hosted-only` → \"hosting\" round-trips and is safe.\n *\n * Airtable, not this module, is the authority on which option strings the\n * \"Status\" single-select accepts. Writing verbatim delegates to it: a typo is\n * rejected loudly at the API, exactly as it was before the rename.\n */\nexport function restoreCell(raw: string): string {\n const canonical = canonicalizeStatus(raw);\n if (canonical === null) return raw;\n const roundTripped = toAirtableStatus(canonical);\n return roundTripped === raw ? roundTripped : raw;\n}\n\nfunction findSite(rows: WebsiteRow[], site: string): WebsiteRow | undefined {\n const wanted = site.trim().toLowerCase();\n return rows.find(\n (r) => siteSlug(r.name) === siteSlug(site) || r.name.trim().toLowerCase() === wanted,\n );\n}\n\n/**\n * Answer \"who would a form submission on this site email?\" — and optionally\n * flip the pre-launch guard, confirming the flip by reading it back.\n *\n * The guard is a single Airtable `Status` cell. Nothing between \"I intended to\n * flip it\" and \"the client received a test lead\" reported the current state, so\n * on 2026-08-03 a flip that never landed sent a real client a test submission.\n * The fix is not a better intention, it is feedback: this reads the row back\n * after every write and refuses to call the flip confirmed on anything less.\n */\nexport async function formsNotifyTarget(\n deps: FormsNotifyTargetDeps,\n): Promise<FormsNotifyTargetResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const rows = await listWebsites(base);\n const row = findSite(rows, deps.site);\n if (!row) {\n // The Websites NAME is not the repo slug (\"Sonder\", not \"gallerysonder\"),\n // and that mismatch has cost time before — so name the near misses rather\n // than making the operator go read Airtable to find the spelling.\n const needle = siteSlug(deps.site);\n const near = rows\n .map((r) => r.name)\n .filter((n) => siteSlug(n).includes(needle) || needle.includes(siteSlug(n)));\n throw Object.assign(\n new Error(\n `No Websites row matches '${deps.site}'. The Websites NAME is not always the repo ` +\n `slug (Sonder, not gallerysonder).` +\n (near.length > 0 ? ` Did you mean: ${near.join(\", \")}?` : \"\"),\n ),\n { exitCode: 2 },\n );\n }\n\n if (!deps.set) {\n return { site: row.name, status: row.status, target: describeNotifyTarget(row) };\n }\n\n // `--restore` is operator free text. It is canonicalized for what this command\n // REPORTS (`flip.to`, and the predicates downstream), but NEVER for what it\n // WRITES — see `restoreCell` below.\n const restoreRaw = deps.restore?.trim();\n const to = deps.set === \"on\" ? VERIFY_STATUS : (canonicalizeStatus(restoreRaw) ?? undefined);\n if (deps.set === \"off\" && !to) {\n throw Object.assign(\n new Error(\n `--set off needs --restore <status>: the status to return to is never inferred. ` +\n `Guessing \"${LIVE_STATUS}\" for a site that was \"hosted-only\" or \"archived\" would start ` +\n `sending real client notifications — the inverse of the failure this command exists ` +\n `to prevent.`,\n ),\n { exitCode: 2 },\n );\n }\n // Only ever flip a LIVE site into verify mode. A site already outside\n // \"maintained\" is guarded already, and rewriting its status would destroy a\n // real value nobody asked us to touch.\n if (deps.set === \"on\" && row.status !== LIVE_STATUS) {\n throw Object.assign(\n new Error(\n `${row.name} is \"${row.status ?? \"blank\"}\", not \"${LIVE_STATUS}\" — notifications ` +\n `already go to the operator only, so there is nothing to flip. Leaving the status ` +\n `untouched.`,\n ),\n { exitCode: 2 },\n );\n }\n\n // `--set on` writes a status this MODULE owns (VERIFY_STATUS), so mapping it to\n // the current Airtable vocabulary is correct. `--set off` writes the operator's\n // own string — see restoreCell.\n const cell = deps.set === \"on\" ? toAirtableStatus(VERIFY_STATUS) : restoreCell(restoreRaw!);\n await updateSiteField(base, row.id, STATUS_COLUMN, cell);\n // Mirror the same cell. The console reads Status from Turso, so without this a\n // site flipped into verify mode still reads as live for up to an hour — on the\n // one surface an operator would check to confirm the flip.\n await deps.siteMirror?.site(row.id, { [STATUS_COLUMN]: cell });\n\n // Read it back. The write returning is NOT evidence the field changed.\n const after = findSite(await listWebsites(base), deps.site);\n if (!after) {\n throw Object.assign(new Error(`${row.name} vanished from Websites during the flip.`), {\n exitCode: 1,\n });\n }\n return {\n site: after.name,\n status: after.status,\n target: describeNotifyTarget(after),\n // `confirmed` compares the RAW cell against the exact string written, not\n // canonical-to-canonical. Canonical equality is too weak to be a read-back\n // guard here: `legacy` and `deprecated` are the same canonical status, so a\n // cell holding either would confirm a flip to the other. Comparing raw is the\n // only comparison that can actually catch \"the cell is not what I sent\". The\n // canonical check rides along so a reader that stopped canonicalizing is\n // caught too.\n flip: {\n from: row.status,\n to: to!,\n confirmed: after.statusRaw === cell && after.status === to,\n },\n };\n}\n","import {\n formsNotifyTarget,\n LIVE_STATUS,\n VERIFY_STATUS,\n type FormsNotifyTargetResult,\n} from \"../../recipes/forms-notify-target.js\";\n\nexport type FormsNotifyTargetCommandOptions = {\n set?: string;\n restore?: string;\n cwd?: string;\n};\n\nconst AUDIENCE_LABEL = {\n client: \"THE CLIENT\",\n operator: \"OPERATOR ONLY\",\n nobody: \"NOBODY\",\n} as const;\n\n/** The line that decides whether it is safe to test-submit. Deliberately the\n * loudest thing on screen: the incident happened because the answer was\n * invisible, not because it was subtle. */\nexport function formatNotifyTarget(r: FormsNotifyTargetResult): string {\n const lines: string[] = [];\n if (r.flip) {\n const arrow = `${r.flip.from ?? \"blank\"} → ${r.flip.to}`;\n lines.push(\n r.flip.confirmed\n ? `${r.site} Status: ${arrow} ✓ confirmed by read-back`\n : `${r.site} Status: ${arrow} ✗ NOT CONFIRMED — the row still reads \"${r.status ?? \"blank\"}\". Nothing was verified; do not test-submit.`,\n );\n } else {\n lines.push(`${r.site} Status: ${r.status ?? \"blank\"}`);\n }\n\n lines.push(`A submission right now would notify: ${AUDIENCE_LABEL[r.target.audience]}`);\n if (r.target.to.length > 0) lines.push(` to: ${r.target.to.join(\", \")}`);\n if (r.target.cc.length > 0) lines.push(` cc: ${r.target.cc.join(\", \")}`);\n lines.push(` ${r.target.reason}`);\n\n if (r.target.audience === \"client\") {\n lines.push(\n \"\",\n `⚠️ A test submission WILL email the client, and email cannot be recalled.`,\n ` Route it to yourself first: reddoor forms-notify-target ${r.site} --set on`,\n );\n }\n if (r.flip?.confirmed && r.flip.to === VERIFY_STATUS) {\n lines.push(\n \"\",\n `Safe to test. When you are done, restore it:`,\n ` reddoor forms-notify-target ${r.site} --set off --restore ${r.flip.from ?? LIVE_STATUS}`,\n );\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * `forms-notify-target <site>` — show who a form submission would email, and\n * optionally flip the pre-launch guard with a read-back confirmation.\n *\n * Read-only by default: answering the question must never be riskier than not\n * asking it.\n */\nexport async function runFormsNotifyTargetCommand(\n site: string | undefined,\n opts: FormsNotifyTargetCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (!site?.trim()) {\n return { output: \"forms-notify-target requires <site> (slug or Airtable name)\", code: 2 };\n }\n const set = opts.set?.trim().toLowerCase();\n if (set !== undefined && set !== \"on\" && set !== \"off\") {\n return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };\n }\n try {\n // #539 Phase 5: the flip writes Status on the Websites row; mirror it so the\n // console (which reads Turso) shows the new routing immediately.\n const { makeSiteMirror } = await import(\"../../db/site-mirror.js\");\n const result = await formsNotifyTarget({\n siteMirror: await makeSiteMirror(),\n site: site.trim(),\n ...(set ? { set: set as \"on\" | \"off\" } : {}),\n ...(opts.restore ? { restore: opts.restore } : {}),\n });\n // An unconfirmed flip must not exit 0: a script (or a person skimming) that\n // reads exit status would otherwise take \"I flipped it\" on faith, which is\n // exactly the assumption that sent a client a test lead.\n return {\n output: formatNotifyTarget(result),\n code: result.flip && !result.flip.confirmed ? 1 : 0,\n };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/recipes/forms-notify-target.ts","../src/cli/commands/forms-notify-target.ts"],"sourcesContent":["import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { SiteMirror } from \"../db/site-mirror.js\";\nimport {\n listWebsites,\n siteSlug,\n updateSiteField,\n type Status,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport { canonicalizeStatus, toAirtableStatus } from \"../reports/airtable/site-status.js\";\nimport { describeNotifyTarget, type NotifyTarget } from \"../forms/notify.js\";\n\n/** The Airtable column the pre-launch guard actually lives in. */\nexport const STATUS_COLUMN = \"Status\";\n\n/** The two ends of the verify flip. Deliberately the ONLY transition this\n * performs: a site in any other status is already guarded (or is deliberately\n * something else, like \"hosted-only\"), and silently rewriting that would be the\n * same class of unseen change as the incident. */\nexport const LIVE_STATUS: Status = \"maintained\";\nexport const VERIFY_STATUS: Status = \"launching\";\n\nexport type FormsNotifyTargetDeps = {\n base?: AirtableBase;\n /** Site slug or the Airtable Websites NAME (both accepted). */\n site: string;\n /** `on` routes notifications to the operator; `off` restores. Omit to read. */\n set?: \"on\" | \"off\";\n /** Status to restore with `--set off`. Required, never inferred. */\n restore?: string;\n /** #539 Phase 5: Turso write-through for the Status cell this flips. Injected\n * at the CLI root, never defaulted — the unit suite calls this with a fake\n * base and must not open a real libSQL handle. */\n siteMirror?: SiteMirror;\n};\n\nexport type FormsNotifyTargetResult = {\n site: string;\n status: Status | null;\n target: NotifyTarget;\n /** Present only when a flip was attempted. `confirmed` is the whole point:\n * it comes from RE-READING the row, not from the write call returning. */\n flip?: { from: Status | null; to: Status; confirmed: boolean };\n};\n\n/**\n * The exact Airtable cell to write for an operator-supplied `--restore` value:\n * the operator's own string, verbatim.\n *\n * This is the ONE non-revertible surface in the #539 Phase 4 stage-1 rename —\n * every other change is code, and `git revert` undoes code. It cannot undo a\n * rewritten Airtable cell. So the rule here is stricter than everywhere else:\n * substitute only when the substitution is provably lossless, i.e. when\n * canonicalizing and mapping back ROUND-TRIPS to the operator's own string.\n *\n * That condition is, today, never false in a way that changes the answer — which\n * is the point. `toAirtableStatus(canonicalizeStatus(raw))` either equals `raw`\n * (so writing it is writing `raw`) or it does not (so we must write `raw`). The\n * function therefore reduces to \"write raw\", and it is written this way so the\n * reduction is visible rather than assumed. The case that made it matter:\n * `--restore legacy` canonicalizes to `archived`, which maps back to\n * \"deprecated\" — a different, real, operator-visible Airtable option that nobody\n * asked for. `hosting` → `hosted-only` → \"hosting\" round-trips and is safe.\n *\n * Airtable, not this module, is the authority on which option strings the\n * \"Status\" single-select accepts. Writing verbatim delegates to it: a typo is\n * rejected loudly at the API, exactly as it was before the rename.\n */\nexport function restoreCell(raw: string): string {\n const canonical = canonicalizeStatus(raw);\n if (canonical === null) return raw;\n const roundTripped = toAirtableStatus(canonical);\n return roundTripped === raw ? roundTripped : raw;\n}\n\nfunction findSite(rows: WebsiteRow[], site: string): WebsiteRow | undefined {\n const wanted = site.trim().toLowerCase();\n return rows.find(\n (r) => siteSlug(r.name) === siteSlug(site) || r.name.trim().toLowerCase() === wanted,\n );\n}\n\n/**\n * Answer \"who would a form submission on this site email?\" — and optionally\n * flip the pre-launch guard, confirming the flip by reading it back.\n *\n * The guard is a single Airtable `Status` cell. Nothing between \"I intended to\n * flip it\" and \"the client received a test lead\" reported the current state, so\n * on 2026-08-03 a flip that never landed sent a real client a test submission.\n * The fix is not a better intention, it is feedback: this reads the row back\n * after every write and refuses to call the flip confirmed on anything less.\n */\nexport async function formsNotifyTarget(\n deps: FormsNotifyTargetDeps,\n): Promise<FormsNotifyTargetResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const rows = await listWebsites(base);\n const row = findSite(rows, deps.site);\n if (!row) {\n // The Websites NAME is not the repo slug (\"Sonder\", not \"gallerysonder\"),\n // and that mismatch has cost time before — so name the near misses rather\n // than making the operator go read Airtable to find the spelling.\n const needle = siteSlug(deps.site);\n const near = rows\n .map((r) => r.name)\n .filter((n) => siteSlug(n).includes(needle) || needle.includes(siteSlug(n)));\n throw Object.assign(\n new Error(\n `No Websites row matches '${deps.site}'. The Websites NAME is not always the repo ` +\n `slug (Sonder, not gallerysonder).` +\n (near.length > 0 ? ` Did you mean: ${near.join(\", \")}?` : \"\"),\n ),\n { exitCode: 2 },\n );\n }\n\n if (!deps.set) {\n return { site: row.name, status: row.status, target: describeNotifyTarget(row) };\n }\n\n // `--restore` is operator free text. It is canonicalized for what this command\n // REPORTS (`flip.to`, and the predicates downstream), but NEVER for what it\n // WRITES — see `restoreCell` below.\n const restoreRaw = deps.restore?.trim();\n const to = deps.set === \"on\" ? VERIFY_STATUS : (canonicalizeStatus(restoreRaw) ?? undefined);\n if (deps.set === \"off\" && !to) {\n throw Object.assign(\n new Error(\n `--set off needs --restore <status>: the status to return to is never inferred. ` +\n `Guessing \"${LIVE_STATUS}\" for a site that was \"hosted-only\" or \"archived\" would start ` +\n `sending real client notifications — the inverse of the failure this command exists ` +\n `to prevent.`,\n ),\n { exitCode: 2 },\n );\n }\n // Only ever flip a LIVE site into verify mode. A site already outside\n // \"maintained\" is guarded already, and rewriting its status would destroy a\n // real value nobody asked us to touch.\n if (deps.set === \"on\" && row.status !== LIVE_STATUS) {\n throw Object.assign(\n new Error(\n `${row.name} is \"${row.status ?? \"blank\"}\", not \"${LIVE_STATUS}\" — notifications ` +\n `already go to the operator only, so there is nothing to flip. Leaving the status ` +\n `untouched.`,\n ),\n { exitCode: 2 },\n );\n }\n\n // `--set on` writes a status this MODULE owns (VERIFY_STATUS), so mapping it to\n // the current Airtable vocabulary is correct. `--set off` writes the operator's\n // own string — see restoreCell.\n const cell = deps.set === \"on\" ? toAirtableStatus(VERIFY_STATUS) : restoreCell(restoreRaw!);\n await updateSiteField(base, row.id, STATUS_COLUMN, cell);\n // Mirror the same cell. The console reads Status from Turso, so without this a\n // site flipped into verify mode still reads as live for up to an hour — on the\n // one surface an operator would check to confirm the flip.\n await deps.siteMirror?.site(row.id, { [STATUS_COLUMN]: cell });\n\n // Read it back. The write returning is NOT evidence the field changed.\n const after = findSite(await listWebsites(base), deps.site);\n if (!after) {\n throw Object.assign(new Error(`${row.name} vanished from Websites during the flip.`), {\n exitCode: 1,\n });\n }\n return {\n site: after.name,\n status: after.status,\n target: describeNotifyTarget(after),\n // `confirmed` compares the RAW cell against the exact string written, not\n // canonical-to-canonical. Canonical equality is too weak to be a read-back\n // guard here: `legacy` and `deprecated` are the same canonical status, so a\n // cell holding either would confirm a flip to the other. Comparing raw is the\n // only comparison that can actually catch \"the cell is not what I sent\". The\n // canonical check rides along so a reader that stopped canonicalizing is\n // caught too.\n flip: {\n from: row.status,\n to: to!,\n confirmed: after.statusRaw === cell && after.status === to,\n },\n };\n}\n","import {\n formsNotifyTarget,\n LIVE_STATUS,\n VERIFY_STATUS,\n type FormsNotifyTargetResult,\n} from \"../../recipes/forms-notify-target.js\";\n\nexport type FormsNotifyTargetCommandOptions = {\n set?: string;\n restore?: string;\n cwd?: string;\n};\n\nconst AUDIENCE_LABEL = {\n client: \"THE CLIENT\",\n operator: \"OPERATOR ONLY\",\n nobody: \"NOBODY\",\n} as const;\n\n/** The line that decides whether it is safe to test-submit. Deliberately the\n * loudest thing on screen: the incident happened because the answer was\n * invisible, not because it was subtle. */\nexport function formatNotifyTarget(r: FormsNotifyTargetResult): string {\n const lines: string[] = [];\n if (r.flip) {\n const arrow = `${r.flip.from ?? \"blank\"} → ${r.flip.to}`;\n lines.push(\n r.flip.confirmed\n ? `${r.site} Status: ${arrow} ✓ confirmed by read-back`\n : `${r.site} Status: ${arrow} ✗ NOT CONFIRMED — the row still reads \"${r.status ?? \"blank\"}\". Nothing was verified; do not test-submit.`,\n );\n } else {\n lines.push(`${r.site} Status: ${r.status ?? \"blank\"}`);\n }\n\n lines.push(`A submission right now would notify: ${AUDIENCE_LABEL[r.target.audience]}`);\n if (r.target.to.length > 0) lines.push(` to: ${r.target.to.join(\", \")}`);\n if (r.target.cc.length > 0) lines.push(` cc: ${r.target.cc.join(\", \")}`);\n lines.push(` ${r.target.reason}`);\n\n if (r.target.audience === \"client\") {\n lines.push(\n \"\",\n `⚠️ A test submission WILL email the client, and email cannot be recalled.`,\n ` Route it to yourself first: reddoor forms-notify-target ${r.site} --set on`,\n );\n }\n if (r.flip?.confirmed && r.flip.to === VERIFY_STATUS) {\n lines.push(\n \"\",\n `Safe to test. When you are done, restore it:`,\n ` reddoor forms-notify-target ${r.site} --set off --restore ${r.flip.from ?? LIVE_STATUS}`,\n );\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * `forms-notify-target <site>` — show who a form submission would email, and\n * optionally flip the pre-launch guard with a read-back confirmation.\n *\n * Read-only by default: answering the question must never be riskier than not\n * asking it.\n */\nexport async function runFormsNotifyTargetCommand(\n site: string | undefined,\n opts: FormsNotifyTargetCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (!site?.trim()) {\n return { output: \"forms-notify-target requires <site> (slug or Airtable name)\", code: 2 };\n }\n const set = opts.set?.trim().toLowerCase();\n if (set !== undefined && set !== \"on\" && set !== \"off\") {\n return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };\n }\n try {\n // #539 Phase 5: the flip writes Status on the Websites row; mirror it so the\n // console (which reads Turso) shows the new routing immediately.\n const { makeSiteMirror } = await import(\"../../db/site-mirror.js\");\n const result = await formsNotifyTarget({\n siteMirror: await makeSiteMirror(),\n site: site.trim(),\n ...(set ? { set: set as \"on\" | \"off\" } : {}),\n ...(opts.restore ? { restore: opts.restore } : {}),\n });\n // An unconfirmed flip must not exit 0: a script (or a person skimming) that\n // reads exit status would otherwise take \"I flipped it\" on faith, which is\n // exactly the assumption that sent a client a test lead.\n return {\n output: formatNotifyTarget(result),\n code: result.flip && !result.flip.confirmed ? 1 : 0,\n };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
@@ -5,20 +5,20 @@ import {
5
5
  appendSkipNotice,
6
6
  fleetWorkdir,
7
7
  prepareFleetSites
8
- } from "./chunk-TVL5MVUF.js";
8
+ } from "./chunk-TCF6S7AD.js";
9
9
  import {
10
10
  resolveSites
11
11
  } from "./chunk-Q47DU255.js";
12
12
  import {
13
13
  healthEndpoint
14
- } from "./chunk-BQRFQSGY.js";
14
+ } from "./chunk-4R3YAQNV.js";
15
15
  import "./chunk-R53VL3RI.js";
16
- import "./chunk-3G25KIWW.js";
16
+ import "./chunk-ZTPPCHDB.js";
17
17
  import "./chunk-Z3JXLULD.js";
18
- import "./chunk-RARREDQE.js";
19
- import "./chunk-XTK5VIQB.js";
20
18
  import "./chunk-LBYOLBW7.js";
19
+ import "./chunk-RARREDQE.js";
21
20
  import "./chunk-XXTZBPUY.js";
21
+ import "./chunk-XTK5VIQB.js";
22
22
 
23
23
  // src/cli/commands/health-endpoint.ts
24
24
  import { resolve } from "path";
@@ -51,4 +51,4 @@ async function runHealthEndpointCommand(site, opts) {
51
51
  export {
52
52
  runHealthEndpointCommand
53
53
  };
54
- //# sourceMappingURL=health-endpoint-UVX6R5N5.js.map
54
+ //# sourceMappingURL=health-endpoint-DHKJUPRM.js.map
package/dist/index.js CHANGED
@@ -1,34 +1,34 @@
1
1
  import "./chunk-2SLED3NB.js";
2
+ import {
3
+ DEFAULT_INIT_STEPS,
4
+ a11yFixturesPage,
5
+ init
6
+ } from "./chunk-4NUKMPOO.js";
2
7
  import {
3
8
  draftReportForSite
4
9
  } from "./chunk-ZRI5VRPG.js";
5
10
  import "./chunk-YFAXOD7Z.js";
6
11
  import {
7
12
  upgradeSvelte4to5
8
- } from "./chunk-Z3KYFUN6.js";
9
- import {
10
- DEFAULT_INIT_STEPS,
11
- a11yFixturesPage,
12
- init
13
- } from "./chunk-UXJZU23T.js";
13
+ } from "./chunk-B6NUZ2ZX.js";
14
14
  import {
15
15
  convertToPnpm
16
- } from "./chunk-XIDUYLSR.js";
16
+ } from "./chunk-7CJ3DVZW.js";
17
17
  import {
18
18
  svelteCodemods
19
- } from "./chunk-IYSEC543.js";
19
+ } from "./chunk-LS6QPANO.js";
20
20
  import "./chunk-J7SZQUCW.js";
21
- import "./chunk-BQRFQSGY.js";
22
- import "./chunk-Y53WOQIJ.js";
21
+ import "./chunk-4R3YAQNV.js";
22
+ import "./chunk-CVJF2IC5.js";
23
+ import "./chunk-R53VL3RI.js";
23
24
  import {
24
25
  onboard,
25
26
  selfCaretRange,
26
27
  selfPackageVersion
27
- } from "./chunk-7RJ2HMMJ.js";
28
+ } from "./chunk-NTLSMJHW.js";
28
29
  import {
29
30
  bumpDeps
30
- } from "./chunk-2NJQLSEU.js";
31
- import "./chunk-R53VL3RI.js";
31
+ } from "./chunk-PPIZHDJL.js";
32
32
  import "./chunk-MVDMKKBN.js";
33
33
  import {
34
34
  approveBlockers
@@ -82,10 +82,11 @@ import {
82
82
  toAirtableStatus
83
83
  } from "./chunk-7NYN2P6I.js";
84
84
  import "./chunk-WEKL2HYJ.js";
85
+ import "./chunk-T7ZUUOZ2.js";
85
86
  import {
86
87
  syncConfigs
87
- } from "./chunk-A6T2R63Z.js";
88
- import "./chunk-3G25KIWW.js";
88
+ } from "./chunk-3HS6LPFX.js";
89
+ import "./chunk-ZTPPCHDB.js";
89
90
  import "./chunk-MLT4QY5X.js";
90
91
  import "./chunk-OTGB6YQO.js";
91
92
  import {
@@ -96,7 +97,7 @@ import {
96
97
  runAudits,
97
98
  runAuditsAcross,
98
99
  securityAudit
99
- } from "./chunk-QD427NIO.js";
100
+ } from "./chunk-JB4Q3C3M.js";
100
101
  import "./chunk-LKGVSM2O.js";
101
102
  import "./chunk-GASBX52O.js";
102
103
  import "./chunk-XHHMM5HX.js";
@@ -106,20 +107,20 @@ import {
106
107
  } from "./chunk-Z3JXLULD.js";
107
108
  import "./chunk-DMPHP7UP.js";
108
109
  import "./chunk-22GKJO5F.js";
109
- import "./chunk-UDRHAQT7.js";
110
- import "./chunk-RARREDQE.js";
111
110
  import "./chunk-OCNSIBMP.js";
112
- import "./chunk-A3WDWNN5.js";
113
- import {
114
- isLeadFormType
115
- } from "./chunk-2OP4JKIQ.js";
116
- import "./chunk-XTK5VIQB.js";
117
111
  import {
118
112
  lighthouseAudit
119
113
  } from "./chunk-2NQUCS7O.js";
114
+ import "./chunk-UDRHAQT7.js";
120
115
  import "./chunk-PTDMVGKL.js";
121
116
  import "./chunk-LBYOLBW7.js";
117
+ import "./chunk-RARREDQE.js";
118
+ import "./chunk-A3WDWNN5.js";
119
+ import {
120
+ isLeadFormType
121
+ } from "./chunk-2OP4JKIQ.js";
122
122
  import "./chunk-XXTZBPUY.js";
123
+ import "./chunk-XTK5VIQB.js";
123
124
  import "./chunk-6VAL7XJT.js";
124
125
  import "./chunk-NMAWXBJA.js";
125
126
  import "./chunk-YLYXW5PH.js";
@@ -1450,6 +1451,9 @@ h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
1450
1451
  .muted { color:#999; }
1451
1452
  .filters { display:flex; flex-wrap:wrap; gap:0.4rem; margin-bottom:1.25rem; }
1452
1453
  .filters button { font:inherit; font-size:0.85rem; padding:0.25rem 0.7rem; border:1px solid #ccc; border-radius:999px; background:transparent; color:inherit; cursor:pointer; }
1454
+ .top-nav { margin: 0.5rem 0 1rem; }
1455
+ .nav-btn { display:inline-block; font-size:0.9rem; padding:0.35rem 0.9rem; border:1px solid currentColor; border-radius:999px; text-decoration:none; }
1456
+ .nav-btn:hover { background: rgba(127,127,127,0.12); }
1453
1457
  .filters button[aria-pressed="true"] { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }
1454
1458
  @media (prefers-color-scheme: dark) { .filters button[aria-pressed="true"] { background:#e8e8e8; color:#111; } }
1455
1459
  .fleet-actions { margin-bottom:1.25rem; }
@@ -1795,6 +1799,9 @@ function renderCockpitHtml(model, operatorEmail = null) {
1795
1799
  ${renderAuthChrome(operatorEmail)}
1796
1800
  <h1>Reddoor fleet cockpit</h1>
1797
1801
  <div class="meta">${total} site${total === 1 ? "" : "s"} on the Reddoor stack. <a href="/fleet">Fleet table \u2192</a></div>
1802
+ <nav class="top-nav" aria-label="Cockpit pages">
1803
+ <a class="nav-btn" href="/audits">Prospect audits \u2192</a>
1804
+ </nav>
1798
1805
  ${verdictBar(model, feed)}
1799
1806
  ${renderNeedsYouFeed(feed)}
1800
1807
  ${renderFleetBrowsePanel(model)}