hatchkit 0.1.40 → 0.1.41

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