create-safest-tools 0.2.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 (240) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +71 -0
  3. package/bin/create-safest-tools.mjs +12 -0
  4. package/package.json +34 -0
  5. package/src/cli.mjs +128 -0
  6. package/src/config.mjs +151 -0
  7. package/src/scaffold.mjs +30 -0
  8. package/template/LICENSE +201 -0
  9. package/template/README.md +35 -0
  10. package/template/console/README.md +28 -0
  11. package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
  12. package/template/console/analytics/controller.ts +133 -0
  13. package/template/console/analytics/events.ts +33 -0
  14. package/template/console/analytics/store.ts +33 -0
  15. package/template/console/analytics/types.ts +38 -0
  16. package/template/console/appeals/AppealWorkspace.tsx +73 -0
  17. package/template/console/appeals/controller.ts +125 -0
  18. package/template/console/appeals/events.ts +33 -0
  19. package/template/console/appeals/store.ts +35 -0
  20. package/template/console/appeals/types.ts +37 -0
  21. package/template/console/assistant/AssistantWorkspace.tsx +133 -0
  22. package/template/console/assistant/types.ts +34 -0
  23. package/template/console/auth/AccountJourney.tsx +69 -0
  24. package/template/console/auth/InvitationAcceptance.tsx +145 -0
  25. package/template/console/auth/OAuthButtons.tsx +78 -0
  26. package/template/console/auth/OperatorLogin.tsx +110 -0
  27. package/template/console/auth/PasswordRecovery.tsx +132 -0
  28. package/template/console/auth/account-route.ts +15 -0
  29. package/template/console/auth/browser-navigation.ts +7 -0
  30. package/template/console/auth/events.ts +15 -0
  31. package/template/console/command/CommandCentre.tsx +119 -0
  32. package/template/console/command/controller.ts +267 -0
  33. package/template/console/command/events.ts +19 -0
  34. package/template/console/command/store.ts +36 -0
  35. package/template/console/command/types.ts +61 -0
  36. package/template/console/components/AnalystIdentity.tsx +25 -0
  37. package/template/console/components/PageHeader.tsx +27 -0
  38. package/template/console/configuration/ConfigurationDialog.tsx +200 -0
  39. package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
  40. package/template/console/configuration/FormBuilder.tsx +143 -0
  41. package/template/console/configuration/api.ts +40 -0
  42. package/template/console/configuration/events.ts +14 -0
  43. package/template/console/configuration/types.ts +79 -0
  44. package/template/console/lib/format.ts +49 -0
  45. package/template/console/lib/http.ts +96 -0
  46. package/template/console/main.tsx +225 -0
  47. package/template/console/operations/OperationsWorkspace.tsx +126 -0
  48. package/template/console/operations/controller.ts +237 -0
  49. package/template/console/operations/events.ts +37 -0
  50. package/template/console/operations/store.ts +32 -0
  51. package/template/console/operations/types.ts +107 -0
  52. package/template/console/people/PeopleWorkspace.tsx +81 -0
  53. package/template/console/people/types.ts +25 -0
  54. package/template/console/profile/ProfileWorkspace.tsx +136 -0
  55. package/template/console/profile/events.ts +9 -0
  56. package/template/console/profile/types.ts +6 -0
  57. package/template/console/quality/QualityWorkspace.tsx +87 -0
  58. package/template/console/quality/controller.ts +154 -0
  59. package/template/console/quality/events.ts +34 -0
  60. package/template/console/quality/store.ts +36 -0
  61. package/template/console/quality/types.ts +70 -0
  62. package/template/console/queues/QueueEditor.tsx +173 -0
  63. package/template/console/queues/QueueWorkspace.tsx +109 -0
  64. package/template/console/queues/controller.ts +194 -0
  65. package/template/console/queues/events.ts +34 -0
  66. package/template/console/queues/store.ts +37 -0
  67. package/template/console/queues/types.ts +102 -0
  68. package/template/console/registry/RegistryDialog.tsx +198 -0
  69. package/template/console/registry/RegistryWorkspace.tsx +108 -0
  70. package/template/console/registry/events.ts +14 -0
  71. package/template/console/registry/types.ts +52 -0
  72. package/template/console/reports/ReportDrawer.tsx +159 -0
  73. package/template/console/reports/ReportWorkspace.tsx +90 -0
  74. package/template/console/reports/controller.ts +517 -0
  75. package/template/console/reports/events.ts +42 -0
  76. package/template/console/reports/store.ts +39 -0
  77. package/template/console/reports/types.ts +230 -0
  78. package/template/console/settings/BrandEditor.tsx +126 -0
  79. package/template/console/settings/ChannelDialog.tsx +241 -0
  80. package/template/console/settings/SettingsWorkspace.tsx +155 -0
  81. package/template/console/settings/events.ts +19 -0
  82. package/template/console/settings/handoff.ts +22 -0
  83. package/template/console/settings/types.ts +93 -0
  84. package/template/console/shell/WorkspaceShell.tsx +160 -0
  85. package/template/console/shell/controller.ts +182 -0
  86. package/template/console/shell/events.ts +25 -0
  87. package/template/console/shell/navigation.ts +61 -0
  88. package/template/console/shell/store.ts +56 -0
  89. package/template/console/shell/types.ts +86 -0
  90. package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
  91. package/template/console/workflows/WorkflowCanvas.tsx +42 -0
  92. package/template/console/workflows/WorkflowDialogs.tsx +6 -0
  93. package/template/console/workflows/WorkflowStudio.tsx +331 -0
  94. package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
  95. package/template/console/workflows/events.ts +17 -0
  96. package/template/console/workflows/graph.ts +156 -0
  97. package/template/console/workflows/templates.ts +70 -0
  98. package/template/console/workflows/types.ts +201 -0
  99. package/template/gitignore.template +18 -0
  100. package/template/migrations/0001_reports_foundation.sql +444 -0
  101. package/template/migrations/0002_human_report_loop.sql +65 -0
  102. package/template/migrations/0003_delivery_reliability.sql +18 -0
  103. package/template/migrations/0004_public_intake.sql +14 -0
  104. package/template/migrations/0005_operations_visibility.sql +24 -0
  105. package/template/migrations/0006_ai_governance.sql +198 -0
  106. package/template/migrations/0007_ai_release_gates.sql +6 -0
  107. package/template/migrations/0008_retention_analytics_exports.sql +52 -0
  108. package/template/migrations/0009_retention_derived_copies.sql +14 -0
  109. package/template/migrations/0010_ai_quality_controls.sql +26 -0
  110. package/template/migrations/0011_analyst_presence.sql +28 -0
  111. package/template/migrations/0012_queue_policies.sql +87 -0
  112. package/template/migrations/0013_routing_agents.sql +63 -0
  113. package/template/migrations/0014_webhook_enrichments.sql +122 -0
  114. package/template/migrations/0015_queue_owned_ai.sql +55 -0
  115. package/template/migrations/0016_operator_accounts.sql +67 -0
  116. package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
  117. package/template/migrations/0018_platform_configuration.sql +382 -0
  118. package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
  119. package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
  120. package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
  121. package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
  122. package/template/migrations/0023_component_connection_execution.sql +75 -0
  123. package/template/migrations/0024_access_runtime_integrity.sql +72 -0
  124. package/template/migrations/0025_installation_timezone.sql +11 -0
  125. package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
  126. package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
  127. package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
  128. package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
  129. package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
  130. package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
  131. package/template/migrations/0032_action_delivery_outbox.sql +55 -0
  132. package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
  133. package/template/migrations/0034_installation_integrations.sql +31 -0
  134. package/template/migrations/0035_workspace_governance.sql +21 -0
  135. package/template/migrations/0036_published_routing_baseline.sql +15 -0
  136. package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
  137. package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
  138. package/template/migrations/0039_secure_reporting_channels.sql +45 -0
  139. package/template/migrations/0040_notification_only_reporting.sql +24 -0
  140. package/template/migrations/0041_better_auth_credentials.sql +17 -0
  141. package/template/package.json +47 -0
  142. package/template/public/_headers +27 -0
  143. package/template/public/app-icon-192.png +0 -0
  144. package/template/public/app-icon-512.png +0 -0
  145. package/template/public/brand-icon.svg +7 -0
  146. package/template/public/brand-tokens.css +80 -0
  147. package/template/public/console/auth-shell.js +19340 -0
  148. package/template/public/customer-brand.js +58 -0
  149. package/template/public/embed/embed.css +139 -0
  150. package/template/public/embed/embed.js +408 -0
  151. package/template/public/embed/index.html +91 -0
  152. package/template/public/favicon.svg +7 -0
  153. package/template/public/fonts/Manrope-Variable.ttf +0 -0
  154. package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
  155. package/template/public/fonts/Newsreader-Variable.ttf +0 -0
  156. package/template/public/index.html +123 -0
  157. package/template/public/logo-primary.svg +7 -0
  158. package/template/public/logo-reversed.svg +7 -0
  159. package/template/public/manifest.webmanifest +21 -0
  160. package/template/public/public-report.js +184 -0
  161. package/template/public/report/index.html +39 -0
  162. package/template/public/report/public-report.css +20 -0
  163. package/template/public/social-card.png +0 -0
  164. package/template/public/styles.css +1521 -0
  165. package/template/public/widget.css +80 -0
  166. package/template/public/widget.js +220 -0
  167. package/template/reports.config.example.json +38 -0
  168. package/template/reports.schema.json +89 -0
  169. package/template/scripts/reports-auth-onboarding.mjs +180 -0
  170. package/template/scripts/reports-backup.mjs +276 -0
  171. package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
  172. package/template/scripts/reports-deploy.mjs +173 -0
  173. package/template/scripts/reports-plan.mjs +226 -0
  174. package/template/scripts/reports-restore.mjs +180 -0
  175. package/template/scripts/reports-secrets.mjs +73 -0
  176. package/template/scripts/reports-uninstall-plan.mjs +32 -0
  177. package/template/src/agent-executor.ts +330 -0
  178. package/template/src/ai-observability.ts +163 -0
  179. package/template/src/ai-registry-validation.ts +244 -0
  180. package/template/src/ai-registry.ts +292 -0
  181. package/template/src/api-cursor.ts +84 -0
  182. package/template/src/assistant.ts +554 -0
  183. package/template/src/audit.ts +39 -0
  184. package/template/src/backup-service.ts +269 -0
  185. package/template/src/budget-control.ts +127 -0
  186. package/template/src/component-executor.ts +845 -0
  187. package/template/src/configuration-registry.ts +550 -0
  188. package/template/src/connection-egress.ts +430 -0
  189. package/template/src/connection-oauth.ts +368 -0
  190. package/template/src/evidence-service.ts +335 -0
  191. package/template/src/human-tasks.ts +316 -0
  192. package/template/src/index.ts +4114 -0
  193. package/template/src/installation-admin.ts +301 -0
  194. package/template/src/intake-abuse.ts +156 -0
  195. package/template/src/platform-registry-validation.ts +367 -0
  196. package/template/src/platform-registry.ts +753 -0
  197. package/template/src/report-admin.ts +342 -0
  198. package/template/src/report-ai-quality.ts +390 -0
  199. package/template/src/report-ai-validation.ts +80 -0
  200. package/template/src/report-ai.ts +857 -0
  201. package/template/src/report-auth.ts +292 -0
  202. package/template/src/report-better-auth.ts +583 -0
  203. package/template/src/report-context-schema.ts +132 -0
  204. package/template/src/report-context.ts +126 -0
  205. package/template/src/report-crypto.ts +142 -0
  206. package/template/src/report-delivery.ts +579 -0
  207. package/template/src/report-form-validation.ts +51 -0
  208. package/template/src/report-governance.ts +510 -0
  209. package/template/src/report-http.ts +90 -0
  210. package/template/src/report-operations.ts +685 -0
  211. package/template/src/report-operator-accounts.ts +810 -0
  212. package/template/src/report-presence.ts +398 -0
  213. package/template/src/report-queue-validation.ts +309 -0
  214. package/template/src/report-queues.ts +478 -0
  215. package/template/src/report-repository.ts +972 -0
  216. package/template/src/report-router-agent.ts +328 -0
  217. package/template/src/report-router-validation.ts +81 -0
  218. package/template/src/report-routing.ts +178 -0
  219. package/template/src/report-turnstile.ts +142 -0
  220. package/template/src/report-types.ts +224 -0
  221. package/template/src/report-validation.ts +279 -0
  222. package/template/src/report-workflow-validation.ts +119 -0
  223. package/template/src/report-workflow.ts +886 -0
  224. package/template/src/shadow-quality.ts +278 -0
  225. package/template/src/workflow-actions.ts +782 -0
  226. package/template/src/workflow-compiler.ts +230 -0
  227. package/template/src/workflow-dynamic-runtime.ts +613 -0
  228. package/template/src/workflow-effects.ts +316 -0
  229. package/template/src/workflow-expressions.ts +191 -0
  230. package/template/src/workflow-platform-types.ts +121 -0
  231. package/template/src/workflow-platform-validation.ts +540 -0
  232. package/template/src/workflow-repository.ts +916 -0
  233. package/template/src/workflow-runs.ts +686 -0
  234. package/template/src/workflow-simulator.ts +211 -0
  235. package/template/src/workspace-branding.ts +128 -0
  236. package/template/src/workspace-governance.ts +279 -0
  237. package/template/tsconfig.console.json +23 -0
  238. package/template/tsconfig.json +22 -0
  239. package/template/vite.console.config.ts +20 -0
  240. package/template/worker-configuration.d.ts +65 -0
@@ -0,0 +1,276 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { createReadStream, createWriteStream } from "node:fs";
4
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
5
+ import { resolve } from "node:path";
6
+ import { Readable, Transform } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
8
+ import { fileURLToPath } from "node:url";
9
+ import { loadReportsPlan } from "./reports-plan.mjs";
10
+
11
+ const projectRoot = fileURLToPath(new URL("..", import.meta.url));
12
+ const wrangler = fileURLToPath(new URL("../node_modules/wrangler/bin/wrangler.js", import.meta.url));
13
+ const BUCKETS = ["profile_media", "workflow_artifacts", "evidence", "exports"];
14
+
15
+ function parseArguments(argv) {
16
+ const result = { planOnly: false, config: "reports.config.json", secrets: ".safest/secrets.env" };
17
+ for (let index = 0; index < argv.length; index += 1) {
18
+ const argument = argv[index];
19
+ if (argument === "--plan") result.planOnly = true;
20
+ else if (argument === "--config" || argument === "--secrets-file") {
21
+ const value = argv[index + 1];
22
+ if (!value || value.startsWith("-")) throw new Error(`${argument} needs a value`);
23
+ index += 1;
24
+ if (argument === "--config") result.config = value;
25
+ else result.secrets = value;
26
+ } else throw new Error(`unknown option: ${argument}`);
27
+ }
28
+ return result;
29
+ }
30
+
31
+ function run(args) {
32
+ return new Promise((resolvePromise, reject) => {
33
+ const child = spawn(process.execPath, [wrangler, ...args], { cwd: projectRoot, env: process.env, stdio: "inherit" });
34
+ child.once("error", reject);
35
+ child.once("exit", (code) => code === 0 ? resolvePromise() : reject(new Error(`D1 export failed with exit code ${code}`)));
36
+ });
37
+ }
38
+
39
+ function parseSecrets(raw) {
40
+ const values = new Map();
41
+ for (const line of raw.split(/\r?\n/u)) {
42
+ const normalized = line.trim();
43
+ if (!normalized || normalized.startsWith("#")) continue;
44
+ const separator = normalized.indexOf("=");
45
+ if (separator > 0) values.set(normalized.slice(0, separator).trim(), normalized.slice(separator + 1).trim());
46
+ }
47
+ return values;
48
+ }
49
+
50
+ function sha256Bytes(value) {
51
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
52
+ }
53
+
54
+ async function sha256File(path) {
55
+ const hash = createHash("sha256");
56
+ let bytes = 0;
57
+ for await (const chunk of createReadStream(path)) {
58
+ hash.update(chunk);
59
+ bytes += chunk.length;
60
+ }
61
+ return { digest: `sha256:${hash.digest("hex")}`, bytes };
62
+ }
63
+
64
+ async function apiJson(baseUrl, adminKey, path, init = {}) {
65
+ const response = await fetch(new URL(path, baseUrl), {
66
+ ...init,
67
+ headers: {
68
+ accept: "application/json",
69
+ "x-admin-key": adminKey,
70
+ ...(init.body ? { "content-type": "application/json" } : {}),
71
+ ...init.headers,
72
+ },
73
+ redirect: "error",
74
+ });
75
+ const body = await response.json().catch(() => ({}));
76
+ if (!response.ok) {
77
+ const message = body?.error?.message;
78
+ throw new Error(typeof message === "string" ? message : `Backup API failed with HTTP ${response.status}`);
79
+ }
80
+ return body;
81
+ }
82
+
83
+ function inventoryFingerprint(inventory) {
84
+ return sha256Bytes(JSON.stringify(inventory.map(({ key, size, etag, version }) => ({ key, size, etag, version }))));
85
+ }
86
+
87
+ async function inventoryBucket(config, adminKey, backupId, bucket) {
88
+ const objects = [];
89
+ let cursor = null;
90
+ do {
91
+ const url = new URL(`/v1/admin/backups/${encodeURIComponent(backupId)}/r2`, config.publicBaseUrl);
92
+ url.searchParams.set("bucket", bucket);
93
+ url.searchParams.set("limit", "1000");
94
+ if (cursor) url.searchParams.set("cursor", cursor);
95
+ const page = await apiJson(config.publicBaseUrl, adminKey, url.pathname + url.search);
96
+ if (!Array.isArray(page.objects)) throw new Error(`Backup inventory for ${bucket} was malformed`);
97
+ for (const object of page.objects) {
98
+ if (!object || typeof object.key !== "string" || !Number.isSafeInteger(object.size) || object.size < 0
99
+ || typeof object.etag !== "string" || typeof object.version !== "string") {
100
+ throw new Error(`Backup inventory for ${bucket} contained an invalid object`);
101
+ }
102
+ objects.push({ key: object.key, size: object.size, etag: object.etag, version: object.version, uploadedAt: object.uploadedAt });
103
+ }
104
+ cursor = typeof page.next_cursor === "string" && page.next_cursor ? page.next_cursor : null;
105
+ } while (cursor);
106
+ objects.sort((left, right) => left.key.localeCompare(right.key) || left.version.localeCompare(right.version));
107
+ return objects;
108
+ }
109
+
110
+ async function downloadObject(config, adminKey, backupId, bucket, object, backupDirectory) {
111
+ const fileName = createHash("sha256").update(`${bucket}\0${object.key}`).digest("hex");
112
+ const relativePath = `r2/${bucket}/${fileName}.object`;
113
+ const outputPath = resolve(backupDirectory, relativePath);
114
+ await mkdir(resolve(backupDirectory, `r2/${bucket}`), { recursive: true, mode: 0o700 });
115
+ const url = new URL(`/v1/admin/backups/${encodeURIComponent(backupId)}/r2/object`, config.publicBaseUrl);
116
+ url.searchParams.set("bucket", bucket);
117
+ url.searchParams.set("key", object.key);
118
+ url.searchParams.set("etag", object.etag);
119
+ const response = await fetch(url, { headers: { "x-admin-key": adminKey }, redirect: "error" });
120
+ if (!response.ok || !response.body) {
121
+ const body = await response.json().catch(() => ({}));
122
+ throw new Error(body?.error?.message ?? `R2 object download failed with HTTP ${response.status}`);
123
+ }
124
+ const hash = createHash("sha256");
125
+ let bytes = 0;
126
+ const meter = new Transform({
127
+ transform(chunk, _encoding, callback) {
128
+ hash.update(chunk);
129
+ bytes += chunk.length;
130
+ callback(null, chunk);
131
+ },
132
+ });
133
+ try {
134
+ await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(outputPath, { flags: "wx", mode: 0o600 }));
135
+ } catch (error) {
136
+ await unlink(outputPath).catch(() => {});
137
+ throw error;
138
+ }
139
+ if (bytes !== object.size) throw new Error(`R2 object size changed during backup: ${bucket}/${object.key}`);
140
+ return { ...object, file: relativePath, digest: `sha256:${hash.digest("hex")}` };
141
+ }
142
+
143
+ async function downloadInventory(config, adminKey, backupId, inventories, backupDirectory) {
144
+ const jobs = BUCKETS.flatMap((bucket) => inventories[bucket].map((object) => ({ bucket, object })));
145
+ const results = new Array(jobs.length);
146
+ let next = 0;
147
+ const workers = Array.from({ length: Math.min(4, Math.max(1, jobs.length)) }, async () => {
148
+ while (true) {
149
+ const index = next;
150
+ next += 1;
151
+ if (index >= jobs.length) return;
152
+ const job = jobs[index];
153
+ results[index] = { bucket: job.bucket, ...await downloadObject(config, adminKey, backupId, job.bucket, job.object, backupDirectory) };
154
+ }
155
+ });
156
+ await Promise.all(workers);
157
+ return results;
158
+ }
159
+
160
+ async function markBackupFailed(config, adminKey, backupId) {
161
+ await apiJson(config.publicBaseUrl, adminKey, `/v1/admin/backups/${encodeURIComponent(backupId)}`, {
162
+ method: "PATCH",
163
+ headers: { "idempotency-key": randomUUID() },
164
+ body: JSON.stringify({ state: "failed", failure_code: "backup_client_failed" }),
165
+ }).catch(() => {});
166
+ }
167
+
168
+ export async function backupReports(argv = process.argv.slice(2)) {
169
+ const options = parseArguments(argv);
170
+ const { config } = await loadReportsPlan(options.config);
171
+ const plan = {
172
+ action: "backup",
173
+ database: config.resources.databaseName,
174
+ r2Buckets: [config.resources.profileMediaBucketName, config.resources.workflowArtifactsBucketName, config.resources.evidenceBucketName, config.resources.exportsBucketName],
175
+ outputDirectory: ".safest/backups/<installation>-<timestamp>-<backup-id>/",
176
+ includes: ["Committed D1 schema and rows", "All four private R2 bucket inventories and object bytes", "Per-object and manifest SHA-256 digests", "Durable backup verification record"],
177
+ excludes: ["pending Queue messages", "live Workflow engine internal state", "Worker secrets", "live Durable Object presence", "external webhook systems"],
178
+ };
179
+ console.log(JSON.stringify(plan, null, 2));
180
+ if (options.planOnly) return plan;
181
+
182
+ const secrets = parseSecrets(await readFile(resolve(projectRoot, options.secrets), "utf8"));
183
+ const adminKey = secrets.get("ADMIN_API_KEY");
184
+ if (!adminKey || adminKey.length < 32) throw new Error("The secrets file must contain ADMIN_API_KEY with at least 32 characters");
185
+ const versionResponse = await apiJson(config.publicBaseUrl, adminKey, "/v1/meta/version");
186
+ const versions = versionResponse?.version;
187
+ if (!versions || typeof versions.application !== "string" || typeof versions.databaseMigration !== "string"
188
+ || typeof versions.workflowCompiler !== "string" || typeof versions.workflowRuntimeProtocol !== "string") {
189
+ throw new Error("The deployed service did not return a complete version inventory");
190
+ }
191
+ const backupId = randomUUID();
192
+ const started = await apiJson(config.publicBaseUrl, adminKey, "/v1/admin/backups", {
193
+ method: "POST",
194
+ headers: { "idempotency-key": backupId },
195
+ body: JSON.stringify({
196
+ backup_id: backupId,
197
+ environment: new URL(config.publicBaseUrl).hostname,
198
+ application_version: versions.application,
199
+ }),
200
+ });
201
+ if (started.backupId !== backupId || started.state !== "running") throw new Error("The backup API did not start the requested backup");
202
+
203
+ const stamp = new Date().toISOString().replaceAll(":", "-");
204
+ const backupDirectory = resolve(projectRoot, `.safest/backups/${config.installationId}-${stamp}-${backupId}`);
205
+ const sqlPath = resolve(backupDirectory, "database.sql");
206
+ await mkdir(backupDirectory, { recursive: true, mode: 0o700 });
207
+
208
+ try {
209
+ await run(["d1", "export", "DB", "--remote", "--output", sqlPath, "--skip-confirmation"]);
210
+ const d1 = await sha256File(sqlPath);
211
+ const inventories = Object.fromEntries(await Promise.all(BUCKETS.map(async (bucket) => [bucket, await inventoryBucket(config, adminKey, backupId, bucket)])));
212
+ const downloaded = await downloadInventory(config, adminKey, backupId, inventories, backupDirectory);
213
+ const verificationInventories = Object.fromEntries(await Promise.all(BUCKETS.map(async (bucket) => [bucket, await inventoryBucket(config, adminKey, backupId, bucket)])));
214
+ for (const bucket of BUCKETS) {
215
+ if (inventoryFingerprint(inventories[bucket]) !== inventoryFingerprint(verificationInventories[bucket])) {
216
+ throw new Error(`R2 inventory changed during backup for ${bucket}; retry after writes settle`);
217
+ }
218
+ }
219
+ const objectBytes = downloaded.reduce((total, object) => total + object.size, 0);
220
+ const r2Manifest = {
221
+ schemaVersion: 1,
222
+ backupId,
223
+ installationId: config.installationId,
224
+ generatedAt: new Date().toISOString(),
225
+ verifiedStableInventory: true,
226
+ objectCount: downloaded.length,
227
+ objectBytes,
228
+ objects: downloaded,
229
+ };
230
+ const r2ManifestBytes = Buffer.from(`${JSON.stringify(r2Manifest, null, 2)}\n`);
231
+ const r2ManifestPath = resolve(backupDirectory, "r2-manifest.json");
232
+ await writeFile(r2ManifestPath, r2ManifestBytes, { mode: 0o600 });
233
+ const r2ManifestDigest = sha256Bytes(r2ManifestBytes);
234
+ await apiJson(config.publicBaseUrl, adminKey, `/v1/admin/backups/${encodeURIComponent(backupId)}`, {
235
+ method: "PATCH",
236
+ headers: { "idempotency-key": randomUUID() },
237
+ body: JSON.stringify({
238
+ state: "verified",
239
+ d1_export_reference: "database.sql",
240
+ d1_digest: d1.digest,
241
+ r2_manifest_reference: "r2-manifest.json",
242
+ r2_manifest_digest: r2ManifestDigest,
243
+ object_count: downloaded.length,
244
+ object_bytes: objectBytes,
245
+ }),
246
+ });
247
+ const manifest = {
248
+ schemaVersion: 2,
249
+ status: "verified",
250
+ backupId,
251
+ installationId: config.installationId,
252
+ sourcePublicBaseUrl: config.publicBaseUrl,
253
+ versions,
254
+ exportedAt: new Date().toISOString(),
255
+ d1: { file: "database.sql", bytes: d1.bytes, digest: d1.digest },
256
+ r2: { manifest: "r2-manifest.json", digest: r2ManifestDigest, objectCount: downloaded.length, objectBytes },
257
+ excludedState: ["pending Queue messages", "live Workflow engine internal state", "Worker secrets", "live Durable Object presence", "external provider state"],
258
+ verification: { d1Digest: true, r2ObjectSizes: true, r2ObjectDigests: true, stableR2Inventory: true, durableManifest: true },
259
+ };
260
+ const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
261
+ await writeFile(resolve(backupDirectory, "manifest.json"), manifestBytes, { mode: 0o600 });
262
+ await writeFile(resolve(backupDirectory, "manifest.sha256"), `${sha256Bytes(manifestBytes)} manifest.json\n`, { mode: 0o600 });
263
+ console.log(`Verified D1 and R2 backup: ${backupDirectory}`);
264
+ return manifest;
265
+ } catch (error) {
266
+ await markBackupFailed(config, adminKey, backupId);
267
+ throw error;
268
+ }
269
+ }
270
+
271
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
272
+ backupReports().catch((error) => {
273
+ console.error(error instanceof Error ? error.message : String(error));
274
+ process.exitCode = 1;
275
+ });
276
+ }
@@ -0,0 +1,152 @@
1
+ import * as clack from "@clack/prompts";
2
+ import { spawn } from "node:child_process";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+
6
+ function runProcess(program, args, { cwd, inherit = false } = {}) {
7
+ return new Promise((resolvePromise, reject) => {
8
+ const child = spawn(program, args, {
9
+ cwd,
10
+ env: { ...process.env, WRANGLER_LOG: "none" },
11
+ stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"],
12
+ });
13
+ let stdout = "";
14
+ let stderr = "";
15
+ if (!inherit) {
16
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
17
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
18
+ }
19
+ child.once("error", reject);
20
+ child.once("exit", (code, signal) => resolvePromise({ code, signal, stdout, stderr }));
21
+ });
22
+ }
23
+
24
+ function jsonOutput(result, label) {
25
+ if (result.code !== 0) throw new Error(`${label} failed`);
26
+ try { return JSON.parse(result.stdout.trim()); } catch { throw new Error(`${label} returned an unreadable response`); }
27
+ }
28
+
29
+ export function findWorkersPaidSubscription(subscriptions) {
30
+ if (!Array.isArray(subscriptions)) return null;
31
+ return subscriptions.find((subscription) => {
32
+ const plan = subscription?.rate_plan ?? {};
33
+ const identity = [plan.id, plan.public_name, plan.scope, ...(Array.isArray(plan.sets) ? plan.sets : [])]
34
+ .filter(Boolean).join(" ").toLowerCase();
35
+ const isWorkersPlan = identity.includes("worker");
36
+ const isFree = /\bfree\b/u.test(identity);
37
+ const activePaidState = subscription?.state === "Paid" || subscription?.state === "Provisioned";
38
+ const hasPaidContract = plan.is_contract === true || (typeof subscription?.price === "number" && subscription.price > 0);
39
+ return isWorkersPlan && !isFree && (activePaidState || hasPaidContract);
40
+ }) ?? null;
41
+ }
42
+
43
+ async function fetchSubscriptions(accountId, token, fetchImpl) {
44
+ const response = await fetchImpl(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/subscriptions`, {
45
+ headers: { authorization: `Bearer ${token}`, accept: "application/json" },
46
+ });
47
+ const body = await response.json().catch(() => null);
48
+ if (!response.ok || body?.success !== true || !Array.isArray(body.result)) {
49
+ return { ok: false, status: response.status, body };
50
+ }
51
+ return { ok: true, subscriptions: body.result };
52
+ }
53
+
54
+ async function billingTokenFromUser(account, ui) {
55
+ ui.note(
56
+ `Wrangler is authenticated, but its OAuth grant cannot read billing subscriptions.\n\n` +
57
+ `Create a temporary read-only token for account “${account.name}”:\n` +
58
+ `1. Open https://dash.cloudflare.com/profile/api-tokens\n` +
59
+ `2. Create a custom token.\n` +
60
+ `3. Add Account → Billing → Read.\n` +
61
+ `4. Limit Account Resources to this account only.\n` +
62
+ `5. Paste the token here. It is used once, is never written to disk, and is never deployed.`,
63
+ "Workers Paid verification",
64
+ );
65
+ const action = await ui.select({
66
+ message: "Verify the Workers subscription",
67
+ options: [
68
+ { value: "token", label: "Enter read-only billing token" },
69
+ { value: "back", label: "Back", hint: "stop before provisioning anything" },
70
+ ],
71
+ });
72
+ if (clack.isCancel(action) || action === "back") return null;
73
+ const token = await ui.password({
74
+ message: "Temporary Cloudflare Billing Read token (input is masked)",
75
+ validate: (value) => value.trim() ? undefined : "Token is required",
76
+ });
77
+ return clack.isCancel(token) ? null : token.trim();
78
+ }
79
+
80
+ export async function verifyWorkersPaid({ account, wranglerToken, interactive, fetchImpl = fetch, ui = clack }) {
81
+ let verification = await fetchSubscriptions(account.id, process.env.CLOUDFLARE_BILLING_API_TOKEN || wranglerToken, fetchImpl);
82
+ if (!verification.ok && (verification.status === 401 || verification.status === 403) && !process.env.CLOUDFLARE_BILLING_API_TOKEN) {
83
+ if (!interactive) {
84
+ throw new Error("Workers Paid could not be verified. Set CLOUDFLARE_BILLING_API_TOKEN to a temporary Account Billing Read token and run setup again.");
85
+ }
86
+ const billingToken = await billingTokenFromUser(account, ui);
87
+ if (!billingToken) throw new Error("Setup cancelled before provisioning: Workers Paid was not verified.");
88
+ verification = await fetchSubscriptions(account.id, billingToken, fetchImpl);
89
+ }
90
+ if (!verification.ok) {
91
+ throw new Error(`Workers Paid could not be verified for “${account.name}” (Cloudflare API ${verification.status}). No resources were provisioned.`);
92
+ }
93
+ const subscription = findWorkersPaidSubscription(verification.subscriptions);
94
+ if (!subscription) {
95
+ throw new Error(`“${account.name}” does not have an active Workers Paid subscription. Upgrade it in Cloudflare, then run setup again. No resources were provisioned.`);
96
+ }
97
+ return subscription;
98
+ }
99
+
100
+ export async function ensureWranglerAuthentication({ wrangler, projectRoot, interactive, ui = clack, runner = runProcess }) {
101
+ let result = await runner(process.execPath, [wrangler, "whoami", "--json"], { cwd: projectRoot });
102
+ if (result.code !== 0) {
103
+ if (!interactive) throw new Error("Cloudflare login is required. Run `npx wrangler login`, approve the requested permissions, then run setup again.");
104
+ ui.note(
105
+ "Your browser will open Cloudflare. Sign in to the account that will own Safest Resolve, review the Wrangler permissions, and approve them.",
106
+ "Cloudflare login",
107
+ );
108
+ const login = await runner(process.execPath, [wrangler, "login"], { cwd: projectRoot, inherit: true });
109
+ if (login.code !== 0) throw new Error("Cloudflare login was not completed. No resources were provisioned.");
110
+ result = await runner(process.execPath, [wrangler, "whoami", "--json"], { cwd: projectRoot });
111
+ }
112
+ const identity = jsonOutput(result, "Wrangler authentication check");
113
+ if (identity.loggedIn !== true || !Array.isArray(identity.accounts) || !identity.accounts.length) {
114
+ throw new Error("Wrangler is authenticated but no accessible Cloudflare account was found.");
115
+ }
116
+ const tokenResult = await runner(process.execPath, [wrangler, "auth", "token", "--json"], { cwd: projectRoot });
117
+ const token = jsonOutput(tokenResult, "Wrangler token lookup");
118
+ if (typeof token.token !== "string" || !token.token) throw new Error("Wrangler did not provide an API token for preflight checks.");
119
+ return { identity, token: token.token };
120
+ }
121
+
122
+ export async function chooseCloudflareAccount({ identity, wranglerConfigPath, interactive, ui = clack }) {
123
+ const absolute = resolve(wranglerConfigPath);
124
+ const wranglerConfig = JSON.parse(await readFile(absolute, "utf8"));
125
+ const configured = typeof wranglerConfig.account_id === "string" ? wranglerConfig.account_id : "";
126
+ if (configured) {
127
+ const account = identity.accounts.find(({ id }) => id === configured);
128
+ if (!account) throw new Error(`wrangler.jsonc selects account ${configured}, but the current Wrangler login cannot access it.`);
129
+ return { account, wranglerConfig, path: absolute, changed: false };
130
+ }
131
+ let account;
132
+ if (identity.accounts.length === 1) account = identity.accounts[0];
133
+ else {
134
+ if (!interactive) throw new Error("Multiple Cloudflare accounts are available. Add the intended account_id to wrangler.jsonc, then run setup again.");
135
+ const selected = await ui.select({
136
+ message: "Which Cloudflare account should own Safest Resolve?",
137
+ options: [
138
+ ...identity.accounts.map((candidate) => ({ value: candidate.id, label: candidate.name, hint: candidate.id })),
139
+ { value: "back", label: "Back", hint: "stop before provisioning anything" },
140
+ ],
141
+ });
142
+ if (clack.isCancel(selected) || selected === "back") throw new Error("Setup cancelled before provisioning: no Cloudflare account was selected.");
143
+ account = identity.accounts.find(({ id }) => id === selected);
144
+ }
145
+ if (!account) throw new Error("The selected Cloudflare account is unavailable.");
146
+ return { account, wranglerConfig: { ...wranglerConfig, account_id: account.id }, path: absolute, changed: true };
147
+ }
148
+
149
+ export async function saveCloudflareAccountSelection(selection) {
150
+ if (!selection.changed) return;
151
+ await writeFile(selection.path, `${JSON.stringify(selection.wranglerConfig, null, 2)}\n`, "utf8");
152
+ }
@@ -0,0 +1,173 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import { configureAuthentication, requiredOAuthSecrets } from "./reports-auth-onboarding.mjs";
7
+ import {
8
+ chooseCloudflareAccount,
9
+ ensureWranglerAuthentication,
10
+ saveCloudflareAccountSelection,
11
+ verifyWorkersPaid,
12
+ } from "./reports-cloudflare-preflight.mjs";
13
+ import { loadReportsPlan } from "./reports-plan.mjs";
14
+ import { initializeSecrets, parseSecrets } from "./reports-secrets.mjs";
15
+
16
+ const projectRoot = fileURLToPath(new URL("..", import.meta.url));
17
+ const wrangler = fileURLToPath(new URL("../node_modules/wrangler/bin/wrangler.js", import.meta.url));
18
+ const REQUIRED_SECRETS = ["BETTER_AUTH_SECRET", "ADMIN_API_KEY", "CONTEXT_SIGNING_SECRET", "CREDENTIAL_ENCRYPTION_KEY", "RATE_LIMIT_PEPPER", "INTEGRATION_API_KEYS_JSON"];
19
+
20
+ function parseArguments(argv) {
21
+ const result = { planOnly: false, yes: false, upgrade: false, config: "reports.config.json", secrets: ".safest/secrets.env" };
22
+ for (let index = 0; index < argv.length; index += 1) {
23
+ const argument = argv[index];
24
+ if (argument === "--plan") result.planOnly = true;
25
+ else if (argument === "--yes") result.yes = true;
26
+ else if (argument === "--upgrade") result.upgrade = true;
27
+ else if (argument === "--config" || argument === "--secrets-file") {
28
+ const value = argv[index + 1];
29
+ if (!value || value.startsWith("-")) throw new Error(`${argument} needs a value`);
30
+ index += 1;
31
+ if (argument === "--config") result.config = value;
32
+ else result.secrets = value;
33
+ } else throw new Error(`unknown option: ${argument}`);
34
+ }
35
+ return result;
36
+ }
37
+
38
+ function runWrangler(args, label) {
39
+ return new Promise((resolvePromise, reject) => {
40
+ console.log(`\n${label}`);
41
+ const child = spawn(process.execPath, [wrangler, ...args], { cwd: projectRoot, env: process.env, stdio: "inherit" });
42
+ child.once("error", reject);
43
+ child.once("exit", (code, signal) => code === 0 ? resolvePromise() : reject(new Error(`${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`)));
44
+ });
45
+ }
46
+
47
+ export function isExistingResourceError(output) {
48
+ return /already exists|already (?:been )?taken|duplicate resource/iu.test(output);
49
+ }
50
+
51
+ function ensureWranglerResource(args, label) {
52
+ return new Promise((resolvePromise, reject) => {
53
+ console.log(`\n${label}`);
54
+ const child = spawn(process.execPath, [wrangler, ...args], { cwd: projectRoot, env: process.env, stdio: ["inherit", "pipe", "pipe"] });
55
+ let output = "";
56
+ child.stdout.on("data", (chunk) => { output += chunk; process.stdout.write(chunk); });
57
+ child.stderr.on("data", (chunk) => { output += chunk; process.stderr.write(chunk); });
58
+ child.once("error", reject);
59
+ child.once("exit", (code, signal) => {
60
+ if (code === 0) return resolvePromise();
61
+ if (isExistingResourceError(output)) {
62
+ console.log("Resource already exists; reusing the exact configured name.");
63
+ return resolvePromise();
64
+ }
65
+ reject(new Error(`${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`));
66
+ });
67
+ });
68
+ }
69
+
70
+ async function validateSecrets(path, config) {
71
+ const raw = await readFile(path, "utf8");
72
+ const values = parseSecrets(raw);
73
+ const missing = [...REQUIRED_SECRETS, ...requiredOAuthSecrets(config)].filter((name) => !values[name]);
74
+ if (missing.length) throw new Error(`secrets file is missing: ${missing.join(", ")}`);
75
+ for (const name of ["BETTER_AUTH_SECRET", "ADMIN_API_KEY", "CONTEXT_SIGNING_SECRET", "CREDENTIAL_ENCRYPTION_KEY", "RATE_LIMIT_PEPPER"]) {
76
+ if ((values[name] ?? "").length < 32) throw new Error(`${name} must contain at least 32 characters`);
77
+ }
78
+ try {
79
+ const keys = JSON.parse(values.INTEGRATION_API_KEYS_JSON);
80
+ if (!keys || typeof keys !== "object" || Array.isArray(keys) || !Object.keys(keys).length) throw new Error();
81
+ } catch { throw new Error("INTEGRATION_API_KEYS_JSON must be a non-empty JSON object"); }
82
+ }
83
+
84
+ async function hasDatabaseId() {
85
+ const config = JSON.parse(await readFile(resolve(projectRoot, "wrangler.jsonc"), "utf8"));
86
+ return typeof config?.d1_databases?.[0]?.database_id === "string" && config.d1_databases[0].database_id.length > 0;
87
+ }
88
+
89
+ async function confirmation(installationId, yes) {
90
+ if (yes) return;
91
+ if (!process.stdin.isTTY) throw new Error("interactive confirmation is required; use --yes only after reviewing setup:plan");
92
+ const input = createInterface({ input: process.stdin, output: process.stdout });
93
+ try {
94
+ const expected = `DEPLOY ${installationId}`;
95
+ const actual = (await input.question(`\nType ${expected} to create or update these customer-owned resources: `)).trim();
96
+ if (actual !== expected) throw new Error("deployment confirmation did not match");
97
+ } finally { input.close(); }
98
+ }
99
+
100
+ export async function deployReports(argv = process.argv.slice(2)) {
101
+ const options = parseArguments(argv);
102
+ let { config, plan } = await loadReportsPlan(options.config);
103
+ console.log(JSON.stringify({ action: options.upgrade ? "upgrade" : "install", ...plan }, null, 2));
104
+ if (options.planOnly) return { status: "planned", plan };
105
+ if (!plan.ready) throw new Error(plan.blockers.join(" "));
106
+ const interactive = process.stdin.isTTY && !options.yes;
107
+ const authentication = await ensureWranglerAuthentication({ wrangler, projectRoot, interactive });
108
+ const accountSelection = await chooseCloudflareAccount({
109
+ identity: authentication.identity,
110
+ wranglerConfigPath: resolve(projectRoot, "wrangler.jsonc"),
111
+ interactive,
112
+ });
113
+ const subscription = await verifyWorkersPaid({
114
+ account: accountSelection.account,
115
+ wranglerToken: authentication.token,
116
+ interactive,
117
+ });
118
+ await saveCloudflareAccountSelection(accountSelection);
119
+ console.log(`\nWorkers Paid verified for ${accountSelection.account.name} (${subscription.rate_plan?.public_name ?? "active Workers subscription"}).`);
120
+ const secretsPath = resolve(projectRoot, options.secrets);
121
+ try {
122
+ await readFile(secretsPath, "utf8");
123
+ } catch (error) {
124
+ if (error?.code !== "ENOENT") throw error;
125
+ await initializeSecrets(secretsPath);
126
+ console.log(`Created ${secretsPath} with generated owner-only secrets.`);
127
+ }
128
+ if (!options.upgrade) {
129
+ const configured = await configureAuthentication({
130
+ configPath: resolve(projectRoot, options.config),
131
+ secretsPath,
132
+ config,
133
+ interactive,
134
+ });
135
+ if (configured.cancelled) throw new Error("Setup cancelled before provisioning. No Cloudflare resources were created.");
136
+ ({ config, plan } = await loadReportsPlan(options.config));
137
+ }
138
+ await validateSecrets(secretsPath, config);
139
+ await confirmation(config.installationId, options.yes);
140
+ const databaseConfigured = await hasDatabaseId();
141
+ if (options.upgrade && !databaseConfigured) throw new Error("upgrade requires an existing D1 database_id in wrangler.jsonc");
142
+ if (!options.upgrade && databaseConfigured) throw new Error("this project already has a D1 database_id; use npm run upgrade instead of setup");
143
+ if (!options.upgrade) {
144
+ await runWrangler(["d1", "create", config.resources.databaseName, "--binding", "DB", "--update-config"], "Create the customer-owned D1 database");
145
+ }
146
+ await ensureWranglerResource(["r2", "bucket", "create", config.resources.profileMediaBucketName], "Ensure the private profile-media R2 bucket exists");
147
+ await ensureWranglerResource(["r2", "bucket", "create", config.resources.workflowArtifactsBucketName], "Ensure the private workflow-artifacts R2 bucket exists");
148
+ await ensureWranglerResource(["r2", "bucket", "create", config.resources.evidenceBucketName], "Ensure the private evidence R2 bucket exists");
149
+ await ensureWranglerResource(["r2", "bucket", "create", config.resources.exportsBucketName], "Ensure the private exports R2 bucket exists");
150
+ await ensureWranglerResource(["queues", "create", config.resources.reportQueueName], "Ensure the report jobs Queue exists");
151
+ await ensureWranglerResource(["queues", "create", config.resources.deliveryQueueName], "Ensure the delivery jobs Queue exists");
152
+ await ensureWranglerResource(["queues", "create", config.resources.operationsDlqName], "Ensure the operations dead-letter Queue exists");
153
+ await runWrangler(["d1", "migrations", "apply", "DB", "--remote"], "Apply forward-only report migrations");
154
+ await runWrangler(["deploy", "--secrets-file", secretsPath, "--strict"], "Deploy the reports Worker and bindings");
155
+ const installationPath = resolve(projectRoot, ".safest/installation.json");
156
+ await mkdir(dirname(installationPath), { recursive: true, mode: 0o700 });
157
+ await writeFile(installationPath, `${JSON.stringify({
158
+ schemaVersion: 1,
159
+ installationId: config.installationId,
160
+ installedAt: new Date().toISOString(),
161
+ resources: config.resources,
162
+ accessAudience: config.access.audience,
163
+ }, null, 2)}\n`, { mode: 0o600 });
164
+ console.log("\nDeployment finished. Verify public /health, bootstrap the infrastructure owner through Access, run the authenticated setup feature checks until /ready succeeds, invite an administrator and analyst, then verify one server report and Queue consumption before production traffic.");
165
+ return { status: "deployed", plan };
166
+ }
167
+
168
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
169
+ deployReports().catch((error) => {
170
+ console.error(error instanceof Error ? error.message : String(error));
171
+ process.exitCode = 1;
172
+ });
173
+ }