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
@@ -1 +1 @@
1
- {"version":3,"file":"adopt.d.ts","sourceRoot":"","sources":["../src/adopt.ts"],"names":[],"mappings":"AAoLA,wBAAsB,QAAQ,CAC5B,GAAG,EAAE,MAAM,EACX,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAAO,GAC5D,OAAO,CAAC,IAAI,CAAC,CAmHf"}
1
+ {"version":3,"file":"adopt.d.ts","sourceRoot":"","sources":["../src/adopt.ts"],"names":[],"mappings":"AA4LA,wBAAsB,QAAQ,CAC5B,GAAG,EAAE,MAAM,EACX,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAAO,GAC5D,OAAO,CAAC,IAAI,CAAC,CAuLf"}
package/dist/adopt.js CHANGED
@@ -100,9 +100,19 @@ export async function runAdopt(cwd, opts = {}) {
100
100
  : state.clientDir
101
101
  ? "client-only"
102
102
  : "client-only");
103
+ // Auto-suggest gh-pages when (a) the manifest already recorded it,
104
+ // or (b) the manifest is silent AND the project looks client-only
105
+ // AND there's no Coolify app already wired up. Otherwise default
106
+ // to coolify (the existing behaviour).
107
+ const inferredDeploymentMode = m?.deploymentMode === "gh-pages"
108
+ ? "gh-pages"
109
+ : m?.deploymentMode === "scaffold-only"
110
+ ? "scaffold-only"
111
+ : "coolify";
103
112
  let plan = {
104
113
  name: m?.name ?? state.packageName ?? "",
105
114
  domain: m?.domain ?? "",
115
+ deploymentMode: inferredDeploymentMode,
106
116
  // Description resolution order:
107
117
  // 1. Persisted manifest value (`--resume` recovery — a previous
108
118
  // run already settled this).
@@ -147,7 +157,50 @@ export async function runAdopt(cwd, opts = {}) {
147
157
  // redundant in that branch.
148
158
  pushKey: !!state.coolifyAppMatch,
149
159
  };
160
+ // When the plan starts in gh-pages mode (from a `--resume` of an
161
+ // earlier adopt, or a manifest the user committed by hand), run
162
+ // the heuristics once up front. Block before even entering the
163
+ // review loop on findings that would make Pages refuse to build —
164
+ // the user needs to either fix the project or switch back to
165
+ // coolify. The edit-step handler runs the same check when the
166
+ // user *switches into* gh-pages from inside the loop, so this
167
+ // covers the gap where they never edit the deploymentMode row.
168
+ if (plan.deploymentMode === "gh-pages") {
169
+ const { detectPagesIncompatibilities, hasBlockingFinding } = await import("./scaffold/pages-heuristics.js");
170
+ const findings = detectPagesIncompatibilities(state.projectDir);
171
+ if (findings.length > 0) {
172
+ console.log(chalk.bold("\n Pages compatibility findings:\n"));
173
+ for (const f of findings) {
174
+ const tag = f.level === "block"
175
+ ? chalk.red("✗ block")
176
+ : f.level === "warn"
177
+ ? chalk.yellow("! warn")
178
+ : chalk.dim("· info");
179
+ console.log(` ${tag} ${chalk.bold(f.title)}`);
180
+ console.log(chalk.dim(` ${f.detail}`));
181
+ for (const ev of f.evidence) {
182
+ console.log(chalk.dim(` → ${ev}`));
183
+ }
184
+ }
185
+ console.log();
186
+ if (hasBlockingFinding(findings)) {
187
+ console.log(chalk.red(" Blocking findings — Pages can't host this project as-is. Fix the issues above\n" +
188
+ " or pick a different deployment mode in the review screen."));
189
+ }
190
+ }
191
+ }
150
192
  plan = await reviewLoop(state, plan);
193
+ // Re-check after the review loop in case the user kept (or switched
194
+ // back to) gh-pages despite blockers being present. The edit handler
195
+ // refuses the switch into gh-pages over blockers, but it can't catch
196
+ // the case where blockers exist on entry AND the user stays put.
197
+ if (plan.deploymentMode === "gh-pages") {
198
+ const { detectPagesIncompatibilities, hasBlockingFinding } = await import("./scaffold/pages-heuristics.js");
199
+ const findings = detectPagesIncompatibilities(state.projectDir);
200
+ if (hasBlockingFinding(findings)) {
201
+ throw new Error("Pages compatibility blockers still present — refusing to adopt with gh-pages mode. Fix the issues listed above or re-run adopt and pick coolify/scaffold-only.");
202
+ }
203
+ }
151
204
  await executePlan(state, plan, {
152
205
  resume: !!opts.resume,
153
206
  regeneratePipeline: !!opts.regeneratePipeline,
@@ -612,70 +665,85 @@ function buildAdoptGroups(state, plan) {
612
665
  },
613
666
  ],
614
667
  },
615
- {
616
- title: "Build pipeline",
617
- steps: [
668
+ // The Docker + GH Actions pipeline is Coolify-targeted (it
669
+ // configures the redeploy webhook + builds an image). gh-pages
670
+ // ships its own workflow that uploads to Pages instead, so we
671
+ // hide this group when the user picks Pages.
672
+ ...(plan.deploymentMode === "coolify"
673
+ ? [
618
674
  {
619
- key: "scaffoldBuildPipeline",
620
- label: "Docker + GH Actions",
621
- set: true,
622
- summary: renderBuildPipelineSummary(state, plan),
675
+ title: "Build pipeline",
676
+ steps: [
677
+ {
678
+ key: "scaffoldBuildPipeline",
679
+ label: "Docker + GH Actions",
680
+ set: true,
681
+ summary: renderBuildPipelineSummary(state, plan),
682
+ },
683
+ ],
623
684
  },
624
- ],
625
- },
685
+ ]
686
+ : []),
626
687
  {
627
688
  title: "Deploy",
628
689
  steps: [
629
- (() => {
630
- // Visibility row. Picking the wrong path here is the #1
631
- // cause of "Permission denied (publickey)" deploy failures
632
- // (Coolify tries SSH against a repo whose GitHub App isn't
633
- // installed). Mark `set: false` when we couldn't auto-detect
634
- // visibility from `gh repo view`, so the cursor parks on it.
635
- const detected = state.gitRemoteIsPrivate;
636
- const summaryBase = plan.isPrivate
637
- ? "privateCoolify clones via GitHub App SSH key"
638
- : "public Coolify clones via HTTPS";
639
- const detectedHint = detected === undefined && state.gitRemoteUrl
640
- ? chalk.dim(" (couldn't auto-detect — confirm)")
641
- : detected !== undefined && detected !== plan.isPrivate
642
- ? chalk.yellow(` (gh says ${detected ? "private" : "public"} — overridden)`)
643
- : "";
644
- return {
645
- key: "isPrivate",
646
- label: "Repo visibility",
647
- // `set: false` when we have a remote but couldn't detect
648
- // (forces the user to confirm before Adopt). Otherwise true.
649
- set: !(detected === undefined && !!state.gitRemoteUrl),
650
- summary: `${summaryBase}${detectedHint}`,
651
- };
652
- })(),
653
- (() => {
654
- // Preflight: when adopt will be wiring a private repo into
655
- // Coolify and Coolify has zero GitHub App sources, the wire
656
- // step will throw at execute time with "no Coolify GitHub
657
- // source configured". Surface that here so the cursor parks
658
- // on this row and the user fixes it before hitting Adopt.
659
- const missingSource = plan.wireCoolify &&
660
- plan.isPrivate &&
661
- state.coolifyConfigured &&
662
- state.coolifyGithubSourceCount === 0;
663
- const baseSummary = plan.wireCoolify
664
- ? state.coolifyAppMatch
665
- ? chalk.dim(`existing app "${state.coolifyAppMatch.name}" — will reconcile build pack`)
666
- : `yes — create app + upsert DNS (port ${plan.appPort})`
667
- : state.coolifyAppMatch
668
- ? chalk.dim(`already exists: ${state.coolifyAppMatch.name}`)
669
- : chalk.dim("no");
670
- return {
671
- key: "wireCoolify",
672
- label: "Coolify + DNS",
673
- set: !missingSource,
674
- summary: missingSource
675
- ? `${baseSummary} ${chalk.yellow("(needs Coolify GitHub App — install one or set visibility to public)")}`
676
- : baseSummary,
677
- };
678
- })(),
690
+ // Top-line choice: where this project deploys. Hides the
691
+ // Coolify-specific rows when set to gh-pages.
692
+ {
693
+ key: "deploymentMode",
694
+ label: "Deployment mode",
695
+ set: true,
696
+ summary: renderAdoptDeploymentModeSummary(plan.deploymentMode, plan.surfaces),
697
+ },
698
+ // Coolify-specific rows only shown when actually deploying
699
+ // to Coolify. gh-pages skips this branch entirely.
700
+ ...(plan.deploymentMode === "coolify"
701
+ ? [
702
+ (() => {
703
+ // Visibility row. Picking the wrong path here is the #1
704
+ // cause of "Permission denied (publickey)" deploy failures
705
+ // (Coolify tries SSH against a repo whose GitHub App isn't
706
+ // installed). Mark `set: false` when we couldn't auto-detect
707
+ // visibility from `gh repo view`, so the cursor parks on it.
708
+ const detected = state.gitRemoteIsPrivate;
709
+ const summaryBase = plan.isPrivate
710
+ ? "private Coolify clones via GitHub App SSH key"
711
+ : "public — Coolify clones via HTTPS";
712
+ const detectedHint = detected === undefined && state.gitRemoteUrl
713
+ ? chalk.dim(" (couldn't auto-detect — confirm)")
714
+ : detected !== undefined && detected !== plan.isPrivate
715
+ ? chalk.yellow(` (gh says ${detected ? "private" : "public"} overridden)`)
716
+ : "";
717
+ return {
718
+ key: "isPrivate",
719
+ label: "Repo visibility",
720
+ set: !(detected === undefined && !!state.gitRemoteUrl),
721
+ summary: `${summaryBase}${detectedHint}`,
722
+ };
723
+ })(),
724
+ (() => {
725
+ const missingSource = plan.wireCoolify &&
726
+ plan.isPrivate &&
727
+ state.coolifyConfigured &&
728
+ state.coolifyGithubSourceCount === 0;
729
+ const baseSummary = plan.wireCoolify
730
+ ? state.coolifyAppMatch
731
+ ? chalk.dim(`existing app "${state.coolifyAppMatch.name}" — will reconcile build pack`)
732
+ : `yes — create app + upsert DNS (port ${plan.appPort})`
733
+ : state.coolifyAppMatch
734
+ ? chalk.dim(`already exists: ${state.coolifyAppMatch.name}`)
735
+ : chalk.dim("no");
736
+ return {
737
+ key: "wireCoolify",
738
+ label: "Coolify + DNS",
739
+ set: !missingSource,
740
+ summary: missingSource
741
+ ? `${baseSummary} ${chalk.yellow("(needs Coolify GitHub App — install one or set visibility to public)")}`
742
+ : baseSummary,
743
+ };
744
+ })(),
745
+ ]
746
+ : []),
679
747
  ],
680
748
  },
681
749
  {
@@ -687,20 +755,39 @@ function buildAdoptGroups(state, plan) {
687
755
  set: true,
688
756
  summary: plan.services.length > 0 ? plan.services.join(", ") : chalk.dim("skip provisioning"),
689
757
  },
690
- {
691
- key: "pushKey",
692
- label: "Push key to Coolify",
693
- set: true,
694
- summary: plan.pushKey
695
- ? state.coolifyAppMatch
696
- ? `yes (${state.coolifyAppMatch.name})`
697
- : "yes Coolify app must exist by name"
698
- : chalk.dim("no"),
699
- },
758
+ // `pushKey` only matters when a Coolify app is the deploy
759
+ // target — Pages reads no secrets from a Coolify env, so the
760
+ // row would just be noise on the gh-pages path.
761
+ ...(plan.deploymentMode === "coolify"
762
+ ? [
763
+ {
764
+ key: "pushKey",
765
+ label: "Push key to Coolify",
766
+ set: true,
767
+ summary: plan.pushKey
768
+ ? state.coolifyAppMatch
769
+ ? `yes (${state.coolifyAppMatch.name})`
770
+ : "yes — Coolify app must exist by name"
771
+ : chalk.dim("no"),
772
+ },
773
+ ]
774
+ : []),
700
775
  ],
701
776
  },
702
777
  ];
703
778
  }
779
+ function renderAdoptDeploymentModeSummary(mode, surfaces) {
780
+ switch (mode) {
781
+ case "coolify":
782
+ return "Coolify (full-stack on Hetzner)";
783
+ case "gh-pages":
784
+ return surfaces === "client-only"
785
+ ? "GitHub Pages (static)"
786
+ : chalk.yellow("GitHub Pages — needs surfaces=client-only");
787
+ case "scaffold-only":
788
+ return "scaffold only (no deploy)";
789
+ }
790
+ }
704
791
  async function editAdoptStep(state, plan, step) {
705
792
  if (step === "name") {
706
793
  const name = (await input({
@@ -744,9 +831,17 @@ async function editAdoptStep(state, plan, step) {
744
831
  // Adjust the dir fields when the surface changes — dropping
745
832
  // server/client dirs that are no longer relevant, and setting
746
833
  // sane defaults for newly-relevant ones.
834
+ // Also: switching away from client-only invalidates gh-pages
835
+ // (Pages can't host a backend). Snap deploymentMode back to
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;
838
+ if (plan.deploymentMode === "gh-pages" && next !== "client-only") {
839
+ console.log(chalk.yellow(" ⚠ gh-pages requires client-only surfaces — switched deployment mode back to coolify."));
840
+ }
747
841
  return {
748
842
  ...plan,
749
843
  surfaces: next,
844
+ deploymentMode: nextDeploymentMode,
750
845
  serverDir: next === "client-only"
751
846
  ? undefined
752
847
  : (plan.serverDir ?? state.serverDir ?? state.projectDir),
@@ -755,6 +850,58 @@ async function editAdoptStep(state, plan, step) {
755
850
  : (plan.clientDir ?? state.clientDir ?? state.projectDir),
756
851
  };
757
852
  }
853
+ if (step === "deploymentMode") {
854
+ const choices = [
855
+ { name: "Coolify (full-stack on Hetzner)", value: "coolify" },
856
+ ];
857
+ if (plan.surfaces === "client-only") {
858
+ choices.push({ name: "GitHub Pages (static)", value: "gh-pages" });
859
+ }
860
+ choices.push({ name: "Scaffold only — don't deploy", value: "scaffold-only" });
861
+ const next = await select({
862
+ message: "Where do you want to deploy?",
863
+ choices,
864
+ default: plan.deploymentMode,
865
+ });
866
+ // When switching INTO gh-pages, run the static-site sanity checks
867
+ // and surface any blockers before letting the user proceed. They
868
+ // can still pick gh-pages over a "warn" finding, but "block"
869
+ // (e.g. Next without `output: "export"`) requires they either fix
870
+ // the project first or step back to coolify.
871
+ if (next === "gh-pages" && plan.deploymentMode !== "gh-pages") {
872
+ const { detectPagesIncompatibilities, hasBlockingFinding } = await import("./scaffold/pages-heuristics.js");
873
+ const findings = detectPagesIncompatibilities(state.projectDir);
874
+ if (findings.length > 0) {
875
+ console.log(chalk.bold("\n Pages compatibility findings:\n"));
876
+ for (const f of findings) {
877
+ const tag = f.level === "block"
878
+ ? chalk.red("✗ block")
879
+ : f.level === "warn"
880
+ ? chalk.yellow("! warn")
881
+ : chalk.dim("· info");
882
+ console.log(` ${tag} ${chalk.bold(f.title)}`);
883
+ console.log(chalk.dim(` ${f.detail}`));
884
+ for (const ev of f.evidence) {
885
+ console.log(chalk.dim(` → ${ev}`));
886
+ }
887
+ }
888
+ console.log();
889
+ if (hasBlockingFinding(findings)) {
890
+ console.log(chalk.red(" Blocking findings present — Pages won't be able to host this project as-is."));
891
+ console.log(chalk.dim(" Fix the issues above (or stay on coolify) and re-pick."));
892
+ // Don't switch — leave plan.deploymentMode unchanged.
893
+ return plan;
894
+ }
895
+ const proceed = await confirm({
896
+ message: "Proceed with gh-pages despite the warnings?",
897
+ default: false,
898
+ });
899
+ if (!proceed)
900
+ return plan;
901
+ }
902
+ }
903
+ return { ...plan, deploymentMode: next };
904
+ }
758
905
  if (step === "serverDir") {
759
906
  const picked = (await input({
760
907
  message: "Server env directory (relative to project root):",
@@ -976,7 +1123,11 @@ async function executePlan(state, plan, opts = { resume: false }) {
976
1123
  // anything that already exists, so this is idempotent across re-runs.
977
1124
  // Must run BEFORE Coolify wiring so the docker-compose.yml exists
978
1125
  // by the time Coolify clones the repo for the first deploy.
979
- if (plan.scaffoldBuildPipeline) {
1126
+ //
1127
+ // Gated on coolify mode — the Coolify-targeted Dockerfile + deploy
1128
+ // webhook workflow aren't useful for gh-pages, which uses its own
1129
+ // `gh-pages.yml` workflow written later in step 3c-pages.
1130
+ if (plan.scaffoldBuildPipeline && plan.deploymentMode === "coolify") {
980
1131
  const pipeResult = await scaffoldBuildPipelineNow(state, plan, remoteUrl, {
981
1132
  force: !!opts.regeneratePipeline,
982
1133
  });
@@ -995,7 +1146,9 @@ async function executePlan(state, plan, opts = { resume: false }) {
995
1146
  // DNS-provider REST endpoints with credentials we already have in
996
1147
  // keychain. Idempotent on the DNS side (upsert); not yet on the
997
1148
  // app-create side (Coolify accepts duplicate app names).
998
- if (plan.wireCoolify && remoteUrl) {
1149
+ //
1150
+ // Skipped for gh-pages — Pages handles its own DNS in step 3c-pages.
1151
+ if (plan.wireCoolify && plan.deploymentMode === "coolify" && remoteUrl) {
999
1152
  try {
1000
1153
  const { wireProjectIntoCoolify } = await import("./deploy/coolify-app.js");
1001
1154
  coolifyResult = await wireProjectIntoCoolify({
@@ -1102,8 +1255,12 @@ async function executePlan(state, plan, opts = { resume: false }) {
1102
1255
  // created" and "app already existed before adopt" branches. Need
1103
1256
  // an app uuid in either case. Run BEFORE the initial git push
1104
1257
  // (below) so the workflow's first run has the secrets in place.
1258
+ //
1259
+ // Skipped for gh-pages — there's no Coolify webhook to hit.
1105
1260
  const appUuidForSecrets = coolifyResult?.appUuid ?? state.coolifyAppMatch?.uuid;
1106
- if (plan.scaffoldBuildPipeline && appUuidForSecrets) {
1261
+ if (plan.scaffoldBuildPipeline &&
1262
+ plan.deploymentMode === "coolify" &&
1263
+ appUuidForSecrets) {
1107
1264
  const slug = repoSlugFromRemote(remoteUrl);
1108
1265
  if (slug) {
1109
1266
  await setCoolifyDeploySecrets({
@@ -1127,7 +1284,10 @@ async function executePlan(state, plan, opts = { resume: false }) {
1127
1284
  // a Coolify one) — gates only on having the build pipeline + a
1128
1285
  // resolvable repo slug. Best-effort: failure surfaces as a caveat
1129
1286
  // with a copy-pasteable manual recipe so adopt finishes cleanly.
1130
- if (plan.scaffoldBuildPipeline) {
1287
+ //
1288
+ // gh-pages doesn't need this secret — the Pages workflow builds
1289
+ // the client without consuming server-side env.
1290
+ if (plan.scaffoldBuildPipeline && plan.deploymentMode === "coolify") {
1131
1291
  const slug = repoSlugFromRemote(remoteUrl);
1132
1292
  if (slug) {
1133
1293
  const secretName = "DOTENV_PRIVATE_KEY_PRODUCTION";
@@ -1157,6 +1317,74 @@ async function executePlan(state, plan, opts = { resume: false }) {
1157
1317
  console.log(chalk.dim(" · Couldn't resolve owner/repo from git remote — push DOTENV_PRIVATE_KEY_PRODUCTION to Actions manually."));
1158
1318
  }
1159
1319
  }
1320
+ // Step 3c-pages: GitHub Pages setup (gh-pages mode only).
1321
+ // Writes .github/workflows/gh-pages.yml + CNAME locally and
1322
+ // configures the remote side (enable Pages, register cname,
1323
+ // wire DNS, poll for the Let's Encrypt cert, flip
1324
+ // https_enforced). Must happen BEFORE the push step below so
1325
+ // the new files land in the same push and the workflow runs.
1326
+ //
1327
+ // Auto-detect heuristic: for a client-only project we deploy
1328
+ // the client dir (typically `packages/client/`). The detected
1329
+ // shape mirrors what gh-pages's own pickSite would have chosen
1330
+ // — node-build, pnpm, root-level build script.
1331
+ if (plan.deploymentMode === "gh-pages" && remoteUrl) {
1332
+ try {
1333
+ const { runPagesSetupProgrammatic } = await import("./deploy/pages.js");
1334
+ const { exec: bashExec } = await import("./utils/exec.js");
1335
+ const slug = repoSlugFromRemote(remoteUrl) ?? plan.name;
1336
+ // For adopt we don't know the exact build layout of the
1337
+ // user's project. Best-guess for a client-only Next.js
1338
+ // app: `packages/client/out` (post-`output: export` build).
1339
+ // If the user has a different layout they can re-run
1340
+ // `hatchkit gh-pages` from the project dir to override.
1341
+ const clientDir = plan.clientDir
1342
+ ? relative(state.projectDir, plan.clientDir)
1343
+ : "packages/client";
1344
+ const detected = {
1345
+ kind: "node-build",
1346
+ publishDir: clientDir ? `${clientDir}/out` : "out",
1347
+ packageManager: "pnpm",
1348
+ buildScript: "build",
1349
+ workDir: "",
1350
+ };
1351
+ const { pageUrl } = await runPagesSetupProgrammatic(state.projectDir, {
1352
+ detected,
1353
+ domain: plan.domain || null,
1354
+ });
1355
+ ledger.record({
1356
+ kind: "ghPages",
1357
+ repo: slug,
1358
+ projectDir: state.projectDir,
1359
+ cname: plan.domain || undefined,
1360
+ });
1361
+ // Stage and commit so the next push picks up the workflow
1362
+ // + CNAME file. If nothing changed (idempotent re-run), the
1363
+ // status check skips the commit entirely.
1364
+ await bashExec("git", ["add", "-A"], { cwd: state.projectDir, silent: true });
1365
+ const status = await bashExec("git", ["status", "--porcelain"], {
1366
+ cwd: state.projectDir,
1367
+ silent: true,
1368
+ });
1369
+ if (status.stdout.trim()) {
1370
+ await bashExec("git", ["commit", "-m", "ci: GitHub Pages setup"], {
1371
+ cwd: state.projectDir,
1372
+ silent: true,
1373
+ });
1374
+ }
1375
+ console.log(chalk.green(` ✓ GitHub Pages will publish at ${pageUrl}`));
1376
+ }
1377
+ catch (err) {
1378
+ caveats.push({
1379
+ title: "GitHub Pages not wired",
1380
+ reason: err.message,
1381
+ recovery: [
1382
+ `Re-run from the project dir: hatchkit gh-pages`,
1383
+ `(it'll pick up where this left off and is idempotent).`,
1384
+ ],
1385
+ });
1386
+ }
1387
+ }
1160
1388
  // Step 3d: push the working branch to origin. Done AFTER secrets
1161
1389
  // are set so the workflow's first run can hit the Coolify webhook
1162
1390
  // without falling through to the "secret not set" branch. Skipped
@@ -1828,6 +2056,10 @@ function writeAdoptManifest(projectDir, plan) {
1828
2056
  mlServices: [],
1829
2057
  s3Provider: (() => (plan.features.includes("s3") ? "existing" : "none"))(),
1830
2058
  deployTarget: "existing",
2059
+ // Persist deployment mode so `--resume` recovers the gh-pages
2060
+ // path without re-asking the user. Same back-compat invariant
2061
+ // as `surfaces` — readers without this field fall back to coolify.
2062
+ deploymentMode: plan.deploymentMode,
1831
2063
  ports: { server: 3000, client: 3001 },
1832
2064
  // Persist the surface choice so `--resume` doesn't re-infer
1833
2065
  // "server-only" just because there's no client/ directory in the