hatchkit 0.1.16 → 0.1.17

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