hatchkit 0.1.4 → 0.1.5

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.
Files changed (63) hide show
  1. package/dist/adopt.d.ts +2 -0
  2. package/dist/adopt.d.ts.map +1 -0
  3. package/dist/adopt.js +552 -0
  4. package/dist/adopt.js.map +1 -0
  5. package/dist/completion.d.ts.map +1 -1
  6. package/dist/completion.js +3 -0
  7. package/dist/completion.js.map +1 -1
  8. package/dist/config.d.ts +30 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +108 -0
  11. package/dist/config.js.map +1 -1
  12. package/dist/deploy/pages.js +50 -9
  13. package/dist/deploy/pages.js.map +1 -1
  14. package/dist/deploy/rename-domain.d.ts.map +1 -1
  15. package/dist/deploy/rename-domain.js +26 -6
  16. package/dist/deploy/rename-domain.js.map +1 -1
  17. package/dist/deploy/rollback.d.ts +10 -0
  18. package/dist/deploy/rollback.d.ts.map +1 -0
  19. package/dist/deploy/rollback.js +295 -0
  20. package/dist/deploy/rollback.js.map +1 -0
  21. package/dist/deploy/terraform.d.ts +10 -1
  22. package/dist/deploy/terraform.d.ts.map +1 -1
  23. package/dist/deploy/terraform.js +177 -42
  24. package/dist/deploy/terraform.js.map +1 -1
  25. package/dist/doctor.d.ts.map +1 -1
  26. package/dist/doctor.js +25 -0
  27. package/dist/doctor.js.map +1 -1
  28. package/dist/explain.d.ts.map +1 -1
  29. package/dist/explain.js +5 -0
  30. package/dist/explain.js.map +1 -1
  31. package/dist/index.js +356 -122
  32. package/dist/index.js.map +1 -1
  33. package/dist/prompts.d.ts.map +1 -1
  34. package/dist/prompts.js +283 -11
  35. package/dist/prompts.js.map +1 -1
  36. package/dist/provision/stripe.d.ts +19 -0
  37. package/dist/provision/stripe.d.ts.map +1 -0
  38. package/dist/provision/stripe.js +58 -0
  39. package/dist/provision/stripe.js.map +1 -0
  40. package/dist/scaffold/dotenvx.d.ts.map +1 -1
  41. package/dist/scaffold/dotenvx.js +35 -11
  42. package/dist/scaffold/dotenvx.js.map +1 -1
  43. package/dist/scaffold/infra.d.ts +21 -1
  44. package/dist/scaffold/infra.d.ts.map +1 -1
  45. package/dist/scaffold/infra.js +66 -20
  46. package/dist/scaffold/infra.js.map +1 -1
  47. package/dist/status.d.ts.map +1 -1
  48. package/dist/status.js +7 -0
  49. package/dist/status.js.map +1 -1
  50. package/dist/utils/coolify-api.d.ts +12 -0
  51. package/dist/utils/coolify-api.d.ts.map +1 -1
  52. package/dist/utils/coolify-api.js +33 -0
  53. package/dist/utils/coolify-api.js.map +1 -1
  54. package/dist/utils/run-ledger.d.ts +68 -0
  55. package/dist/utils/run-ledger.d.ts.map +1 -0
  56. package/dist/utils/run-ledger.js +99 -0
  57. package/dist/utils/run-ledger.js.map +1 -0
  58. package/dist/utils/secrets.d.ts +2 -0
  59. package/dist/utils/secrets.d.ts.map +1 -1
  60. package/dist/utils/secrets.js +2 -0
  61. package/dist/utils/secrets.js.map +1 -1
  62. package/package.json +2 -2
  63. package/scripts/release-prep.mjs +56 -98
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { runCoolifySetup } from "./deploy/coolify.js";
7
7
  import { setupGitHub } from "./deploy/github.js";
8
8
  import { deployMlServices } from "./deploy/gpu.js";
9
9
  import { pushProjectKeyToCoolify, showProjectKey } from "./deploy/keys.js";
10
+ import { handleCreateFailure, runRollback } from "./deploy/rollback.js";
10
11
  import { runTerraform } from "./deploy/terraform.js";
11
12
  import { collectProjectConfig } from "./prompts.js";
12
13
  import { runProvision, runUnprovision } from "./provision/index.js";
@@ -16,6 +17,8 @@ import { mlEnvVarName, printMlSummary, resolveMlServices } from "./scaffold/ml-c
16
17
  import { runUpdate } from "./scaffold/update.js";
17
18
  import { exec, execOk } from "./utils/exec.js";
18
19
  import { parseCreateFlags } from "./utils/flags.js";
20
+ import { RunLedger } from "./utils/run-ledger.js";
21
+ import { SECRET_KEYS } from "./utils/secrets.js";
19
22
  import { getCliVersion } from "./utils/version.js";
20
23
  // ---------------------------------------------------------------------------
21
24
  // CLI
@@ -118,6 +121,18 @@ async function main() {
118
121
  return printHelp("remove");
119
122
  await handleRemove();
120
123
  break;
124
+ case "adopt": {
125
+ if (args.includes("--help"))
126
+ return printHelp("adopt");
127
+ const { runAdopt } = await import("./adopt.js");
128
+ await runAdopt(resolve("."));
129
+ break;
130
+ }
131
+ case "destroy":
132
+ if (args.includes("--help"))
133
+ return printHelp("destroy");
134
+ await handleDestroy();
135
+ break;
121
136
  case "rename-domain": {
122
137
  if (args.includes("--help"))
123
138
  return printHelp("rename-domain");
@@ -289,6 +304,44 @@ async function handleAdd() {
289
304
  }
290
305
  await runProvision({ baseName, services, surfaces, enableDevObs });
291
306
  }
307
+ async function handleDestroy() {
308
+ // `hatchkit destroy <project> [--yes] [--recipe]`
309
+ // destroy reads the run ledger written by `hatchkit create` and
310
+ // reverses the recorded steps. With --recipe it just prints the
311
+ // shell-command rollback recipe and exits (no execution).
312
+ const positional = args.slice(1).filter((a) => !a.startsWith("--"));
313
+ const skipConfirm = args.includes("--yes") || args.includes("-y");
314
+ const recipeOnly = args.includes("--recipe");
315
+ let name = positional[0];
316
+ if (!name) {
317
+ const { input } = await import("@inquirer/prompts");
318
+ const { validateProjectName } = await import("./utils/validate.js");
319
+ name = await input({
320
+ message: "Project name to destroy:",
321
+ validate: validateProjectName,
322
+ });
323
+ }
324
+ const ledger = RunLedger.load(name);
325
+ if (!ledger) {
326
+ console.log(chalk.yellow(` No run ledger found for "${name}". Either it was never created via \`hatchkit create\` or the ledger was already cleaned up.`));
327
+ process.exit(1);
328
+ }
329
+ const { printRecipe } = await import("./deploy/rollback.js");
330
+ printRecipe(ledger);
331
+ if (recipeOnly)
332
+ return;
333
+ if (!skipConfirm) {
334
+ const ok = await confirm({
335
+ message: `Roll back ${ledger.steps.length} step(s) for ${chalk.cyan(name)}?`,
336
+ default: false,
337
+ });
338
+ if (!ok) {
339
+ console.log(chalk.dim(" Aborted. Ledger left in place."));
340
+ return;
341
+ }
342
+ }
343
+ await runRollback(ledger, { yes: skipConfirm });
344
+ }
292
345
  async function handleRemove() {
293
346
  // Mirrors handleAdd: `hatchkit remove [<name>] [<services>] [--dry-run] [--yes]`
294
347
  // hatchkit remove (fully interactive)
@@ -408,6 +461,18 @@ async function handleCreate() {
408
461
  await ensureS3(config.s3Provider);
409
462
  }
410
463
  }
464
+ // Pre-flight observability + email + Stripe providers used by `hatchkit
465
+ // create` directly (not just `add`): if the user picked the analytics
466
+ // feature, GlitchTip needs to be configured before we can mint a DSN
467
+ // for them. Same for Stripe webhook auto-provisioning.
468
+ if (config.features.includes("analytics")) {
469
+ const { ensureGlitchtip } = await import("./config.js");
470
+ await ensureGlitchtip();
471
+ }
472
+ if (config.features.includes("stripe")) {
473
+ const { ensureStripe } = await import("./config.js");
474
+ await ensureStripe();
475
+ }
411
476
  const appDir = resolve(config.name);
412
477
  // Resolve ML services (reuse or deploy)
413
478
  const { reuse, deploy } = await resolveMlServices(config);
@@ -427,146 +492,250 @@ async function handleCreate() {
427
492
  if (config.dryRun) {
428
493
  console.log(chalk.yellow("\n [dry-run mode — no changes will be made]\n"));
429
494
  }
430
- // Step 1: Scaffold app repo
495
+ // Run ledger append-only record of what each step accomplished, so
496
+ // a mid-run failure can offer a tailored cleanup recipe + auto-undo.
497
+ // Skipped for dry-run (nothing to undo) and when the user opted out of
498
+ // both scaffolding and deployment (also nothing to undo).
499
+ const useLedger = !config.dryRun && (config.scaffoldRepo || config.runDeployment);
500
+ const ledger = useLedger ? RunLedger.start(config.name) : null;
501
+ // Hoisted across the try-block boundary so the success summary below
502
+ // can read them. Declared with let so they can be reassigned inside.
431
503
  let scaffoldResult;
432
- if (config.scaffoldRepo) {
433
- scaffoldResult = await scaffoldApp(config, appDir);
434
- const { ports } = scaffoldResult;
435
- console.log(chalk.dim(` Ports: server=${ports.server}, client=${ports.client}` +
436
- (ports.nativeHmr ? `, native HMR=${ports.nativeHmr}` : "")));
437
- if (scaffoldResult.dotenvx) {
438
- const { printDotenvxSummary } = await import("./scaffold/dotenvx.js");
439
- printDotenvxSummary(scaffoldResult.dotenvx, config.name);
440
- }
441
- }
442
- if (config.dryRun) {
443
- scaffoldInfra(config, INFRA_ROOT, {
444
- serverPort: scaffoldResult?.ports.server,
445
- clientPort: scaffoldResult?.ports.client,
446
- });
447
- console.log(chalk.green("\n ✓ Dry run complete. No changes were made.\n"));
448
- return;
449
- }
450
- // Step 2: Install deps. Required for the initial commit to pick up
451
- // the lockfile delta and for the user to `pnpm dev` immediately.
452
504
  let installedDeps = false;
453
- if (config.scaffoldRepo) {
454
- const hasPnpm = await execOk("pnpm", ["--version"]);
455
- if (!hasPnpm) {
456
- console.log(chalk.yellow(" pnpm not found on PATH — skipping install step. Install deps with your preferred tool once available."));
457
- }
458
- else {
459
- // In non-interactive mode, auto-accept the install so `--yes`
460
- // doesn't stall waiting on a y/n prompt.
461
- const shouldInstall = nonInteractive
462
- ? true
463
- : await confirm({
464
- message: "Install dependencies now (pnpm install)?",
465
- default: true,
505
+ try {
506
+ // Step 1: Scaffold app repo
507
+ if (config.scaffoldRepo) {
508
+ scaffoldResult = await scaffoldApp(config, appDir);
509
+ ledger?.record({ kind: "scaffold", path: appDir });
510
+ if (scaffoldResult.dotenvx) {
511
+ ledger?.record({
512
+ kind: "keychain",
513
+ account: SECRET_KEYS.dotenvxPrivateKey(config.name),
466
514
  });
467
- if (shouldInstall) {
468
- const res = await exec("pnpm", ["install"], {
469
- cwd: appDir,
470
- spinner: "Installing dependencies...",
471
- });
472
- if (res.exitCode === 0) {
473
- installedDeps = true;
515
+ }
516
+ const { ports } = scaffoldResult;
517
+ console.log(chalk.dim(` Ports: server=${ports.server}, client=${ports.client}` +
518
+ (ports.nativeHmr ? `, native HMR=${ports.nativeHmr}` : "")));
519
+ if (scaffoldResult.dotenvx) {
520
+ const { printDotenvxSummary } = await import("./scaffold/dotenvx.js");
521
+ printDotenvxSummary(scaffoldResult.dotenvx, config.name);
522
+ }
523
+ // Auto-provision GlitchTip + write its DSN encrypted into
524
+ // .env.production. The user picked the `analytics` feature; we
525
+ // already verified GlitchTip is configured during pre-flight.
526
+ if (config.features.includes("analytics")) {
527
+ try {
528
+ const { provisionGlitchtipClient } = await import("./provision/glitchtip.js");
529
+ const { set: dotenvxSet } = await import("@dotenvx/dotenvx");
530
+ const ora = (await import("ora")).default;
531
+ const spinner = ora(`GlitchTip: creating project ${config.name}`).start();
532
+ const res = await provisionGlitchtipClient(config.name);
533
+ ledger?.record({ kind: "glitchtip", project: config.name });
534
+ spinner.succeed(`GlitchTip project ready (DSN encrypted into .env.production)`);
535
+ const prodEnvPath = join(appDir, "packages/server/.env.production");
536
+ dotenvxSet("GLITCHTIP_DSN", res.dsn, { path: prodEnvPath, encrypt: true });
474
537
  }
475
- else {
476
- console.log(chalk.yellow(" pnpm install failed — continuing anyway."));
538
+ catch (err) {
539
+ console.log(chalk.yellow(` Couldn't auto-provision GlitchTip: ${err.message}`));
540
+ console.log(chalk.dim(` Run \`hatchkit add ${config.name} glitchtip\` once GlitchTip is reachable.`));
541
+ }
542
+ }
543
+ // Stripe: register a webhook endpoint at https://<domain>/api/stripe/webhook
544
+ // and write STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET encrypted
545
+ // into .env.production. The publishable key is non-secret (it ships
546
+ // in the browser bundle); we still encrypt-write it so the same env
547
+ // file is the single source of truth per environment.
548
+ if (config.features.includes("stripe")) {
549
+ try {
550
+ const { provisionStripeWebhook } = await import("./provision/stripe.js");
551
+ const { getStripeConfig } = await import("./config.js");
552
+ const { set: dotenvxSet } = await import("@dotenvx/dotenvx");
553
+ const ora = (await import("ora")).default;
554
+ const spinner = ora(`Stripe: registering webhook for ${config.domain}`).start();
555
+ const stripeCfg = await getStripeConfig();
556
+ const webhook = await provisionStripeWebhook(config.name, config.domain);
557
+ spinner.succeed(`Stripe webhook ready (${webhook.mode} mode → https://${config.domain}/api/stripe/webhook)`);
558
+ const prodEnvPath = join(appDir, "packages/server/.env.production");
559
+ if (stripeCfg) {
560
+ dotenvxSet("STRIPE_SECRET_KEY", stripeCfg.secretKey, {
561
+ path: prodEnvPath,
562
+ encrypt: true,
563
+ });
564
+ dotenvxSet("STRIPE_PUBLISHABLE_KEY", stripeCfg.publishableKey, {
565
+ path: prodEnvPath,
566
+ encrypt: true,
567
+ });
568
+ }
569
+ dotenvxSet("STRIPE_WEBHOOK_SECRET", webhook.signingSecret, {
570
+ path: prodEnvPath,
571
+ encrypt: true,
572
+ });
573
+ }
574
+ catch (err) {
575
+ console.log(chalk.yellow(` Couldn't auto-provision Stripe webhook: ${err.message}`));
576
+ console.log(chalk.dim(` Create one manually: dashboard.stripe.com → Developers → Webhooks,\n` +
577
+ ` point at https://${config.domain}/api/stripe/webhook, then\n` +
578
+ ` \`dotenvx set STRIPE_WEBHOOK_SECRET <whsec_…> -f packages/server/.env.production\`.`));
477
579
  }
478
580
  }
479
581
  }
480
- }
481
- // Step 3: Git + GitHub — must run BEFORE scaffoldInfra so the repo
482
- // URL can be threaded into the Coolify env (GITHUB_REPO_URL).
483
- let repoUrl = null;
484
- if (config.scaffoldRepo) {
485
- repoUrl = await setupGitHub(config, appDir);
486
- }
487
- // Step 4: Generate infra configs (with repo URL + ports baked in).
488
- scaffoldInfra(config, INFRA_ROOT, {
489
- repoUrl: repoUrl ?? undefined,
490
- serverPort: scaffoldResult?.ports.server,
491
- clientPort: scaffoldResult?.ports.client,
492
- });
493
- // Step 5: Terraform (DNS + optionally server)
494
- if (config.runDeployment) {
495
- await runTerraform(config, INFRA_ROOT);
496
- }
497
- // Step 6: Coolify setup
498
- if (config.runDeployment) {
499
- await runCoolifySetup(config, INFRA_ROOT);
500
- // Provision a per-project MongoDB container on Coolify when the
501
- // user picked that path. Best-effort: a failure here doesn't undo
502
- // the app deploy — we surface clear instructions instead.
503
- if (config.mongodbProvider === "coolify" && config.scaffoldRepo) {
504
- try {
505
- const { provisionCoolifyMongo } = await import("./deploy/coolify-mongo.js");
506
- const serverEnvDir = join(appDir, "packages/server");
507
- await provisionCoolifyMongo(config, serverEnvDir);
582
+ if (config.dryRun) {
583
+ scaffoldInfra(config, INFRA_ROOT, {
584
+ serverPort: scaffoldResult?.ports.server,
585
+ clientPort: scaffoldResult?.ports.client,
586
+ });
587
+ console.log(chalk.green("\n ✓ Dry run complete. No changes were made.\n"));
588
+ return;
589
+ }
590
+ // Step 2: Install deps. Required for the initial commit to pick up
591
+ // the lockfile delta and for the user to `pnpm dev` immediately.
592
+ if (config.scaffoldRepo) {
593
+ const hasPnpm = await execOk("pnpm", ["--version"]);
594
+ if (!hasPnpm) {
595
+ console.log(chalk.yellow(" pnpm not found on PATH skipping install step. Install deps with your preferred tool once available."));
508
596
  }
509
- catch (err) {
510
- console.log(chalk.yellow(` Couldn't auto-provision MongoDB: ${err.message}`));
511
- console.log(chalk.dim(` Create one manually in Coolify: New → Database → MongoDB,\n` +
512
- ` then set MONGODB_URI on the app's env (or run\n` +
513
- ` \`dotenvx set MONGODB_URI <url> -f packages/server/.env.production\`).`));
597
+ else {
598
+ // In non-interactive mode, auto-accept the install so `--yes`
599
+ // doesn't stall waiting on a y/n prompt.
600
+ const shouldInstall = nonInteractive
601
+ ? true
602
+ : await confirm({
603
+ message: "Install dependencies now (pnpm install)?",
604
+ default: true,
605
+ });
606
+ if (shouldInstall) {
607
+ const res = await exec("pnpm", ["install"], {
608
+ cwd: appDir,
609
+ spinner: "Installing dependencies...",
610
+ });
611
+ if (res.exitCode === 0) {
612
+ installedDeps = true;
613
+ }
614
+ else {
615
+ console.log(chalk.yellow(" pnpm install failed — continuing anyway."));
616
+ }
617
+ }
514
618
  }
515
619
  }
516
- // Push the dotenvx private key to Coolify so the starter's server
517
- // can decrypt .env.production at runtime. Best-effort if the
518
- // Coolify app doesn't exist yet (race with the stack script), we
519
- // print the manual command instead of failing the whole flow.
520
- if (scaffoldResult?.dotenvx) {
521
- try {
522
- await pushProjectKeyToCoolify(config.name);
620
+ // Step 3: Git + GitHub must run BEFORE scaffoldInfra so the repo
621
+ // URL can be threaded into the Coolify env (GITHUB_REPO_URL).
622
+ let repoUrl = null;
623
+ if (config.scaffoldRepo) {
624
+ repoUrl = await setupGitHub(config, appDir);
625
+ if (repoUrl) {
626
+ // Strip the https://github.com/ prefix so the recorded value is
627
+ // gh-CLI-friendly (`gh repo delete <owner>/<repo>`).
628
+ const slug = repoUrl.replace(/^https?:\/\/github\.com\//, "");
629
+ ledger?.record({ kind: "github", repo: slug });
523
630
  }
524
- catch (err) {
525
- console.log(chalk.yellow(` Couldn't auto-push dotenvx key: ${err.message}`));
526
- console.log(chalk.dim(` Push manually once the Coolify app exists: hatchkit keys push ${config.name}`));
631
+ }
632
+ // Step 4: Generate infra configs (with repo URL + ports baked in).
633
+ const infraResult = scaffoldInfra(config, INFRA_ROOT, {
634
+ repoUrl: repoUrl ?? undefined,
635
+ serverPort: scaffoldResult?.ports.server,
636
+ clientPort: scaffoldResult?.ports.client,
637
+ });
638
+ if (infraResult.tfvarsPath) {
639
+ ledger?.record({ kind: "tfvars", path: infraResult.tfvarsPath });
640
+ }
641
+ if (infraResult.coolifyEnvPath) {
642
+ ledger?.record({ kind: "coolifyEnv", path: infraResult.coolifyEnvPath });
643
+ }
644
+ // Step 5: Terraform (DNS + optionally server)
645
+ if (config.runDeployment) {
646
+ const tfResult = await runTerraform(config, INFRA_ROOT);
647
+ if (tfResult.applied) {
648
+ ledger?.record({
649
+ kind: "terraformApplied",
650
+ stackDir: tfResult.applied.stackDir,
651
+ tfvarsPath: tfResult.applied.tfvarsPath,
652
+ });
527
653
  }
528
654
  }
529
- }
530
- // Step 7: Deploy ML services
531
- if (config.runDeployment &&
532
- deploy.length > 0 &&
533
- config.gpuPlatforms &&
534
- config.gpuPlatforms.length > 0) {
535
- const endpoints = await deployMlServices(deploy, config.gpuPlatforms, SERVICES_ROOT, config.customHfModelId);
536
- // Print env vars to set
537
- if (Object.keys(endpoints).length > 0) {
538
- const { mlPlatformUrlEnv } = await import("./scaffold/ml-client.js");
539
- const knownServices = [
540
- "3d-extraction",
541
- "subtitles",
542
- "image-recognition",
543
- "background-removal",
544
- "custom-hf",
545
- ];
546
- console.log(chalk.bold("\n ML service endpoints (add to Coolify env):"));
547
- console.log(chalk.dim(` ML_BACKEND=${config.gpuPlatforms[0]}`));
548
- for (const [service, byPlatform] of Object.entries(endpoints)) {
549
- if (!knownServices.includes(service))
550
- continue;
551
- const svc = service;
552
- // Per-platform URL — the runtime config picks one based on ML_BACKEND.
553
- for (const [platform, url] of Object.entries(byPlatform)) {
554
- if (!url)
555
- continue;
556
- console.log(chalk.dim(` ${mlPlatformUrlEnv(svc, platform)}=${url}`));
655
+ // Step 6: Coolify setup
656
+ if (config.runDeployment) {
657
+ await runCoolifySetup(config, INFRA_ROOT);
658
+ // Provision a per-project MongoDB container on Coolify when the
659
+ // user picked that path. Best-effort: a failure here doesn't undo
660
+ // the app deploy — we surface clear instructions instead.
661
+ if (config.mongodbProvider === "coolify" && config.scaffoldRepo) {
662
+ try {
663
+ const { provisionCoolifyMongo } = await import("./deploy/coolify-mongo.js");
664
+ const serverEnvDir = join(appDir, "packages/server");
665
+ await provisionCoolifyMongo(config, serverEnvDir);
666
+ }
667
+ catch (err) {
668
+ console.log(chalk.yellow(` Couldn't auto-provision MongoDB: ${err.message}`));
669
+ console.log(chalk.dim(` Create one manually in Coolify: New → Database → MongoDB,\n` +
670
+ ` then set MONGODB_URI on the app's env (or run\n` +
671
+ ` \`dotenvx set MONGODB_URI <url> -f packages/server/.env.production\`).`));
672
+ }
673
+ }
674
+ // Push the dotenvx private key to Coolify so the starter's server
675
+ // can decrypt .env.production at runtime. Best-effort — if the
676
+ // Coolify app doesn't exist yet (race with the stack script), we
677
+ // print the manual command instead of failing the whole flow.
678
+ if (scaffoldResult?.dotenvx) {
679
+ try {
680
+ await pushProjectKeyToCoolify(config.name);
557
681
  }
558
- // Legacy ENDPOINT for back-compat — points at the default platform.
559
- const defaultUrl = byPlatform[config.gpuPlatforms[0]];
560
- if (defaultUrl) {
561
- console.log(chalk.dim(` ${mlEnvVarName(svc)}=${defaultUrl}`));
682
+ catch (err) {
683
+ console.log(chalk.yellow(` Couldn't auto-push dotenvx key: ${err.message}`));
684
+ console.log(chalk.dim(` Push manually once the Coolify app exists: hatchkit keys push ${config.name}`));
562
685
  }
563
686
  }
564
687
  }
688
+ // Step 7: Deploy ML services
689
+ if (config.runDeployment &&
690
+ deploy.length > 0 &&
691
+ config.gpuPlatforms &&
692
+ config.gpuPlatforms.length > 0) {
693
+ const endpoints = await deployMlServices(deploy, config.gpuPlatforms, SERVICES_ROOT, config.customHfModelId);
694
+ // Print env vars to set
695
+ if (Object.keys(endpoints).length > 0) {
696
+ const { mlPlatformUrlEnv } = await import("./scaffold/ml-client.js");
697
+ const knownServices = [
698
+ "3d-extraction",
699
+ "subtitles",
700
+ "image-recognition",
701
+ "background-removal",
702
+ "custom-hf",
703
+ ];
704
+ console.log(chalk.bold("\n ML service endpoints (add to Coolify env):"));
705
+ console.log(chalk.dim(` ML_BACKEND=${config.gpuPlatforms[0]}`));
706
+ for (const [service, byPlatform] of Object.entries(endpoints)) {
707
+ if (!knownServices.includes(service))
708
+ continue;
709
+ const svc = service;
710
+ // Per-platform URL — the runtime config picks one based on ML_BACKEND.
711
+ for (const [platform, url] of Object.entries(byPlatform)) {
712
+ if (!url)
713
+ continue;
714
+ console.log(chalk.dim(` ${mlPlatformUrlEnv(svc, platform)}=${url}`));
715
+ }
716
+ // Legacy ENDPOINT for back-compat — points at the default platform.
717
+ const defaultUrl = byPlatform[config.gpuPlatforms[0]];
718
+ if (defaultUrl) {
719
+ console.log(chalk.dim(` ${mlEnvVarName(svc)}=${defaultUrl}`));
720
+ }
721
+ }
722
+ }
723
+ }
724
+ ledger?.complete();
725
+ }
726
+ catch (err) {
727
+ if (ledger) {
728
+ await handleCreateFailure(ledger, err);
729
+ }
730
+ else {
731
+ console.log(chalk.red(`\n ✗ ${err instanceof Error ? err.message : String(err)}\n`));
732
+ }
733
+ process.exit(1);
565
734
  }
566
735
  // Final summary
567
736
  console.log(chalk.bold("\n ── Done! ─────────────────────────────────────────────────\n"));
568
737
  console.log(` App: ${chalk.cyan(`https://${config.domain}`)}`);
569
- console.log(` API: ${chalk.cyan(`https://api.${config.domain}`)}`);
738
+ console.log(` API: ${chalk.cyan(`https://${config.domain}/api`)}`);
570
739
  console.log(` App dir: ${chalk.dim(appDir)}`);
571
740
  console.log(` Config: ${chalk.dim(getConfigPath())}`);
572
741
  if (config.scaffoldRepo) {
@@ -577,9 +746,6 @@ async function handleCreate() {
577
746
  console.log(chalk.yellow(`\n Next: cd ${config.name} && pnpm install && pnpm dev`));
578
747
  }
579
748
  }
580
- if (config.features.includes("stripe")) {
581
- console.log(chalk.yellow("\n Next: Set STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET in Coolify"));
582
- }
583
749
  if (config.features.includes("mobile")) {
584
750
  console.log(chalk.yellow("\n Next (mobile): generate native projects once:"));
585
751
  console.log(chalk.dim(" pnpm cap:add:ios # requires Xcode"));
@@ -628,6 +794,7 @@ async function handleConfig() {
628
794
  case "glitchtip":
629
795
  case "openpanel":
630
796
  case "resend":
797
+ case "stripe":
631
798
  await reconfigureProvider(provider);
632
799
  break;
633
800
  case "s3": {
@@ -882,6 +1049,40 @@ function printHelp(topic) {
882
1049
  hatchkit add raptor-runner all --surfaces=shared \\
883
1050
  --server-dir ./raptor-runner/packages/server \\
884
1051
  --client-dir ./raptor-runner/packages/client
1052
+ `);
1053
+ return;
1054
+ }
1055
+ if (topic === "adopt") {
1056
+ console.log(`
1057
+ ${chalk.bold("hatchkit adopt")} — bring an existing project under hatchkit management
1058
+
1059
+ ${chalk.bold("Usage:")}
1060
+ cd <project-dir> && hatchkit adopt
1061
+
1062
+ ${chalk.bold("What it does:")}
1063
+ Inverse of \`hatchkit create\`. Inspects the current directory,
1064
+ detects what's already there (package.json name, repo layout,
1065
+ .env state, dotenvx encryption, Coolify app match, feature flags
1066
+ inferable from deps), then runs a stepper-style review where you
1067
+ confirm or change each detected value. On confirm:
1068
+
1069
+ · If \`.env.production\` isn't dotenvx-encrypted yet, encrypts it
1070
+ (generates \`.env.keys\` with the private key).
1071
+ · Imports DOTENV_PRIVATE_KEY_PRODUCTION into the OS keychain.
1072
+ · Writes \`.hatchkit.json\` so \`update\`, \`add\`, \`keys\` recognise
1073
+ the project.
1074
+ · Optionally provisions GlitchTip / OpenPanel / Resend clients
1075
+ (same machinery as \`hatchkit add\`).
1076
+ · Optionally pushes the dotenvx private key to Coolify.
1077
+
1078
+ ${chalk.bold("When to use:")}
1079
+ The project wasn't created by hatchkit but you want it to be
1080
+ managed going forward.
1081
+
1082
+ ${chalk.bold("Refuses to run twice:")}
1083
+ If \`.hatchkit.json\` already exists, exits with a hint to use
1084
+ \`hatchkit update\` (add features) or \`hatchkit add\` (re-provision
1085
+ clients) instead.
885
1086
  `);
886
1087
  return;
887
1088
  }
@@ -918,6 +1119,37 @@ function printHelp(topic) {
918
1119
  hatchkit remove raptor-runner all
919
1120
  hatchkit remove raptor-runner glitchtip,resend --dry-run
920
1121
  hatchkit remove raptor-runner all --yes
1122
+ `);
1123
+ return;
1124
+ }
1125
+ if (topic === "destroy") {
1126
+ console.log(`
1127
+ ${chalk.bold("hatchkit destroy")} — undo a project that ${chalk.cyan("hatchkit create")} set up
1128
+
1129
+ ${chalk.bold("Usage:")}
1130
+ hatchkit destroy [<project-name>] [--yes] [--recipe]
1131
+
1132
+ Reads the run ledger written by ${chalk.cyan("hatchkit create")} and reverses the
1133
+ recorded steps in reverse order. Destructive operations
1134
+ (rm -rf the local repo, gh repo delete, terraform destroy) prompt
1135
+ per-step unless ${chalk.dim("--yes")} is passed.
1136
+
1137
+ ${chalk.bold("What it can undo:")}
1138
+ - local project directory ${chalk.dim("rm -rf")}
1139
+ - GitHub repo ${chalk.dim("gh repo delete")}
1140
+ - GlitchTip project ${chalk.dim("DELETE /api/0/projects")}
1141
+ - generated tfvars + Coolify .env files ${chalk.dim("rm")}
1142
+ - dotenvx private key in keychain ${chalk.dim("keytar deletePassword")}
1143
+ - Terraform-applied resources ${chalk.dim("terraform destroy")}
1144
+
1145
+ ${chalk.bold("Options:")}
1146
+ --yes, -y Skip per-step confirmation on destructive operations.
1147
+ --recipe Print the rollback recipe (bash commands) and exit. No execution.
1148
+
1149
+ ${chalk.bold("Examples:")}
1150
+ hatchkit destroy ai-playground
1151
+ hatchkit destroy ai-playground --recipe ${chalk.dim("# just print the recipe")}
1152
+ hatchkit destroy ai-playground --yes ${chalk.dim("# no confirmations")}
921
1153
  `);
922
1154
  return;
923
1155
  }
@@ -1020,9 +1252,11 @@ function printHelp(topic) {
1020
1252
 
1021
1253
  ${chalk.bold("Projects:")}
1022
1254
  create Scaffold a new project (interactive)
1255
+ adopt Bring an existing project under hatchkit management (run in project dir)
1023
1256
  update Add features to an already-scaffolded project (run in project dir)
1024
1257
  add Create GlitchTip / OpenPanel / Resend clients for an existing project
1025
1258
  remove Delete the -dev/-prod clients created by 'add' (inverse of add)
1259
+ destroy Roll back everything ${chalk.cyan("hatchkit create")} did for a project
1026
1260
  rename-domain Move a scaffolded project to a new domain (rewrites tfvars/env/manifest)
1027
1261
  gh-pages Wire GitHub Pages for the current repo (static / Vite / Jekyll — with DNS)
1028
1262
  dns DNS reconciliation helpers (link-to-cloudflare, …)