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
package/dist/inventory.js CHANGED
@@ -23,16 +23,17 @@
23
23
  *
24
24
  * Everything is read-only. No mutations. Safe to run anywhere.
25
25
  */
26
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
26
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
27
27
  import { join, resolve } from "node:path";
28
28
  import { confirm, input } from "@inquirer/prompts";
29
29
  import chalk from "chalk";
30
30
  import { getCoolifyConfig, getDnsConfig, getGlitchtipConfig, getOpenpanelConfig, getResendConfig, getS3Config, getStripeConfig, } from "./config.js";
31
31
  import { locateEnvKeysFile, locateEnvProductionFile } from "./deploy/keys.js";
32
- import { MANIFEST_FILENAME, readManifest } from "./scaffold/manifest.js";
32
+ import { MANIFEST_FILENAME, MANIFEST_VERSION, readManifest, } from "./scaffold/manifest.js";
33
33
  import { CloudflareApi } from "./utils/cloudflare-api.js";
34
34
  import { CoolifyApi } from "./utils/coolify-api.js";
35
35
  import { exec, execOk } from "./utils/exec.js";
36
+ import { listS3Buckets } from "./utils/s3-admin.js";
36
37
  import { SECRET_KEYS, getSecret } from "./utils/secrets.js";
37
38
  import { getCliVersion } from "./utils/version.js";
38
39
  export async function runInventory(cwd, opts = {}) {
@@ -42,10 +43,40 @@ export async function runInventory(cwd, opts = {}) {
42
43
  autoAccept: opts.yes ?? false,
43
44
  });
44
45
  if (opts.json) {
45
- console.log(JSON.stringify(report, null, 2));
46
+ // Sets serialize as `{}` by default — surface them as arrays so
47
+ // JSON consumers can actually read the detected signals.
48
+ console.log(JSON.stringify(report, (_k, v) => (v instanceof Set ? Array.from(v).sort() : v), 2));
46
49
  return;
47
50
  }
48
51
  console.log(renderInventoryHuman(report));
52
+ // Persist inferred identity as a minimal `.hatchkit.json` unless
53
+ // suppressed. Skip when there's already a manifest (adopt owns it
54
+ // — we don't overwrite), when the inputs aren't sufficient (name +
55
+ // domain are required by the schema), or when `--no-save` was passed.
56
+ if (opts.noSave)
57
+ return;
58
+ if (report.local.manifestPresent)
59
+ return;
60
+ if (!report.inferred.name || !report.inferred.domain)
61
+ return;
62
+ const absCwd = resolve(cwd);
63
+ const writeIt = () => {
64
+ writeMinimalManifest(absCwd, report.inferred, report.local);
65
+ console.log(chalk.dim(` Wrote minimal ${MANIFEST_FILENAME}. Run \`hatchkit adopt --resume\` to wire features + deploy config.`));
66
+ };
67
+ if (opts.save) {
68
+ writeIt();
69
+ return;
70
+ }
71
+ // Only ask in interactive mode (TTY + not --yes).
72
+ if (!process.stdin.isTTY || opts.yes)
73
+ return;
74
+ const ok = await confirm({
75
+ message: `Save inferred identity as a minimal ${MANIFEST_FILENAME}? (Run \`hatchkit adopt --resume\` afterwards to flesh out features + deploy config.)`,
76
+ default: true,
77
+ });
78
+ if (ok)
79
+ writeIt();
49
80
  }
50
81
  export async function collectInventory(cwd, opts = {}) {
51
82
  const absCwd = resolve(cwd);
@@ -67,19 +98,25 @@ export async function collectInventory(cwd, opts = {}) {
67
98
  if (opts.interactive) {
68
99
  identity = await promptForGaps(local, inferred, sources, !!opts.autoAccept);
69
100
  }
101
+ // Per-provider expectation flags — drives which `missing` findings
102
+ // surface as red ✗ vs dim ·. A library with no Coolify-deploy signals
103
+ // gets dim · for "no Coolify app named foo" (expected absence), but a
104
+ // project whose .env.development declares STRIPE_* gets red ✗ if no
105
+ // matching webhook exists (genuine missing).
106
+ const expectations = computeExpectations(local, identity);
70
107
  // Provider scans — every one is best-effort and returns its own
71
108
  // findings + skip reason. Running them in parallel keeps wall-time
72
109
  // close to the slowest single round-trip.
73
110
  const scanResults = await Promise.all([
74
- scanCoolify(identity),
75
- scanDns(identity),
76
- scanR2(identity, local.manifest),
111
+ scanCoolify(identity, expectations.coolify),
112
+ scanDns(identity, expectations.dns),
113
+ scanR2(identity, local.manifest, expectations.r2),
77
114
  scanS3Other(identity),
78
- scanGitHub(identity),
79
- scanResend(identity),
80
- scanGlitchtip(identity),
81
- scanOpenpanel(identity),
82
- scanStripe(identity),
115
+ scanGitHub(identity, { github: expectations.github, githubPages: expectations.githubPages }),
116
+ scanResend(identity, expectations.resend),
117
+ scanGlitchtip(identity, expectations.glitchtip),
118
+ scanOpenpanel(identity, expectations.openpanel),
119
+ scanStripe(identity, expectations.stripe),
83
120
  ]);
84
121
  const findings = [];
85
122
  const skipped = [];
@@ -94,7 +131,8 @@ export async function collectInventory(cwd, opts = {}) {
94
131
  findings.push(...driftFindings);
95
132
  const drifts = findings.filter((f) => f.status === "drift");
96
133
  const present = findings.filter((f) => f.status === "present").length;
97
- const missing = findings.filter((f) => f.status === "missing").length;
134
+ const missingAll = findings.filter((f) => f.status === "missing");
135
+ const expectedMissing = missingAll.filter((f) => f.expected).length;
98
136
  return {
99
137
  cliVersion: getCliVersion(),
100
138
  cwd: absCwd,
@@ -104,7 +142,13 @@ export async function collectInventory(cwd, opts = {}) {
104
142
  findings,
105
143
  drifts,
106
144
  skipped,
107
- summary: { present, drift: drifts.length, missing, skipped: skipped.length },
145
+ summary: {
146
+ present,
147
+ drift: drifts.length,
148
+ missing: missingAll.length,
149
+ expectedMissing,
150
+ skipped: skipped.length,
151
+ },
108
152
  };
109
153
  }
110
154
  // ---------------------------------------------------------------------------
@@ -250,6 +294,13 @@ function inferLocal(cwd) {
250
294
  }
251
295
  }
252
296
  const envKeysPresent = !!locateEnvKeysFile(cwd);
297
+ // Provider expectation signals — read plaintext .env.* files and
298
+ // every package.json under the project for hints about which
299
+ // providers this project actually depends on. Used to mark `missing`
300
+ // findings as `expected: true` so the renderer only shows red ✗ when
301
+ // local state declares the resource should exist.
302
+ const envSignals = collectEnvSignals(cwd, serverDir, clientDir);
303
+ const packageDeps = collectPackageDeps(cwd, serverDir, clientDir);
253
304
  // `.git` lives at the repo root — in a worktree it's a file pointing
254
305
  // at the main repo, in a normal clone it's a directory. Either form
255
306
  // is fine for existsSync. Walk up from cwd so running `hatchkit
@@ -273,7 +324,131 @@ function inferLocal(cwd) {
273
324
  cnameFile,
274
325
  dotenvxEncrypted,
275
326
  envKeysPresent,
327
+ envSignals,
328
+ packageDeps,
329
+ };
330
+ }
331
+ // ---------------------------------------------------------------------------
332
+ // Expectation-signal collectors (used to mark findings as `expected`)
333
+ // ---------------------------------------------------------------------------
334
+ /** Walk plaintext .env.example / .env.development files at the project
335
+ * root and any detected server/client dirs, returning the set of
336
+ * env-var-name prefixes encountered. Encrypted .env.production is
337
+ * intentionally NOT decrypted — we read declarative templates only. */
338
+ function collectEnvSignals(cwd, serverDir, clientDir) {
339
+ const signals = new Set();
340
+ const filenames = [".env.example", ".env.development", ".env"];
341
+ const dirs = [cwd, serverDir, clientDir].filter((d) => !!d);
342
+ // Patterns we recognize — prefix → signal name. Keep this list
343
+ // tight; over-broad matches lead to spurious "expected" flags.
344
+ const patterns = [
345
+ { re: /^\s*RESEND_/m, signal: "RESEND" },
346
+ { re: /^\s*GLITCHTIP_DSN|^\s*PUBLIC_GLITCHTIP_DSN/m, signal: "GLITCHTIP" },
347
+ { re: /^\s*SENTRY_DSN|^\s*PUBLIC_SENTRY_DSN/m, signal: "SENTRY" },
348
+ { re: /^\s*OPENPANEL_|^\s*PUBLIC_OPENPANEL_/m, signal: "OPENPANEL" },
349
+ { re: /^\s*STRIPE_/m, signal: "STRIPE" },
350
+ { re: /^\s*R2_/m, signal: "R2" },
351
+ { re: /^\s*S3_|^\s*AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY|REGION)/m, signal: "S3" },
352
+ ];
353
+ for (const dir of dirs) {
354
+ for (const name of filenames) {
355
+ const p = join(dir, name);
356
+ if (!existsSync(p))
357
+ continue;
358
+ let body;
359
+ try {
360
+ body = readFileSync(p, "utf-8");
361
+ }
362
+ catch {
363
+ continue;
364
+ }
365
+ // Strip commented-out lines — `# RESEND_API_KEY=...` shouldn't
366
+ // count as a signal that the project uses Resend.
367
+ const live = body
368
+ .split("\n")
369
+ .filter((l) => !/^\s*#/.test(l))
370
+ .join("\n");
371
+ for (const { re, signal } of patterns) {
372
+ if (re.test(live))
373
+ signals.add(signal);
374
+ }
375
+ }
376
+ }
377
+ return signals;
378
+ }
379
+ /** Read every package.json under the project (root + server/client
380
+ * dirs) and return the union of declared dependency names (deps +
381
+ * devDeps + peerDeps). Cheap heuristic — we don't follow workspace
382
+ * globs, just probe the conventional monorepo locations. */
383
+ function collectPackageDeps(cwd, serverDir, clientDir) {
384
+ const out = new Set();
385
+ const dirs = [cwd, serverDir, clientDir].filter((d) => !!d);
386
+ for (const dir of dirs) {
387
+ const p = join(dir, "package.json");
388
+ if (!existsSync(p))
389
+ continue;
390
+ try {
391
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
392
+ for (const block of [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies]) {
393
+ if (!block)
394
+ continue;
395
+ for (const name of Object.keys(block))
396
+ out.add(name);
397
+ }
398
+ }
399
+ catch {
400
+ // Unparseable package.json — skip.
401
+ }
402
+ }
403
+ return out;
404
+ }
405
+ // ---------------------------------------------------------------------------
406
+ // Minimal `.hatchkit.json` writer
407
+ // ---------------------------------------------------------------------------
408
+ //
409
+ // When inventory saves inferred identity it writes a minimal manifest
410
+ // that follows the full schema (so every other hatchkit command — adopt,
411
+ // update, sync, keys — can read it) but with conservative defaults for
412
+ // fields inventory can't reliably infer. Run `hatchkit adopt --resume`
413
+ // afterwards to flesh out features, surfaces, ports, etc. — adopt's
414
+ // stepper reads this manifest as its starting state.
415
+ export function writeMinimalManifest(cwd, identity, local) {
416
+ if (!identity.name || !identity.domain) {
417
+ throw new Error("Can't write .hatchkit.json without an inferred name and domain. " +
418
+ "Pass --name / --domain or run inventory interactively to provide them.");
419
+ }
420
+ // Surfaces from local layout — both dirs → "both", just one → that
421
+ // one, neither → fall back to "both" (most common scaffold shape).
422
+ const surfaces = local.serverDir && local.clientDir
423
+ ? "both"
424
+ : local.serverDir
425
+ ? "server-only"
426
+ : local.clientDir
427
+ ? "client-only"
428
+ : "both";
429
+ const manifest = {
430
+ version: MANIFEST_VERSION,
431
+ cliVersion: getCliVersion(),
432
+ scaffoldedAt: new Date().toISOString(),
433
+ name: identity.name,
434
+ domain: identity.domain,
435
+ features: [],
436
+ mlServices: [],
437
+ // "none" is the conservative default — even if local deps signal R2
438
+ // usage, we don't claim ownership of buckets we haven't provisioned.
439
+ // `hatchkit adopt --resume` will prompt for the real value.
440
+ s3Provider: "none",
441
+ deployTarget: "existing",
442
+ surfaces,
443
+ // Conventional defaults — `hatchkit adopt --resume` lets the user
444
+ // override if the project actually uses different ports.
445
+ ports: { server: 3000, client: 5173 },
276
446
  };
447
+ writeManifestFile(cwd, manifest);
448
+ return manifest;
449
+ }
450
+ function writeManifestFile(cwd, manifest) {
451
+ writeFileSync(join(cwd, MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
277
452
  }
278
453
  function findGitRoot(startDir) {
279
454
  let dir = startDir;
@@ -306,7 +481,8 @@ function firstExistingDir(root, rels) {
306
481
  function inferIdentity(local, override) {
307
482
  const sources = {};
308
483
  const out = {};
309
- // name: flag > manifest > package.json > basename(cwd) as last resort
484
+ // name: flag > manifest > identity-file > package.json > basename(cwd)
485
+ // name: flag > manifest > package.json > basename(cwd)
310
486
  if (override.name) {
311
487
  out.name = override.name;
312
488
  sources.name = "flag";
@@ -329,10 +505,9 @@ function inferIdentity(local, override) {
329
505
  sources.name = "cwd-basename";
330
506
  }
331
507
  }
332
- // domain: flag > manifest > CNAME file > package homepage url? (skip;
333
- // too noisy). We deliberately don't derive a domain from the project
334
- // name too speculative, and the matching layer already tries common
335
- // domain patterns against any zone we list.
508
+ // domain: flag > manifest > CNAME file. We deliberately don't derive
509
+ // a domain from the project name too speculative, and the matching
510
+ // layer already tries common domain patterns against any zone we list.
336
511
  if (override.domain) {
337
512
  out.domain = override.domain;
338
513
  sources.domain = "flag";
@@ -345,15 +520,11 @@ function inferIdentity(local, override) {
345
520
  out.domain = local.cnameFile.content;
346
521
  sources.domain = "cname-file";
347
522
  }
348
- // repo: flag > git remote
523
+ // repo: flag (else filled in by caller from git remote)
349
524
  if (override.repo) {
350
525
  out.repo = override.repo;
351
526
  sources.repo = "flag";
352
527
  }
353
- else {
354
- // git remote is resolved async — leave undefined here; the caller
355
- // fills it via resolveGitRemote (run before prompting).
356
- }
357
528
  return { input: out, sources };
358
529
  }
359
530
  async function resolveGitRemote(local) {
@@ -522,7 +693,46 @@ function rel(cwd, abs) {
522
693
  return abs.slice(cwd.length + 1);
523
694
  return abs;
524
695
  }
525
- async function scanCoolify(input) {
696
+ export function computeExpectations(local, identity) {
697
+ const env = local.envSignals;
698
+ const deps = local.packageDeps;
699
+ const hasManifestBuckets = (() => {
700
+ const b = local.manifest?.s3Buckets;
701
+ if (!b)
702
+ return false;
703
+ return Object.values(b).some((v) => v && typeof v === "object");
704
+ })();
705
+ return {
706
+ // Coolify is the project's deploy target whenever there's a manifest
707
+ // (every hatchkit-scaffolded project deploys there) or a deploy
708
+ // workflow committed.
709
+ coolify: !!local.manifest || !!local.deployWorkflowPath,
710
+ // DNS is always relevant when we know a domain — the user wouldn't
711
+ // have a domain unless they intended to route something to it.
712
+ dns: !!identity.domain,
713
+ r2: hasManifestBuckets || env.has("R2") || env.has("S3") || deps.has("@aws-sdk/client-s3"),
714
+ github: !!identity.repo,
715
+ // Pages is expected when a deploy workflow is committed or the
716
+ // repo has a CNAME file at one of the conventional locations.
717
+ githubPages: !!local.ghPagesWorkflowPath || !!local.cnameFile,
718
+ resend: env.has("RESEND") || deps.has("resend"),
719
+ // The Sentry SDK works against GlitchTip (same wire protocol), so
720
+ // either signal counts. Same for an explicit GLITCHTIP_DSN.
721
+ glitchtip: env.has("GLITCHTIP") ||
722
+ env.has("SENTRY") ||
723
+ deps.has("glitchtip") ||
724
+ hasDepMatching(deps, /^@sentry\//),
725
+ openpanel: env.has("OPENPANEL") || hasDepMatching(deps, /^@openpanel\//) || deps.has("openpanel"),
726
+ stripe: env.has("STRIPE") || deps.has("stripe") || deps.has("@stripe/stripe-js"),
727
+ };
728
+ }
729
+ function hasDepMatching(deps, re) {
730
+ for (const d of deps)
731
+ if (re.test(d))
732
+ return true;
733
+ return false;
734
+ }
735
+ async function scanCoolify(input, expected) {
526
736
  const provider = "coolify";
527
737
  const findings = [];
528
738
  const skipped = [];
@@ -602,6 +812,7 @@ async function scanCoolify(input) {
602
812
  kind: "application",
603
813
  identity: input.name,
604
814
  status: "missing",
815
+ expected,
605
816
  detail: `no Coolify project or app named ${wantedNames.join(" / ")} (${apps.length} app(s) total)`,
606
817
  });
607
818
  }
@@ -634,7 +845,7 @@ function collectFqdns(app) {
634
845
  function nameAliases(name) {
635
846
  return [name, `${name}-server`, `${name}-client`, `${name}-web`, `${name}-api`];
636
847
  }
637
- async function scanDns(input) {
848
+ async function scanDns(input, expected) {
638
849
  const provider = "dns";
639
850
  const findings = [];
640
851
  const skipped = [];
@@ -677,6 +888,7 @@ async function scanDns(input) {
677
888
  kind: "zone",
678
889
  identity: apex,
679
890
  status: "missing",
891
+ expected,
680
892
  detail: "no Cloudflare zone for this apex",
681
893
  });
682
894
  return { provider, findings, skipped };
@@ -745,7 +957,7 @@ function relevantRecordProbes(input) {
745
957
  }
746
958
  return out;
747
959
  }
748
- async function scanR2(input, manifest) {
960
+ async function scanR2(input, manifest, expected) {
749
961
  const provider = "s3:r2";
750
962
  const findings = [];
751
963
  const skipped = [];
@@ -839,39 +1051,62 @@ async function scanR2(input, manifest) {
839
1051
  kind: "bucket",
840
1052
  identity: input.name ?? "(candidates)",
841
1053
  status: "missing",
1054
+ expected,
842
1055
  detail: `no R2 bucket matches ${Array.from(candidates).join(" / ")} (account ${accountId.slice(0, 6)}…)`,
843
1056
  });
844
1057
  }
845
1058
  return { provider, findings, skipped, raw: { accountId, live, manifestBuckets } };
846
1059
  }
847
- async function scanS3Other(_input) {
848
- // Hetzner Object Storage + AWS S3 don't have a "list all buckets for
849
- // this access key" call exposed in the existing client. We surface
850
- // presence only, so the user knows where else to look. A full impl
851
- // would need an `@aws-sdk/client-s3` `ListBuckets` call, which is
852
- // already a dep but adding that here doubles the surface area;
853
- // ship without for now and revisit if anyone asks.
1060
+ async function scanS3Other(input) {
1061
+ // Account-wide `ListBuckets` over Hetzner Object Storage + AWS S3 via
1062
+ // the AWS SDK (already a dep assets/mirror.ts uses it for the
1063
+ // streaming copy path). Emits one `present` finding per live bucket
1064
+ // plus a credentials-level info line so the user can spot a misrouted
1065
+ // endpoint without scrolling to drift.
854
1066
  const provider = "s3";
855
1067
  const findings = [];
856
1068
  const skipped = [];
1069
+ const nameMatch = input.name?.toLowerCase();
857
1070
  for (const p of ["hetzner", "aws"]) {
858
1071
  const cfg = await getS3Config(p);
859
- if (cfg) {
1072
+ if (!cfg) {
1073
+ skipped.push({ provider: `s3:${p}`, reason: "not configured" });
1074
+ continue;
1075
+ }
1076
+ try {
1077
+ const buckets = await listS3Buckets(cfg);
860
1078
  findings.push({
861
1079
  provider: `s3:${p}`,
862
1080
  kind: "credentials",
863
1081
  identity: p,
864
1082
  status: "info",
865
- detail: `endpoint: ${cfg.endpoint} bucket inventory not implemented for ${p}`,
1083
+ detail: `endpoint: ${cfg.endpoint} · ${buckets.length} bucket${buckets.length === 1 ? "" : "s"}`,
866
1084
  });
1085
+ for (const b of buckets) {
1086
+ const matchesProject = nameMatch !== undefined &&
1087
+ (b.name.toLowerCase() === nameMatch || b.name.toLowerCase().startsWith(`${nameMatch}-`));
1088
+ findings.push({
1089
+ provider: `s3:${p}`,
1090
+ kind: "bucket",
1091
+ identity: b.name,
1092
+ status: "present",
1093
+ detail: b.creationDate
1094
+ ? `created ${b.creationDate.toISOString().slice(0, 10)}`
1095
+ : undefined,
1096
+ expected: matchesProject || undefined,
1097
+ });
1098
+ }
867
1099
  }
868
- else {
869
- skipped.push({ provider: `s3:${p}`, reason: "not configured" });
1100
+ catch (err) {
1101
+ skipped.push({
1102
+ provider: `s3:${p}`,
1103
+ reason: `ListBuckets failed: ${err.message.split("\n")[0]}`,
1104
+ });
870
1105
  }
871
1106
  }
872
1107
  return { provider, findings, skipped };
873
1108
  }
874
- async function scanGitHub(input) {
1109
+ async function scanGitHub(input, expects) {
875
1110
  const provider = "github";
876
1111
  const findings = [];
877
1112
  const skipped = [];
@@ -920,6 +1155,7 @@ async function scanGitHub(input) {
920
1155
  kind: "repository",
921
1156
  identity: input.repo,
922
1157
  status: "missing",
1158
+ expected: expects.github,
923
1159
  detail: res.stderr.trim().split("\n")[0],
924
1160
  });
925
1161
  return { provider, findings, skipped, raw: { repoInfo } };
@@ -959,6 +1195,7 @@ async function scanGitHub(input) {
959
1195
  kind: "page-site",
960
1196
  identity: input.repo,
961
1197
  status: "missing",
1198
+ expected: expects.githubPages,
962
1199
  detail: "Pages is not enabled on this repo",
963
1200
  });
964
1201
  }
@@ -1019,7 +1256,7 @@ async function scanGitHub(input) {
1019
1256
  }
1020
1257
  return { provider, findings, skipped, raw: { repoInfo, pages } };
1021
1258
  }
1022
- async function scanResend(input) {
1259
+ async function scanResend(input, expected) {
1023
1260
  const provider = "resend";
1024
1261
  const findings = [];
1025
1262
  const skipped = [];
@@ -1047,6 +1284,7 @@ async function scanResend(input) {
1047
1284
  kind: "verified-domain",
1048
1285
  identity: input.domain,
1049
1286
  status: "missing",
1287
+ expected,
1050
1288
  detail: `no Resend domain entry for ${input.domain} (${(body.data ?? []).length} domain(s) total)`,
1051
1289
  });
1052
1290
  }
@@ -1070,7 +1308,7 @@ async function scanResend(input) {
1070
1308
  }
1071
1309
  return { provider, findings, skipped };
1072
1310
  }
1073
- async function scanGlitchtip(input) {
1311
+ async function scanGlitchtip(input, expected) {
1074
1312
  const provider = "glitchtip";
1075
1313
  const findings = [];
1076
1314
  const skipped = [];
@@ -1097,6 +1335,7 @@ async function scanGlitchtip(input) {
1097
1335
  kind: "project",
1098
1336
  identity: input.name,
1099
1337
  status: "missing",
1338
+ expected,
1100
1339
  detail: `no GlitchTip project matching ${wanted.join(" / ")} (${body.length} total in org)`,
1101
1340
  });
1102
1341
  }
@@ -1120,7 +1359,7 @@ async function scanGlitchtip(input) {
1120
1359
  }
1121
1360
  return { provider, findings, skipped };
1122
1361
  }
1123
- async function scanOpenpanel(input) {
1362
+ async function scanOpenpanel(input, expected) {
1124
1363
  const provider = "openpanel";
1125
1364
  const findings = [];
1126
1365
  const skipped = [];
@@ -1158,6 +1397,7 @@ async function scanOpenpanel(input) {
1158
1397
  kind: "project",
1159
1398
  identity: input.name,
1160
1399
  status: "missing",
1400
+ expected,
1161
1401
  detail: `no OpenPanel project matching ${wanted.join(" / ")} (${projects.length} total)`,
1162
1402
  });
1163
1403
  }
@@ -1180,7 +1420,7 @@ async function scanOpenpanel(input) {
1180
1420
  }
1181
1421
  return { provider, findings, skipped };
1182
1422
  }
1183
- async function scanStripe(input) {
1423
+ async function scanStripe(input, expected) {
1184
1424
  const provider = "stripe";
1185
1425
  const findings = [];
1186
1426
  const skipped = [];
@@ -1215,6 +1455,7 @@ async function scanStripe(input) {
1215
1455
  kind: "webhook-endpoint",
1216
1456
  identity: `${mode} mode`,
1217
1457
  status: "missing",
1458
+ expected,
1218
1459
  detail: `no webhook endpoint with URL containing ${input.domain} (${(body.data ?? []).length} endpoint(s) in ${mode} mode)`,
1219
1460
  });
1220
1461
  }
@@ -1486,13 +1727,7 @@ export function renderInventoryHuman(report) {
1486
1727
  continue;
1487
1728
  lines.push(chalk.bold(` ${providerKey}`));
1488
1729
  for (const f of findings) {
1489
- const icon = f.status === "present"
1490
- ? chalk.green("✓")
1491
- : f.status === "missing"
1492
- ? chalk.red("✗")
1493
- : f.status === "drift"
1494
- ? chalk.yellow("⚠")
1495
- : chalk.dim("·");
1730
+ const icon = findingIcon(f);
1496
1731
  const kind = chalk.dim(`(${f.kind})`);
1497
1732
  const detail = f.detail ? chalk.dim(` — ${f.detail}`) : "";
1498
1733
  lines.push(` ${icon} ${f.identity} ${kind}${detail}`);
@@ -1506,12 +1741,10 @@ export function renderInventoryHuman(report) {
1506
1741
  }
1507
1742
  lines.push("");
1508
1743
  }
1509
- lines.push(` ${chalk.green(`${report.summary.present} present`)} ${report.summary.drift > 0
1510
- ? chalk.yellow(`${report.summary.drift} drift`)
1511
- : chalk.dim("0 drift")} ${report.summary.missing > 0
1512
- ? chalk.red(`${report.summary.missing} missing`)
1513
- : chalk.dim("0 missing")} ${chalk.dim(`${report.summary.skipped} skipped`)}`);
1514
- lines.push("");
1744
+ // Final one-page summary compact per-provider roll-up, no detail
1745
+ // fluff. The reader can scroll up for specifics; this block answers
1746
+ // "what does this project have, and what needs attention?" at a glance.
1747
+ lines.push(renderInventorySummary(report));
1515
1748
  return lines.join("\n");
1516
1749
  }
1517
1750
  function sourceTag(s) {
@@ -1519,4 +1752,252 @@ function sourceTag(s) {
1519
1752
  return chalk.dim(" (will prompt)");
1520
1753
  return chalk.dim(` ← ${s}`);
1521
1754
  }
1755
+ /** Status icon for a single finding. Red ✗ is reserved for `missing`
1756
+ * findings the project actually expects (declared in manifest, env,
1757
+ * package deps, workflows). An unexpected absence — e.g. "no Coolify
1758
+ * app named foo" for a library that doesn't deploy — is dim · instead. */
1759
+ function findingIcon(f) {
1760
+ if (f.status === "present")
1761
+ return chalk.green("✓");
1762
+ if (f.status === "drift")
1763
+ return chalk.yellow("⚠");
1764
+ if (f.status === "missing")
1765
+ return f.expected ? chalk.red("✗") : chalk.dim("·");
1766
+ return chalk.dim("·");
1767
+ }
1768
+ function renderInventorySummary(report) {
1769
+ const lines = [];
1770
+ lines.push(chalk.dim(` ${"─".repeat(58)}`));
1771
+ lines.push(chalk.bold(" Summary"));
1772
+ lines.push("");
1773
+ // Group findings by *effective* provider — drift findings get
1774
+ // re-attributed to the provider they're about, so each provider's
1775
+ // summary line reflects all its state (including drift).
1776
+ const grouped = new Map();
1777
+ for (const f of report.findings) {
1778
+ const effective = f.provider === "drift" ? driftToProvider(f.kind) : f.provider;
1779
+ const list = grouped.get(effective);
1780
+ if (list)
1781
+ list.push(f);
1782
+ else
1783
+ grouped.set(effective, [f]);
1784
+ }
1785
+ const rolls = [];
1786
+ for (const [providerKey, findings] of grouped) {
1787
+ rolls.push(rollUpProvider(providerKey, findings));
1788
+ }
1789
+ // Append skipped providers (not in grouped) as dim rows so the
1790
+ // summary lists every provider hatchkit knows about, not just the
1791
+ // ones that returned findings.
1792
+ const seen = new Set(grouped.keys());
1793
+ for (const s of report.skipped) {
1794
+ if (seen.has(s.provider))
1795
+ continue;
1796
+ seen.add(s.provider);
1797
+ rolls.push({
1798
+ label: providerLabel(s.provider),
1799
+ icon: chalk.dim("·"),
1800
+ text: chalk.dim(s.reason),
1801
+ });
1802
+ }
1803
+ const labelWidth = Math.max(...rolls.map((r) => r.label.length), 10);
1804
+ for (const r of rolls) {
1805
+ lines.push(` ${r.label.padEnd(labelWidth + 2)} ${r.icon} ${r.text}`);
1806
+ }
1807
+ // Bottom-line takeaway — counts only `expected` missing (the
1808
+ // actionable subset). Unexpected absences ("no Coolify app named foo"
1809
+ // for a CLI library) are excluded; they're not problems to fix.
1810
+ lines.push("");
1811
+ const drift = report.summary.drift;
1812
+ const missing = report.summary.expectedMissing;
1813
+ const present = report.summary.present;
1814
+ if (drift > 0 || missing > 0) {
1815
+ const parts = [];
1816
+ if (drift > 0) {
1817
+ parts.push(chalk.yellow(`⚠ ${drift} drift${drift === 1 ? "" : "s"} to reconcile`));
1818
+ }
1819
+ if (missing > 0) {
1820
+ parts.push(chalk.red(`✗ ${missing} expected resource${missing === 1 ? "" : "s"} missing`));
1821
+ }
1822
+ lines.push(` ${parts.join(chalk.dim(" · "))}`);
1823
+ }
1824
+ else if (present > 0) {
1825
+ lines.push(` ${chalk.green("✓ All clear")} — ${present} resource${present === 1 ? "" : "s"} tracked, nothing out of sync.`);
1826
+ }
1827
+ else {
1828
+ lines.push(chalk.dim(" Nothing matched — try `--name`, `--domain`, or `--repo` to narrow."));
1829
+ }
1830
+ lines.push("");
1831
+ return lines.join("\n");
1832
+ }
1833
+ /** Drift findings live under provider "drift" in `report.findings`,
1834
+ * but conceptually they belong to the provider they describe. Pin
1835
+ * each drift `kind` to its source provider for the summary roll-up. */
1836
+ function driftToProvider(driftKind) {
1837
+ if (driftKind.startsWith("coolify"))
1838
+ return "coolify";
1839
+ if (driftKind === "bucket" || driftKind === "bucket-cors")
1840
+ return "s3:r2";
1841
+ if (driftKind.startsWith("github-pages"))
1842
+ return "github-pages";
1843
+ if (driftKind === "missing-secret")
1844
+ return "github";
1845
+ return "drift";
1846
+ }
1847
+ function rollUpProvider(providerKey, findings) {
1848
+ const drifts = findings.filter((f) => f.status === "drift");
1849
+ const present = findings.filter((f) => f.status === "present");
1850
+ const missing = findings.filter((f) => f.status === "missing");
1851
+ const label = providerLabel(providerKey);
1852
+ if (drifts.length > 0) {
1853
+ return {
1854
+ label,
1855
+ icon: chalk.yellow("⚠"),
1856
+ text: chalk.yellow(`${drifts.length} drift${drifts.length === 1 ? "" : "s"} — see above`),
1857
+ };
1858
+ }
1859
+ if (present.length > 0) {
1860
+ return {
1861
+ label,
1862
+ icon: chalk.green("✓"),
1863
+ text: summarizePresent(providerKey, present, missing),
1864
+ };
1865
+ }
1866
+ if (missing.length > 0) {
1867
+ // Red ✗ only when the project locally declares this resource
1868
+ // should exist. Otherwise dim · with a softer "no match" label —
1869
+ // a CLI library shouldn't get a red mark for "no Coolify app".
1870
+ const anyExpected = missing.some((f) => f.expected);
1871
+ return {
1872
+ label,
1873
+ icon: anyExpected ? chalk.red("✗") : chalk.dim("·"),
1874
+ text: anyExpected
1875
+ ? chalk.red(summarizeMissing(providerKey))
1876
+ : chalk.dim("no match (not declared by this project)"),
1877
+ };
1878
+ }
1879
+ // Only `info`-level findings → no actionable state to surface.
1880
+ return {
1881
+ label,
1882
+ icon: chalk.dim("·"),
1883
+ text: chalk.dim(findings[0]?.identity ?? ""),
1884
+ };
1885
+ }
1886
+ /** Compact "what's here" string for a provider that has at least one
1887
+ * present finding. Per-provider tuned to keep the line short. */
1888
+ function summarizePresent(providerKey, present, missing) {
1889
+ const partial = missing.length > 0 ? chalk.dim(` (${missing.length} missing)`) : "";
1890
+ switch (providerKey) {
1891
+ case "coolify": {
1892
+ const apps = present.filter((f) => f.kind === "application").map((f) => f.identity);
1893
+ const projects = present.filter((f) => f.kind === "project");
1894
+ const parts = [];
1895
+ if (apps.length)
1896
+ parts.push(`${apps.length} app: ${apps.join(", ")}`);
1897
+ if (projects.length)
1898
+ parts.push(`${projects.length} project${projects.length === 1 ? "" : "s"}`);
1899
+ return (parts.join(", ") || present[0].identity) + partial;
1900
+ }
1901
+ case "dns": {
1902
+ const zone = present.find((f) => f.kind === "zone");
1903
+ const records = present.filter((f) => f.kind === "dns-record");
1904
+ const base = zone
1905
+ ? `${zone.identity} (${records.length} record${records.length === 1 ? "" : "s"})`
1906
+ : `${records.length} record${records.length === 1 ? "" : "s"}`;
1907
+ return base + partial;
1908
+ }
1909
+ case "s3:r2": {
1910
+ const buckets = present.filter((f) => f.kind === "bucket").map((f) => f.identity);
1911
+ return (`${buckets.length} bucket${buckets.length === 1 ? "" : "s"}: ${buckets.join(", ")}` +
1912
+ partial);
1913
+ }
1914
+ case "github": {
1915
+ const repo = present.find((f) => f.kind === "repository");
1916
+ if (repo) {
1917
+ // Detail is "private · default: main · …" — pull just the
1918
+ // visibility (first segment) to keep the line short.
1919
+ const visibility = repo.detail?.split(" · ")[0];
1920
+ return `${repo.identity}${visibility ? chalk.dim(` (${visibility})`) : ""}`;
1921
+ }
1922
+ return present[0].identity;
1923
+ }
1924
+ case "github-pages": {
1925
+ const site = present.find((f) => f.kind === "page-site");
1926
+ if (site) {
1927
+ const cname = site.detail?.match(/cname:\s*([^\s·]+)/)?.[1];
1928
+ return cname ? `live at ${cname}` : "enabled";
1929
+ }
1930
+ return "enabled";
1931
+ }
1932
+ case "resend": {
1933
+ const domains = present.filter((f) => f.kind === "verified-domain").map((f) => f.identity);
1934
+ return domains.join(", ") + partial;
1935
+ }
1936
+ case "glitchtip":
1937
+ case "openpanel": {
1938
+ const projects = present.filter((f) => f.kind === "project").map((f) => f.identity);
1939
+ return projects.join(", ") + partial;
1940
+ }
1941
+ case "stripe": {
1942
+ const hooks = present.filter((f) => f.kind === "webhook-endpoint");
1943
+ return `${hooks.length} webhook${hooks.length === 1 ? "" : "s"}` + partial;
1944
+ }
1945
+ default:
1946
+ return present[0].identity + partial;
1947
+ }
1948
+ }
1949
+ /** Compact "what's not here" string for a provider where every
1950
+ * finding is `missing`. The detailed reason is in the per-provider
1951
+ * block above — this is just the headline. */
1952
+ function summarizeMissing(providerKey) {
1953
+ switch (providerKey) {
1954
+ case "coolify":
1955
+ return "no matching app";
1956
+ case "dns":
1957
+ return "no zone for this domain";
1958
+ case "s3:r2":
1959
+ return "no matching buckets";
1960
+ case "github":
1961
+ return "repo not found";
1962
+ case "github-pages":
1963
+ return "Pages not enabled";
1964
+ case "resend":
1965
+ return "domain not verified";
1966
+ case "glitchtip":
1967
+ case "openpanel":
1968
+ return "no matching project";
1969
+ case "stripe":
1970
+ return "no webhook for this domain";
1971
+ default:
1972
+ return "not found";
1973
+ }
1974
+ }
1975
+ function providerLabel(key) {
1976
+ switch (key) {
1977
+ case "coolify":
1978
+ return "Coolify";
1979
+ case "dns":
1980
+ return "DNS";
1981
+ case "s3:r2":
1982
+ return "R2";
1983
+ case "s3:hetzner":
1984
+ return "Hetzner S3";
1985
+ case "s3:aws":
1986
+ return "AWS S3";
1987
+ case "github":
1988
+ return "GitHub";
1989
+ case "github-pages":
1990
+ return "Pages";
1991
+ case "resend":
1992
+ return "Resend";
1993
+ case "glitchtip":
1994
+ return "GlitchTip";
1995
+ case "openpanel":
1996
+ return "OpenPanel";
1997
+ case "stripe":
1998
+ return "Stripe";
1999
+ default:
2000
+ return key;
2001
+ }
2002
+ }
1522
2003
  //# sourceMappingURL=inventory.js.map