hatchkit 0.1.16 → 0.1.18

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.js CHANGED
@@ -41,13 +41,15 @@ import { ensureGitHub, getCoolifyConfig } from "./config.js";
41
41
  import { ownerFromRemote, repoSlugFromRemote, setCoolifyDeploySecrets, } from "./deploy/gh-actions-secrets.js";
42
42
  import { pushInitialBranch } from "./deploy/github.js";
43
43
  import { pushProjectKeyToCoolify } from "./deploy/keys.js";
44
+ import { handleAdoptFailure } from "./deploy/rollback.js";
44
45
  import { runProvision } from "./provision/index.js";
45
46
  import { detectBuildPipeline, scaffoldBuildPipeline } from "./scaffold/build-pipeline.js";
46
47
  import { MANIFEST_FILENAME, readManifest, writeManifest, } from "./scaffold/manifest.js";
47
48
  import { CoolifyApi } from "./utils/coolify-api.js";
48
49
  import { exec, execOk } from "./utils/exec.js";
49
50
  import { multiselect } from "./utils/multiselect.js";
50
- import { SECRET_KEYS, setSecret } from "./utils/secrets.js";
51
+ import { RunLedger } from "./utils/run-ledger.js";
52
+ import { SECRET_KEYS, getSecret, setSecret } from "./utils/secrets.js";
51
53
  import { validateDomain, validateProjectName } from "./utils/validate.js";
52
54
  import { getCliVersion } from "./utils/version.js";
53
55
  export async function runAdopt(cwd, opts = {}) {
@@ -118,7 +120,10 @@ export async function runAdopt(cwd, opts = {}) {
118
120
  pushKey: !!state.coolifyAppMatch,
119
121
  };
120
122
  plan = await reviewLoop(state, plan);
121
- await executePlan(state, plan);
123
+ await executePlan(state, plan, {
124
+ resume: !!opts.resume,
125
+ regeneratePipeline: !!opts.regeneratePipeline,
126
+ });
122
127
  }
123
128
  // ---------------------------------------------------------------------------
124
129
  // Detection
@@ -173,18 +178,29 @@ async function detectProject(projectDir) {
173
178
  prodEnvIsEncrypted = /DOTENV_PUBLIC_KEY_PRODUCTION/.test(head);
174
179
  }
175
180
  const hasEnvKeys = existsSync(envKeysPath);
176
- // Coolify app match — best-effort, requires Coolify configured. If
177
- // it isn't, leave it undefined; the user can still adopt without it.
181
+ // Coolify probes — best-effort, requires Coolify configured. If
182
+ // it isn't, leave fields undefined; the user can still adopt without it.
183
+ // The two probes (apps + GitHub sources) are independent, so run
184
+ // them in parallel to keep detection latency in line with one call.
178
185
  let coolifyAppMatch;
186
+ let coolifyConfigured = false;
187
+ let coolifyGithubSourceCount;
179
188
  try {
180
189
  const cfg = await getCoolifyConfig();
181
- if (cfg && packageName) {
190
+ if (cfg) {
191
+ coolifyConfigured = true;
182
192
  const api = new CoolifyApi({ url: cfg.url, token: cfg.token });
183
- const apps = await api.listApplications();
184
- const wanted = [packageName, `${packageName}-web`, `${packageName}-server`];
185
- const match = apps.find((a) => wanted.includes(a.name));
186
- if (match)
187
- coolifyAppMatch = { uuid: match.uuid, name: match.name };
193
+ const [apps, sources] = await Promise.all([
194
+ api.listApplications(),
195
+ api.listGithubSources().catch(() => []),
196
+ ]);
197
+ coolifyGithubSourceCount = sources.length;
198
+ if (packageName) {
199
+ const wanted = [packageName, `${packageName}-web`, `${packageName}-server`];
200
+ const match = apps.find((a) => wanted.includes(a.name));
201
+ if (match)
202
+ coolifyAppMatch = { uuid: match.uuid, name: match.name };
203
+ }
188
204
  }
189
205
  }
190
206
  catch {
@@ -219,6 +235,8 @@ async function detectProject(projectDir) {
219
235
  prodEnvIsEncrypted,
220
236
  hasEnvKeys,
221
237
  coolifyAppMatch,
238
+ coolifyConfigured,
239
+ coolifyGithubSourceCount,
222
240
  isGitRepo,
223
241
  gitRemoteUrl,
224
242
  existingManifest,
@@ -340,6 +358,13 @@ function printDetected(state) {
340
358
  lines.push(row("Coolify app", state.coolifyAppMatch
341
359
  ? chalk.green(`${state.coolifyAppMatch.name} ✓`)
342
360
  : chalk.dim("(no match)")));
361
+ // Only show the GitHub-App row when Coolify is configured AND the
362
+ // count is zero — having sources is the boring expected state and
363
+ // doesn't need a row of its own. Zero is the case worth surfacing
364
+ // because it'll bite at execute time for private repos.
365
+ if (state.coolifyConfigured && state.coolifyGithubSourceCount === 0) {
366
+ lines.push(row("Coolify sources", chalk.yellow("no GitHub Apps installed — required for private repos")));
367
+ }
343
368
  lines.push(row("git remote", state.gitRemoteUrl
344
369
  ? chalk.green(state.gitRemoteUrl)
345
370
  : state.isGitRepo
@@ -513,18 +538,33 @@ function buildAdoptGroups(state, plan) {
513
538
  {
514
539
  title: "Deploy",
515
540
  steps: [
516
- {
517
- key: "wireCoolify",
518
- label: "Coolify + DNS",
519
- set: true,
520
- summary: plan.wireCoolify
541
+ (() => {
542
+ // Preflight: when adopt will create a (private) GitHub repo
543
+ // and Coolify has zero GitHub App sources, wireCoolify will
544
+ // throw at execute time with "no Coolify GitHub source
545
+ // configured". Surface that here so the cursor parks on this
546
+ // row and the user fixes it before hitting Adopt.
547
+ const willBePrivate = plan.setupGitHub;
548
+ const missingSource = plan.wireCoolify &&
549
+ willBePrivate &&
550
+ state.coolifyConfigured &&
551
+ state.coolifyGithubSourceCount === 0;
552
+ const baseSummary = plan.wireCoolify
521
553
  ? state.coolifyAppMatch
522
554
  ? chalk.dim(`existing app "${state.coolifyAppMatch.name}" — will skip create`)
523
555
  : `yes — create app + upsert DNS (port ${plan.appPort})`
524
556
  : state.coolifyAppMatch
525
557
  ? chalk.dim(`already exists: ${state.coolifyAppMatch.name}`)
526
- : chalk.dim("no"),
527
- },
558
+ : chalk.dim("no");
559
+ return {
560
+ key: "wireCoolify",
561
+ label: "Coolify + DNS",
562
+ set: !missingSource,
563
+ summary: missingSource
564
+ ? `${baseSummary} ${chalk.yellow("(needs Coolify GitHub App — install one or set this to no)")}`
565
+ : baseSummary,
566
+ };
567
+ })(),
528
568
  ],
529
569
  },
530
570
  {
@@ -711,161 +751,294 @@ async function editAdoptStep(state, plan, step) {
711
751
  }
712
752
  return plan;
713
753
  }
714
- // ---------------------------------------------------------------------------
715
- // Execution
716
- // ---------------------------------------------------------------------------
717
- async function executePlan(state, plan) {
754
+ async function executePlan(state, plan, opts = { resume: false }) {
718
755
  console.log(chalk.bold("\n ── Adopting ──────────────────────────────────────────────\n"));
719
- // Step 1: bootstrap / encrypt dotenvx so a key actually exists.
720
- if (plan.bootstrapDotenvx) {
721
- await bootstrapDotenvxNow(state, plan);
722
- }
723
- else {
724
- console.log(chalk.dim(" · Skipping dotenvx bootstrap (per stepper choice)."));
725
- }
726
- await importKeyToKeychain(state, plan);
727
- // Step 2: write the manifest. Done after key import so a partial
728
- // failure doesn't leave a manifest pointing at no key. The
729
- // manifest lives at the project ROOT (not under packages/server).
730
- writeAdoptManifest(state.projectDir, plan);
731
- console.log(chalk.green(` ✓ Wrote ${MANIFEST_FILENAME} at ${relativeTo(state.projectDir)}`));
732
- // Step 3: GitHub remote (init + create + push). Skipped if origin is
733
- // already set or the user opted out.
756
+ const caveats = [];
757
+ // Run ledger — append-only record of mutations so a mid-flight
758
+ // throw or a later `hatchkit destroy` can reverse just the things
759
+ // adopt actually created. Each `record(...)` call is gated by a
760
+ // "did this run create it (vs reuse)?" check captured BEFORE the
761
+ // mutation, so the user's pre-existing repo / files / Coolify
762
+ // resources never end up in the ledger. See cli/src/utils/run-ledger.ts.
763
+ //
764
+ // On --resume we preserve the previous attempt's ledger so undo
765
+ // covers BOTH runs' mutations otherwise a Coolify app the first
766
+ // run created (and the second run finds-by-name and reuses) would
767
+ // be invisible to a later destroy.
768
+ const ledger = opts.resume ? RunLedger.resumeOrStart(plan.name) : RunLedger.start(plan.name);
734
769
  let remoteUrl = state.gitRemoteUrl;
735
- if (plan.setupGitHub && !state.gitRemoteUrl) {
736
- remoteUrl = await setupGitHubRemote(state, plan);
737
- }
738
- else if (state.gitRemoteUrl) {
739
- console.log(chalk.dim(` · git origin already set → ${state.gitRemoteUrl}`));
740
- }
741
- // Step 3a: Scaffold the build pipeline (Dockerfile + compose +
742
- // GitHub Actions workflow). Detection inside the scaffolder skips
743
- // anything that already exists, so this is idempotent across re-runs.
744
- // Must run BEFORE Coolify wiring so the docker-compose.yml exists
745
- // by the time Coolify clones the repo for the first deploy.
746
- if (plan.scaffoldBuildPipeline) {
747
- await scaffoldBuildPipelineNow(state, plan, remoteUrl);
748
- }
749
- // Step 3b: Wire the repo into Coolify + DNS via direct API calls.
750
- // No infra/ submodule, no Terraform — just hits the Coolify and
751
- // DNS-provider REST endpoints with credentials we already have in
752
- // keychain. Idempotent on the DNS side (upsert); not yet on the
753
- // app-create side (Coolify accepts duplicate app names).
754
770
  let coolifyResult;
755
- if (plan.wireCoolify && remoteUrl) {
756
- try {
757
- const { wireProjectIntoCoolify } = await import("./deploy/coolify-app.js");
758
- coolifyResult = await wireProjectIntoCoolify({
759
- projectName: plan.name,
760
- domain: plan.domain,
761
- gitRepository: remoteUrl,
762
- // hatchkit's canonical pipeline = GitHub Actions builds image →
763
- // pushes to GHCR → Coolify pulls via docker-compose.yml. The
764
- // build-pipeline scaffold step (run earlier in this flow)
765
- // either kept the user's existing compose or wrote one
766
- // pointing at ghcr.io/<owner>/<name>:latest. Either way the
767
- // Coolify app reads docker-compose.yml from the repo root.
768
- buildPack: "dockercompose",
769
- // ports_exposes is still required by the Coolify API even for
770
- // dockercompose; it's purely metadata once the compose file
771
- // takes over.
772
- portsExposes: plan.surfaces === "client-only" ? "80" : plan.appPort,
773
- // Default assumption: anything we just `gh repo create --private`d
774
- // is private. If origin was already set we don't know for sure;
775
- // try public first (cheaper auth) and let the orchestrator handle
776
- // the fallback.
777
- isPrivate: plan.setupGitHub,
778
- });
771
+ try {
772
+ // Step 1: bootstrap / encrypt dotenvx so a key actually exists.
773
+ if (plan.bootstrapDotenvx) {
774
+ const dotenvxResult = await bootstrapDotenvxNow(state, plan);
775
+ if (dotenvxResult.createdKeysFile) {
776
+ // Only record the keys file when *this run* generated it.
777
+ // A pre-existing .env.keys belongs to the user — never delete it.
778
+ ledger.record({ kind: "dotenvxKeysFile", path: dotenvxResult.keysPath });
779
+ }
779
780
  }
780
- catch (err) {
781
- console.log(chalk.yellow(`\n Couldn't wire Coolify: ${err.message}`));
782
- console.log(chalk.dim(` Create the app manually in the Coolify dashboard pointing at\n` +
783
- ` ${remoteUrl}\n` +
784
- ` with domain ${plan.domain} and port ${plan.appPort}, then run\n` +
785
- ` hatchkit keys push ${plan.name}`));
781
+ else {
782
+ console.log(chalk.dim(" · Skipping dotenvx bootstrap (per stepper choice)."));
786
783
  }
787
- }
788
- else if (plan.wireCoolify && !remoteUrl) {
789
- console.log(chalk.yellow(" Coolify wiring needs a git remote URL skipping (no `origin` set and the GitHub step\n" +
790
- " was off). Set the remote yourself or re-run with `setup GitHub remote = yes`."));
791
- }
792
- // Step 3c: push the deploy-webhook secrets to the GitHub repo so
793
- // the scaffolded deploy.yml workflow can hit Coolify. Run whether
794
- // wireCoolify ran or not — covers both the "fresh app we just
795
- // created" and "app already existed before adopt" branches. Need
796
- // an app uuid in either case. Run BEFORE the initial git push
797
- // (below) so the workflow's first run has the secrets in place.
798
- const appUuidForSecrets = coolifyResult?.appUuid ?? state.coolifyAppMatch?.uuid;
799
- if (plan.scaffoldBuildPipeline && appUuidForSecrets) {
800
- const slug = repoSlugFromRemote(remoteUrl);
801
- if (slug) {
802
- await setCoolifyDeploySecrets({
803
- projectDir: state.projectDir,
804
- repoSlug: slug,
805
- apps: [{ uuid: appUuidForSecrets }],
806
- });
784
+ const importResult = await importKeyToKeychain(state, plan);
785
+ if (importResult.imported && importResult.created) {
786
+ // Same gate: only record a keychain entry adopt itself just put
787
+ // there. A pre-existing entry is owned by an earlier run.
788
+ ledger.record({ kind: "keychain", account: importResult.account });
807
789
  }
808
- else {
809
- console.log(chalk.dim(" · Couldn't resolve owner/repo from git remote set the deploy secrets manually."));
790
+ // Step 2: write the manifest. Done after key import so a partial
791
+ // failure doesn't leave a manifest pointing at no key. The
792
+ // manifest lives at the project ROOT (not under packages/server).
793
+ const manifestPath = join(state.projectDir, MANIFEST_FILENAME);
794
+ writeAdoptManifest(state.projectDir, plan);
795
+ console.log(chalk.green(` ✓ Wrote ${MANIFEST_FILENAME} at ${relativeTo(state.projectDir)}`));
796
+ if (!state.hasManifest) {
797
+ // Only on first-time adopt — `--resume` reuses the manifest the
798
+ // earlier run created, so that earlier run's ledger (if any) is
799
+ // the one responsible for cleanup.
800
+ ledger.record({ kind: "manifest", path: manifestPath });
810
801
  }
811
- }
812
- // Step 3d: push the working branch to origin. Done AFTER secrets
813
- // are set so the workflow's first run can hit the Coolify webhook
814
- // without falling through to the "secret not set" branch. Skipped
815
- // when there's no remote yet (e.g. user opted out of GitHub) or
816
- // when origin already had history before adopt.
817
- if (plan.setupGitHub && remoteUrl && !state.gitRemoteUrl) {
818
- await pushInitialBranch(state.projectDir);
819
- }
820
- // Step 4: provision clients via the existing `add` machinery so the
821
- // surfaces stepper, idempotency, and env writes behave identically
822
- // to a normal `hatchkit add`. Forward the surface choice — runProvision
823
- // uses the same vocabulary, so a client-only adopt produces a
824
- // client-only `add`.
825
- if (plan.services.length > 0) {
826
- console.log();
827
- const provisionMode = plan.surfaces === "both"
828
- ? "shared"
829
- : plan.surfaces === "server-only"
830
- ? "server-only"
831
- : "client-only";
832
- await runProvision({
833
- baseName: plan.name,
834
- services: plan.services,
835
- surfaces: {
836
- mode: provisionMode,
837
- serverEnvDir: plan.serverDir,
838
- clientEnvDir: plan.clientDir,
839
- },
840
- });
841
- }
842
- // Step 5: push key to Coolify — but only when wireCoolify didn't
843
- // already do it. wireCoolify's success path includes a setAppEnv
844
- // pass that pushes DOTENV_PRIVATE_KEY_PRODUCTION; if it failed,
845
- // there's no app to push to and pushing again would just produce
846
- // a confusing second error message.
847
- const wiredEnvAlready = plan.wireCoolify && coolifyResult !== undefined;
848
- if (plan.pushKey && !wiredEnvAlready) {
849
- if (plan.wireCoolify && !coolifyResult) {
850
- console.log(chalk.dim(` · Skipping standalone key push — Coolify wiring failed, no app to push to.`));
802
+ // Step 3: GitHub remote (init + create + push). Skipped if origin is
803
+ // already set or the user opted out.
804
+ if (plan.setupGitHub && !state.gitRemoteUrl) {
805
+ const ghResult = await setupGitHubRemote(state, plan);
806
+ remoteUrl = ghResult.url;
807
+ // gitInit BEFORE github so that, on undo, the GitHub repo gets
808
+ // deleted first the local .git would still exist long enough
809
+ // for the user to read the recipe / abort if they want.
810
+ if (ghResult.gitInitialized) {
811
+ ledger.record({ kind: "gitInit", path: join(state.projectDir, ".git") });
812
+ }
813
+ if (ghResult.repoSlug) {
814
+ ledger.record({ kind: "github", repo: ghResult.repoSlug });
815
+ }
851
816
  }
852
- else {
817
+ else if (state.gitRemoteUrl) {
818
+ console.log(chalk.dim(` · git origin already set → ${state.gitRemoteUrl}`));
819
+ }
820
+ // Step 3a: Scaffold the build pipeline (Dockerfile + compose +
821
+ // GitHub Actions workflow). Detection inside the scaffolder skips
822
+ // anything that already exists, so this is idempotent across re-runs.
823
+ // Must run BEFORE Coolify wiring so the docker-compose.yml exists
824
+ // by the time Coolify clones the repo for the first deploy.
825
+ if (plan.scaffoldBuildPipeline) {
826
+ const pipeResult = await scaffoldBuildPipelineNow(state, plan, remoteUrl, {
827
+ force: !!opts.regeneratePipeline,
828
+ });
829
+ // Record only files we *created*. The `overwritten` list is
830
+ // deliberately not recorded — those files existed before this
831
+ // run (the user's), and a later `hatchkit destroy` must never
832
+ // delete pre-existing content even after we rewrote it for
833
+ // them. Worst case post-destroy: the user is left with a
834
+ // hatchkit-flavoured Dockerfile they can simply delete.
835
+ for (const abs of pipeResult.createdAbsPaths) {
836
+ ledger.record({ kind: "scaffoldedFile", path: abs });
837
+ }
838
+ }
839
+ // Step 3b: Wire the repo into Coolify + DNS via direct API calls.
840
+ // No infra/ submodule, no Terraform — just hits the Coolify and
841
+ // DNS-provider REST endpoints with credentials we already have in
842
+ // keychain. Idempotent on the DNS side (upsert); not yet on the
843
+ // app-create side (Coolify accepts duplicate app names).
844
+ if (plan.wireCoolify && remoteUrl) {
853
845
  try {
854
- // Use the matched app name when we have one — adopt creates
855
- // apps with the bare project name (no `-web` suffix that the
856
- // create-flow scaffold uses).
857
- await pushProjectKeyToCoolify(plan.name, {
858
- appName: state.coolifyAppMatch?.name ?? plan.name,
846
+ const { wireProjectIntoCoolify } = await import("./deploy/coolify-app.js");
847
+ coolifyResult = await wireProjectIntoCoolify({
848
+ projectName: plan.name,
849
+ domain: plan.domain,
850
+ gitRepository: remoteUrl,
851
+ // hatchkit's canonical pipeline = GitHub Actions builds image →
852
+ // pushes to GHCR → Coolify pulls via docker-compose.yml. The
853
+ // build-pipeline scaffold step (run earlier in this flow)
854
+ // either kept the user's existing compose or wrote one
855
+ // pointing at ghcr.io/<owner>/<name>:latest. Either way the
856
+ // Coolify app reads docker-compose.yml from the repo root.
857
+ buildPack: "dockercompose",
858
+ // ports_exposes is still required by the Coolify API even for
859
+ // dockercompose; it's purely metadata once the compose file
860
+ // takes over.
861
+ portsExposes: plan.surfaces === "client-only" ? "80" : plan.appPort,
862
+ // Default assumption: anything we just `gh repo create --private`d
863
+ // is private. If origin was already set we don't know for sure;
864
+ // try public first (cheaper auth) and let the orchestrator handle
865
+ // the fallback.
866
+ isPrivate: plan.setupGitHub,
859
867
  });
860
- console.log(chalk.green(`\n ✓ Pushed dotenvx key to Coolify`));
868
+ // Record only the bits we actually created. wireProjectIntoCoolify
869
+ // returns explicit `*Created` flags exactly so adopt can guard
870
+ // each ledger entry against the "found by name, reused" branch.
871
+ if (coolifyResult.appCreated) {
872
+ ledger.record({ kind: "coolifyApp", uuid: coolifyResult.appUuid });
873
+ }
874
+ if (coolifyResult.projectCreated) {
875
+ ledger.record({ kind: "coolifyProject", uuid: coolifyResult.projectUuid });
876
+ }
877
+ if (coolifyResult.dnsRecordCreatedV4 &&
878
+ coolifyResult.dnsZoneId &&
879
+ coolifyResult.dnsRecordId) {
880
+ ledger.record({
881
+ kind: "cloudflareDnsRecord",
882
+ zoneId: coolifyResult.dnsZoneId,
883
+ recordId: coolifyResult.dnsRecordId,
884
+ name: plan.domain,
885
+ type: "A",
886
+ });
887
+ }
888
+ if (coolifyResult.dnsRecordCreatedV6 &&
889
+ coolifyResult.dnsZoneId &&
890
+ coolifyResult.dnsRecordIdV6) {
891
+ ledger.record({
892
+ kind: "cloudflareDnsRecord",
893
+ zoneId: coolifyResult.dnsZoneId,
894
+ recordId: coolifyResult.dnsRecordIdV6,
895
+ name: plan.domain,
896
+ type: "AAAA",
897
+ });
898
+ }
861
899
  }
862
900
  catch (err) {
863
- console.log(chalk.yellow(`\n Couldn't push dotenvx key to Coolify: ${err.message}`));
864
- console.log(chalk.dim(` Once the app exists, run: \`hatchkit keys push ${plan.name}\``));
901
+ // Brief inline note the full recovery recipe lands in the
902
+ // caveats block at the end, so users see one consolidated
903
+ // "what's missing + how to fix" instead of competing hints
904
+ // scattered through the run.
905
+ console.log(chalk.yellow(`\n ✗ Coolify wiring failed: ${err.message}`));
906
+ caveats.push({
907
+ title: "Coolify app not wired",
908
+ reason: err.message,
909
+ recovery: [
910
+ `After fixing the cause above, re-run: hatchkit adopt --resume`,
911
+ `Or create the app manually pointing at ${remoteUrl}`,
912
+ `(domain ${plan.domain}, port ${plan.appPort}), then: hatchkit keys push ${plan.name}`,
913
+ ],
914
+ });
915
+ }
916
+ }
917
+ else if (plan.wireCoolify && !remoteUrl) {
918
+ console.log(chalk.yellow("\n ✗ Coolify wiring skipped — no git remote available."));
919
+ caveats.push({
920
+ title: "Coolify app not wired",
921
+ reason: "No `origin` remote set and the GitHub step was disabled.",
922
+ recovery: [
923
+ `Set a remote yourself, then re-run: hatchkit adopt --resume`,
924
+ `Or re-run and toggle "GitHub remote = yes" in the stepper.`,
925
+ ],
926
+ });
927
+ }
928
+ // Step 3c: push the deploy-webhook secrets to the GitHub repo so
929
+ // the scaffolded deploy.yml workflow can hit Coolify. Run whether
930
+ // wireCoolify ran or not — covers both the "fresh app we just
931
+ // created" and "app already existed before adopt" branches. Need
932
+ // an app uuid in either case. Run BEFORE the initial git push
933
+ // (below) so the workflow's first run has the secrets in place.
934
+ const appUuidForSecrets = coolifyResult?.appUuid ?? state.coolifyAppMatch?.uuid;
935
+ if (plan.scaffoldBuildPipeline && appUuidForSecrets) {
936
+ const slug = repoSlugFromRemote(remoteUrl);
937
+ if (slug) {
938
+ await setCoolifyDeploySecrets({
939
+ projectDir: state.projectDir,
940
+ repoSlug: slug,
941
+ apps: [{ uuid: appUuidForSecrets }],
942
+ });
943
+ }
944
+ else {
945
+ console.log(chalk.dim(" · Couldn't resolve owner/repo from git remote — set the deploy secrets manually."));
865
946
  }
866
947
  }
948
+ // Step 3d: push the working branch to origin. Done AFTER secrets
949
+ // are set so the workflow's first run can hit the Coolify webhook
950
+ // without falling through to the "secret not set" branch. Skipped
951
+ // when there's no remote yet (e.g. user opted out of GitHub) or
952
+ // when origin already had history before adopt.
953
+ if (plan.setupGitHub && remoteUrl && !state.gitRemoteUrl) {
954
+ await pushInitialBranch(state.projectDir);
955
+ }
956
+ // Step 4: provision clients via the existing `add` machinery so the
957
+ // surfaces stepper, idempotency, and env writes behave identically
958
+ // to a normal `hatchkit add`. Forward the surface choice — runProvision
959
+ // uses the same vocabulary, so a client-only adopt produces a
960
+ // client-only `add`.
961
+ if (plan.services.length > 0) {
962
+ console.log();
963
+ const provisionMode = plan.surfaces === "both"
964
+ ? "shared"
965
+ : plan.surfaces === "server-only"
966
+ ? "server-only"
967
+ : "client-only";
968
+ await runProvision({
969
+ baseName: plan.name,
970
+ services: plan.services,
971
+ surfaces: {
972
+ mode: provisionMode,
973
+ serverEnvDir: plan.serverDir,
974
+ clientEnvDir: plan.clientDir,
975
+ },
976
+ // Record per-resource as runProvision creates them. Done via
977
+ // callback so a mid-loop failure (e.g. Resend after GlitchTip
978
+ // already succeeded) still leaves a complete trail of what
979
+ // to undo.
980
+ onProvisioned: (event) => {
981
+ if (event.service === "glitchtip") {
982
+ ledger.record({ kind: "glitchtip", project: event.project });
983
+ }
984
+ else if (event.service === "openpanel") {
985
+ ledger.record({ kind: "openpanel", project: event.project });
986
+ }
987
+ else if (event.service === "resend") {
988
+ ledger.record({ kind: "resend", client: event.client });
989
+ }
990
+ },
991
+ });
992
+ }
993
+ // Step 5: push key to Coolify — but only when wireCoolify didn't
994
+ // already do it. wireCoolify's success path includes a setAppEnv
995
+ // pass that pushes DOTENV_PRIVATE_KEY_PRODUCTION; if it failed,
996
+ // there's no app to push to and pushing again would just produce
997
+ // a confusing second error message.
998
+ const wiredEnvAlready = plan.wireCoolify && coolifyResult !== undefined;
999
+ if (plan.pushKey && !wiredEnvAlready) {
1000
+ if (plan.wireCoolify && !coolifyResult) {
1001
+ console.log(chalk.dim(` · Skipping standalone key push — Coolify wiring failed, no app to push to.`));
1002
+ }
1003
+ else {
1004
+ try {
1005
+ // Use the matched app name when we have one — adopt creates
1006
+ // apps with the bare project name (no `-web` suffix that the
1007
+ // create-flow scaffold uses).
1008
+ await pushProjectKeyToCoolify(plan.name, {
1009
+ appName: state.coolifyAppMatch?.name ?? plan.name,
1010
+ });
1011
+ console.log(chalk.green(`\n ✓ Pushed dotenvx key to Coolify`));
1012
+ }
1013
+ catch (err) {
1014
+ console.log(chalk.yellow(`\n ✗ Couldn't push dotenvx key to Coolify: ${err.message}`));
1015
+ caveats.push({
1016
+ title: "dotenvx key not pushed to Coolify",
1017
+ reason: err.message,
1018
+ recovery: [`Once the app exists, run: hatchkit keys push ${plan.name}`],
1019
+ });
1020
+ }
1021
+ }
1022
+ }
1023
+ ledger.complete();
867
1024
  }
868
- console.log(chalk.bold("\n ── Adopted ───────────────────────────────────────────────\n"));
1025
+ catch (err) {
1026
+ // Mid-flight throw — surface the partial state via the same
1027
+ // recipe/rollback/leave UX `hatchkit create` uses, then exit.
1028
+ // The ledger holds only resources adopt itself created (see the
1029
+ // gating on each `ledger.record(...)` call above), so a "yes,
1030
+ // roll back" choice is safe to take.
1031
+ await handleAdoptFailure(ledger, err);
1032
+ process.exit(1);
1033
+ }
1034
+ // Banner reflects partial state — when caveats exist, callers see
1035
+ // "Adopted (incomplete)" so the success line doesn't drown out the
1036
+ // unfinished work that still needs them. The body is the same; the
1037
+ // caveats block lands underneath.
1038
+ const banner = caveats.length > 0
1039
+ ? "── Adopted (incomplete) ─────────────"
1040
+ : "── Adopted ─────────────────────────";
1041
+ console.log(chalk.bold(`\n ${banner}─────────────────────\n`));
869
1042
  console.log(` Project: ${chalk.cyan(plan.name)}`);
870
1043
  console.log(` Domain: ${chalk.cyan(plan.domain)}`);
871
1044
  if (plan.serverDir)
@@ -902,7 +1075,19 @@ async function executePlan(state, plan) {
902
1075
  console.log(` DNS: ${chalk.yellow("✗")} ${chalk.dim(`add ${manual} manually`)}`);
903
1076
  }
904
1077
  }
905
- console.log();
1078
+ if (caveats.length > 0) {
1079
+ console.log(chalk.bold(chalk.yellow(`\n Caveats (${caveats.length}):\n`)));
1080
+ for (const c of caveats) {
1081
+ console.log(` ${chalk.yellow("✗")} ${chalk.bold(c.title)}`);
1082
+ console.log(` ${chalk.dim(c.reason)}`);
1083
+ for (const r of c.recovery)
1084
+ console.log(` ${chalk.dim("→")} ${chalk.dim(r)}`);
1085
+ console.log();
1086
+ }
1087
+ }
1088
+ else {
1089
+ console.log();
1090
+ }
906
1091
  }
907
1092
  /** Where dotenvx writes the encrypted env. Server-only / both layouts
908
1093
  * use the server dir (canonical for runtime decryption); client-only
@@ -914,7 +1099,12 @@ function dotenvxRootFor(plan, projectDir) {
914
1099
  return plan.serverDir ?? projectDir;
915
1100
  }
916
1101
  async function bootstrapDotenvxNow(state, plan) {
917
- const prodPath = join(dotenvxRootFor(plan, state.projectDir), ".env.production");
1102
+ const root = dotenvxRootFor(plan, state.projectDir);
1103
+ const prodPath = join(root, ".env.production");
1104
+ const keysPath = join(root, ".env.keys");
1105
+ // Snapshot existence BEFORE the dotenvx call so we know whether
1106
+ // we're about to create the keys file or just reuse the existing one.
1107
+ const keysExistedBefore = existsSync(keysPath);
918
1108
  const ora = (await import("ora")).default;
919
1109
  const label = state.prodEnvIsEncrypted
920
1110
  ? "Re-encrypting .env.production with dotenvx..."
@@ -940,6 +1130,7 @@ async function bootstrapDotenvxNow(state, plan) {
940
1130
  spinner.fail("Failed to initialize dotenvx");
941
1131
  throw err;
942
1132
  }
1133
+ return { keysPath, createdKeysFile: !keysExistedBefore };
943
1134
  }
944
1135
  async function setupGitHubRemote(state, plan) {
945
1136
  // Pre-flight gh CLI auth. ensureGitHub prompts the user to log in
@@ -950,14 +1141,16 @@ async function setupGitHubRemote(state, plan) {
950
1141
  }
951
1142
  catch (err) {
952
1143
  console.log(chalk.yellow(`\n Couldn't reach GitHub (${err.message}). Skipping remote creation.`));
953
- return undefined;
1144
+ return { gitInitialized: false };
954
1145
  }
955
1146
  console.log(chalk.bold("\n ── GitHub ────────────────────────────────────────────────\n"));
1147
+ let gitInitialized = false;
956
1148
  if (!state.isGitRepo) {
957
1149
  await exec("git", ["init"], {
958
1150
  cwd: state.projectDir,
959
1151
  spinner: "Initializing git repo...",
960
1152
  });
1153
+ gitInitialized = true;
961
1154
  }
962
1155
  // Stage everything + commit when there's anything staged.
963
1156
  // `git diff --cached --quiet` exits 0 → no diff (nothing staged)
@@ -987,30 +1180,37 @@ async function setupGitHubRemote(state, plan) {
987
1180
  console.log(chalk.yellow(" Could not create GitHub repo. Push manually once it exists:"));
988
1181
  console.log(chalk.dim(` cd ${state.projectDir}`));
989
1182
  console.log(chalk.dim(` gh repo create ${plan.name} --private --source=. --push`));
990
- return undefined;
1183
+ return { gitInitialized };
991
1184
  }
992
1185
  const urlRes = await exec("gh", ["repo", "view", "--json", "url", "-q", ".url"], {
993
1186
  cwd: state.projectDir,
994
1187
  });
995
1188
  const url = urlRes.stdout.trim();
996
1189
  console.log(chalk.green(` ✓ GitHub repo: ${url}`));
997
- return url || undefined;
1190
+ const repoSlug = url ? url.replace(/^https?:\/\/github\.com\//, "") : undefined;
1191
+ return { url: url || undefined, gitInitialized, repoSlug };
998
1192
  }
999
- // (pushInitialBranch lives in deploy/github.ts so create + adopt share it.)
1000
1193
  async function importKeyToKeychain(state, plan) {
1194
+ const account = SECRET_KEYS.dotenvxPrivateKey(plan.name);
1001
1195
  const envKeysPath = join(dotenvxRootFor(plan, state.projectDir), ".env.keys");
1002
1196
  if (!existsSync(envKeysPath)) {
1003
1197
  console.log(chalk.yellow(` · No .env.keys at ${relativeTo(envKeysPath)} — nothing to import to keychain.`));
1004
- return;
1198
+ return { account, created: false, imported: false };
1005
1199
  }
1006
1200
  const text = readFileSync(envKeysPath, "utf-8");
1007
1201
  const m = text.match(/^DOTENV_PRIVATE_KEY_PRODUCTION="?([0-9a-fA-F]+)"?/m);
1008
1202
  if (!m) {
1009
1203
  console.log(chalk.yellow(` · ${relativeTo(envKeysPath)} doesn't contain DOTENV_PRIVATE_KEY_PRODUCTION — skipping import.`));
1010
- return;
1204
+ return { account, created: false, imported: false };
1011
1205
  }
1012
- await setSecret(SECRET_KEYS.dotenvxPrivateKey(plan.name), m[1]);
1206
+ // Snapshot existence BEFORE writing so we can tell adopt's caller
1207
+ // whether the keychain entry is brand-new (record for rollback) or
1208
+ // a re-import of one that already existed (don't record — the
1209
+ // earlier run owns the rollback).
1210
+ const existing = await getSecret(account);
1211
+ await setSecret(account, m[1]);
1013
1212
  console.log(chalk.green(` ✓ Imported dotenvx private key into the OS keychain (service: hatchkit)`));
1213
+ return { account, created: !existing, imported: true };
1014
1214
  }
1015
1215
  function writeAdoptManifest(projectDir, plan) {
1016
1216
  // Unknown bits (ports, deployTarget specifics) get conservative
@@ -1069,7 +1269,7 @@ function renderBuildPipelineSummary(state, plan) {
1069
1269
  const keepPart = kept.length > 0 ? chalk.dim(` · keep ${kept.join(", ")}`) : "";
1070
1270
  return `${writePart}${keepPart}`;
1071
1271
  }
1072
- async function scaffoldBuildPipelineNow(state, plan, remoteUrl) {
1272
+ async function scaffoldBuildPipelineNow(state, plan, remoteUrl, opts = {}) {
1073
1273
  // Owner inference for the GHCR image. Falls back to "OWNER" if we
1074
1274
  // can't tell — the scaffolded compose still works once the user
1075
1275
  // edits it, and they get a clear hint in the summary.
@@ -1083,9 +1283,13 @@ async function scaffoldBuildPipelineNow(state, plan, remoteUrl) {
1083
1283
  port: Number(plan.appPort) || 3000,
1084
1284
  surfaces: plan.surfaces,
1085
1285
  defaultBranch,
1286
+ force: !!opts.force,
1086
1287
  });
1087
- if (result.written.length > 0) {
1088
- console.log(chalk.green(` ✓ Scaffolded: ${result.written.join(", ")}`));
1288
+ if (result.created.length > 0) {
1289
+ console.log(chalk.green(` ✓ Scaffolded: ${result.created.join(", ")}`));
1290
+ }
1291
+ if (result.overwritten.length > 0) {
1292
+ console.log(chalk.yellow(` ↻ Regenerated: ${result.overwritten.join(", ")}`));
1089
1293
  }
1090
1294
  if (result.skipped.length > 0) {
1091
1295
  console.log(chalk.dim(` · Kept existing: ${result.skipped.join(", ")}`));
@@ -1094,6 +1298,12 @@ async function scaffoldBuildPipelineNow(state, plan, remoteUrl) {
1094
1298
  console.log(chalk.yellow(" ⚠ Couldn't infer GitHub owner from origin — edit `image: ghcr.io/OWNER/...`\n" +
1095
1299
  " in docker-compose.yml before pushing."));
1096
1300
  }
1301
+ // Promote project-relative paths to absolute so the caller doesn't
1302
+ // need to know the project root for ledger entries / dedup.
1303
+ return {
1304
+ createdAbsPaths: result.created.map((rel) => join(state.projectDir, rel)),
1305
+ overwrittenAbsPaths: result.overwritten.map((rel) => join(state.projectDir, rel)),
1306
+ };
1097
1307
  }
1098
1308
  /** Best-effort default branch detection. `git symbolic-ref` only
1099
1309
  * works after `git remote set-head origin -a` has cached the