hatchkit 0.1.41 → 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 (112) hide show
  1. package/dist/adopt.js +362 -13
  2. package/dist/adopt.js.map +1 -1
  3. package/dist/config.d.ts +32 -10
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +91 -38
  6. package/dist/config.js.map +1 -1
  7. package/dist/deploy/coolify-app.d.ts.map +1 -1
  8. package/dist/deploy/coolify-app.js +0 -7
  9. package/dist/deploy/coolify-app.js.map +1 -1
  10. package/dist/deploy/coolify.d.ts.map +1 -1
  11. package/dist/deploy/coolify.js +20 -1
  12. package/dist/deploy/coolify.js.map +1 -1
  13. package/dist/deploy/ghcr.d.ts +4 -2
  14. package/dist/deploy/ghcr.d.ts.map +1 -1
  15. package/dist/deploy/ghcr.js +1 -1
  16. package/dist/deploy/ghcr.js.map +1 -1
  17. package/dist/deploy/github.d.ts +4 -3
  18. package/dist/deploy/github.d.ts.map +1 -1
  19. package/dist/deploy/github.js +5 -2
  20. package/dist/deploy/github.js.map +1 -1
  21. package/dist/deploy/pages.d.ts.map +1 -1
  22. package/dist/deploy/pages.js +8 -14
  23. package/dist/deploy/pages.js.map +1 -1
  24. package/dist/deploy/regen-infra.d.ts.map +1 -1
  25. package/dist/deploy/regen-infra.js +1 -11
  26. package/dist/deploy/regen-infra.js.map +1 -1
  27. package/dist/deploy/rollback.d.ts.map +1 -1
  28. package/dist/deploy/rollback.js +30 -6
  29. package/dist/deploy/rollback.js.map +1 -1
  30. package/dist/deploy/terraform.d.ts.map +1 -1
  31. package/dist/deploy/terraform.js +20 -37
  32. package/dist/deploy/terraform.js.map +1 -1
  33. package/dist/dns.d.ts.map +1 -1
  34. package/dist/dns.js +4 -5
  35. package/dist/dns.js.map +1 -1
  36. package/dist/doctor.d.ts +15 -0
  37. package/dist/doctor.d.ts.map +1 -1
  38. package/dist/doctor.js +110 -36
  39. package/dist/doctor.js.map +1 -1
  40. package/dist/email/index.d.ts +31 -0
  41. package/dist/email/index.d.ts.map +1 -0
  42. package/dist/email/index.js +251 -0
  43. package/dist/email/index.js.map +1 -0
  44. package/dist/email/presets.d.ts +14 -0
  45. package/dist/email/presets.d.ts.map +1 -0
  46. package/dist/email/presets.js +33 -0
  47. package/dist/email/presets.js.map +1 -0
  48. package/dist/email/setup.d.ts +93 -0
  49. package/dist/email/setup.d.ts.map +1 -0
  50. package/dist/email/setup.js +263 -0
  51. package/dist/email/setup.js.map +1 -0
  52. package/dist/email/spf.d.ts +56 -0
  53. package/dist/email/spf.d.ts.map +1 -0
  54. package/dist/email/spf.js +102 -0
  55. package/dist/email/spf.js.map +1 -0
  56. package/dist/index.js +113 -4
  57. package/dist/index.js.map +1 -1
  58. package/dist/inventory.d.ts.map +1 -1
  59. package/dist/inventory.js +34 -11
  60. package/dist/inventory.js.map +1 -1
  61. package/dist/overview.d.ts.map +1 -1
  62. package/dist/overview.js +43 -15
  63. package/dist/overview.js.map +1 -1
  64. package/dist/prompts.d.ts +5 -0
  65. package/dist/prompts.d.ts.map +1 -1
  66. package/dist/prompts.js +29 -7
  67. package/dist/prompts.js.map +1 -1
  68. package/dist/provision/index.d.ts +20 -1
  69. package/dist/provision/index.d.ts.map +1 -1
  70. package/dist/provision/index.js +115 -0
  71. package/dist/provision/index.js.map +1 -1
  72. package/dist/provision/s3-buckets.js +1 -1
  73. package/dist/provision/s3-buckets.js.map +1 -1
  74. package/dist/scaffold/app.d.ts.map +1 -1
  75. package/dist/scaffold/app.js +15 -7
  76. package/dist/scaffold/app.js.map +1 -1
  77. package/dist/scaffold/build-pipeline.d.ts +16 -0
  78. package/dist/scaffold/build-pipeline.d.ts.map +1 -1
  79. package/dist/scaffold/build-pipeline.js +47 -4
  80. package/dist/scaffold/build-pipeline.js.map +1 -1
  81. package/dist/scaffold/infra.d.ts +4 -5
  82. package/dist/scaffold/infra.d.ts.map +1 -1
  83. package/dist/scaffold/infra.js +11 -56
  84. package/dist/scaffold/infra.js.map +1 -1
  85. package/dist/scaffold/manifest.d.ts.map +1 -1
  86. package/dist/scaffold/manifest.js +1 -0
  87. package/dist/scaffold/manifest.js.map +1 -1
  88. package/dist/scaffold/pages-heuristics.d.ts.map +1 -1
  89. package/dist/scaffold/pages-heuristics.js +10 -10
  90. package/dist/scaffold/pages-heuristics.js.map +1 -1
  91. package/dist/scaffold/pages-mode.js +2 -4
  92. package/dist/scaffold/pages-mode.js.map +1 -1
  93. package/dist/scaffold/pkg-json.d.ts +4 -0
  94. package/dist/scaffold/pkg-json.d.ts.map +1 -1
  95. package/dist/scaffold/pkg-json.js +17 -0
  96. package/dist/scaffold/pkg-json.js.map +1 -1
  97. package/dist/scaffold/update.js +1 -1
  98. package/dist/scaffold/update.js.map +1 -1
  99. package/dist/templates/build-pipeline/Dockerfile.nextjs.hbs +103 -0
  100. package/dist/templates/build-pipeline/docker-compose.yml.hbs +23 -6
  101. package/dist/utils/cloudflare-api.d.ts +146 -20
  102. package/dist/utils/cloudflare-api.d.ts.map +1 -1
  103. package/dist/utils/cloudflare-api.js +203 -11
  104. package/dist/utils/cloudflare-api.js.map +1 -1
  105. package/dist/utils/run-ledger.d.ts +22 -1
  106. package/dist/utils/run-ledger.d.ts.map +1 -1
  107. package/dist/utils/run-ledger.js.map +1 -1
  108. package/dist/utils/s3-admin.d.ts +9 -0
  109. package/dist/utils/s3-admin.d.ts.map +1 -0
  110. package/dist/utils/s3-admin.js +46 -0
  111. package/dist/utils/s3-admin.js.map +1 -0
  112. package/package.json +1 -1
package/dist/adopt.js CHANGED
@@ -834,7 +834,9 @@ async function editAdoptStep(state, plan, step) {
834
834
  // Also: switching away from client-only invalidates gh-pages
835
835
  // (Pages can't host a backend). Snap deploymentMode back to
836
836
  // coolify in that case so the user doesn't keep an invalid combo.
837
- const nextDeploymentMode = plan.deploymentMode === "gh-pages" && next !== "client-only" ? "coolify" : plan.deploymentMode;
837
+ const nextDeploymentMode = plan.deploymentMode === "gh-pages" && next !== "client-only"
838
+ ? "coolify"
839
+ : plan.deploymentMode;
838
840
  if (plan.deploymentMode === "gh-pages" && next !== "client-only") {
839
841
  console.log(chalk.yellow(" ⚠ gh-pages requires client-only surfaces — switched deployment mode back to coolify."));
840
842
  }
@@ -958,10 +960,15 @@ async function editAdoptStep(state, plan, step) {
958
960
  checked: plan.services.includes("openpanel"),
959
961
  },
960
962
  {
961
- name: "Resend (email)",
963
+ name: "Resend (transactional email)",
962
964
  value: "resend",
963
965
  checked: plan.services.includes("resend"),
964
966
  },
967
+ {
968
+ name: "Email forwarding (Cloudflare Email Routing → your inbox)",
969
+ value: "email",
970
+ checked: plan.services.includes("email"),
971
+ },
965
972
  ],
966
973
  });
967
974
  return { ...plan, services };
@@ -1127,10 +1134,14 @@ async function executePlan(state, plan, opts = { resume: false }) {
1127
1134
  // Gated on coolify mode — the Coolify-targeted Dockerfile + deploy
1128
1135
  // webhook workflow aren't useful for gh-pages, which uses its own
1129
1136
  // `gh-pages.yml` workflow written later in step 3c-pages.
1137
+ let scaffoldedAbsPaths = [];
1138
+ let overwrittenAbsPaths = [];
1130
1139
  if (plan.scaffoldBuildPipeline && plan.deploymentMode === "coolify") {
1131
1140
  const pipeResult = await scaffoldBuildPipelineNow(state, plan, remoteUrl, {
1132
1141
  force: !!opts.regeneratePipeline,
1133
1142
  });
1143
+ scaffoldedAbsPaths = pipeResult.createdAbsPaths;
1144
+ overwrittenAbsPaths = pipeResult.overwrittenAbsPaths;
1134
1145
  // Record only files we *created*. The `overwritten` list is
1135
1146
  // deliberately not recorded — those files existed before this
1136
1147
  // run (the user's), and a later `hatchkit destroy` must never
@@ -1258,9 +1269,7 @@ async function executePlan(state, plan, opts = { resume: false }) {
1258
1269
  //
1259
1270
  // Skipped for gh-pages — there's no Coolify webhook to hit.
1260
1271
  const appUuidForSecrets = coolifyResult?.appUuid ?? state.coolifyAppMatch?.uuid;
1261
- if (plan.scaffoldBuildPipeline &&
1262
- plan.deploymentMode === "coolify" &&
1263
- appUuidForSecrets) {
1272
+ if (plan.scaffoldBuildPipeline && plan.deploymentMode === "coolify" && appUuidForSecrets) {
1264
1273
  const slug = repoSlugFromRemote(remoteUrl);
1265
1274
  if (slug) {
1266
1275
  await setCoolifyDeploySecrets({
@@ -1385,13 +1394,36 @@ async function executePlan(state, plan, opts = { resume: false }) {
1385
1394
  });
1386
1395
  }
1387
1396
  }
1388
- // Step 3d: push the working branch to origin. Done AFTER secrets
1389
- // are set so the workflow's first run can hit the Coolify webhook
1390
- // without falling through to the "secret not set" branch. Skipped
1391
- // when there's no remote yet (e.g. user opted out of GitHub) or
1392
- // when origin already had history before adopt.
1393
- if (plan.setupGitHub && remoteUrl && !state.gitRemoteUrl) {
1394
- await pushInitialBranch(state.projectDir);
1397
+ // Step 3d: push to origin so GitHub Actions builds + pushes the
1398
+ // GHCR image. Done AFTER Coolify wiring + secrets so the workflow's
1399
+ // first run can hit the redeploy webhook on its own.
1400
+ //
1401
+ // Two paths:
1402
+ // · Brand-new remote (this run just ran `gh repo create`)
1403
+ // pushInitialBranch pushes the whole tree.
1404
+ // · Pre-existing remote → commitAndPushScaffold makes a
1405
+ // pathspec-scoped commit of just the files hatchkit wrote
1406
+ // (manifest + build-pipeline scaffold) and pushes it. Without
1407
+ // this push the workflow file lives only in the working tree,
1408
+ // Actions never fires, and the GHCR-wait below times out.
1409
+ //
1410
+ // `pushedThisRun` gates the GHCR step below — we only wait for a
1411
+ // new image when a push actually went out.
1412
+ let pushedThisRun = false;
1413
+ if (remoteUrl) {
1414
+ if (plan.setupGitHub && !state.gitRemoteUrl) {
1415
+ pushedThisRun = await pushInitialBranch(state.projectDir);
1416
+ }
1417
+ else if (state.gitRemoteUrl) {
1418
+ const result = await commitAndPushScaffold(state, {
1419
+ scaffoldedAbsPaths,
1420
+ overwrittenAbsPaths,
1421
+ manifestPath,
1422
+ });
1423
+ pushedThisRun = result.pushed;
1424
+ if (result.caveat)
1425
+ caveats.push(result.caveat);
1426
+ }
1395
1427
  }
1396
1428
  // Step 3e: GHCR setup. Two paths, gated on the user's earlier
1397
1429
  // public/private choice:
@@ -1414,7 +1446,25 @@ async function executePlan(state, plan, opts = { resume: false }) {
1414
1446
  remoteUrl &&
1415
1447
  coolifyResult !== undefined) {
1416
1448
  const slug = repoSlugFromRemote(remoteUrl);
1417
- if (slug) {
1449
+ if (slug && !pushedThisRun && !plan.isPrivate) {
1450
+ // No push went out (either nothing changed on disk this run,
1451
+ // or the auto-commit-push failed). Without a push the
1452
+ // build-and-deploy workflow doesn't run, so polling GHCR for
1453
+ // a brand-new image would just time out. Defer the visibility
1454
+ // PATCH to the next `--resume` once the user has pushed.
1455
+ caveats.push({
1456
+ title: "GHCR visibility not set — no push triggered",
1457
+ reason: "Adopt didn't push to origin this run, so the build-and-deploy workflow hasn't been triggered to publish the GHCR image.",
1458
+ recovery: [
1459
+ "Commit + push so the workflow runs:",
1460
+ ` cd ${state.projectDir}`,
1461
+ ` git add . && git commit -m "chore: adopt hatchkit"`,
1462
+ ` git push`,
1463
+ "Then re-run: hatchkit adopt --resume",
1464
+ ],
1465
+ });
1466
+ }
1467
+ else if (slug) {
1418
1468
  const { makeGhcrPackagePublic, registerGhcrCredsWithCoolify } = await import("./deploy/ghcr.js");
1419
1469
  if (plan.isPrivate) {
1420
1470
  // Read the full GHCR config (token + the PAT owner's GitHub
@@ -1492,6 +1542,44 @@ async function executePlan(state, plan, opts = { resume: false }) {
1492
1542
  else if (event.service === "resend") {
1493
1543
  ledger.record({ kind: "resend", client: event.client });
1494
1544
  }
1545
+ else if (event.service === "email") {
1546
+ // Email setup creates three kinds of mutable state on
1547
+ // Cloudflare: the destination address (account-scoped), the
1548
+ // forwarding rules (zone-scoped), and the apex MX/SPF/DMARC
1549
+ // records (also zone-scoped). We only record what THIS run
1550
+ // created — `destinationCreatedThisRun` and `r.created` /
1551
+ // `dnsRecords` (which the provision orchestrator already
1552
+ // pre-filtered to `created: true` entries). MX/SPF/DMARC
1553
+ // upserts on a zone that already had them stay out of the
1554
+ // ledger so destroy never yanks pre-existing records.
1555
+ if (event.destinationCreatedThisRun) {
1556
+ ledger.record({
1557
+ kind: "cloudflareEmailDestination",
1558
+ accountId: event.accountId,
1559
+ destinationId: event.destinationId,
1560
+ email: event.destinationEmail,
1561
+ });
1562
+ }
1563
+ for (const dns of event.dnsRecords) {
1564
+ ledger.record({
1565
+ kind: "cloudflareDnsRecord",
1566
+ zoneId: event.zoneId,
1567
+ recordId: dns.id,
1568
+ name: dns.name,
1569
+ type: dns.type,
1570
+ });
1571
+ }
1572
+ for (const rule of event.rules) {
1573
+ if (!rule.created)
1574
+ continue;
1575
+ ledger.record({
1576
+ kind: "cloudflareEmailRoutingRule",
1577
+ zoneId: event.zoneId,
1578
+ ruleId: rule.id,
1579
+ address: rule.address,
1580
+ });
1581
+ }
1582
+ }
1495
1583
  },
1496
1584
  });
1497
1585
  }
@@ -1942,6 +2030,267 @@ async function ensureEnvProductionCommitted(state, plan) {
1942
2030
  ` git -C ${relativeTo(state.projectDir)} commit -m "chore(dotenvx): commit encrypted .env.production" -- ${relativeTo(prodPath, state.projectDir)}`));
1943
2031
  }
1944
2032
  }
2033
+ /**
2034
+ * Sniff the working tree for state that would make auto-commit + push
2035
+ * surprising or destructive. We refuse to touch git when:
2036
+ *
2037
+ * · A merge / rebase / cherry-pick / revert / bisect is in progress
2038
+ * — adopt isn't allowed to add commits on top of half-resolved
2039
+ * conflicts.
2040
+ * · Any tracked file *outside* hatchkit's path list has unstaged or
2041
+ * staged changes. Even though the commit itself is pathspec-scoped
2042
+ * (so the user's modifications wouldn't be swept in), the push
2043
+ * would still land hatchkit's commit on top of the user's WIP on
2044
+ * the same branch — entangling their unpushed work with the
2045
+ * hatchkit commit on origin. Make them park or commit it first.
2046
+ *
2047
+ * Untracked files (status `??`) are deliberately ignored — they're
2048
+ * common debris (editor swaps, build artifacts not in gitignore, etc.)
2049
+ * and never end up in our pathspec commit anyway.
2050
+ */
2051
+ async function detectUserWip(projectDir, hatchkitAbsPaths) {
2052
+ // In-progress operations: probe the .git dir for marker files. We
2053
+ // resolve --git-dir via git itself so this works in worktrees (where
2054
+ // .git is a file, not a directory) and submodules.
2055
+ const gitDirRes = await exec("git", ["rev-parse", "--git-dir"], {
2056
+ cwd: projectDir,
2057
+ silent: true,
2058
+ });
2059
+ if (gitDirRes.exitCode === 0) {
2060
+ const raw = gitDirRes.stdout.trim();
2061
+ const gitDir = raw.startsWith("/") ? raw : join(projectDir, raw);
2062
+ const markers = [
2063
+ ["MERGE_HEAD", "merge"],
2064
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
2065
+ ["REVERT_HEAD", "revert"],
2066
+ ["rebase-merge", "rebase"],
2067
+ ["rebase-apply", "rebase"],
2068
+ ["BISECT_LOG", "bisect"],
2069
+ ];
2070
+ for (const [marker, op] of markers) {
2071
+ if (existsSync(join(gitDir, marker)))
2072
+ return { kind: "in-progress", op };
2073
+ }
2074
+ }
2075
+ // Repo-root-relative path matching. `git status --porcelain` emits
2076
+ // paths relative to the repo root, not necessarily our cwd, so we
2077
+ // normalize hatchkit's absolute paths the same way before comparing.
2078
+ const rootRes = await exec("git", ["rev-parse", "--show-toplevel"], {
2079
+ cwd: projectDir,
2080
+ silent: true,
2081
+ });
2082
+ if (rootRes.exitCode !== 0) {
2083
+ return { kind: "error", reason: "git rev-parse --show-toplevel failed" };
2084
+ }
2085
+ const repoRoot = rootRes.stdout.trim();
2086
+ const hatchkitRelToRoot = new Set(hatchkitAbsPaths.map((p) => relative(repoRoot, p)));
2087
+ const status = await exec("git", ["status", "--porcelain", "--untracked-files=no"], {
2088
+ cwd: projectDir,
2089
+ silent: true,
2090
+ });
2091
+ if (status.exitCode !== 0) {
2092
+ return { kind: "error", reason: "git status failed" };
2093
+ }
2094
+ const userFiles = [];
2095
+ for (const line of status.stdout.split("\n")) {
2096
+ if (line.length < 4)
2097
+ continue;
2098
+ const code = line.slice(0, 2);
2099
+ let rest = line.slice(3);
2100
+ // Renames/copies show up as "OLD -> NEW". We want the new path.
2101
+ if (rest.includes(" -> "))
2102
+ rest = rest.split(" -> ").pop() ?? rest;
2103
+ // Git quotes paths with special chars in C-style. If a path is
2104
+ // quoted we conservatively treat it as user WIP rather than try
2105
+ // to unquote and risk a false negative.
2106
+ const path = rest.startsWith('"') && rest.endsWith('"') ? rest.slice(1, -1) : rest;
2107
+ if (!hatchkitRelToRoot.has(path)) {
2108
+ userFiles.push({ status: code, path });
2109
+ }
2110
+ }
2111
+ if (userFiles.length > 0)
2112
+ return { kind: "user-changes", files: userFiles };
2113
+ return { kind: "ok" };
2114
+ }
2115
+ /**
2116
+ * Commit + push the files hatchkit wrote this run (manifest +
2117
+ * scaffolded build pipeline) to a pre-existing remote so the
2118
+ * build-and-deploy workflow fires.
2119
+ *
2120
+ * Pathspec-scoped on purpose: a plain `git add -A` would sweep up
2121
+ * whatever WIP the user happened to have in the working tree —
2122
+ * surprising behavior for an adopt. By listing only the paths
2123
+ * hatchkit just wrote, the resulting commit is exactly "the adopt
2124
+ * step", and anything else stays staged in the user's hands.
2125
+ *
2126
+ * Hard-stops with a caveat when `detectUserWip` finds unrelated user
2127
+ * changes or an in-progress git operation. The push would otherwise
2128
+ * land hatchkit's commit on top of WIP that isn't part of adopt — a
2129
+ * surprise we explicitly refuse to do.
2130
+ *
2131
+ * Returns `{ pushed: false }` (no caveat) for the idempotent case —
2132
+ * everything hatchkit wrote was already byte-identical to HEAD, so
2133
+ * there was nothing to push. Failures during commit/push surface as
2134
+ * a caveat with a copy-pasteable manual recipe.
2135
+ */
2136
+ async function commitAndPushScaffold(state, paths) {
2137
+ const all = [
2138
+ ...paths.scaffoldedAbsPaths,
2139
+ ...paths.overwrittenAbsPaths,
2140
+ paths.manifestPath,
2141
+ ].filter((p, i, arr) => arr.indexOf(p) === i && existsSync(p));
2142
+ if (all.length === 0)
2143
+ return { pushed: false };
2144
+ // Hard stop: refuse to auto-commit on top of in-progress git ops
2145
+ // or unrelated user changes. See detectUserWip docstring for the
2146
+ // exact policy.
2147
+ const wip = await detectUserWip(state.projectDir, all);
2148
+ if (wip.kind === "in-progress") {
2149
+ return {
2150
+ pushed: false,
2151
+ caveat: {
2152
+ title: `Refusing to auto-commit — git ${wip.op} in progress`,
2153
+ reason: `A ${wip.op} is in progress in ${state.projectDir}. Adopt won't stack commits on top of half-resolved git state.`,
2154
+ recovery: [
2155
+ `Finish or abort the ${wip.op}, then re-run adopt:`,
2156
+ wip.op === "rebase"
2157
+ ? ` git rebase --continue # or: git rebase --abort`
2158
+ : ` git ${wip.op} --abort # or finish it manually and commit`,
2159
+ "Then: hatchkit adopt --resume",
2160
+ ],
2161
+ },
2162
+ };
2163
+ }
2164
+ if (wip.kind === "user-changes") {
2165
+ const preview = wip.files.slice(0, 8).map((f) => ` ${f.status} ${f.path}`);
2166
+ const extra = wip.files.length > 8 ? [` ... and ${wip.files.length - 8} more`] : [];
2167
+ return {
2168
+ pushed: false,
2169
+ caveat: {
2170
+ title: "Refusing to auto-commit — working tree has unrelated changes",
2171
+ reason: `Found ${wip.files.length} modified file(s) outside the hatchkit scaffold. Auto-committing + pushing now would land the adopt commit on top of WIP that isn't part of the adopt step.`,
2172
+ recovery: [
2173
+ "Hatchkit wanted to commit + push these files:",
2174
+ ...all.map((p) => ` + ${relativeTo(p, state.projectDir)}`),
2175
+ "",
2176
+ "Your working tree also has changes to:",
2177
+ ...preview,
2178
+ ...extra,
2179
+ "",
2180
+ "Park, commit, or discard your WIP first — whichever fits:",
2181
+ ` git stash push -u -m "pre-hatchkit-adopt" # park on the side`,
2182
+ ` # or: git add . && git commit -m "..." # keep in history`,
2183
+ ` # or: git checkout -- <file> # discard a file`,
2184
+ "Then re-run: hatchkit adopt --resume",
2185
+ ],
2186
+ },
2187
+ };
2188
+ }
2189
+ if (wip.kind === "error") {
2190
+ return {
2191
+ pushed: false,
2192
+ caveat: {
2193
+ title: "Refusing to auto-commit — couldn't verify a clean working tree",
2194
+ reason: `Working-tree detection failed: ${wip.reason}. Adopt won't auto-commit without knowing what else is in the tree.`,
2195
+ recovery: [
2196
+ "Commit + push the scaffold manually:",
2197
+ ` cd ${state.projectDir}`,
2198
+ ` git add ${all.map((p) => relativeTo(p, state.projectDir)).join(" ")}`,
2199
+ ` git commit -m "chore(hatchkit): adopt scaffold + manifest"`,
2200
+ ` git push`,
2201
+ "Then re-run: hatchkit adopt --resume",
2202
+ ],
2203
+ },
2204
+ };
2205
+ }
2206
+ // Definitive pre-commit notice. The user opted into adopt, but an
2207
+ // auto-commit-and-push on a pre-existing remote is a meaningful
2208
+ // side effect — they should see exactly what's happening before it
2209
+ // lands on origin.
2210
+ console.log();
2211
+ console.log(chalk.bold.yellow(" ⚠ hatchkit is about to commit + push to origin:"));
2212
+ for (const p of all) {
2213
+ console.log(chalk.yellow(` + ${relativeTo(p, state.projectDir)}`));
2214
+ }
2215
+ console.log(chalk.dim(" (working tree verified clean of unrelated changes — auto-commit is safe)"));
2216
+ console.log();
2217
+ // Pathspec stage: only the hatchkit-owned files. `--` separates
2218
+ // pathspecs from refs so a file named "main" doesn't get confused
2219
+ // for a branch.
2220
+ const stage = await exec("git", ["add", "--", ...all], {
2221
+ cwd: state.projectDir,
2222
+ silent: true,
2223
+ });
2224
+ if (stage.exitCode !== 0) {
2225
+ return {
2226
+ pushed: false,
2227
+ caveat: {
2228
+ title: "Couldn't stage hatchkit scaffold for commit",
2229
+ reason: (stage.stderr || stage.stdout).split(/\r?\n/)[0] || "git add failed",
2230
+ recovery: [
2231
+ "Stage + commit + push manually so the workflow runs:",
2232
+ ` cd ${state.projectDir}`,
2233
+ ` git add ${all.map((p) => relativeTo(p, state.projectDir)).join(" ")}`,
2234
+ ` git commit -m "chore(hatchkit): adopt scaffold + manifest"`,
2235
+ ` git push`,
2236
+ "Then re-run: hatchkit adopt --resume",
2237
+ ],
2238
+ },
2239
+ };
2240
+ }
2241
+ // Nothing in the staged index means every file was byte-identical
2242
+ // to HEAD — this is the idempotent re-run case. No push needed.
2243
+ const cleanStaged = await execOk("git", ["diff", "--cached", "--quiet"], {
2244
+ cwd: state.projectDir,
2245
+ });
2246
+ if (cleanStaged)
2247
+ return { pushed: false };
2248
+ // Pathspec on the commit too — anything else the user happened to
2249
+ // stage themselves before running adopt stays out of this commit.
2250
+ const commit = await exec("git", ["commit", "-m", "chore(hatchkit): adopt scaffold + manifest", "--", ...all], { cwd: state.projectDir, silent: true });
2251
+ if (commit.exitCode !== 0) {
2252
+ return {
2253
+ pushed: false,
2254
+ caveat: {
2255
+ title: "Couldn't commit hatchkit scaffold automatically",
2256
+ reason: (commit.stderr || commit.stdout).split(/\r?\n/)[0] || "git commit failed",
2257
+ recovery: [
2258
+ "Commit + push the scaffold manually:",
2259
+ ` cd ${state.projectDir}`,
2260
+ ` git commit -m "chore(hatchkit): adopt scaffold + manifest" -- ${all.map((p) => relativeTo(p, state.projectDir)).join(" ")}`,
2261
+ ` git push`,
2262
+ "Then re-run: hatchkit adopt --resume",
2263
+ ],
2264
+ },
2265
+ };
2266
+ }
2267
+ console.log(chalk.green(` ✓ Committed hatchkit scaffold (${all.length} files)`));
2268
+ const headRes = await exec("git", ["symbolic-ref", "--short", "HEAD"], {
2269
+ cwd: state.projectDir,
2270
+ silent: true,
2271
+ });
2272
+ const branch = headRes.exitCode === 0 ? headRes.stdout.trim() : "main";
2273
+ const push = await exec("git", ["push", "origin", branch], {
2274
+ cwd: state.projectDir,
2275
+ spinner: `Pushing ${branch} to origin...`,
2276
+ });
2277
+ if (push.exitCode !== 0) {
2278
+ return {
2279
+ pushed: false,
2280
+ caveat: {
2281
+ title: `Couldn't push ${branch} to origin`,
2282
+ reason: (push.stderr || push.stdout).split(/\r?\n/)[0] || `git push exited ${push.exitCode}`,
2283
+ recovery: [
2284
+ "Push the new commit so Actions can build the image:",
2285
+ ` cd ${state.projectDir}`,
2286
+ ` git push origin ${branch}`,
2287
+ "Then re-run: hatchkit adopt --resume",
2288
+ ],
2289
+ },
2290
+ };
2291
+ }
2292
+ return { pushed: true };
2293
+ }
1945
2294
  async function setupGitHubRemote(state, plan) {
1946
2295
  // Pre-flight gh CLI auth. ensureGitHub prompts the user to log in
1947
2296
  // when needed; if they cancel, surface a clear "you can do this