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