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.d.ts.map +1 -1
- package/dist/adopt.js +364 -166
- package/dist/adopt.js.map +1 -1
- package/dist/deploy/coolify-app.d.ts +16 -0
- package/dist/deploy/coolify-app.d.ts.map +1 -1
- package/dist/deploy/coolify-app.js +34 -16
- package/dist/deploy/coolify-app.js.map +1 -1
- package/dist/deploy/rollback.d.ts +5 -0
- package/dist/deploy/rollback.d.ts.map +1 -1
- package/dist/deploy/rollback.js +95 -2
- package/dist/deploy/rollback.js.map +1 -1
- package/dist/index.js +21 -8
- package/dist/index.js.map +1 -1
- package/dist/provision/index.d.ts +20 -0
- package/dist/provision/index.d.ts.map +1 -1
- package/dist/provision/index.js +6 -0
- package/dist/provision/index.js.map +1 -1
- package/dist/utils/cloudflare-api.d.ts +3 -0
- package/dist/utils/cloudflare-api.d.ts.map +1 -1
- package/dist/utils/cloudflare-api.js +14 -0
- package/dist/utils/cloudflare-api.js.map +1 -1
- package/dist/utils/run-ledger.d.ts +47 -1
- package/dist/utils/run-ledger.d.ts.map +1 -1
- package/dist/utils/run-ledger.js +15 -0
- package/dist/utils/run-ledger.js.map +1 -1
- package/package.json +1 -1
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 {
|
|
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
|
|
177
|
-
// it isn't, leave
|
|
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
|
|
187
|
+
if (cfg) {
|
|
188
|
+
coolifyConfigured = true;
|
|
182
189
|
const api = new CoolifyApi({ url: cfg.url, token: cfg.token });
|
|
183
|
-
const apps = await
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
781
|
-
console.log(chalk.
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
809
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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
|
-
|
|
864
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|