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.d.ts +1 -0
- package/dist/adopt.d.ts.map +1 -1
- package/dist/adopt.js +379 -169
- 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 +35 -9
- 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/scaffold/build-pipeline.d.ts +33 -1
- package/dist/scaffold/build-pipeline.d.ts.map +1 -1
- package/dist/scaffold/build-pipeline.js +61 -10
- package/dist/scaffold/build-pipeline.js.map +1 -1
- package/dist/templates/build-pipeline/Dockerfile.client.hbs +1 -1
- package/dist/templates/build-pipeline/Dockerfile.server.hbs +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,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
|
|
177
|
-
// it isn't, leave
|
|
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
|
|
190
|
+
if (cfg) {
|
|
191
|
+
coolifyConfigured = true;
|
|
182
192
|
const api = new CoolifyApi({ url: cfg.url, token: cfg.token });
|
|
183
|
-
const apps = await
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
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}`));
|
|
781
|
+
else {
|
|
782
|
+
console.log(chalk.dim(" · Skipping dotenvx bootstrap (per stepper choice)."));
|
|
786
783
|
}
|
|
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
|
-
});
|
|
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
|
-
|
|
809
|
-
|
|
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
|
-
|
|
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.`));
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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
|
-
|
|
864
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1088
|
-
console.log(chalk.green(` ✓ Scaffolded: ${result.
|
|
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
|