hatchkit 0.1.40 → 0.1.42

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 (127) hide show
  1. package/dist/adopt.d.ts.map +1 -1
  2. package/dist/adopt.js +663 -82
  3. package/dist/adopt.js.map +1 -1
  4. package/dist/config.d.ts +32 -10
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +91 -38
  7. package/dist/config.js.map +1 -1
  8. package/dist/deploy/coolify-app.d.ts.map +1 -1
  9. package/dist/deploy/coolify-app.js +0 -7
  10. package/dist/deploy/coolify-app.js.map +1 -1
  11. package/dist/deploy/coolify.d.ts.map +1 -1
  12. package/dist/deploy/coolify.js +20 -1
  13. package/dist/deploy/coolify.js.map +1 -1
  14. package/dist/deploy/ghcr.d.ts +4 -2
  15. package/dist/deploy/ghcr.d.ts.map +1 -1
  16. package/dist/deploy/ghcr.js +1 -1
  17. package/dist/deploy/ghcr.js.map +1 -1
  18. package/dist/deploy/github.d.ts +4 -3
  19. package/dist/deploy/github.d.ts.map +1 -1
  20. package/dist/deploy/github.js +5 -2
  21. package/dist/deploy/github.js.map +1 -1
  22. package/dist/deploy/pages.d.ts +41 -0
  23. package/dist/deploy/pages.d.ts.map +1 -1
  24. package/dist/deploy/pages.js +363 -22
  25. package/dist/deploy/pages.js.map +1 -1
  26. package/dist/deploy/regen-infra.d.ts.map +1 -1
  27. package/dist/deploy/regen-infra.js +5 -11
  28. package/dist/deploy/regen-infra.js.map +1 -1
  29. package/dist/deploy/rollback.d.ts.map +1 -1
  30. package/dist/deploy/rollback.js +44 -6
  31. package/dist/deploy/rollback.js.map +1 -1
  32. package/dist/deploy/terraform.d.ts.map +1 -1
  33. package/dist/deploy/terraform.js +20 -37
  34. package/dist/deploy/terraform.js.map +1 -1
  35. package/dist/dns.d.ts.map +1 -1
  36. package/dist/dns.js +4 -5
  37. package/dist/dns.js.map +1 -1
  38. package/dist/doctor.d.ts +15 -0
  39. package/dist/doctor.d.ts.map +1 -1
  40. package/dist/doctor.js +110 -36
  41. package/dist/doctor.js.map +1 -1
  42. package/dist/email/index.d.ts +31 -0
  43. package/dist/email/index.d.ts.map +1 -0
  44. package/dist/email/index.js +251 -0
  45. package/dist/email/index.js.map +1 -0
  46. package/dist/email/presets.d.ts +14 -0
  47. package/dist/email/presets.d.ts.map +1 -0
  48. package/dist/email/presets.js +33 -0
  49. package/dist/email/presets.js.map +1 -0
  50. package/dist/email/setup.d.ts +93 -0
  51. package/dist/email/setup.d.ts.map +1 -0
  52. package/dist/email/setup.js +263 -0
  53. package/dist/email/setup.js.map +1 -0
  54. package/dist/email/spf.d.ts +56 -0
  55. package/dist/email/spf.d.ts.map +1 -0
  56. package/dist/email/spf.js +102 -0
  57. package/dist/email/spf.js.map +1 -0
  58. package/dist/index.js +306 -22
  59. package/dist/index.js.map +1 -1
  60. package/dist/inventory.d.ts +37 -0
  61. package/dist/inventory.d.ts.map +1 -1
  62. package/dist/inventory.js +536 -55
  63. package/dist/inventory.js.map +1 -1
  64. package/dist/overview.d.ts +101 -0
  65. package/dist/overview.d.ts.map +1 -0
  66. package/dist/overview.js +880 -0
  67. package/dist/overview.js.map +1 -0
  68. package/dist/prompts.d.ts +27 -0
  69. package/dist/prompts.d.ts.map +1 -1
  70. package/dist/prompts.js +262 -34
  71. package/dist/prompts.js.map +1 -1
  72. package/dist/provision/index.d.ts +20 -1
  73. package/dist/provision/index.d.ts.map +1 -1
  74. package/dist/provision/index.js +115 -0
  75. package/dist/provision/index.js.map +1 -1
  76. package/dist/provision/s3-buckets.js +1 -1
  77. package/dist/provision/s3-buckets.js.map +1 -1
  78. package/dist/scaffold/app.d.ts.map +1 -1
  79. package/dist/scaffold/app.js +15 -7
  80. package/dist/scaffold/app.js.map +1 -1
  81. package/dist/scaffold/build-pipeline.d.ts +16 -0
  82. package/dist/scaffold/build-pipeline.d.ts.map +1 -1
  83. package/dist/scaffold/build-pipeline.js +47 -4
  84. package/dist/scaffold/build-pipeline.js.map +1 -1
  85. package/dist/scaffold/infra.d.ts +4 -5
  86. package/dist/scaffold/infra.d.ts.map +1 -1
  87. package/dist/scaffold/infra.js +18 -57
  88. package/dist/scaffold/infra.js.map +1 -1
  89. package/dist/scaffold/manifest.d.ts +6 -0
  90. package/dist/scaffold/manifest.d.ts.map +1 -1
  91. package/dist/scaffold/manifest.js +2 -0
  92. package/dist/scaffold/manifest.js.map +1 -1
  93. package/dist/scaffold/pages-heuristics.d.ts +17 -0
  94. package/dist/scaffold/pages-heuristics.d.ts.map +1 -0
  95. package/dist/scaffold/pages-heuristics.js +344 -0
  96. package/dist/scaffold/pages-heuristics.js.map +1 -0
  97. package/dist/scaffold/pages-mode.d.ts +10 -0
  98. package/dist/scaffold/pages-mode.d.ts.map +1 -0
  99. package/dist/scaffold/pages-mode.js +107 -0
  100. package/dist/scaffold/pages-mode.js.map +1 -0
  101. package/dist/scaffold/pkg-json.d.ts +4 -0
  102. package/dist/scaffold/pkg-json.d.ts.map +1 -1
  103. package/dist/scaffold/pkg-json.js +17 -0
  104. package/dist/scaffold/pkg-json.js.map +1 -1
  105. package/dist/scaffold/surfaces.d.ts.map +1 -1
  106. package/dist/scaffold/surfaces.js +12 -1
  107. package/dist/scaffold/surfaces.js.map +1 -1
  108. package/dist/scaffold/update.js +1 -1
  109. package/dist/scaffold/update.js.map +1 -1
  110. package/dist/templates/build-pipeline/Dockerfile.nextjs.hbs +103 -0
  111. package/dist/templates/build-pipeline/docker-compose.yml.hbs +23 -6
  112. package/dist/utils/cloudflare-api.d.ts +158 -13
  113. package/dist/utils/cloudflare-api.d.ts.map +1 -1
  114. package/dist/utils/cloudflare-api.js +219 -11
  115. package/dist/utils/cloudflare-api.js.map +1 -1
  116. package/dist/utils/coolify-api.d.ts +9 -0
  117. package/dist/utils/coolify-api.d.ts.map +1 -1
  118. package/dist/utils/coolify-api.js +26 -0
  119. package/dist/utils/coolify-api.js.map +1 -1
  120. package/dist/utils/run-ledger.d.ts +42 -1
  121. package/dist/utils/run-ledger.d.ts.map +1 -1
  122. package/dist/utils/run-ledger.js.map +1 -1
  123. package/dist/utils/s3-admin.d.ts +9 -0
  124. package/dist/utils/s3-admin.d.ts.map +1 -0
  125. package/dist/utils/s3-admin.js +46 -0
  126. package/dist/utils/s3-admin.js.map +1 -0
  127. package/package.json +1 -1
@@ -0,0 +1,880 @@
1
+ /*
2
+ * `hatchkit overview` — fleet-level survey of what's living on every
3
+ * configured provider, with no project filter.
4
+ *
5
+ * Distinct from `inventory` (which is project-scoped — "what does THIS
6
+ * project have?") and `doctor` (which validates credentials). Overview
7
+ * answers "what does my whole hatchkit footprint look like across all
8
+ * configured providers, in one glance?"
9
+ *
10
+ * For each configured provider it pulls the top-level resource list
11
+ * (apps, projects, databases, zones, buckets, domains, webhooks) and
12
+ * renders a compact tree. Read-only: every call is a GET.
13
+ */
14
+ import chalk from "chalk";
15
+ import { getCoolifyConfig, getDnsConfig, getGlitchtipConfig, getOpenpanelConfig, getResendConfig, getS3Config, getStripeConfig, } from "./config.js";
16
+ import { CloudflareApi } from "./utils/cloudflare-api.js";
17
+ import { CoolifyApi } from "./utils/coolify-api.js";
18
+ import { execOk } from "./utils/exec.js";
19
+ import { listS3Buckets } from "./utils/s3-admin.js";
20
+ import { SECRET_KEYS, getSecret } from "./utils/secrets.js";
21
+ import { getCliVersion } from "./utils/version.js";
22
+ export async function runOverview(opts = {}) {
23
+ const report = await collectOverview();
24
+ if (opts.json) {
25
+ console.log(JSON.stringify(report, null, 2));
26
+ return;
27
+ }
28
+ console.log(renderOverviewHuman(report, { all: opts.all ?? false }));
29
+ }
30
+ export async function collectOverview() {
31
+ // Every probe is best-effort. Running them in parallel keeps wall-time
32
+ // close to the slowest single provider.
33
+ const probes = await Promise.all([
34
+ probeCoolify(),
35
+ probeDns(),
36
+ probeR2(),
37
+ probeS3Other("hetzner"),
38
+ probeS3Other("aws"),
39
+ probeResend(),
40
+ probeGlitchtip(),
41
+ probeOpenpanel(),
42
+ probeStripe(),
43
+ ]);
44
+ const providers = probes.map((p) => p.provider);
45
+ // Cross-reference engine reads raw probe outputs to flag fleet-level
46
+ // orphans + dead links (e.g. Coolify app deploys from a repo that no
47
+ // longer exists). Best-effort: each check returns `[]` when it can't
48
+ // run.
49
+ const crossReferences = await runCrossReferences(probes);
50
+ // Include kind metadata only for kinds that actually have findings —
51
+ // keeps the JSON small.
52
+ const crossReferenceKinds = CROSS_REFERENCE_KINDS.filter((k) => crossReferences.some((c) => c.kind === k.kind));
53
+ let configured = 0;
54
+ let empty = 0;
55
+ let skipped = 0;
56
+ let errored = 0;
57
+ let totalResources = 0;
58
+ for (const p of providers) {
59
+ switch (p.status) {
60
+ case "present":
61
+ configured++;
62
+ totalResources += p.resources?.length ?? 0;
63
+ break;
64
+ case "empty":
65
+ empty++;
66
+ break;
67
+ case "skip":
68
+ skipped++;
69
+ break;
70
+ case "error":
71
+ errored++;
72
+ break;
73
+ }
74
+ }
75
+ const crossRefWarnings = crossReferences.filter((c) => c.severity === "warn").length;
76
+ return {
77
+ cliVersion: getCliVersion(),
78
+ providers,
79
+ crossReferences,
80
+ crossReferenceKinds,
81
+ summary: {
82
+ configured,
83
+ empty,
84
+ skipped,
85
+ errored,
86
+ totalResources,
87
+ crossRefWarnings,
88
+ },
89
+ };
90
+ }
91
+ // ---------------------------------------------------------------------------
92
+ // Provider probes
93
+ // ---------------------------------------------------------------------------
94
+ async function probeCoolify() {
95
+ const key = "coolify";
96
+ const label = "Coolify";
97
+ const cfg = await getCoolifyConfig();
98
+ if (!cfg)
99
+ return { provider: { key, label, status: "skip", detail: "not configured" } };
100
+ const api = new CoolifyApi({ url: cfg.url, token: cfg.token });
101
+ try {
102
+ const [apps, projects, databases] = await Promise.all([
103
+ api.listApplications().catch(() => []),
104
+ api.listProjects().catch(() => []),
105
+ api.listDatabases().catch(() => []),
106
+ ]);
107
+ // Hydrate each app via getApplication to surface fqdn +
108
+ // gitRepository. These are what the cross-reference engine needs
109
+ // — without them we can't detect orphan zones or repo-gone deploys.
110
+ // Parallel to keep wall-time tolerable for fleets with many apps;
111
+ // failures degrade gracefully (the cross-ref using that app skips).
112
+ const hydrated = (await Promise.all(apps.map((a) => api.getApplication(a.uuid).catch(() => null)))).filter((a) => a !== null);
113
+ const resources = [
114
+ ...apps.map((a) => ({ kind: "app", identity: a.name, detail: a.description })),
115
+ ...projects.map((p) => ({ kind: "project", identity: p.name })),
116
+ ...databases.map((d) => ({ kind: "database", identity: d.name, detail: d.type })),
117
+ ];
118
+ if (resources.length === 0) {
119
+ return {
120
+ provider: { key, label, status: "empty", detail: cfg.url, resources },
121
+ rawCoolifyApps: hydrated,
122
+ };
123
+ }
124
+ const summary = pluralized([
125
+ [apps.length, "app", "apps"],
126
+ [projects.length, "project", "projects"],
127
+ [databases.length, "database", "databases"],
128
+ ]);
129
+ return {
130
+ provider: {
131
+ key,
132
+ label,
133
+ status: "present",
134
+ summary: `${summary} ${chalk.dim(`@ ${stripScheme(cfg.url)}`)}`,
135
+ preview: apps.map((a) => a.name).slice(0, 6),
136
+ resources,
137
+ },
138
+ rawCoolifyApps: hydrated,
139
+ };
140
+ }
141
+ catch (err) {
142
+ return {
143
+ provider: {
144
+ key,
145
+ label,
146
+ status: "error",
147
+ detail: `Coolify request failed: ${err.message.split("\n")[0]}`,
148
+ },
149
+ };
150
+ }
151
+ }
152
+ async function probeDns() {
153
+ const key = "dns:cloudflare";
154
+ const label = "Cloudflare DNS";
155
+ const cfg = await getDnsConfig();
156
+ if (!cfg)
157
+ return { provider: { key, label, status: "skip", detail: "not configured" } };
158
+ if (cfg.provider !== "cloudflare") {
159
+ return {
160
+ provider: {
161
+ key: `dns:${cfg.provider}`,
162
+ label: `${cfg.provider} DNS`,
163
+ status: "skip",
164
+ detail: "no list API exposed (Cloudflare only for now)",
165
+ },
166
+ };
167
+ }
168
+ if (!cfg.apiToken) {
169
+ return {
170
+ provider: {
171
+ key,
172
+ label,
173
+ status: "error",
174
+ detail: "Cloudflare API token missing from keychain",
175
+ },
176
+ };
177
+ }
178
+ const cf = new CloudflareApi({ token: cfg.apiToken });
179
+ try {
180
+ const zones = await cf.listZones();
181
+ if (zones.length === 0) {
182
+ return { provider: { key, label, status: "empty", resources: [] }, rawZones: zones };
183
+ }
184
+ return {
185
+ provider: {
186
+ key,
187
+ label,
188
+ status: "present",
189
+ summary: `${zones.length} zone${zones.length === 1 ? "" : "s"}`,
190
+ preview: zones.map((z) => z.name).slice(0, 6),
191
+ resources: zones.map((z) => ({ kind: "zone", identity: z.name })),
192
+ },
193
+ rawZones: zones,
194
+ };
195
+ }
196
+ catch (err) {
197
+ return {
198
+ provider: {
199
+ key,
200
+ label,
201
+ status: "error",
202
+ detail: `Cloudflare zones list failed: ${err.message.split("\n")[0]}`,
203
+ },
204
+ };
205
+ }
206
+ }
207
+ async function probeR2() {
208
+ const key = "s3:r2";
209
+ const label = "R2";
210
+ const cfg = await getS3Config("r2");
211
+ if (!cfg)
212
+ return { provider: { key, label, status: "skip", detail: "not configured" } };
213
+ const adminToken = await getSecret(SECRET_KEYS.r2AdminToken);
214
+ if (!adminToken) {
215
+ return {
216
+ provider: {
217
+ key,
218
+ label,
219
+ status: "error",
220
+ detail: "R2 admin token not in keychain; can't list buckets",
221
+ },
222
+ };
223
+ }
224
+ const accountId = cfg.endpoint?.match(/https?:\/\/([0-9a-f]{32})\.r2\.cloudflarestorage\.com/i)?.[1];
225
+ if (!accountId) {
226
+ return {
227
+ provider: {
228
+ key,
229
+ label,
230
+ status: "error",
231
+ detail: `endpoint ${cfg.endpoint} isn't an R2 endpoint`,
232
+ },
233
+ };
234
+ }
235
+ const cf = new CloudflareApi({ token: adminToken });
236
+ try {
237
+ const buckets = await cf.listR2Buckets(accountId);
238
+ if (buckets.length === 0) {
239
+ return {
240
+ provider: {
241
+ key,
242
+ label,
243
+ status: "empty",
244
+ detail: `account ${accountId.slice(0, 6)}…`,
245
+ resources: [],
246
+ },
247
+ rawR2Buckets: buckets,
248
+ };
249
+ }
250
+ return {
251
+ provider: {
252
+ key,
253
+ label,
254
+ status: "present",
255
+ summary: `${buckets.length} bucket${buckets.length === 1 ? "" : "s"} ${chalk.dim(`(account ${accountId.slice(0, 6)}…)`)}`,
256
+ preview: buckets.map((b) => b.name).slice(0, 6),
257
+ resources: buckets.map((b) => ({
258
+ kind: "bucket",
259
+ identity: b.name,
260
+ detail: b.storage_class,
261
+ })),
262
+ },
263
+ rawR2Buckets: buckets,
264
+ };
265
+ }
266
+ catch (err) {
267
+ return {
268
+ provider: {
269
+ key,
270
+ label,
271
+ status: "error",
272
+ detail: `R2 list failed: ${err.message.split("\n")[0]}`,
273
+ },
274
+ };
275
+ }
276
+ }
277
+ async function probeS3Other(provider) {
278
+ const key = `s3:${provider}`;
279
+ const label = provider === "hetzner" ? "Hetzner S3" : "AWS S3";
280
+ const cfg = await getS3Config(provider);
281
+ if (!cfg)
282
+ return { provider: { key, label, status: "skip", detail: "not configured" } };
283
+ try {
284
+ const buckets = await listS3Buckets(cfg);
285
+ if (buckets.length === 0) {
286
+ return {
287
+ provider: {
288
+ key,
289
+ label,
290
+ status: "empty",
291
+ detail: `endpoint ${cfg.endpoint}`,
292
+ resources: [],
293
+ },
294
+ };
295
+ }
296
+ return {
297
+ provider: {
298
+ key,
299
+ label,
300
+ status: "present",
301
+ summary: `${buckets.length} bucket${buckets.length === 1 ? "" : "s"} ${chalk.dim(`@ ${cfg.endpoint}`)}`,
302
+ preview: buckets.map((b) => b.name).slice(0, 6),
303
+ resources: buckets.map((b) => ({
304
+ kind: "bucket",
305
+ identity: b.name,
306
+ detail: b.creationDate ? b.creationDate.toISOString().slice(0, 10) : undefined,
307
+ })),
308
+ },
309
+ };
310
+ }
311
+ catch (err) {
312
+ return {
313
+ provider: {
314
+ key,
315
+ label,
316
+ status: "error",
317
+ detail: `ListBuckets failed: ${err.message.split("\n")[0]}`,
318
+ },
319
+ };
320
+ }
321
+ }
322
+ async function probeResend() {
323
+ const key = "resend";
324
+ const label = "Resend";
325
+ const cfg = await getResendConfig();
326
+ if (!cfg)
327
+ return { provider: { key, label, status: "skip", detail: "not configured" } };
328
+ try {
329
+ const res = await fetch("https://api.resend.com/domains", {
330
+ headers: { Authorization: `Bearer ${cfg.apiKey}` },
331
+ });
332
+ if (!res.ok)
333
+ throw new Error(`HTTP ${res.status}`);
334
+ const body = (await res.json());
335
+ const domains = (body.data ?? []).filter((d) => typeof d.name === "string");
336
+ if (domains.length === 0) {
337
+ return { provider: { key, label, status: "empty", resources: [] }, rawResendDomains: [] };
338
+ }
339
+ return {
340
+ provider: {
341
+ key,
342
+ label,
343
+ status: "present",
344
+ summary: `${domains.length} domain${domains.length === 1 ? "" : "s"}`,
345
+ preview: domains.map((d) => `${d.name}${d.status ? ` (${d.status})` : ""}`).slice(0, 6),
346
+ resources: domains.map((d) => ({
347
+ kind: "verified-domain",
348
+ identity: d.name,
349
+ detail: d.status,
350
+ })),
351
+ },
352
+ rawResendDomains: domains,
353
+ };
354
+ }
355
+ catch (err) {
356
+ return {
357
+ provider: {
358
+ key,
359
+ label,
360
+ status: "error",
361
+ detail: `Resend list failed: ${err.message.split("\n")[0]}`,
362
+ },
363
+ };
364
+ }
365
+ }
366
+ async function probeGlitchtip() {
367
+ const key = "glitchtip";
368
+ const label = "GlitchTip";
369
+ const cfg = await getGlitchtipConfig();
370
+ if (!cfg)
371
+ return { provider: { key, label, status: "skip", detail: "not configured" } };
372
+ try {
373
+ const res = await fetch(`${cfg.url.replace(/\/$/, "")}/api/0/organizations/${cfg.organizationSlug}/projects/`, { headers: { Authorization: `Bearer ${cfg.token}` } });
374
+ if (!res.ok)
375
+ throw new Error(`HTTP ${res.status}`);
376
+ const body = (await res.json());
377
+ const projects = body.filter((p) => typeof p.name === "string");
378
+ if (projects.length === 0) {
379
+ return {
380
+ provider: { key, label, status: "empty", resources: [] },
381
+ rawGlitchtipProjects: [],
382
+ };
383
+ }
384
+ return {
385
+ provider: {
386
+ key,
387
+ label,
388
+ status: "present",
389
+ summary: `${projects.length} project${projects.length === 1 ? "" : "s"} ${chalk.dim(`(org ${cfg.organizationSlug})`)}`,
390
+ preview: projects.map((p) => p.slug ?? p.name).slice(0, 6),
391
+ resources: projects.map((p) => ({
392
+ kind: "project",
393
+ identity: p.slug ?? p.name,
394
+ detail: p.platform,
395
+ })),
396
+ },
397
+ rawGlitchtipProjects: projects,
398
+ };
399
+ }
400
+ catch (err) {
401
+ return {
402
+ provider: {
403
+ key,
404
+ label,
405
+ status: "error",
406
+ detail: `GlitchTip list failed: ${err.message.split("\n")[0]}`,
407
+ },
408
+ };
409
+ }
410
+ }
411
+ async function probeOpenpanel() {
412
+ const key = "openpanel";
413
+ const label = "OpenPanel";
414
+ const cfg = await getOpenpanelConfig();
415
+ if (!cfg)
416
+ return { provider: { key, label, status: "skip", detail: "not configured" } };
417
+ try {
418
+ const base = (cfg.apiUrl ?? cfg.url).replace(/\/$/, "");
419
+ const res = await fetch(`${base}/manage/projects`, {
420
+ headers: {
421
+ "openpanel-client-id": cfg.rootClientId,
422
+ "openpanel-client-secret": cfg.rootClientSecret,
423
+ },
424
+ });
425
+ if (!res.ok)
426
+ throw new Error(`HTTP ${res.status}`);
427
+ const raw = (await res.json());
428
+ // Same shape duality as scanOpenpanel — bare array or `{ data }`.
429
+ const projects = Array.isArray(raw)
430
+ ? raw
431
+ : (raw.data ?? []);
432
+ if (projects.length === 0) {
433
+ return {
434
+ provider: { key, label, status: "empty", resources: [] },
435
+ rawOpenpanelProjects: [],
436
+ };
437
+ }
438
+ return {
439
+ provider: {
440
+ key,
441
+ label,
442
+ status: "present",
443
+ summary: `${projects.length} project${projects.length === 1 ? "" : "s"}`,
444
+ preview: projects.map((p) => p.name ?? p.id ?? "?").slice(0, 6),
445
+ resources: projects.map((p) => ({
446
+ kind: "project",
447
+ identity: p.name ?? p.id ?? "?",
448
+ })),
449
+ },
450
+ rawOpenpanelProjects: projects,
451
+ };
452
+ }
453
+ catch (err) {
454
+ return {
455
+ provider: {
456
+ key,
457
+ label,
458
+ status: "error",
459
+ detail: `OpenPanel list failed: ${err.message.split("\n")[0]}`,
460
+ },
461
+ };
462
+ }
463
+ }
464
+ async function probeStripe() {
465
+ const key = "stripe";
466
+ const label = "Stripe";
467
+ const cfg = await getStripeConfig();
468
+ if (!cfg)
469
+ return { provider: { key, label, status: "skip", detail: "not configured" } };
470
+ const modes = [];
471
+ if (cfg.testSecretKey)
472
+ modes.push({ mode: "test", key: cfg.testSecretKey });
473
+ if (cfg.liveSecretKey)
474
+ modes.push({ mode: "live", key: cfg.liveSecretKey });
475
+ if (modes.length === 0) {
476
+ return { provider: { key, label, status: "skip", detail: "no master keys stored" } };
477
+ }
478
+ const resources = [];
479
+ for (const m of modes) {
480
+ try {
481
+ const res = await fetch("https://api.stripe.com/v1/webhook_endpoints?limit=100", {
482
+ headers: { Authorization: `Bearer ${m.key}` },
483
+ });
484
+ if (!res.ok)
485
+ throw new Error(`HTTP ${res.status}`);
486
+ const body = (await res.json());
487
+ for (const w of body.data ?? []) {
488
+ if (!w.id || !w.url)
489
+ continue;
490
+ resources.push({
491
+ kind: `webhook (${m.mode})`,
492
+ identity: w.url,
493
+ detail: `${w.id} · ${w.status ?? "?"}`,
494
+ });
495
+ }
496
+ }
497
+ catch (err) {
498
+ return {
499
+ provider: {
500
+ key,
501
+ label,
502
+ status: "error",
503
+ detail: `Stripe ${m.mode}-mode list failed: ${err.message.split("\n")[0]}`,
504
+ },
505
+ };
506
+ }
507
+ }
508
+ if (resources.length === 0) {
509
+ return { provider: { key, label, status: "empty", resources } };
510
+ }
511
+ const byMode = new Map();
512
+ for (const r of resources)
513
+ byMode.set(r.kind, (byMode.get(r.kind) ?? 0) + 1);
514
+ const summary = `${resources.length} webhook${resources.length === 1 ? "" : "s"} (${Array.from(byMode.entries())
515
+ .map(([k, c]) => `${c} ${k.replace("webhook ", "")}`)
516
+ .join(", ")})`;
517
+ return {
518
+ provider: {
519
+ key,
520
+ label,
521
+ status: "present",
522
+ summary,
523
+ preview: resources.slice(0, 6).map((r) => r.identity),
524
+ resources,
525
+ },
526
+ };
527
+ }
528
+ // ---------------------------------------------------------------------------
529
+ // Helpers + renderer
530
+ // ---------------------------------------------------------------------------
531
+ // ---------------------------------------------------------------------------
532
+ // Cross-reference engine
533
+ // ---------------------------------------------------------------------------
534
+ //
535
+ // Runs after every probe completes. Reads each probe's raw data and
536
+ // looks for inconsistencies that a single-provider lens can't see:
537
+ //
538
+ // · `coolify-repo-gone` — Coolify app deploys from a repo
539
+ // that `gh repo view` can't find
540
+ // · `coolify-fqdn-no-zone` — app fqdn references an apex with
541
+ // no Cloudflare zone configured
542
+ // · `orphan-r2-bucket` — bucket name doesn't prefix-match
543
+ // any Coolify app or project
544
+ // · `orphan-glitchtip-project` — GlitchTip project name has no
545
+ // Coolify app counterpart
546
+ // · `orphan-openpanel-project` — same for OpenPanel
547
+ // · `unused-cloudflare-zone` — zone with no Coolify app fqdn
548
+ // pointing into it
549
+ //
550
+ // Every check is best-effort. Missing prerequisite data (e.g. no
551
+ // hydrated Coolify apps because Coolify isn't configured) just skips
552
+ // the cross-references that need it; nothing here can fail loudly.
553
+ /** Canonical metadata for every cross-reference category. Single
554
+ * source of truth — `runCrossReferences` consults this when emitting
555
+ * findings, and the renderer prints each kind's `description` once. */
556
+ const CROSS_REFERENCE_KINDS = [
557
+ {
558
+ kind: "coolify-repo-gone",
559
+ severity: "warn",
560
+ headline: "Coolify apps deploying from a missing GitHub repo",
561
+ description: [
562
+ "The Coolify app's git source points at a repo that `gh` can't find.",
563
+ "Either the repo was deleted/renamed, or it's private and `gh` lacks access.",
564
+ "Update the source in Coolify, or destroy the app if it's dead.",
565
+ ],
566
+ },
567
+ {
568
+ kind: "coolify-fqdn-no-zone",
569
+ severity: "warn",
570
+ headline: "Coolify app fqdn with no Cloudflare zone",
571
+ description: [
572
+ "The fqdn's apex isn't in your Cloudflare account, so DNS isn't managed by hatchkit.",
573
+ "Either add the zone to Cloudflare, or move DNS for this app to wherever the apex lives.",
574
+ ],
575
+ },
576
+ {
577
+ kind: "orphan-r2-bucket",
578
+ severity: "info",
579
+ headline: "R2 buckets with no matching Coolify app",
580
+ description: [
581
+ "Bucket follows the `<project>-<role>` naming convention but no app of that name exists.",
582
+ "Could be an orphan from a destroyed project, or a hand-created bucket — review + delete if dead.",
583
+ ],
584
+ },
585
+ {
586
+ kind: "orphan-glitchtip-project",
587
+ severity: "info",
588
+ headline: "GlitchTip projects with no matching Coolify app",
589
+ description: [
590
+ "Could be orphans from destroyed apps, or projects for something hatchkit doesn't manage.",
591
+ ],
592
+ },
593
+ {
594
+ kind: "orphan-openpanel-project",
595
+ severity: "info",
596
+ headline: "OpenPanel projects with no matching Coolify app",
597
+ description: [
598
+ "Could be orphans from destroyed apps, or projects for something hatchkit doesn't manage.",
599
+ ],
600
+ },
601
+ {
602
+ kind: "unused-cloudflare-zone",
603
+ severity: "info",
604
+ headline: "Cloudflare zones with no Coolify app pointing into them",
605
+ description: [
606
+ "Could host a static site (gh-pages, Vercel) or be reserved — hatchkit can only check Coolify.",
607
+ ],
608
+ },
609
+ ];
610
+ async function runCrossReferences(probes) {
611
+ const out = [];
612
+ const coolifyApps = probes.flatMap((p) => p.rawCoolifyApps ?? []);
613
+ const zones = probes.flatMap((p) => p.rawZones ?? []);
614
+ const r2Buckets = probes.flatMap((p) => p.rawR2Buckets ?? []);
615
+ const glitchtipProjects = probes.flatMap((p) => p.rawGlitchtipProjects ?? []);
616
+ const openpanelProjects = probes.flatMap((p) => p.rawOpenpanelProjects ?? []);
617
+ const appNames = new Set(coolifyApps.map((a) => a.name));
618
+ // Strip a single trailing `-<role>` segment so "foo-server" + "foo-client"
619
+ // both alias to "foo" — that's what the inventory scans match against
620
+ // and what most R2/obs project naming conventions use.
621
+ const projectStems = new Set(Array.from(appNames).map((n) => n.replace(/-(server|client|web|api|backend|frontend)$/, "")));
622
+ // CR1: Coolify app deploys from a gone GitHub repo. Run `gh repo view`
623
+ // per unique repo (dedupe across apps that share one). Skipped when
624
+ // `gh` isn't authenticated.
625
+ const ghAvailable = (await execOk("gh", ["auth", "status"])) && (await execOk("gh", ["--version"]));
626
+ if (ghAvailable) {
627
+ const repoSlugs = new Map(); // slug → [appNames]
628
+ for (const app of coolifyApps) {
629
+ if (!app.gitRepository)
630
+ continue;
631
+ const slug = repoSlugFromUrl(app.gitRepository);
632
+ if (!slug)
633
+ continue;
634
+ const existing = repoSlugs.get(slug) ?? [];
635
+ existing.push(app.name);
636
+ repoSlugs.set(slug, existing);
637
+ }
638
+ const checks = await Promise.all(Array.from(repoSlugs.entries()).map(async ([slug, apps]) => ({
639
+ slug,
640
+ apps,
641
+ exists: await execOk("gh", ["repo", "view", slug, "--json", "name"]),
642
+ })));
643
+ for (const c of checks) {
644
+ if (c.exists)
645
+ continue;
646
+ out.push({
647
+ kind: "coolify-repo-gone",
648
+ severity: "warn",
649
+ subject: c.slug,
650
+ context: `via ${c.apps.join(", ")}`,
651
+ });
652
+ }
653
+ }
654
+ // CR2: Coolify app fqdn references an apex with no Cloudflare zone.
655
+ // Only meaningful when both Coolify hydration AND zones list ran.
656
+ if (coolifyApps.length > 0 && zones.length > 0) {
657
+ const zoneNames = new Set(zones.map((z) => z.name.toLowerCase()));
658
+ for (const app of coolifyApps) {
659
+ const fqdns = collectFqdns(app);
660
+ for (const f of fqdns) {
661
+ const apex = apexOf(f);
662
+ if (!apex)
663
+ continue;
664
+ if (zoneNames.has(apex.toLowerCase()))
665
+ continue;
666
+ // Skip Coolify's auto-assigned sslip.io hosts — they don't need a zone.
667
+ if (/\.sslip\.io$/i.test(f))
668
+ continue;
669
+ out.push({
670
+ kind: "coolify-fqdn-no-zone",
671
+ severity: "warn",
672
+ subject: f,
673
+ context: `app ${app.name} (apex ${apex})`,
674
+ });
675
+ }
676
+ }
677
+ }
678
+ // CR3: Orphan R2 bucket — name doesn't prefix-match any Coolify app or stem.
679
+ if (r2Buckets.length > 0 && appNames.size > 0) {
680
+ for (const b of r2Buckets) {
681
+ const prefix = b.name.replace(/-(assets|state|public|private|backups?)$/, "");
682
+ if (appNames.has(prefix) || projectStems.has(prefix))
683
+ continue;
684
+ // Skip buckets that don't follow the `<project>-<role>` convention
685
+ // — they may be hand-created and unrelated to hatchkit projects.
686
+ if (prefix === b.name)
687
+ continue;
688
+ out.push({
689
+ kind: "orphan-r2-bucket",
690
+ severity: "info",
691
+ subject: b.name,
692
+ context: `looked for app "${prefix}"`,
693
+ });
694
+ }
695
+ }
696
+ // CR4 + CR5: orphan obs projects (GlitchTip + OpenPanel). Match by
697
+ // name OR slug against Coolify app names + stems.
698
+ for (const p of glitchtipProjects) {
699
+ const label = p.slug ?? p.name;
700
+ if (!label)
701
+ continue;
702
+ const stem = label.replace(/-(server|client|web|api|prod|dev|staging)$/, "");
703
+ if (appNames.has(label) || appNames.has(stem) || projectStems.has(stem))
704
+ continue;
705
+ out.push({
706
+ kind: "orphan-glitchtip-project",
707
+ severity: "info",
708
+ subject: label,
709
+ });
710
+ }
711
+ for (const p of openpanelProjects) {
712
+ const label = p.name ?? p.id;
713
+ if (!label)
714
+ continue;
715
+ const stem = label.replace(/-(server|client|web|api|prod|dev|staging)$/, "");
716
+ if (appNames.has(label) || appNames.has(stem) || projectStems.has(stem))
717
+ continue;
718
+ out.push({
719
+ kind: "orphan-openpanel-project",
720
+ severity: "info",
721
+ subject: label,
722
+ });
723
+ }
724
+ // CR6: Cloudflare zone with no consumer — no Coolify app fqdn ends
725
+ // inside it. Info-only because the zone might be for a static site
726
+ // (gh-pages, Vercel, etc.) that hatchkit doesn't track.
727
+ if (zones.length > 0 && coolifyApps.length > 0) {
728
+ const consumed = new Set();
729
+ for (const app of coolifyApps) {
730
+ for (const f of collectFqdns(app)) {
731
+ const apex = apexOf(f);
732
+ if (apex)
733
+ consumed.add(apex.toLowerCase());
734
+ }
735
+ }
736
+ for (const z of zones) {
737
+ if (consumed.has(z.name.toLowerCase()))
738
+ continue;
739
+ out.push({
740
+ kind: "unused-cloudflare-zone",
741
+ severity: "info",
742
+ subject: z.name,
743
+ });
744
+ }
745
+ }
746
+ return out;
747
+ }
748
+ function collectFqdns(app) {
749
+ const fqdns = [];
750
+ if (app.fqdn) {
751
+ for (const part of app.fqdn.split(",")) {
752
+ const trimmed = part
753
+ .trim()
754
+ .replace(/^https?:\/\//, "")
755
+ .replace(/\/.*$/, "");
756
+ if (trimmed)
757
+ fqdns.push(trimmed);
758
+ }
759
+ }
760
+ if (app.dockerComposeDomains) {
761
+ for (const d of app.dockerComposeDomains) {
762
+ const stripped = d.domain.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
763
+ if (stripped)
764
+ fqdns.push(stripped);
765
+ }
766
+ }
767
+ return Array.from(new Set(fqdns));
768
+ }
769
+ function apexOf(domain) {
770
+ const parts = domain.replace(/\.$/, "").split(".");
771
+ if (parts.length < 2)
772
+ return undefined;
773
+ return parts.slice(-2).join(".");
774
+ }
775
+ function repoSlugFromUrl(url) {
776
+ const ssh = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/);
777
+ if (ssh)
778
+ return `${ssh[1]}/${ssh[2]}`;
779
+ const https = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/.*)?$/);
780
+ if (https)
781
+ return `${https[1]}/${https[2]}`;
782
+ // Some Coolify configs store just `owner/repo` (shorthand for the
783
+ // GitHub App clone path). Accept that form too.
784
+ if (/^[^/\s]+\/[^/\s]+$/.test(url))
785
+ return url;
786
+ return undefined;
787
+ }
788
+ function pluralized(parts) {
789
+ return parts
790
+ .filter(([n]) => n > 0)
791
+ .map(([n, s, p]) => `${n} ${n === 1 ? s : p}`)
792
+ .join(", ");
793
+ }
794
+ function stripScheme(url) {
795
+ return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
796
+ }
797
+ export function renderOverviewHuman(report, opts = { all: false }) {
798
+ const lines = [];
799
+ lines.push(chalk.bold(" hatchkit overview"));
800
+ lines.push(chalk.dim(` v${report.cliVersion} · read-only fleet survey`));
801
+ lines.push("");
802
+ for (const p of report.providers) {
803
+ if (p.status === "skip") {
804
+ lines.push(` ${chalk.dim("·")} ${chalk.bold(p.label)} ${chalk.dim(p.detail ?? "")}`);
805
+ continue;
806
+ }
807
+ if (p.status === "error") {
808
+ lines.push(` ${chalk.red("✗")} ${chalk.bold(p.label)} ${chalk.red(p.detail ?? "")}`);
809
+ continue;
810
+ }
811
+ if (p.status === "empty") {
812
+ lines.push(` ${chalk.dim("◯")} ${chalk.bold(p.label)} ${chalk.dim(p.detail ?? "no resources")}`);
813
+ continue;
814
+ }
815
+ // present
816
+ lines.push(` ${chalk.green("✓")} ${chalk.bold(p.label)} ${chalk.dim(p.summary ?? "")}`);
817
+ const items = opts.all
818
+ ? (p.resources ?? []).map((r) => (r.detail ? `${r.identity} (${r.detail})` : r.identity))
819
+ : (p.preview ?? []);
820
+ const truncated = !opts.all && p.resources && p.resources.length > (p.preview?.length ?? 0)
821
+ ? ` ${chalk.dim(`(+${p.resources.length - (p.preview?.length ?? 0)} more — pass --all)`)}`
822
+ : "";
823
+ if (items.length > 0) {
824
+ lines.push(chalk.dim(` ${items.join(", ")}${truncated}`));
825
+ }
826
+ }
827
+ // Cross-references block — grouped by `kind` so the explanation is
828
+ // printed once and the affected subjects list under it. Warns first,
829
+ // then info-level observations.
830
+ if (report.crossReferences.length > 0) {
831
+ lines.push("");
832
+ lines.push(chalk.bold(" Cross-references"));
833
+ const byKind = new Map();
834
+ for (const c of report.crossReferences) {
835
+ const existing = byKind.get(c.kind);
836
+ if (existing)
837
+ existing.push(c);
838
+ else
839
+ byKind.set(c.kind, [c]);
840
+ }
841
+ // Order: warns first, then infos. Within each tier, follow the
842
+ // CROSS_REFERENCE_KINDS declaration order so output is stable.
843
+ const ordered = [...report.crossReferenceKinds]
844
+ .filter((k) => byKind.has(k.kind))
845
+ .sort((a, b) => {
846
+ if (a.severity === b.severity)
847
+ return 0;
848
+ return a.severity === "warn" ? -1 : 1;
849
+ });
850
+ for (const k of ordered) {
851
+ const findings = byKind.get(k.kind) ?? [];
852
+ const icon = k.severity === "warn" ? chalk.yellow("⚠") : chalk.dim("·");
853
+ lines.push("");
854
+ lines.push(` ${icon} ${chalk.bold(k.headline)} ${chalk.dim(`(${findings.length})`)}`);
855
+ for (const line of k.description) {
856
+ lines.push(chalk.dim(` ${line}`));
857
+ }
858
+ for (const f of findings) {
859
+ const ctx = f.context ? chalk.dim(` — ${f.context}`) : "";
860
+ lines.push(` · ${f.subject}${ctx}`);
861
+ }
862
+ }
863
+ }
864
+ lines.push("");
865
+ const s = report.summary;
866
+ const parts = [
867
+ `${chalk.green(`${s.configured} configured`)}`,
868
+ s.empty ? chalk.dim(`${s.empty} empty`) : null,
869
+ s.errored ? chalk.red(`${s.errored} error${s.errored === 1 ? "" : "s"}`) : null,
870
+ chalk.dim(`${s.skipped} not configured`),
871
+ chalk.dim(`${s.totalResources} total resource${s.totalResources === 1 ? "" : "s"}`),
872
+ s.crossRefWarnings > 0
873
+ ? chalk.yellow(`${s.crossRefWarnings} cross-ref warning${s.crossRefWarnings === 1 ? "" : "s"}`)
874
+ : null,
875
+ ].filter(Boolean);
876
+ lines.push(` ${parts.join(" · ")}`);
877
+ lines.push("");
878
+ return lines.join("\n");
879
+ }
880
+ //# sourceMappingURL=overview.js.map