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.
- package/dist/adopt.d.ts.map +1 -1
- package/dist/adopt.js +663 -82
- package/dist/adopt.js.map +1 -1
- package/dist/config.d.ts +32 -10
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +91 -38
- package/dist/config.js.map +1 -1
- package/dist/deploy/coolify-app.d.ts.map +1 -1
- package/dist/deploy/coolify-app.js +0 -7
- package/dist/deploy/coolify-app.js.map +1 -1
- package/dist/deploy/coolify.d.ts.map +1 -1
- package/dist/deploy/coolify.js +20 -1
- package/dist/deploy/coolify.js.map +1 -1
- package/dist/deploy/ghcr.d.ts +4 -2
- package/dist/deploy/ghcr.d.ts.map +1 -1
- package/dist/deploy/ghcr.js +1 -1
- package/dist/deploy/ghcr.js.map +1 -1
- package/dist/deploy/github.d.ts +4 -3
- package/dist/deploy/github.d.ts.map +1 -1
- package/dist/deploy/github.js +5 -2
- package/dist/deploy/github.js.map +1 -1
- package/dist/deploy/pages.d.ts +41 -0
- package/dist/deploy/pages.d.ts.map +1 -1
- package/dist/deploy/pages.js +363 -22
- package/dist/deploy/pages.js.map +1 -1
- package/dist/deploy/regen-infra.d.ts.map +1 -1
- package/dist/deploy/regen-infra.js +5 -11
- package/dist/deploy/regen-infra.js.map +1 -1
- package/dist/deploy/rollback.d.ts.map +1 -1
- package/dist/deploy/rollback.js +44 -6
- package/dist/deploy/rollback.js.map +1 -1
- package/dist/deploy/terraform.d.ts.map +1 -1
- package/dist/deploy/terraform.js +20 -37
- package/dist/deploy/terraform.js.map +1 -1
- package/dist/dns.d.ts.map +1 -1
- package/dist/dns.js +4 -5
- package/dist/dns.js.map +1 -1
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +110 -36
- package/dist/doctor.js.map +1 -1
- package/dist/email/index.d.ts +31 -0
- package/dist/email/index.d.ts.map +1 -0
- package/dist/email/index.js +251 -0
- package/dist/email/index.js.map +1 -0
- package/dist/email/presets.d.ts +14 -0
- package/dist/email/presets.d.ts.map +1 -0
- package/dist/email/presets.js +33 -0
- package/dist/email/presets.js.map +1 -0
- package/dist/email/setup.d.ts +93 -0
- package/dist/email/setup.d.ts.map +1 -0
- package/dist/email/setup.js +263 -0
- package/dist/email/setup.js.map +1 -0
- package/dist/email/spf.d.ts +56 -0
- package/dist/email/spf.d.ts.map +1 -0
- package/dist/email/spf.js +102 -0
- package/dist/email/spf.js.map +1 -0
- package/dist/index.js +306 -22
- package/dist/index.js.map +1 -1
- package/dist/inventory.d.ts +37 -0
- package/dist/inventory.d.ts.map +1 -1
- package/dist/inventory.js +536 -55
- package/dist/inventory.js.map +1 -1
- package/dist/overview.d.ts +101 -0
- package/dist/overview.d.ts.map +1 -0
- package/dist/overview.js +880 -0
- package/dist/overview.js.map +1 -0
- package/dist/prompts.d.ts +27 -0
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +262 -34
- package/dist/prompts.js.map +1 -1
- package/dist/provision/index.d.ts +20 -1
- package/dist/provision/index.d.ts.map +1 -1
- package/dist/provision/index.js +115 -0
- package/dist/provision/index.js.map +1 -1
- package/dist/provision/s3-buckets.js +1 -1
- package/dist/provision/s3-buckets.js.map +1 -1
- package/dist/scaffold/app.d.ts.map +1 -1
- package/dist/scaffold/app.js +15 -7
- package/dist/scaffold/app.js.map +1 -1
- package/dist/scaffold/build-pipeline.d.ts +16 -0
- package/dist/scaffold/build-pipeline.d.ts.map +1 -1
- package/dist/scaffold/build-pipeline.js +47 -4
- package/dist/scaffold/build-pipeline.js.map +1 -1
- package/dist/scaffold/infra.d.ts +4 -5
- package/dist/scaffold/infra.d.ts.map +1 -1
- package/dist/scaffold/infra.js +18 -57
- package/dist/scaffold/infra.js.map +1 -1
- package/dist/scaffold/manifest.d.ts +6 -0
- package/dist/scaffold/manifest.d.ts.map +1 -1
- package/dist/scaffold/manifest.js +2 -0
- package/dist/scaffold/manifest.js.map +1 -1
- package/dist/scaffold/pages-heuristics.d.ts +17 -0
- package/dist/scaffold/pages-heuristics.d.ts.map +1 -0
- package/dist/scaffold/pages-heuristics.js +344 -0
- package/dist/scaffold/pages-heuristics.js.map +1 -0
- package/dist/scaffold/pages-mode.d.ts +10 -0
- package/dist/scaffold/pages-mode.d.ts.map +1 -0
- package/dist/scaffold/pages-mode.js +107 -0
- package/dist/scaffold/pages-mode.js.map +1 -0
- package/dist/scaffold/pkg-json.d.ts +4 -0
- package/dist/scaffold/pkg-json.d.ts.map +1 -1
- package/dist/scaffold/pkg-json.js +17 -0
- package/dist/scaffold/pkg-json.js.map +1 -1
- package/dist/scaffold/surfaces.d.ts.map +1 -1
- package/dist/scaffold/surfaces.js +12 -1
- package/dist/scaffold/surfaces.js.map +1 -1
- package/dist/scaffold/update.js +1 -1
- package/dist/scaffold/update.js.map +1 -1
- package/dist/templates/build-pipeline/Dockerfile.nextjs.hbs +103 -0
- package/dist/templates/build-pipeline/docker-compose.yml.hbs +23 -6
- package/dist/utils/cloudflare-api.d.ts +158 -13
- package/dist/utils/cloudflare-api.d.ts.map +1 -1
- package/dist/utils/cloudflare-api.js +219 -11
- package/dist/utils/cloudflare-api.js.map +1 -1
- package/dist/utils/coolify-api.d.ts +9 -0
- package/dist/utils/coolify-api.d.ts.map +1 -1
- package/dist/utils/coolify-api.js +26 -0
- package/dist/utils/coolify-api.js.map +1 -1
- package/dist/utils/run-ledger.d.ts +42 -1
- package/dist/utils/run-ledger.d.ts.map +1 -1
- package/dist/utils/run-ledger.js.map +1 -1
- package/dist/utils/s3-admin.d.ts +9 -0
- package/dist/utils/s3-admin.d.ts.map +1 -0
- package/dist/utils/s3-admin.js +46 -0
- package/dist/utils/s3-admin.js.map +1 -0
- package/package.json +1 -1
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
|
-
|
|
617
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
: "
|
|
698
|
-
|
|
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,19 @@ 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"
|
|
838
|
+
? "coolify"
|
|
839
|
+
: plan.deploymentMode;
|
|
840
|
+
if (plan.deploymentMode === "gh-pages" && next !== "client-only") {
|
|
841
|
+
console.log(chalk.yellow(" ⚠ gh-pages requires client-only surfaces — switched deployment mode back to coolify."));
|
|
842
|
+
}
|
|
747
843
|
return {
|
|
748
844
|
...plan,
|
|
749
845
|
surfaces: next,
|
|
846
|
+
deploymentMode: nextDeploymentMode,
|
|
750
847
|
serverDir: next === "client-only"
|
|
751
848
|
? undefined
|
|
752
849
|
: (plan.serverDir ?? state.serverDir ?? state.projectDir),
|
|
@@ -755,6 +852,58 @@ async function editAdoptStep(state, plan, step) {
|
|
|
755
852
|
: (plan.clientDir ?? state.clientDir ?? state.projectDir),
|
|
756
853
|
};
|
|
757
854
|
}
|
|
855
|
+
if (step === "deploymentMode") {
|
|
856
|
+
const choices = [
|
|
857
|
+
{ name: "Coolify (full-stack on Hetzner)", value: "coolify" },
|
|
858
|
+
];
|
|
859
|
+
if (plan.surfaces === "client-only") {
|
|
860
|
+
choices.push({ name: "GitHub Pages (static)", value: "gh-pages" });
|
|
861
|
+
}
|
|
862
|
+
choices.push({ name: "Scaffold only — don't deploy", value: "scaffold-only" });
|
|
863
|
+
const next = await select({
|
|
864
|
+
message: "Where do you want to deploy?",
|
|
865
|
+
choices,
|
|
866
|
+
default: plan.deploymentMode,
|
|
867
|
+
});
|
|
868
|
+
// When switching INTO gh-pages, run the static-site sanity checks
|
|
869
|
+
// and surface any blockers before letting the user proceed. They
|
|
870
|
+
// can still pick gh-pages over a "warn" finding, but "block"
|
|
871
|
+
// (e.g. Next without `output: "export"`) requires they either fix
|
|
872
|
+
// the project first or step back to coolify.
|
|
873
|
+
if (next === "gh-pages" && plan.deploymentMode !== "gh-pages") {
|
|
874
|
+
const { detectPagesIncompatibilities, hasBlockingFinding } = await import("./scaffold/pages-heuristics.js");
|
|
875
|
+
const findings = detectPagesIncompatibilities(state.projectDir);
|
|
876
|
+
if (findings.length > 0) {
|
|
877
|
+
console.log(chalk.bold("\n Pages compatibility findings:\n"));
|
|
878
|
+
for (const f of findings) {
|
|
879
|
+
const tag = f.level === "block"
|
|
880
|
+
? chalk.red("✗ block")
|
|
881
|
+
: f.level === "warn"
|
|
882
|
+
? chalk.yellow("! warn")
|
|
883
|
+
: chalk.dim("· info");
|
|
884
|
+
console.log(` ${tag} ${chalk.bold(f.title)}`);
|
|
885
|
+
console.log(chalk.dim(` ${f.detail}`));
|
|
886
|
+
for (const ev of f.evidence) {
|
|
887
|
+
console.log(chalk.dim(` → ${ev}`));
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
console.log();
|
|
891
|
+
if (hasBlockingFinding(findings)) {
|
|
892
|
+
console.log(chalk.red(" Blocking findings present — Pages won't be able to host this project as-is."));
|
|
893
|
+
console.log(chalk.dim(" Fix the issues above (or stay on coolify) and re-pick."));
|
|
894
|
+
// Don't switch — leave plan.deploymentMode unchanged.
|
|
895
|
+
return plan;
|
|
896
|
+
}
|
|
897
|
+
const proceed = await confirm({
|
|
898
|
+
message: "Proceed with gh-pages despite the warnings?",
|
|
899
|
+
default: false,
|
|
900
|
+
});
|
|
901
|
+
if (!proceed)
|
|
902
|
+
return plan;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return { ...plan, deploymentMode: next };
|
|
906
|
+
}
|
|
758
907
|
if (step === "serverDir") {
|
|
759
908
|
const picked = (await input({
|
|
760
909
|
message: "Server env directory (relative to project root):",
|
|
@@ -811,10 +960,15 @@ async function editAdoptStep(state, plan, step) {
|
|
|
811
960
|
checked: plan.services.includes("openpanel"),
|
|
812
961
|
},
|
|
813
962
|
{
|
|
814
|
-
name: "Resend (email)",
|
|
963
|
+
name: "Resend (transactional email)",
|
|
815
964
|
value: "resend",
|
|
816
965
|
checked: plan.services.includes("resend"),
|
|
817
966
|
},
|
|
967
|
+
{
|
|
968
|
+
name: "Email forwarding (Cloudflare Email Routing → your inbox)",
|
|
969
|
+
value: "email",
|
|
970
|
+
checked: plan.services.includes("email"),
|
|
971
|
+
},
|
|
818
972
|
],
|
|
819
973
|
});
|
|
820
974
|
return { ...plan, services };
|
|
@@ -976,10 +1130,18 @@ async function executePlan(state, plan, opts = { resume: false }) {
|
|
|
976
1130
|
// anything that already exists, so this is idempotent across re-runs.
|
|
977
1131
|
// Must run BEFORE Coolify wiring so the docker-compose.yml exists
|
|
978
1132
|
// by the time Coolify clones the repo for the first deploy.
|
|
979
|
-
|
|
1133
|
+
//
|
|
1134
|
+
// Gated on coolify mode — the Coolify-targeted Dockerfile + deploy
|
|
1135
|
+
// webhook workflow aren't useful for gh-pages, which uses its own
|
|
1136
|
+
// `gh-pages.yml` workflow written later in step 3c-pages.
|
|
1137
|
+
let scaffoldedAbsPaths = [];
|
|
1138
|
+
let overwrittenAbsPaths = [];
|
|
1139
|
+
if (plan.scaffoldBuildPipeline && plan.deploymentMode === "coolify") {
|
|
980
1140
|
const pipeResult = await scaffoldBuildPipelineNow(state, plan, remoteUrl, {
|
|
981
1141
|
force: !!opts.regeneratePipeline,
|
|
982
1142
|
});
|
|
1143
|
+
scaffoldedAbsPaths = pipeResult.createdAbsPaths;
|
|
1144
|
+
overwrittenAbsPaths = pipeResult.overwrittenAbsPaths;
|
|
983
1145
|
// Record only files we *created*. The `overwritten` list is
|
|
984
1146
|
// deliberately not recorded — those files existed before this
|
|
985
1147
|
// run (the user's), and a later `hatchkit destroy` must never
|
|
@@ -995,7 +1157,9 @@ async function executePlan(state, plan, opts = { resume: false }) {
|
|
|
995
1157
|
// DNS-provider REST endpoints with credentials we already have in
|
|
996
1158
|
// keychain. Idempotent on the DNS side (upsert); not yet on the
|
|
997
1159
|
// app-create side (Coolify accepts duplicate app names).
|
|
998
|
-
|
|
1160
|
+
//
|
|
1161
|
+
// Skipped for gh-pages — Pages handles its own DNS in step 3c-pages.
|
|
1162
|
+
if (plan.wireCoolify && plan.deploymentMode === "coolify" && remoteUrl) {
|
|
999
1163
|
try {
|
|
1000
1164
|
const { wireProjectIntoCoolify } = await import("./deploy/coolify-app.js");
|
|
1001
1165
|
coolifyResult = await wireProjectIntoCoolify({
|
|
@@ -1102,8 +1266,10 @@ async function executePlan(state, plan, opts = { resume: false }) {
|
|
|
1102
1266
|
// created" and "app already existed before adopt" branches. Need
|
|
1103
1267
|
// an app uuid in either case. Run BEFORE the initial git push
|
|
1104
1268
|
// (below) so the workflow's first run has the secrets in place.
|
|
1269
|
+
//
|
|
1270
|
+
// Skipped for gh-pages — there's no Coolify webhook to hit.
|
|
1105
1271
|
const appUuidForSecrets = coolifyResult?.appUuid ?? state.coolifyAppMatch?.uuid;
|
|
1106
|
-
if (plan.scaffoldBuildPipeline && appUuidForSecrets) {
|
|
1272
|
+
if (plan.scaffoldBuildPipeline && plan.deploymentMode === "coolify" && appUuidForSecrets) {
|
|
1107
1273
|
const slug = repoSlugFromRemote(remoteUrl);
|
|
1108
1274
|
if (slug) {
|
|
1109
1275
|
await setCoolifyDeploySecrets({
|
|
@@ -1127,7 +1293,10 @@ async function executePlan(state, plan, opts = { resume: false }) {
|
|
|
1127
1293
|
// a Coolify one) — gates only on having the build pipeline + a
|
|
1128
1294
|
// resolvable repo slug. Best-effort: failure surfaces as a caveat
|
|
1129
1295
|
// with a copy-pasteable manual recipe so adopt finishes cleanly.
|
|
1130
|
-
|
|
1296
|
+
//
|
|
1297
|
+
// gh-pages doesn't need this secret — the Pages workflow builds
|
|
1298
|
+
// the client without consuming server-side env.
|
|
1299
|
+
if (plan.scaffoldBuildPipeline && plan.deploymentMode === "coolify") {
|
|
1131
1300
|
const slug = repoSlugFromRemote(remoteUrl);
|
|
1132
1301
|
if (slug) {
|
|
1133
1302
|
const secretName = "DOTENV_PRIVATE_KEY_PRODUCTION";
|
|
@@ -1157,13 +1326,104 @@ async function executePlan(state, plan, opts = { resume: false }) {
|
|
|
1157
1326
|
console.log(chalk.dim(" · Couldn't resolve owner/repo from git remote — push DOTENV_PRIVATE_KEY_PRODUCTION to Actions manually."));
|
|
1158
1327
|
}
|
|
1159
1328
|
}
|
|
1160
|
-
// Step
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
|
|
1166
|
-
|
|
1329
|
+
// Step 3c-pages: GitHub Pages setup (gh-pages mode only).
|
|
1330
|
+
// Writes .github/workflows/gh-pages.yml + CNAME locally and
|
|
1331
|
+
// configures the remote side (enable Pages, register cname,
|
|
1332
|
+
// wire DNS, poll for the Let's Encrypt cert, flip
|
|
1333
|
+
// https_enforced). Must happen BEFORE the push step below so
|
|
1334
|
+
// the new files land in the same push and the workflow runs.
|
|
1335
|
+
//
|
|
1336
|
+
// Auto-detect heuristic: for a client-only project we deploy
|
|
1337
|
+
// the client dir (typically `packages/client/`). The detected
|
|
1338
|
+
// shape mirrors what gh-pages's own pickSite would have chosen
|
|
1339
|
+
// — node-build, pnpm, root-level build script.
|
|
1340
|
+
if (plan.deploymentMode === "gh-pages" && remoteUrl) {
|
|
1341
|
+
try {
|
|
1342
|
+
const { runPagesSetupProgrammatic } = await import("./deploy/pages.js");
|
|
1343
|
+
const { exec: bashExec } = await import("./utils/exec.js");
|
|
1344
|
+
const slug = repoSlugFromRemote(remoteUrl) ?? plan.name;
|
|
1345
|
+
// For adopt we don't know the exact build layout of the
|
|
1346
|
+
// user's project. Best-guess for a client-only Next.js
|
|
1347
|
+
// app: `packages/client/out` (post-`output: export` build).
|
|
1348
|
+
// If the user has a different layout they can re-run
|
|
1349
|
+
// `hatchkit gh-pages` from the project dir to override.
|
|
1350
|
+
const clientDir = plan.clientDir
|
|
1351
|
+
? relative(state.projectDir, plan.clientDir)
|
|
1352
|
+
: "packages/client";
|
|
1353
|
+
const detected = {
|
|
1354
|
+
kind: "node-build",
|
|
1355
|
+
publishDir: clientDir ? `${clientDir}/out` : "out",
|
|
1356
|
+
packageManager: "pnpm",
|
|
1357
|
+
buildScript: "build",
|
|
1358
|
+
workDir: "",
|
|
1359
|
+
};
|
|
1360
|
+
const { pageUrl } = await runPagesSetupProgrammatic(state.projectDir, {
|
|
1361
|
+
detected,
|
|
1362
|
+
domain: plan.domain || null,
|
|
1363
|
+
});
|
|
1364
|
+
ledger.record({
|
|
1365
|
+
kind: "ghPages",
|
|
1366
|
+
repo: slug,
|
|
1367
|
+
projectDir: state.projectDir,
|
|
1368
|
+
cname: plan.domain || undefined,
|
|
1369
|
+
});
|
|
1370
|
+
// Stage and commit so the next push picks up the workflow
|
|
1371
|
+
// + CNAME file. If nothing changed (idempotent re-run), the
|
|
1372
|
+
// status check skips the commit entirely.
|
|
1373
|
+
await bashExec("git", ["add", "-A"], { cwd: state.projectDir, silent: true });
|
|
1374
|
+
const status = await bashExec("git", ["status", "--porcelain"], {
|
|
1375
|
+
cwd: state.projectDir,
|
|
1376
|
+
silent: true,
|
|
1377
|
+
});
|
|
1378
|
+
if (status.stdout.trim()) {
|
|
1379
|
+
await bashExec("git", ["commit", "-m", "ci: GitHub Pages setup"], {
|
|
1380
|
+
cwd: state.projectDir,
|
|
1381
|
+
silent: true,
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
console.log(chalk.green(` ✓ GitHub Pages will publish at ${pageUrl}`));
|
|
1385
|
+
}
|
|
1386
|
+
catch (err) {
|
|
1387
|
+
caveats.push({
|
|
1388
|
+
title: "GitHub Pages not wired",
|
|
1389
|
+
reason: err.message,
|
|
1390
|
+
recovery: [
|
|
1391
|
+
`Re-run from the project dir: hatchkit gh-pages`,
|
|
1392
|
+
`(it'll pick up where this left off and is idempotent).`,
|
|
1393
|
+
],
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
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
|
+
}
|
|
1167
1427
|
}
|
|
1168
1428
|
// Step 3e: GHCR setup. Two paths, gated on the user's earlier
|
|
1169
1429
|
// public/private choice:
|
|
@@ -1186,7 +1446,25 @@ async function executePlan(state, plan, opts = { resume: false }) {
|
|
|
1186
1446
|
remoteUrl &&
|
|
1187
1447
|
coolifyResult !== undefined) {
|
|
1188
1448
|
const slug = repoSlugFromRemote(remoteUrl);
|
|
1189
|
-
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) {
|
|
1190
1468
|
const { makeGhcrPackagePublic, registerGhcrCredsWithCoolify } = await import("./deploy/ghcr.js");
|
|
1191
1469
|
if (plan.isPrivate) {
|
|
1192
1470
|
// Read the full GHCR config (token + the PAT owner's GitHub
|
|
@@ -1264,6 +1542,44 @@ async function executePlan(state, plan, opts = { resume: false }) {
|
|
|
1264
1542
|
else if (event.service === "resend") {
|
|
1265
1543
|
ledger.record({ kind: "resend", client: event.client });
|
|
1266
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
|
+
}
|
|
1267
1583
|
},
|
|
1268
1584
|
});
|
|
1269
1585
|
}
|
|
@@ -1714,6 +2030,267 @@ async function ensureEnvProductionCommitted(state, plan) {
|
|
|
1714
2030
|
` git -C ${relativeTo(state.projectDir)} commit -m "chore(dotenvx): commit encrypted .env.production" -- ${relativeTo(prodPath, state.projectDir)}`));
|
|
1715
2031
|
}
|
|
1716
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
|
+
}
|
|
1717
2294
|
async function setupGitHubRemote(state, plan) {
|
|
1718
2295
|
// Pre-flight gh CLI auth. ensureGitHub prompts the user to log in
|
|
1719
2296
|
// when needed; if they cancel, surface a clear "you can do this
|
|
@@ -1828,6 +2405,10 @@ function writeAdoptManifest(projectDir, plan) {
|
|
|
1828
2405
|
mlServices: [],
|
|
1829
2406
|
s3Provider: (() => (plan.features.includes("s3") ? "existing" : "none"))(),
|
|
1830
2407
|
deployTarget: "existing",
|
|
2408
|
+
// Persist deployment mode so `--resume` recovers the gh-pages
|
|
2409
|
+
// path without re-asking the user. Same back-compat invariant
|
|
2410
|
+
// as `surfaces` — readers without this field fall back to coolify.
|
|
2411
|
+
deploymentMode: plan.deploymentMode,
|
|
1831
2412
|
ports: { server: 3000, client: 3001 },
|
|
1832
2413
|
// Persist the surface choice so `--resume` doesn't re-infer
|
|
1833
2414
|
// "server-only" just because there's no client/ directory in the
|