hatchkit 0.1.3 → 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 (87) 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/coolify-mongo.d.ts +12 -0
  13. package/dist/deploy/coolify-mongo.d.ts.map +1 -0
  14. package/dist/deploy/coolify-mongo.js +109 -0
  15. package/dist/deploy/coolify-mongo.js.map +1 -0
  16. package/dist/deploy/gpu.d.ts +9 -2
  17. package/dist/deploy/gpu.d.ts.map +1 -1
  18. package/dist/deploy/gpu.js +63 -39
  19. package/dist/deploy/gpu.js.map +1 -1
  20. package/dist/deploy/pages.js +50 -9
  21. package/dist/deploy/pages.js.map +1 -1
  22. package/dist/deploy/rename-domain.d.ts.map +1 -1
  23. package/dist/deploy/rename-domain.js +26 -6
  24. package/dist/deploy/rename-domain.js.map +1 -1
  25. package/dist/deploy/rollback.d.ts +10 -0
  26. package/dist/deploy/rollback.d.ts.map +1 -0
  27. package/dist/deploy/rollback.js +295 -0
  28. package/dist/deploy/rollback.js.map +1 -0
  29. package/dist/deploy/terraform.d.ts +10 -1
  30. package/dist/deploy/terraform.d.ts.map +1 -1
  31. package/dist/deploy/terraform.js +177 -42
  32. package/dist/deploy/terraform.js.map +1 -1
  33. package/dist/doctor.d.ts.map +1 -1
  34. package/dist/doctor.js +25 -0
  35. package/dist/doctor.js.map +1 -1
  36. package/dist/explain.d.ts.map +1 -1
  37. package/dist/explain.js +5 -0
  38. package/dist/explain.js.map +1 -1
  39. package/dist/index.js +351 -88
  40. package/dist/index.js.map +1 -1
  41. package/dist/prompts.d.ts +15 -2
  42. package/dist/prompts.d.ts.map +1 -1
  43. package/dist/prompts.js +334 -17
  44. package/dist/prompts.js.map +1 -1
  45. package/dist/provision/stripe.d.ts +19 -0
  46. package/dist/provision/stripe.d.ts.map +1 -0
  47. package/dist/provision/stripe.js +58 -0
  48. package/dist/provision/stripe.js.map +1 -0
  49. package/dist/scaffold/app.js +8 -0
  50. package/dist/scaffold/app.js.map +1 -1
  51. package/dist/scaffold/dotenvx.d.ts.map +1 -1
  52. package/dist/scaffold/dotenvx.js +41 -10
  53. package/dist/scaffold/dotenvx.js.map +1 -1
  54. package/dist/scaffold/infra.d.ts +21 -1
  55. package/dist/scaffold/infra.d.ts.map +1 -1
  56. package/dist/scaffold/infra.js +66 -20
  57. package/dist/scaffold/infra.js.map +1 -1
  58. package/dist/scaffold/manifest.d.ts +4 -2
  59. package/dist/scaffold/manifest.d.ts.map +1 -1
  60. package/dist/scaffold/manifest.js +1 -1
  61. package/dist/scaffold/manifest.js.map +1 -1
  62. package/dist/scaffold/ml-client.d.ts +9 -2
  63. package/dist/scaffold/ml-client.d.ts.map +1 -1
  64. package/dist/scaffold/ml-client.js +11 -1
  65. package/dist/scaffold/ml-client.js.map +1 -1
  66. package/dist/status.d.ts.map +1 -1
  67. package/dist/status.js +7 -0
  68. package/dist/status.js.map +1 -1
  69. package/dist/templates/base/env.example.hbs +10 -0
  70. package/dist/templates/base/src/config.ts.hbs +24 -4
  71. package/dist/utils/coolify-api.d.ts +56 -0
  72. package/dist/utils/coolify-api.d.ts.map +1 -1
  73. package/dist/utils/coolify-api.js +79 -0
  74. package/dist/utils/coolify-api.js.map +1 -1
  75. package/dist/utils/flags.d.ts.map +1 -1
  76. package/dist/utils/flags.js +4 -0
  77. package/dist/utils/flags.js.map +1 -1
  78. package/dist/utils/run-ledger.d.ts +68 -0
  79. package/dist/utils/run-ledger.d.ts.map +1 -0
  80. package/dist/utils/run-ledger.js +99 -0
  81. package/dist/utils/run-ledger.js.map +1 -0
  82. package/dist/utils/secrets.d.ts +2 -0
  83. package/dist/utils/secrets.d.ts.map +1 -1
  84. package/dist/utils/secrets.js +2 -0
  85. package/dist/utils/secrets.js.map +1 -1
  86. package/package.json +6 -5
  87. package/scripts/release-prep.mjs +88 -0
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,100 +492,208 @@ 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);
504
+ let installedDeps = false;
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),
514
+ });
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 });
537
+ }
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\`.`));
579
+ }
580
+ }
440
581
  }
441
- }
442
- if (config.dryRun) {
443
- scaffoldInfra(config, INFRA_ROOT, {
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."));
596
+ }
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
+ }
618
+ }
619
+ }
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 });
630
+ }
631
+ }
632
+ // Step 4: Generate infra configs (with repo URL + ports baked in).
633
+ const infraResult = scaffoldInfra(config, INFRA_ROOT, {
634
+ repoUrl: repoUrl ?? undefined,
444
635
  serverPort: scaffoldResult?.ports.server,
445
636
  clientPort: scaffoldResult?.ports.client,
446
637
  });
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
- 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."));
638
+ if (infraResult.tfvarsPath) {
639
+ ledger?.record({ kind: "tfvars", path: infraResult.tfvarsPath });
457
640
  }
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,
466
- });
467
- if (shouldInstall) {
468
- const res = await exec("pnpm", ["install"], {
469
- cwd: appDir,
470
- spinner: "Installing dependencies...",
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,
471
652
  });
472
- if (res.exitCode === 0) {
473
- installedDeps = true;
474
- }
475
- else {
476
- console.log(chalk.yellow(" pnpm install failed — continuing anyway."));
477
- }
478
653
  }
479
654
  }
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
- // Push the dotenvx private key to Coolify so the starter's server
501
- // can decrypt .env.production at runtime. Best-effort — if the
502
- // Coolify app doesn't exist yet (race with the stack script), we
503
- // print the manual command instead of failing the whole flow.
504
- if (scaffoldResult?.dotenvx) {
505
- try {
506
- await pushProjectKeyToCoolify(config.name);
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
+ }
507
673
  }
508
- catch (err) {
509
- console.log(chalk.yellow(` Couldn't auto-push dotenvx key: ${err.message}`));
510
- console.log(chalk.dim(` Push manually once the Coolify app exists: hatchkit keys push ${config.name}`));
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);
681
+ }
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}`));
685
+ }
511
686
  }
512
687
  }
513
- }
514
- // Step 7: Deploy ML services
515
- if (config.runDeployment && deploy.length > 0 && config.gpuPlatform) {
516
- const endpoints = await deployMlServices(deploy, config.gpuPlatform, SERVICES_ROOT, config.customHfModelId);
517
- // Print env vars to set
518
- if (Object.keys(endpoints).length > 0) {
519
- console.log(chalk.bold("\n ML service endpoints (add to Coolify env):"));
520
- for (const [service, endpoint] of Object.entries(endpoints)) {
521
- // Service keys come from our own `deploy: MlService[]` so the
522
- // cast is sound, but narrow via the literal-array check so a
523
- // stray unknown slips don't silently format wrong.
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");
524
697
  const knownServices = [
525
698
  "3d-extraction",
526
699
  "subtitles",
@@ -528,16 +701,41 @@ async function handleCreate() {
528
701
  "background-removal",
529
702
  "custom-hf",
530
703
  ];
531
- if (knownServices.includes(service)) {
532
- console.log(chalk.dim(` ${mlEnvVarName(service)}=${endpoint}`));
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
+ }
533
721
  }
534
722
  }
535
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);
536
734
  }
537
735
  // Final summary
538
736
  console.log(chalk.bold("\n ── Done! ─────────────────────────────────────────────────\n"));
539
737
  console.log(` App: ${chalk.cyan(`https://${config.domain}`)}`);
540
- console.log(` API: ${chalk.cyan(`https://api.${config.domain}`)}`);
738
+ console.log(` API: ${chalk.cyan(`https://${config.domain}/api`)}`);
541
739
  console.log(` App dir: ${chalk.dim(appDir)}`);
542
740
  console.log(` Config: ${chalk.dim(getConfigPath())}`);
543
741
  if (config.scaffoldRepo) {
@@ -548,9 +746,6 @@ async function handleCreate() {
548
746
  console.log(chalk.yellow(`\n Next: cd ${config.name} && pnpm install && pnpm dev`));
549
747
  }
550
748
  }
551
- if (config.features.includes("stripe")) {
552
- console.log(chalk.yellow("\n Next: Set STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET in Coolify"));
553
- }
554
749
  if (config.features.includes("mobile")) {
555
750
  console.log(chalk.yellow("\n Next (mobile): generate native projects once:"));
556
751
  console.log(chalk.dim(" pnpm cap:add:ios # requires Xcode"));
@@ -599,6 +794,7 @@ async function handleConfig() {
599
794
  case "glitchtip":
600
795
  case "openpanel":
601
796
  case "resend":
797
+ case "stripe":
602
798
  await reconfigureProvider(provider);
603
799
  break;
604
800
  case "s3": {
@@ -853,6 +1049,40 @@ function printHelp(topic) {
853
1049
  hatchkit add raptor-runner all --surfaces=shared \\
854
1050
  --server-dir ./raptor-runner/packages/server \\
855
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.
856
1086
  `);
857
1087
  return;
858
1088
  }
@@ -889,6 +1119,37 @@ function printHelp(topic) {
889
1119
  hatchkit remove raptor-runner all
890
1120
  hatchkit remove raptor-runner glitchtip,resend --dry-run
891
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")}
892
1153
  `);
893
1154
  return;
894
1155
  }
@@ -991,9 +1252,11 @@ function printHelp(topic) {
991
1252
 
992
1253
  ${chalk.bold("Projects:")}
993
1254
  create Scaffold a new project (interactive)
1255
+ adopt Bring an existing project under hatchkit management (run in project dir)
994
1256
  update Add features to an already-scaffolded project (run in project dir)
995
1257
  add Create GlitchTip / OpenPanel / Resend clients for an existing project
996
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
997
1260
  rename-domain Move a scaffolded project to a new domain (rewrites tfvars/env/manifest)
998
1261
  gh-pages Wire GitHub Pages for the current repo (static / Vite / Jekyll — with DNS)
999
1262
  dns DNS reconciliation helpers (link-to-cloudflare, …)