vibecarbon 0.9.0 → 0.11.0

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 (88) hide show
  1. package/LICENSE +110 -663
  2. package/README.md +4 -4
  3. package/carbon/.env.example +11 -0
  4. package/carbon/backup/compose-backup.sh +8 -0
  5. package/carbon/docker-compose.yml +11 -0
  6. package/carbon/ha/primary-init.sql +10 -2
  7. package/carbon/k8s/base/backup/cronjob.yaml +9 -0
  8. package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
  9. package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
  10. package/carbon/k8s/values/supabase.values.yaml +6 -1
  11. package/carbon/src/client/lib/supabase.ts +41 -0
  12. package/carbon/src/client/locales/de.json +27 -12
  13. package/carbon/src/client/locales/en.json +30 -15
  14. package/carbon/src/client/locales/es.json +27 -12
  15. package/carbon/src/client/locales/fr.json +27 -12
  16. package/carbon/src/client/locales/pt.json +27 -12
  17. package/carbon/src/client/pages/Checkout.tsx +1 -1
  18. package/carbon/src/server/billing/providers/paddle.ts +21 -2
  19. package/carbon/src/server/billing/providers/polar.ts +14 -0
  20. package/carbon/src/server/index.ts +12 -9
  21. package/carbon/src/server/lib/client-ip.ts +68 -0
  22. package/carbon/src/server/lib/env.ts +20 -6
  23. package/carbon/src/server/lib/rate-limiter.ts +11 -65
  24. package/carbon/src/server/lib/supabase.ts +10 -4
  25. package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
  26. package/carbon/src/server/middleware/requirePlan.ts +64 -34
  27. package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
  28. package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
  29. package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
  30. package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
  31. package/carbon/src/server/routes/v1/auth.ts +5 -2
  32. package/carbon/src/server/routes/v1/billing.ts +24 -64
  33. package/carbon/src/server/routes/v1/index.ts +8 -1
  34. package/carbon/src/server/routes/v1/newsletter.ts +18 -12
  35. package/carbon/src/server/routes/v1/theme.ts +36 -15
  36. package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
  37. package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
  38. package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
  39. package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
  40. package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
  41. package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
  42. package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
  43. package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
  44. package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
  45. package/carbon/tests/unit/server/client-ip.test.ts +76 -0
  46. package/carbon/tests/unit/server/env-bool.test.ts +63 -0
  47. package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
  48. package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
  49. package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
  50. package/carbon/volumes/db/wal-archive.sh +36 -0
  51. package/package.json +2 -2
  52. package/src/backup.js +0 -2
  53. package/src/cli.js +16 -0
  54. package/src/deploy.js +16 -5
  55. package/src/destroy.js +968 -886
  56. package/src/failover.js +118 -237
  57. package/src/lib/command.js +19 -1
  58. package/src/lib/deploy/compose/ha.js +180 -102
  59. package/src/lib/deploy/compose/index.js +106 -57
  60. package/src/lib/deploy/compose/single.js +421 -0
  61. package/src/lib/deploy/image.js +38 -30
  62. package/src/lib/deploy/k8s/ha/index.js +397 -150
  63. package/src/lib/deploy/k8s/k3s.js +411 -279
  64. package/src/lib/deploy/orchestrator.js +274 -453
  65. package/src/lib/deploy/remote-build.js +8 -1
  66. package/src/lib/deploy/replication.js +540 -0
  67. package/src/lib/deploy/state.js +62 -5
  68. package/src/lib/deploy/tier-registry.js +30 -0
  69. package/src/lib/deploy/utils.js +30 -2
  70. package/src/lib/deploy/wireguard.js +146 -0
  71. package/src/lib/host-keys.js +120 -5
  72. package/src/lib/iac/index.js +57 -23
  73. package/src/lib/licensing/gate.js +59 -0
  74. package/src/lib/licensing/index.js +1 -1
  75. package/src/lib/licensing/tiers.js +6 -5
  76. package/src/lib/prod-confirm.js +65 -0
  77. package/src/lib/providers/hetzner-s3.js +85 -0
  78. package/src/lib/retry.js +59 -0
  79. package/src/lib/scale-plan.js +159 -0
  80. package/src/lib/ssh.js +35 -35
  81. package/src/restore.js +102 -14
  82. package/src/scale.js +105 -133
  83. package/src/status.js +122 -0
  84. package/src/upgrade.js +0 -4
  85. package/carbon/ha/activate-standby.sh +0 -52
  86. package/carbon/ha/standby-init.sh +0 -74
  87. package/carbon/src/server/lib/request.ts +0 -34
  88. package/src/lib/pod-backups.js +0 -53
package/src/destroy.js CHANGED
@@ -32,6 +32,12 @@ import {
32
32
  } from './lib/cloudflare.js';
33
33
  import { c, printBanner } from './lib/colors.js';
34
34
  import { loadCredentials, loadProjectConfig, saveProjectConfig } from './lib/config.js';
35
+ import {
36
+ isComposeTier,
37
+ isK8sTier,
38
+ pulumiStackEnvs,
39
+ resolveTier,
40
+ } from './lib/deploy/tier-registry.js';
35
41
  import { fetchWithRetry } from './lib/fetch-retry.js';
36
42
  import {
37
43
  deleteDNSRecord as hetznerDnsDeleteRecord,
@@ -41,6 +47,7 @@ import { getApiToken, getS3Credentials } from './lib/hetzner-guided-setup.js';
41
47
  import { perfAsync } from './lib/perf.js';
42
48
  import { assertInProjectDir } from './lib/project-guard.js';
43
49
  import { HetznerS3Provider, sanitizeBucketName } from './lib/providers/hetzner-s3.js';
50
+ import { pollUntil } from './lib/retry.js';
44
51
  import { createTracker } from './lib/tracker.js';
45
52
  import { VERSION } from './lib/version.js';
46
53
 
@@ -54,6 +61,35 @@ export function requiresProdTypeToConfirm(envName) {
54
61
  return /^(prod|production)$/i.test(envName);
55
62
  }
56
63
 
64
+ /**
65
+ * Pure planner: derive every teardown target from persisted config, with no
66
+ * network/filesystem access. Returns the canonical tier id plus the derived
67
+ * Pulumi stack envs, cluster names, whether a k8s Pulumi stack exists, and the
68
+ * owned server IPs that gate ownership-filtered DNS deletes.
69
+ *
70
+ * tier dispatch is the ONLY place allowed to look at deployMode/ha, so this
71
+ * delegates to tier-registry (resolveTier / pulumiStackEnvs) rather than
72
+ * re-deriving mode+ha combinations locally.
73
+ *
74
+ * @param {object} envConfig - persisted environment config.
75
+ * @param {object} projectConfig - persisted project config (needs projectName).
76
+ * @param {string} environment - environment name being destroyed.
77
+ * @returns {{ tier: string, stackEnvs: string[], hasPulumiStack: boolean, clusterNames: string[], ownedIps: string[] }}
78
+ */
79
+ export function planDestroyTargets(envConfig, projectConfig, environment) {
80
+ const tier = resolveTier(envConfig);
81
+ const stackEnvs = pulumiStackEnvs(tier, environment);
82
+ const hasPulumiStack = isK8sTier(tier);
83
+ const projectName = projectConfig.projectName;
84
+ // For HA k8s each sub-cluster has its own network/naming prefix
85
+ // (<project>-<env>-primary / -standby); single tiers get one.
86
+ const clusterNames = stackEnvs.map((stackEnv) => `${projectName}-${stackEnv}`);
87
+ // Owned IPs gate every DNS delete — only records pointing at this env's
88
+ // servers are removed. See deleteDNSRecord doc in lib/cloudflare.js.
89
+ const ownedIps = (envConfig.servers || []).map((srv) => srv.ip).filter(Boolean);
90
+ return { tier, stackEnvs, hasPulumiStack, clusterNames, ownedIps };
91
+ }
92
+
57
93
  // ============================================================================
58
94
  // CLI ARGUMENT PARSING
59
95
  // ============================================================================
@@ -278,18 +314,23 @@ async function hetznerDeleteServer(apiToken, serverId) {
278
314
  // Observed during compose restore. The same delete-then-poll fix is
279
315
  // applied in destroyComposeHA.
280
316
  if (response.status !== 404) {
281
- const deadline = Date.now() + 90_000;
282
- while (Date.now() < deadline) {
283
- try {
317
+ // Poll GET /servers/{id} until 404 (90s budget, 2s fixed interval). A
318
+ // throwing probe (transient) is treated as falsy and retried; on budget
319
+ // exhaustion we simply stop waiting — the return value below is unaffected.
320
+ await pollUntil(
321
+ async () => {
284
322
  const probe = await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}`, {
285
323
  headers: { Authorization: `Bearer ${apiToken}` },
286
324
  });
287
- if (probe.status === 404) break;
288
- } catch {
289
- // Transient — retry next loop iteration
290
- }
291
- await new Promise((r) => setTimeout(r, 2_000));
292
- }
325
+ return probe.status === 404;
326
+ },
327
+ {
328
+ budgetMs: 90_000,
329
+ initialDelayMs: 2_000,
330
+ backoffFactor: 1,
331
+ description: `server ${serverId} deletion`,
332
+ },
333
+ ).catch(() => {});
293
334
  }
294
335
 
295
336
  return response.status !== 404;
@@ -303,43 +344,65 @@ async function hetznerDeleteFirewall(apiToken, name) {
303
344
  // (also fixed identically in destroyComposeHA).
304
345
  const headers = { Authorization: `Bearer ${apiToken}` };
305
346
  const lookupUrl = `https://api.hetzner.cloud/v1/firewalls?name=${encodeURIComponent(name)}`;
306
- const deadlineMs = 30_000;
307
- const start = Date.now();
308
347
  let everExisted = false;
309
- while (Date.now() - start < deadlineMs) {
310
- const listResp = await fetchWithRetry(lookupUrl, { headers });
311
- const { firewalls } = await listResp.json();
312
- const fw = firewalls?.[0];
313
- if (!fw) return everExisted;
314
- everExisted = true;
315
- if (fw.applied_to?.length > 0) {
316
- const serverResources = fw.applied_to
317
- .filter((a) => a.type === 'server' && a.server?.id)
318
- .map((a) => ({ type: 'server', server: { id: a.server.id } }));
319
- if (serverResources.length > 0) {
320
- try {
321
- await fetchWithRetry(
322
- `https://api.hetzner.cloud/v1/firewalls/${fw.id}/actions/remove_from_resources`,
323
- {
324
- method: 'POST',
325
- headers: { ...headers, 'Content-Type': 'application/json' },
326
- body: JSON.stringify({ remove_from: serverResources }),
327
- },
328
- );
329
- } catch {
330
- // Servers may already be gone — proceed to DELETE anyway
348
+ let apiError = null;
349
+ // Delete-until-gone under a 30s budget with a fixed 3s interval. Each probe
350
+ // re-looks-up the firewall (to refresh applied_to), detaches servers, then
351
+ // DELETEs; a 409 (still attached) returns a falsy result so pollUntil backs
352
+ // off and retries. A truthy wrapper object signals "done" — needed because
353
+ // the "firewall gone" outcome can carry `everExisted === false`, which a bare
354
+ // falsy return would misread as "keep polling". A genuine not-found therefore
355
+ // returns the truthy { value: false } wrapper PROMPTLY (pollUntil never
356
+ // throws for it). pollUntil only throws on budget exhaustion, which means the
357
+ // delete never completed either the lookup/DELETE kept failing (err.cause
358
+ // is the last API error) or the firewall stayed attached (persistent 409).
359
+ // Either way it's a probable LEAK, not a not-found, so we remember the
360
+ // underlying error and let the caller surface it instead of "not found".
361
+ const outcome = await pollUntil(
362
+ async () => {
363
+ const listResp = await fetchWithRetry(lookupUrl, { headers });
364
+ const { firewalls } = await listResp.json();
365
+ const fw = firewalls?.[0];
366
+ if (!fw) return { value: everExisted };
367
+ everExisted = true;
368
+ if (fw.applied_to?.length > 0) {
369
+ const serverResources = fw.applied_to
370
+ .filter((a) => a.type === 'server' && a.server?.id)
371
+ .map((a) => ({ type: 'server', server: { id: a.server.id } }));
372
+ if (serverResources.length > 0) {
373
+ try {
374
+ await fetchWithRetry(
375
+ `https://api.hetzner.cloud/v1/firewalls/${fw.id}/actions/remove_from_resources`,
376
+ {
377
+ method: 'POST',
378
+ headers: { ...headers, 'Content-Type': 'application/json' },
379
+ body: JSON.stringify({ remove_from: serverResources }),
380
+ },
381
+ );
382
+ } catch {
383
+ // Servers may already be gone — proceed to DELETE anyway
384
+ }
331
385
  }
332
386
  }
333
- }
334
- const delResp = await fetchWithRetry(`https://api.hetzner.cloud/v1/firewalls/${fw.id}`, {
335
- method: 'DELETE',
336
- headers,
337
- });
338
- if (delResp.ok || delResp.status === 404) return true;
339
- // 409 = still attached; back off and re-look-up to refresh applied_to.
340
- await new Promise((r) => setTimeout(r, 3_000));
341
- }
342
- return false;
387
+ const delResp = await fetchWithRetry(`https://api.hetzner.cloud/v1/firewalls/${fw.id}`, {
388
+ method: 'DELETE',
389
+ headers,
390
+ });
391
+ if (delResp.ok || delResp.status === 404) return { value: true };
392
+ // 409 = still attached; back off and re-look-up to refresh applied_to.
393
+ return null;
394
+ },
395
+ {
396
+ budgetMs: 30_000,
397
+ initialDelayMs: 3_000,
398
+ backoffFactor: 1,
399
+ description: `firewall ${name} deletion`,
400
+ },
401
+ ).catch((err) => {
402
+ apiError = err?.cause ?? err;
403
+ return { value: false };
404
+ });
405
+ return { deleted: outcome.value === true, everExisted, apiError };
343
406
  }
344
407
 
345
408
  async function hetznerDeleteSSHKey(apiToken, name) {
@@ -735,6 +798,62 @@ async function handleBackupBucket(envConfig, projectConfig, args, spinner) {
735
798
  }
736
799
  }
737
800
 
801
+ /**
802
+ * Delete the dedicated Pulumi state bucket.
803
+ *
804
+ * SAFETY: this MUST run only AFTER all Pulumi stack teardown (destroy +
805
+ * removeStack) has completed — deleting the state backend while a stack op is
806
+ * still in flight would corrupt the destroy. Callers invoke this at the very
807
+ * end of their teardown, past the Pulumi destroy step.
808
+ *
809
+ * No-op for pre-split envs whose Pulumi state still lived in the app storage
810
+ * bucket (envConfig.s3.stateBucket unset, or equal to the app bucket) — that
811
+ * bucket is already removed via the normal app-bucket deletion path, so there's
812
+ * nothing extra to delete here.
813
+ */
814
+ async function deleteStateBucket(envConfig, projectConfig, spinner, results) {
815
+ const stateBucket = envConfig.s3?.stateBucket;
816
+ if (!stateBucket || stateBucket === envConfig.s3?.bucket) return;
817
+ const region = envConfig.s3?.region || 'fsn1';
818
+ spinner.start(`Deleting Pulumi state bucket: ${stateBucket}`);
819
+ try {
820
+ const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
821
+ if (!s3Creds) {
822
+ spinner.stop('State bucket skipped (no credentials available)');
823
+ results.issues.push({
824
+ step: 'state bucket',
825
+ detail: `${stateBucket} (${region}) not deleted: no S3 credentials available`,
826
+ hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
827
+ });
828
+ return;
829
+ }
830
+ const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, region);
831
+ try {
832
+ const result = await s3Provider.emptyAndDeleteBucket(stateBucket);
833
+ spinner.stop(
834
+ result.deleted
835
+ ? result.objectsRemoved > 0
836
+ ? `Pulumi state bucket deleted (${result.objectsRemoved} objects removed)`
837
+ : 'Pulumi state bucket deleted'
838
+ : 'Pulumi state bucket not found',
839
+ );
840
+ } catch (bucketError) {
841
+ if (bucketError.name === 'NoSuchBucket' || bucketError.$metadata?.httpStatusCode === 404) {
842
+ spinner.stop('Pulumi state bucket not found (already deleted or never created)');
843
+ } else {
844
+ throw bucketError;
845
+ }
846
+ }
847
+ } catch (error) {
848
+ spinner.stop(`Pulumi state bucket deletion failed: ${error.message}`);
849
+ results.issues.push({
850
+ step: 'state bucket',
851
+ detail: `${stateBucket} (${region}) not deleted: ${error.message}`,
852
+ hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
853
+ });
854
+ }
855
+ }
856
+
738
857
  /**
739
858
  * Final outro for the destroy flow. If sub-steps left state behind (e.g. an
740
859
  * S3 bucket that couldn't be emptied), surface them with hints and set a
@@ -761,700 +880,402 @@ function destroyOutro(envName, timeStr, results) {
761
880
  }
762
881
 
763
882
  // ============================================================================
764
- // MAIN DESTRUCTION FLOW
883
+ // SHARED TEARDOWN TAIL
765
884
  // ============================================================================
766
885
 
767
- async function main() {
768
- const { values, positional, errors } = parseFlags(process.argv.slice(2), SPEC);
769
-
770
- if (errors.length > 0) {
771
- for (const e of errors) {
772
- process.stderr.write(`${c.error('✗')} ${e}\n`);
886
+ /**
887
+ * Shared teardown tail run once after the per-tier strategy. Covers, in order:
888
+ * app S3 bucket delete, Pulumi state bucket, backup bucket, (optional) GitHub
889
+ * environment, project-config removal + save, local env-file cleanup,
890
+ * (optional) summary note, and the final outro.
891
+ *
892
+ * Tier-specific divergences are explicit booleans rather than silent unions:
893
+ * - `deleteGithubEnv` — only k8s deletes the GitHub environment.
894
+ * - `showSummary` — only k8s prints the deleted-resources summary note.
895
+ * - `defaultBucketName` — k8s falls back to the predictable deploy-time bucket
896
+ * name (sanitizeBucketName(projectName)) when envConfig.s3.bucket is unset;
897
+ * compose tiers only delete an explicitly-configured bucket.
898
+ */
899
+ async function finishDestroy({
900
+ envConfig,
901
+ projectConfig,
902
+ environment,
903
+ args,
904
+ results,
905
+ spinner,
906
+ tracker,
907
+ cwd,
908
+ deleteGithubEnv = false,
909
+ showSummary = false,
910
+ defaultBucketName = false,
911
+ }) {
912
+ // 1. Delete the app S3 bucket (created during deploy bootstrap, not part of
913
+ // any Pulumi stack's resource graph). k8s can fall back to the predictable
914
+ // deploy-time name; compose tiers only act on a configured bucket.
915
+ const s3BucketName =
916
+ envConfig.s3?.bucket ||
917
+ (defaultBucketName ? sanitizeBucketName(projectConfig.projectName) : null);
918
+ if (s3BucketName) {
919
+ const s3Region = envConfig.s3?.region || 'fsn1';
920
+ spinner.start(`Deleting S3 bucket: ${s3BucketName}`);
921
+ try {
922
+ const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
923
+ if (s3Creds) {
924
+ const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, s3Region);
925
+ try {
926
+ const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
927
+ if (result.deleted) {
928
+ results.s3Bucket = s3BucketName;
929
+ spinner.stop(
930
+ result.objectsRemoved > 0
931
+ ? `S3 bucket deleted (${result.objectsRemoved} objects removed)`
932
+ : 'S3 bucket deleted',
933
+ );
934
+ } else {
935
+ spinner.stop('S3 bucket not found');
936
+ }
937
+ } catch (bucketError) {
938
+ // NoSuchBucket means it was already deleted or never created — not an error
939
+ if (
940
+ bucketError.name === 'NoSuchBucket' ||
941
+ bucketError.$metadata?.httpStatusCode === 404
942
+ ) {
943
+ spinner.stop('S3 bucket not found (already deleted or never created)');
944
+ } else {
945
+ throw bucketError;
946
+ }
947
+ }
948
+ } else {
949
+ spinner.stop('S3 bucket skipped (no credentials available)');
950
+ results.issues.push({
951
+ step: 'S3 bucket',
952
+ detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
953
+ hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
954
+ });
955
+ }
956
+ } catch (error) {
957
+ spinner.stop(`S3 bucket deletion failed: ${error.message}`);
958
+ results.issues.push({
959
+ step: 'S3 bucket',
960
+ detail: `${s3BucketName} (${s3Region}) not deleted: ${error.message}`,
961
+ hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
962
+ });
773
963
  }
774
- process.stderr.write(`Run ${c.info('vibecarbon destroy -h')} for usage.\n`);
775
- process.exit(1);
776
964
  }
777
965
 
778
- if (values.h) {
779
- process.stdout.write(renderHelp(SPEC));
780
- process.exit(0);
781
- }
782
- if (values.v) {
783
- console.log(`destroy-vibecarbon v${VERSION}`);
784
- process.exit(0);
966
+ // 2. State bucket: deleted LAST relative to any Pulumi stack teardown (the
967
+ // strategy already ran `pulumi destroy` + `removeStack` before returning),
968
+ // so tearing the state backend down here is safe.
969
+ await deleteStateBucket(envConfig, projectConfig, spinner, results);
970
+
971
+ // 3. Backup bucket: preserved by default, deleted with --purge-backups.
972
+ await handleBackupBucket(envConfig, projectConfig, args, spinner);
973
+
974
+ // 4. GitHub environment (k8s only).
975
+ if (deleteGithubEnv) {
976
+ spinner.start(`Deleting GitHub environment: ${environment}`);
977
+ try {
978
+ results.github = deleteGitHubEnvironment(environment);
979
+ spinner.stop(
980
+ results.github
981
+ ? 'GitHub environment deleted'
982
+ : 'GitHub environment not found (or no access)',
983
+ );
984
+ } catch (error) {
985
+ spinner.stop(`Failed to delete GitHub environment: ${error.message}`);
986
+ }
785
987
  }
786
988
 
787
- // Build the legacy `args` struct that the orchestration code below
788
- // reads (env / yes / destroyOrphans / purgeBackups). Field renames
789
- // happen at the boundary so the orchestration stays untouched —
790
- // each downstream branch is hundreds of lines and well-tested.
791
- const envSeed =
792
- /** @type {string|undefined} */ (positional.env) ||
793
- /** @type {string|null} */ (values.env) ||
794
- null;
795
- const args = {
796
- env: envSeed,
797
- yes: !!values.y,
798
- destroyOrphans: !!values.orphans,
799
- purgeBackups: !!values.purge,
800
- };
989
+ // 5. Remove the destroyed environment from project config.
990
+ spinner.start('Updating project configuration');
991
+ const updatedConfig = { ...projectConfig };
992
+ if (updatedConfig.environments) {
993
+ delete updatedConfig.environments[environment];
994
+ }
995
+ saveProjectConfig(updatedConfig);
996
+ spinner.stop('Configuration updated');
997
+
998
+ // 6. Local env-file cleanup (idempotent — keys, kubeconfigs, deploy-state).
999
+ cleanupLocalEnvFiles(cwd, environment);
1000
+
1001
+ // 7. Summary note (k8s only).
1002
+ if (showSummary) {
1003
+ const deletedCount =
1004
+ results.servers.length +
1005
+ results.volumes.length +
1006
+ results.firewalls.length +
1007
+ results.sshKeys.length +
1008
+ results.dns.length +
1009
+ results.healthChecks.length +
1010
+ results.loadBalancers.length +
1011
+ (results.s3Bucket ? 1 : 0) +
1012
+ (results.github ? 1 : 0);
1013
+
1014
+ p.note(
1015
+ `
1016
+ ${c.success('Deleted resources:')}
1017
+ Servers: ${results.servers.length > 0 ? results.servers.join(', ') : 'None'}
1018
+ Volumes: ${results.volumes.length > 0 ? results.volumes.join(', ') : 'None'}
1019
+ Firewalls: ${results.firewalls.length > 0 ? results.firewalls.join(', ') : 'None'}
1020
+ SSH Keys: ${results.sshKeys.length > 0 ? results.sshKeys.join(', ') : 'None'}
1021
+ DNS Records: ${results.dns.length > 0 ? results.dns.join(', ') : 'None'}
1022
+ Health Checks: ${results.healthChecks.length > 0 ? results.healthChecks.join(', ') : 'None'}
1023
+ Load Balancers: ${results.loadBalancers.length > 0 ? results.loadBalancers.join(', ') : 'None'}
1024
+ S3 Bucket: ${results.s3Bucket || 'None'}
1025
+ GitHub Env: ${results.github ? environment : 'None'}
801
1026
 
802
- // Check if current working directory exists
803
- let cwd;
804
- try {
805
- cwd = process.cwd();
806
- } catch {
807
- console.error(`\n${c.error('Error:')} Current working directory does not exist.`);
808
- console.error(
809
- `This can happen if the project directory was deleted while this command was running.`,
1027
+ ${c.dim(`Total: ${deletedCount} resources deleted`)}
1028
+ `,
1029
+ 'Destruction Complete',
810
1030
  );
811
- console.error(`\nPlease navigate to a valid directory and try again.`);
812
- process.exit(1);
813
1031
  }
814
1032
 
815
- // Project guard runs before banner so an accidental `vibecarbon
816
- // destroy` from a parent directory emits the canonical message.
817
- assertInProjectDir(cwd);
1033
+ const { formatted: timeStr } = tracker.finish();
1034
+ destroyOutro(environment, timeStr, results);
1035
+ }
818
1036
 
819
- console.clear();
820
- printBanner();
821
- p.intro(`${c.bold('vibecarbon destroy')} ${c.dim(`v${VERSION}`)}`);
1037
+ // ============================================================================
1038
+ // PER-TIER DESTROY STRATEGIES
1039
+ // ============================================================================
822
1040
 
823
- const projectConfig = loadProjectConfig(cwd);
1041
+ /**
1042
+ * compose-ha: hand the whole teardown (both nodes, firewalls, DNS) to the
1043
+ * compose HA helper. The shared tail handles buckets/config afterwards.
1044
+ */
1045
+ async function destroyComposeHATier({
1046
+ envConfig,
1047
+ projectConfig,
1048
+ environment,
1049
+ hetznerToken,
1050
+ cloudflareToken,
1051
+ tracker,
1052
+ }) {
1053
+ const { destroyComposeHA } = await import('./lib/deploy/compose/ha.js');
1054
+
1055
+ const s2 = tracker.spinner();
1056
+ s2.start('Destroying Compose HA environment...');
1057
+ try {
1058
+ await destroyComposeHA({
1059
+ projectName: projectConfig.projectName,
1060
+ environment,
1061
+ envConfig,
1062
+ hetznerToken,
1063
+ cloudflareToken,
1064
+ onProgress: (msg) => s2.message(msg),
1065
+ });
1066
+ s2.stop('Compose HA environment destroyed');
1067
+ } catch (error) {
1068
+ s2.stop(`Compose HA destruction failed: ${error.message}`);
1069
+ }
1070
+ }
824
1071
 
825
- // TTY guard: if the operator seeded an env, no env prompt fires;
826
- // otherwise we'd open a picker that hangs off-TTY.
827
- const envCount = Object.keys(projectConfig?.environments || {}).length;
828
- requireTTYOrFlags({
829
- requirements: [
830
- {
831
- flag: 'env',
832
- description: 'name an environment to destroy',
833
- satisfied: !!envSeed || envCount <= 1,
834
- },
835
- ],
836
- });
1072
+ /**
1073
+ * compose (single VPS): stop the stack, then reap the VPS, firewall, SSH key
1074
+ * and DNS via the Hetzner/Cloudflare APIs directly (no Pulumi teardown).
1075
+ */
1076
+ async function destroyComposeTier({
1077
+ plan,
1078
+ envConfig,
1079
+ projectConfig,
1080
+ environment,
1081
+ hetznerToken,
1082
+ cloudflareToken,
1083
+ results,
1084
+ spinner: s,
1085
+ cwd,
1086
+ }) {
1087
+ const { ownedIps } = plan;
1088
+ const serverIp = envConfig.servers?.[0]?.ip;
1089
+ const sshKeyPath = join(cwd, '.vibecarbon', `deploy_key_${environment}`);
837
1090
 
838
- if (!projectConfig.environments || Object.keys(projectConfig.environments).length === 0) {
839
- // Check for orphan Pulumi stacks (deployment interrupted before config save)
840
- const orphanScan = p.spinner();
841
- orphanScan.start('Scanning for orphan Pulumi stacks...');
842
- const orphans = await findOrphanPulumiStacks(projectConfig);
843
- orphanScan.stop(
844
- orphans.length > 0 ? `Found ${orphans.length} orphan stack(s)` : 'No orphan stacks found',
845
- );
1091
+ if (serverIp && existsSync(sshKeyPath)) {
1092
+ // Stop and remove Docker Compose services
1093
+ s.start('Stopping Docker Compose services...');
1094
+ try {
1095
+ const { destroyCompose } = await import('./lib/deploy/compose/index.js');
1096
+ await destroyCompose(serverIp, sshKeyPath, projectConfig.projectName);
1097
+ s.stop('Docker Compose services removed');
1098
+ results.servers.push(`vps-${serverIp}`);
1099
+ } catch (error) {
1100
+ s.stop(`Failed to stop services: ${error.message}`);
1101
+ }
1102
+ }
846
1103
 
847
- if (orphans.length > 0) {
848
- p.log.warn('No environments found in .vibecarbon.json');
849
- p.log.info(
850
- `Found ${orphans.length} orphan Pulumi stack(s) likely from interrupted deployments:`,
851
- );
852
- for (const orphan of orphans) {
853
- p.log.info(` ${c.bold(orphan.name)}`);
1104
+ // Delete Hetzner VPS if auto-provisioned (has server ID in config)
1105
+ const serverId = envConfig.servers?.[0]?.id;
1106
+ // The `name` field in envConfig.servers is a role label ("master") set by
1107
+ // the orchestrator, not the Hetzner-assigned hostname. Pulumi's compose
1108
+ // program names the server `${projectName}-${environment}`. The
1109
+ // orchestrator persists that under `hetznerName`; configs older than
1110
+ // that change only have role-style names, so derive the Pulumi name as
1111
+ // a deterministic fallback — looking up "master" in Hetzner never
1112
+ // matches and leaks the VPS.
1113
+ const serverHetznerName =
1114
+ envConfig.servers?.[0]?.hetznerName || `${projectConfig.projectName}-${environment}`;
1115
+ if (serverId && hetznerToken) {
1116
+ s.start(`Deleting VPS (${serverIp})...`);
1117
+ try {
1118
+ const deleted = await hetznerDeleteServer(hetznerToken, serverId);
1119
+ s.stop(deleted ? 'VPS deleted' : 'VPS not found');
1120
+ } catch (error) {
1121
+ s.stop(`Failed to delete VPS: ${error.message}`);
1122
+ }
1123
+ } else if (!serverId && hetznerToken && serverHetznerName) {
1124
+ // Fallback: find server by name if ID wasn't saved (older deploys
1125
+ // or runs where Pulumi didn't propagate serverId).
1126
+ s.start(`Looking up VPS by name: ${serverHetznerName}...`);
1127
+ try {
1128
+ const servers = await hetznerListServers(hetznerToken);
1129
+ const server = servers.find((sv) => sv.name === serverHetznerName);
1130
+ if (server) {
1131
+ const deleted = await hetznerDeleteServer(hetznerToken, server.id);
1132
+ s.stop(deleted ? 'VPS deleted' : 'VPS not found');
1133
+ } else {
1134
+ s.stop('VPS not found');
854
1135
  }
1136
+ } catch (error) {
1137
+ s.stop(`Failed to delete VPS: ${error.message}`);
1138
+ }
1139
+ }
855
1140
 
856
- // Auto-destroying orphans is dangerous: when projectConfig.s3Config is
857
- // null (e.g., a typo in the saved config), listStacks falls back to
858
- // the local file:// backend at ~/.vibecarbon/pulumi-state/, which is
859
- // GLOBAL across projects/scenarios. A scenario's --yes destroy then
860
- // nukes other scenarios' stacks (observed 2026-04-26 batch run #5:
861
- // compose's final-destroy destroyed compose-ha's e2-primary/e2-standby
862
- // mid-deploy because compose's empty config let the orphan path fire).
863
- //
864
- // The auto-destroy used to honor --yes, but cross-scenario blast is
865
- // worse than leaving local state around. Require an explicit flag now.
866
- // For interactive use, prompt as before.
867
- p.log.info('');
868
- if (!args.destroyOrphans) {
869
- p.log.warn('Skipping orphan cleanup. Re-run with -orphans to remove these stacks.');
870
- p.outro('Done (orphans not destroyed)');
871
- process.exit(0);
1141
+ // Delete Hetzner firewall.
1142
+ // Pulumi (src/lib/iac/programs/hetzner-compose.js) names the firewall
1143
+ // `${projectName}-${environment}-firewall`
1144
+ // Earlier this used `vibecarbon-${projectName}-${envName}` which never
1145
+ // matched anything Pulumi created the firewall always leaked, and
1146
+ // the next compose restore re-deploy hit "name is already used
1147
+ // (uniqueness_error)" on Pulumi up. Match the actual Pulumi naming
1148
+ // convention (the equivalent fix exists in destroyComposeHA).
1149
+ const fwName = `${projectConfig.projectName}-${environment}-firewall`;
1150
+ if (hetznerToken) {
1151
+ s.start(`Deleting firewall: ${fwName}`);
1152
+ try {
1153
+ const result = await hetznerDeleteFirewall(hetznerToken, fwName);
1154
+ if (result.deleted) {
1155
+ results.firewalls.push(fwName);
1156
+ s.stop('Firewall deleted');
1157
+ } else if (result.apiError) {
1158
+ // Delete never confirmed within budget (persistent API error or a
1159
+ // firewall that stayed attached) — a probable LEAK, not a not-found.
1160
+ // A leaked firewall fails the NEXT deploy with uniqueness_error, so
1161
+ // surface it as an issue instead of the misleading "not found".
1162
+ s.stop(`Failed to delete firewall: ${result.apiError.message}`);
1163
+ results.issues.push({
1164
+ step: 'firewall',
1165
+ detail: `${fwName} may still exist (delete did not complete): ${result.apiError.message}`,
1166
+ hint: 'Delete it via the Hetzner console — a leaked firewall makes the next deploy fail with "name is already used (uniqueness_error)".',
1167
+ });
1168
+ } else {
1169
+ s.stop('Firewall not found');
872
1170
  }
873
- const shouldDestroy = args.yes
874
- ? true
875
- : await p.confirm({
876
- message: `Destroy these ${orphans.length} orphan stack(s)?`,
877
- initialValue: false, // Default to NO for safety
878
- });
1171
+ } catch (error) {
1172
+ s.stop(`Failed to delete firewall: ${error.message}`);
1173
+ }
1174
+ }
879
1175
 
880
- if (p.isCancel(shouldDestroy) || !shouldDestroy) {
881
- p.cancel('Operation cancelled.');
882
- process.exit(0);
1176
+ // Delete Hetzner SSH key.
1177
+ // Pulumi names the key
1178
+ // `${projectName}-${environment}-${location}-key`
1179
+ // — the location suffix is what kept the old `vibecarbon-` prefix from
1180
+ // ever matching. Same root cause as the firewall above.
1181
+ const composeRegion = envConfig.region || envConfig.servers?.[0]?.region;
1182
+ const hetznerSshKeyName = composeRegion
1183
+ ? `${projectConfig.projectName}-${environment}-${composeRegion}-key`
1184
+ : null;
1185
+ if (hetznerToken && hetznerSshKeyName) {
1186
+ s.start(`Deleting SSH key: ${hetznerSshKeyName}`);
1187
+ try {
1188
+ const deleted = await hetznerDeleteSSHKey(hetznerToken, hetznerSshKeyName);
1189
+ if (deleted) results.sshKeys.push(hetznerSshKeyName);
1190
+ s.stop(deleted ? 'SSH key deleted' : 'SSH key not found');
1191
+ } catch (error) {
1192
+ s.stop(`Failed to delete SSH key: ${error.message}`);
1193
+ }
1194
+ }
1195
+
1196
+ // DNS cleanup is ownership-filtered (plan.ownedIps): only records pointing at
1197
+ // this env's server IPs are deleted. Shared zones (e.g. parallel e2e
1198
+ // scenarios on the same root domain) used to lose neighbours' records here.
1199
+
1200
+ // Delete Cloudflare DNS if configured
1201
+ if (cloudflareToken && envConfig.dns?.cloudflareZoneId && envConfig.domain) {
1202
+ const zoneId = envConfig.dns.cloudflareZoneId;
1203
+
1204
+ s.start(`Deleting DNS record: ${envConfig.domain}`);
1205
+ try {
1206
+ const result = await cloudflareDeleteDNSRecord(
1207
+ cloudflareToken,
1208
+ zoneId,
1209
+ envConfig.domain,
1210
+ ownedIps,
1211
+ );
1212
+ if (result.deleted > 0) results.dns.push(envConfig.domain);
1213
+ if (result.skipped > 0) {
1214
+ s.stop(
1215
+ `DNS record preserved (${result.skipped} unowned target(s): ${result.skippedTargets.join(', ')})`,
1216
+ );
1217
+ } else {
1218
+ s.stop(result.deleted > 0 ? 'DNS record deleted' : 'DNS record not found');
883
1219
  }
1220
+ } catch (error) {
1221
+ s.stop(`DNS deletion failed: ${error.message}`);
1222
+ }
1223
+ }
884
1224
 
885
- // Load the HCLOUD token + S3 backend config the same way the
886
- // main destroy path does. Pulumi's hcloud provider needs a valid
887
- // token to delete resources; an empty token surfaces as
888
- // "Missing Hetzner Cloud API token" mid-destroy. The s3Config
889
- // must match the one findOrphanPulumiStacks used to discover
890
- // these stacks, otherwise resolveBackendUrl picks a different
891
- // backend and the destroy can't find them.
892
- const orphanCreds = loadCredentials();
893
- const orphanApiToken =
894
- process.env.HETZNER_API_TOKEN ||
895
- orphanCreds?.hetzner?.apiToken ||
896
- orphanCreds?.hetznerApiToken ||
897
- '';
898
- const orphanS3Config =
899
- projectConfig?.s3Config && orphanCreds?.s3
900
- ? {
901
- ...projectConfig.s3Config,
902
- accessKey: orphanCreds.s3.accessKey,
903
- secretKey: orphanCreds.s3.secretKey,
904
- }
905
- : null;
906
-
907
- if (!orphanApiToken) {
908
- p.log.error(
909
- 'No Hetzner API token found in HETZNER_API_TOKEN env var or ~/.vibecarbon/credentials.json — cannot destroy orphan stacks.',
910
- );
911
- process.exit(1);
912
- }
913
-
914
- const orphanSpinner = p.spinner();
915
- for (const orphan of orphans) {
916
- orphanSpinner.start(`Destroying orphan stack ${c.bold(orphan.name)} (pulumi destroy)...`);
917
- try {
918
- await destroyOrphanPulumiStack(orphan, {
919
- apiToken: orphanApiToken,
920
- s3Config: orphanS3Config,
921
- });
922
- orphanSpinner.stop(`Destroyed: ${orphan.name}`);
923
- } catch (error) {
924
- orphanSpinner.error(`Failed to destroy ${orphan.name}: ${error.message}`);
925
- }
926
- }
927
-
928
- p.outro('Orphan cleanup complete');
929
- process.exit(0);
930
- }
931
-
932
- p.log.error('No environments found in .vibecarbon.json');
933
- p.log.info('Nothing to destroy.');
934
- process.exit(1);
935
- }
936
-
937
- p.log.info(`Project: ${c.bold(projectConfig.projectName)}`);
938
-
939
- // Select environment to destroy
940
- const envNames = Object.keys(projectConfig.environments);
941
- let envName = args.env;
942
-
943
- if (!envName) {
944
- if (envNames.length === 1) {
945
- envName = envNames[0];
946
- } else {
947
- envName = await p.select({
948
- message: 'Which environment do you want to destroy?',
949
- options: envNames.map((name) => ({
950
- value: name,
951
- label: name,
952
- hint: projectConfig.environments[name].servers?.map((s) => s.ip).join(', '),
953
- })),
954
- });
955
- }
956
- }
957
-
958
- if (p.isCancel(envName)) {
959
- p.cancel('Operation cancelled.');
960
- process.exit(0);
961
- }
962
-
963
- const envConfig = projectConfig.environments[envName];
964
- if (!envConfig) {
965
- p.log.error(`Environment '${envName}' not found`);
966
- p.log.info(`Available environments: ${envNames.join(', ')}`);
967
- process.exit(1);
968
- }
969
-
970
- // Show what will be deleted
971
- const serverList =
972
- envConfig.servers?.map((s) => ` ${c.error('•')} ${s.name} (${s.ip})`).join('\n') ||
973
- ' None';
974
-
975
- p.note(
976
- `
977
- ${c.danger('This will permanently delete:')}
978
-
979
- ${c.bold('Servers:')}
980
- ${serverList}
981
-
982
- ${c.bold('Firewalls:')}
983
- ${envConfig.servers?.map((s) => ` ${c.error('•')} ${s.name}-fw`).join('\n') || ' None'}
984
-
985
- ${c.bold('SSH Keys:')}
986
- ${c.error('•')} vibecarbon-${projectConfig.projectName}-${envName}
987
-
988
- ${
989
- envConfig.dns?.provider === 'cloudflare'
990
- ? `${c.bold('Cloudflare Resources:')}
991
- ${c.error('•')} DNS record: ${envConfig.domain}
992
- ${c.error('•')} Health checks
993
- ${envConfig.ha ? `${c.error('•')} Wildcard DNS record` : ''}
994
- `
995
- : ''
996
- }
997
- ${
998
- envConfig.s3?.bucket
999
- ? `${c.bold('S3 Storage:')}
1000
- ${c.error('•')} Bucket: ${envConfig.s3.bucket} (all objects will be deleted)
1001
- `
1002
- : ''
1003
- }${
1004
- envConfig.backupS3?.bucket
1005
- ? `${c.bold('S3 Backups:')}
1006
- ${args.purgeBackups ? `${c.error('•')} Bucket: ${envConfig.backupS3.bucket} (WILL BE DELETED — --purge-backups)` : `${c.success('•')} Bucket: ${envConfig.backupS3.bucket} (PRESERVED for recovery)`}
1007
- `
1008
- : ''
1009
- }
1010
- ${c.bold('GitHub:')}
1011
- ${c.error('•')} Environment: ${envName}
1012
-
1013
- ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1014
- `,
1015
- `Destroying: ${envName}`,
1016
- );
1017
-
1018
- const needsProdConfirm = requiresProdTypeToConfirm(envName);
1019
-
1020
- // First prompt: only when not --yes.
1021
- if (!args.yes) {
1022
- const confirmed = await p.confirm({
1023
- message: `Are you sure you want to destroy the ${c.bold(envName)} environment?`,
1024
- initialValue: false,
1025
- });
1026
- if (!confirmed || p.isCancel(confirmed)) {
1027
- p.cancel('Operation cancelled.');
1028
- process.exit(0);
1029
- }
1030
- }
1031
-
1032
- // Type-to-confirm: runs whenever NOT --yes, OR the env is prod/production
1033
- // (even with --yes). Protects against `--env prod --yes` typos in CI/scripts.
1034
- if (!args.yes || needsProdConfirm) {
1035
- const confirmSlug = `${projectConfig.projectName}-${envName}`;
1036
- if (args.yes && needsProdConfirm) {
1037
- p.log.warn(
1038
- `Destroying a production environment still requires type-to-confirm, even with --yes.`,
1039
- );
1040
- }
1041
- const doubleConfirm = await p.text({
1042
- message: `Type "${confirmSlug}" to confirm:`,
1043
- validate: (v) => (v !== confirmSlug ? `Please type "${confirmSlug}" to confirm` : undefined),
1044
- });
1045
- if (p.isCancel(doubleConfirm)) {
1046
- p.cancel('Operation cancelled.');
1047
- process.exit(0);
1048
- }
1049
- }
1050
-
1051
- // Offer to create a backup before destroying
1052
- const serverIp = envConfig.servers?.[0]?.ip;
1053
- const sshKeyPath = join(cwd, '.vibecarbon', `deploy_key_${envName}`);
1054
- if (serverIp && existsSync(sshKeyPath) && !args.yes) {
1055
- const offerBackup = await p.confirm({
1056
- message: 'Create a backup before destroying? (recommended)',
1057
- initialValue: true,
1058
- });
1059
-
1060
- if (!p.isCancel(offerBackup) && offerBackup) {
1061
- const backupSpinner = p.spinner();
1062
- backupSpinner.start('Creating backup...');
1063
- try {
1064
- if (envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha') {
1065
- const { backupCompose } = await import('./lib/deploy/compose/index.js');
1066
- await backupCompose(serverIp, sshKeyPath, projectConfig.projectName, {
1067
- retain: envConfig.backup?.retentionDays,
1068
- });
1069
- backupSpinner.stop('wal-g base backup pushed to S3');
1070
- }
1071
- } catch (backupError) {
1072
- backupSpinner.stop(`Backup failed: ${backupError.message}`);
1073
- p.log.warn('Continuing with destroy...');
1074
- }
1075
- }
1076
- }
1077
-
1078
- // Get API tokens (env var → credentials file → interactive prompt)
1079
- const hetznerToken = await getApiToken(projectConfig.projectName);
1080
- if (!hetznerToken) {
1081
- p.log.error('Hetzner API token is required');
1082
- process.exit(1);
1083
- }
1084
-
1085
- let cloudflareToken = null;
1086
- if (envConfig.dns?.provider === 'cloudflare' && envConfig.dns?.cloudflareZoneId) {
1087
- // Check env var → credentials file → prompt
1088
- cloudflareToken = process.env.CLOUDFLARE_API_TOKEN;
1089
-
1090
- if (!cloudflareToken) {
1091
- const creds = loadCredentials();
1092
- if (creds.cloudflare?.apiToken) {
1093
- cloudflareToken = creds.cloudflare.apiToken;
1094
- p.log.info('✓ Using Cloudflare API token from ~/.vibecarbon/credentials.json');
1095
- }
1096
- }
1097
-
1098
- if (!cloudflareToken) {
1099
- cloudflareToken = await p.password({
1100
- message: 'Enter Cloudflare API token (for DNS cleanup)',
1101
- validate: (v) => (v.length < 10 ? 'API token is required' : undefined),
1102
- });
1103
-
1104
- if (p.isCancel(cloudflareToken)) {
1105
- p.cancel('Operation cancelled.');
1106
- process.exit(0);
1107
- }
1108
- } else if (process.env.CLOUDFLARE_API_TOKEN) {
1109
- p.log.info('✓ Using Cloudflare API token from CLOUDFLARE_API_TOKEN environment variable');
1110
- }
1111
- }
1112
-
1113
- // ========== DESTRUCTION ==========
1114
-
1115
- const tracker = createTracker('destroy', { environment: envName });
1116
- const s = tracker.spinner();
1117
- const results = {
1118
- servers: [],
1119
- volumes: [],
1120
- firewalls: [],
1121
- sshKeys: [],
1122
- dns: [],
1123
- healthChecks: [],
1124
- loadBalancers: [],
1125
- s3Bucket: null,
1126
- github: false,
1127
- // Sub-step failures that didn't abort destroy but left state behind
1128
- // (e.g., S3 bucket couldn't be emptied). The outro surfaces these so the
1129
- // operator doesn't miss them, and the process exits non-zero so CI sees it.
1130
- issues: [],
1131
- };
1132
-
1133
- // ========== COMPOSE-HA DESTRUCTION (if deployed via Docker Compose HA) ==========
1134
- if (envConfig.deployMode === 'compose-ha') {
1135
- const { destroyComposeHA } = await import('./lib/deploy/compose/ha.js');
1136
-
1137
- const s2 = tracker.spinner();
1138
- s2.start('Destroying Compose HA environment...');
1139
- try {
1140
- await destroyComposeHA({
1141
- projectName: projectConfig.projectName,
1142
- environment: envName,
1143
- envConfig,
1144
- hetznerToken,
1145
- cloudflareToken,
1146
- onProgress: (msg) => s2.message(msg),
1147
- });
1148
- s2.stop('Compose HA environment destroyed');
1149
- } catch (error) {
1150
- s2.stop(`Compose HA destruction failed: ${error.message}`);
1151
- }
1152
-
1153
- // Delete S3 bucket if configured
1154
- if (envConfig.s3?.bucket) {
1155
- const s3BucketName = envConfig.s3.bucket;
1156
- const s3Region = envConfig.s3?.region || 'fsn1';
1157
- s2.start(`Deleting S3 bucket: ${s3BucketName}`);
1158
- try {
1159
- const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
1160
- if (s3Creds) {
1161
- const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, s3Region);
1162
- try {
1163
- const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
1164
- s2.stop(result.deleted ? 'S3 bucket deleted' : 'S3 bucket not found');
1165
- } catch (bucketError) {
1166
- if (
1167
- bucketError.name === 'NoSuchBucket' ||
1168
- bucketError.$metadata?.httpStatusCode === 404
1169
- ) {
1170
- s2.stop('S3 bucket not found (already deleted)');
1171
- } else {
1172
- s2.stop(`S3 bucket deletion failed: ${bucketError.message}`);
1173
- results.issues.push({
1174
- step: 'S3 bucket',
1175
- detail: `${s3BucketName} (${s3Region}) not deleted: ${bucketError.message}`,
1176
- hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
1177
- });
1178
- }
1179
- }
1180
- } else {
1181
- s2.stop('S3 bucket skipped (no credentials available)');
1182
- results.issues.push({
1183
- step: 'S3 bucket',
1184
- detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
1185
- hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
1186
- });
1187
- }
1188
- } catch (error) {
1189
- s2.stop(`S3 bucket deletion failed: ${error.message}`);
1190
- results.issues.push({
1191
- step: 'S3 bucket',
1192
- detail: `${s3BucketName} not deleted: ${error.message}`,
1193
- hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket.',
1194
- });
1195
- }
1196
- }
1197
-
1198
- // Backup bucket: preserved by default, deleted with --purge-backups.
1199
- await handleBackupBucket(envConfig, projectConfig, args, s2);
1200
-
1201
- // Update project config to remove environment
1202
- const updatedConfig = { ...projectConfig };
1203
- if (updatedConfig.environments) {
1204
- delete updatedConfig.environments[envName];
1205
- }
1206
- saveProjectConfig(updatedConfig);
1207
-
1208
- cleanupLocalEnvFiles(cwd, envName);
1209
-
1210
- const { formatted: timeStr } = tracker.finish();
1211
- destroyOutro(envName, timeStr, results);
1212
- return;
1213
- }
1214
-
1215
- // ========== COMPOSE DESTRUCTION (if deployed via Docker Compose) ==========
1216
- if (envConfig.deployMode === 'compose') {
1217
- const serverIp = envConfig.servers?.[0]?.ip;
1218
- const sshKeyPath = join(cwd, '.vibecarbon', `deploy_key_${envName}`);
1219
-
1220
- if (serverIp && existsSync(sshKeyPath)) {
1221
- // Stop and remove Docker Compose services
1222
- s.start('Stopping Docker Compose services...');
1223
- try {
1224
- const { destroyCompose } = await import('./lib/deploy/compose/index.js');
1225
- await destroyCompose(serverIp, sshKeyPath, projectConfig.projectName);
1226
- s.stop('Docker Compose services removed');
1227
- results.servers.push(`vps-${serverIp}`);
1228
- } catch (error) {
1229
- s.stop(`Failed to stop services: ${error.message}`);
1230
- }
1231
- }
1232
-
1233
- // Delete Hetzner VPS if auto-provisioned (has server ID in config)
1234
- const serverId = envConfig.servers?.[0]?.id;
1235
- // The `name` field in envConfig.servers is a role label ("master") set by
1236
- // the orchestrator, not the Hetzner-assigned hostname. Pulumi's compose
1237
- // program names the server `${projectName}-${environment}`. The
1238
- // orchestrator persists that under `hetznerName`; configs older than
1239
- // that change only have role-style names, so derive the Pulumi name as
1240
- // a deterministic fallback — looking up "master" in Hetzner never
1241
- // matches and leaks the VPS.
1242
- const serverHetznerName =
1243
- envConfig.servers?.[0]?.hetznerName || `${projectConfig.projectName}-${envName}`;
1244
- if (serverId && hetznerToken) {
1245
- s.start(`Deleting VPS (${serverIp})...`);
1246
- try {
1247
- const deleted = await hetznerDeleteServer(hetznerToken, serverId);
1248
- s.stop(deleted ? 'VPS deleted' : 'VPS not found');
1249
- } catch (error) {
1250
- s.stop(`Failed to delete VPS: ${error.message}`);
1251
- }
1252
- } else if (!serverId && hetznerToken && serverHetznerName) {
1253
- // Fallback: find server by name if ID wasn't saved (older deploys
1254
- // or runs where Pulumi didn't propagate serverId).
1255
- s.start(`Looking up VPS by name: ${serverHetznerName}...`);
1256
- try {
1257
- const servers = await hetznerListServers(hetznerToken);
1258
- const server = servers.find((sv) => sv.name === serverHetznerName);
1259
- if (server) {
1260
- const deleted = await hetznerDeleteServer(hetznerToken, server.id);
1261
- s.stop(deleted ? 'VPS deleted' : 'VPS not found');
1262
- } else {
1263
- s.stop('VPS not found');
1264
- }
1265
- } catch (error) {
1266
- s.stop(`Failed to delete VPS: ${error.message}`);
1267
- }
1268
- }
1269
-
1270
- // Delete Hetzner firewall.
1271
- // Pulumi (src/lib/iac/programs/hetzner-compose.js) names the firewall
1272
- // `${projectName}-${environment}-firewall`
1273
- // Earlier this used `vibecarbon-${projectName}-${envName}` which never
1274
- // matched anything Pulumi created — the firewall always leaked, and
1275
- // the next compose restore re-deploy hit "name is already used
1276
- // (uniqueness_error)" on Pulumi up. Match the actual Pulumi naming
1277
- // convention (the equivalent fix exists in destroyComposeHA).
1278
- const fwName = `${projectConfig.projectName}-${envName}-firewall`;
1279
- if (hetznerToken) {
1280
- s.start(`Deleting firewall: ${fwName}`);
1281
- try {
1282
- const deleted = await hetznerDeleteFirewall(hetznerToken, fwName);
1283
- if (deleted) results.firewalls.push(fwName);
1284
- s.stop(deleted ? 'Firewall deleted' : 'Firewall not found');
1285
- } catch (error) {
1286
- s.stop(`Failed to delete firewall: ${error.message}`);
1287
- }
1288
- }
1289
-
1290
- // Delete Hetzner SSH key.
1291
- // Pulumi names the key
1292
- // `${projectName}-${environment}-${location}-key`
1293
- // — the location suffix is what kept the old `vibecarbon-` prefix from
1294
- // ever matching. Same root cause as the firewall above.
1295
- const composeRegion = envConfig.region || envConfig.servers?.[0]?.region;
1296
- const hetznerSshKeyName = composeRegion
1297
- ? `${projectConfig.projectName}-${envName}-${composeRegion}-key`
1298
- : null;
1299
- if (hetznerToken && hetznerSshKeyName) {
1300
- s.start(`Deleting SSH key: ${hetznerSshKeyName}`);
1301
- try {
1302
- const deleted = await hetznerDeleteSSHKey(hetznerToken, hetznerSshKeyName);
1303
- if (deleted) results.sshKeys.push(hetznerSshKeyName);
1304
- s.stop(deleted ? 'SSH key deleted' : 'SSH key not found');
1305
- } catch (error) {
1306
- s.stop(`Failed to delete SSH key: ${error.message}`);
1307
- }
1308
- }
1309
-
1310
- // DNS cleanup is ownership-filtered: only records pointing at this env's
1311
- // server IPs are deleted. Shared zones (e.g. parallel e2e scenarios
1312
- // on the same root domain) used to lose neighbours' records here — see
1313
- // the deleteDNSRecord doc for context.
1314
- const ownedIps = (envConfig.servers || []).map((srv) => srv.ip).filter(Boolean);
1315
-
1316
- // Delete Cloudflare DNS if configured
1317
- if (cloudflareToken && envConfig.dns?.cloudflareZoneId && envConfig.domain) {
1318
- const zoneId = envConfig.dns.cloudflareZoneId;
1319
-
1320
- s.start(`Deleting DNS record: ${envConfig.domain}`);
1321
- try {
1322
- const result = await cloudflareDeleteDNSRecord(
1323
- cloudflareToken,
1324
- zoneId,
1325
- envConfig.domain,
1326
- ownedIps,
1327
- );
1328
- if (result.deleted > 0) results.dns.push(envConfig.domain);
1329
- if (result.skipped > 0) {
1330
- s.stop(
1331
- `DNS record preserved (${result.skipped} unowned target(s): ${result.skippedTargets.join(', ')})`,
1332
- );
1333
- } else {
1334
- s.stop(result.deleted > 0 ? 'DNS record deleted' : 'DNS record not found');
1335
- }
1336
- } catch (error) {
1337
- s.stop(`DNS deletion failed: ${error.message}`);
1338
- }
1339
- }
1340
-
1341
- // Delete Hetzner DNS if configured
1342
- if (envConfig.dns?.provider === 'hetzner' && envConfig.dns?.hetznerZoneId && envConfig.domain) {
1343
- const zoneId = envConfig.dns.hetznerZoneId;
1344
- s.start(`Deleting Hetzner DNS record: ${envConfig.domain}`);
1345
- try {
1346
- const zone = await hetznerDnsGetZone(hetznerToken, zoneId);
1347
- const [root, wildcard] = await Promise.all([
1348
- hetznerDnsDeleteRecord(hetznerToken, zoneId, zone.name, envConfig.domain, ownedIps),
1349
- hetznerDnsDeleteRecord(
1350
- hetznerToken,
1351
- zoneId,
1352
- zone.name,
1353
- `*.${envConfig.domain}`,
1354
- ownedIps,
1355
- ),
1356
- ]);
1357
- if (root.deleted || wildcard.deleted) results.dns.push(envConfig.domain);
1358
- const preserved = [root, wildcard].filter((r) => r.skipped);
1359
- if (preserved.length > 0) {
1360
- const targets = preserved.flatMap((p) => p.skippedTargets);
1361
- s.stop(`Hetzner DNS records preserved (unowned targets: ${targets.join(', ')})`);
1362
- } else {
1363
- s.stop('Hetzner DNS records deleted');
1364
- }
1365
- } catch (error) {
1366
- s.stop(`Hetzner DNS cleanup failed: ${error.message}`);
1367
- }
1368
- }
1369
-
1370
- // Delete S3 bucket if configured
1371
- if (envConfig.s3?.bucket) {
1372
- const s3BucketName = envConfig.s3.bucket;
1373
- const s3Region = envConfig.s3?.region || 'fsn1';
1374
- s.start(`Deleting S3 bucket: ${s3BucketName}`);
1375
- try {
1376
- const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
1377
- if (s3Creds) {
1378
- const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, s3Region);
1379
- try {
1380
- const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
1381
- if (result.deleted) results.s3Bucket = s3BucketName;
1382
- s.stop(result.deleted ? 'S3 bucket deleted' : 'S3 bucket not found');
1383
- } catch (bucketError) {
1384
- if (
1385
- bucketError.name === 'NoSuchBucket' ||
1386
- bucketError.$metadata?.httpStatusCode === 404
1387
- ) {
1388
- s.stop('S3 bucket not found (already deleted or never created)');
1389
- } else {
1390
- throw bucketError;
1391
- }
1392
- }
1393
- } else {
1394
- s.stop('S3 bucket skipped (no credentials available)');
1395
- results.issues.push({
1396
- step: 'S3 bucket',
1397
- detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
1398
- hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
1399
- });
1400
- }
1401
- } catch (error) {
1402
- s.stop(`S3 bucket deletion failed: ${error.message}`);
1403
- results.issues.push({
1404
- step: 'S3 bucket',
1405
- detail: `${s3BucketName} (${s3Region}) not deleted: ${error.message}`,
1406
- hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
1407
- });
1408
- }
1409
- }
1410
-
1411
- // Backup bucket: preserved by default, deleted with --purge-backups.
1412
- await handleBackupBucket(envConfig, projectConfig, args, s);
1413
-
1414
- // Clean up local SSH key files
1415
- const sshKeyFiles = [sshKeyPath, `${sshKeyPath}.pub`];
1416
- for (const f of sshKeyFiles) {
1417
- if (existsSync(f)) {
1418
- try {
1419
- rmSync(f);
1420
- } catch {
1421
- // Non-fatal
1422
- }
1225
+ // Delete Hetzner DNS if configured
1226
+ if (envConfig.dns?.provider === 'hetzner' && envConfig.dns?.hetznerZoneId && envConfig.domain) {
1227
+ const zoneId = envConfig.dns.hetznerZoneId;
1228
+ s.start(`Deleting Hetzner DNS record: ${envConfig.domain}`);
1229
+ try {
1230
+ const zone = await hetznerDnsGetZone(hetznerToken, zoneId);
1231
+ const [root, wildcard] = await Promise.all([
1232
+ hetznerDnsDeleteRecord(hetznerToken, zoneId, zone.name, envConfig.domain, ownedIps),
1233
+ hetznerDnsDeleteRecord(hetznerToken, zoneId, zone.name, `*.${envConfig.domain}`, ownedIps),
1234
+ ]);
1235
+ if (root.deleted || wildcard.deleted) results.dns.push(envConfig.domain);
1236
+ const preserved = [root, wildcard].filter((r) => r.skipped);
1237
+ if (preserved.length > 0) {
1238
+ const targets = preserved.flatMap((p) => p.skippedTargets);
1239
+ s.stop(`Hetzner DNS records preserved (unowned targets: ${targets.join(', ')})`);
1240
+ } else {
1241
+ s.stop('Hetzner DNS records deleted');
1423
1242
  }
1243
+ } catch (error) {
1244
+ s.stop(`Hetzner DNS cleanup failed: ${error.message}`);
1424
1245
  }
1425
-
1426
- // Update project config to remove environment
1427
- const updatedConfig = { ...projectConfig };
1428
- if (updatedConfig.environments) {
1429
- delete updatedConfig.environments[envName];
1430
- }
1431
- saveProjectConfig(updatedConfig);
1432
-
1433
- cleanupLocalEnvFiles(cwd, envName);
1434
-
1435
- const { formatted: timeStr } = tracker.finish();
1436
- destroyOutro(envName, timeStr, results);
1437
- return;
1438
1246
  }
1247
+ }
1439
1248
 
1440
- // Identify Pulumi stacks to destroy. HA k8s uses two stacks:
1441
- // {env}-primary + {env}-standby; single k8s uses just {env}.
1442
- const isHAK8s = envConfig.ha?.enabled && envConfig.deployMode === 'kubernetes';
1443
- const pulumiStackEnvs = isHAK8s ? [`${envName}-primary`, `${envName}-standby`] : [envName];
1444
- const hasPulumiStack = envConfig.deployMode === 'kubernetes';
1249
+ /**
1250
+ * k8s / k8s-ha: Cloudflare + Hetzner DNS, then Pulumi-managed infra teardown
1251
+ * (volume pre-scan, CCM LB cleanup, CSI release, CA-worker sweep, `pulumi
1252
+ * destroy` per stack, orphan sweep, HA shared-key delete), then orphaned CSI
1253
+ * volume cleanup. plan.stackEnvs already differs for k8s vs k8s-ha, so a single
1254
+ * function serves both.
1255
+ */
1256
+ async function destroyK8sTier({
1257
+ plan,
1258
+ envConfig,
1259
+ projectConfig,
1260
+ environment,
1261
+ hetznerToken,
1262
+ cloudflareToken,
1263
+ results,
1264
+ spinner: s,
1265
+ cwd,
1266
+ }) {
1267
+ const { stackEnvs, clusterNames, hasPulumiStack, ownedIps } = plan;
1268
+ const isHAK8s = plan.tier === 'k8s-ha';
1445
1269
 
1446
1270
  // Volume IDs collected from ALL cluster servers (master, supabase, workers).
1447
- // Hetzner CSI names volumes pvc-<uuid> — not the cluster name — so we must collect IDs
1448
- // before the servers are deleted rather than trying to match by name/label afterwards.
1271
+ // Hetzner CSI names volumes pvc-<uuid> — not the cluster name — so we must
1272
+ // collect IDs before the servers are deleted rather than matching by
1273
+ // name/label afterwards.
1449
1274
  const allClusterVolumeIds = [];
1450
- // Datacenter location names for all servers in the cluster (e.g. "fsn1", "nbg1").
1451
- // Used to find pvc-* volumes created by CSI that were detached before destroy runs.
1275
+ // Datacenter location names for all servers in the cluster (e.g. "fsn1").
1276
+ // Used to find pvc-* volumes created by CSI that were detached before destroy.
1452
1277
  const clusterLocations = new Set();
1453
1278
 
1454
- // Owned IPs gate every DNS delete — only records pointing at this env's
1455
- // servers are removed. See deleteDNSRecord doc in lib/cloudflare.js.
1456
- const ownedIps = (envConfig.servers || []).map((srv) => srv.ip).filter(Boolean);
1457
-
1458
1279
  // 1. Delete Cloudflare resources first (while servers still exist for reference)
1459
1280
  if (cloudflareToken && envConfig.dns?.cloudflareZoneId) {
1460
1281
  const zoneId = envConfig.dns.cloudflareZoneId;
@@ -1558,18 +1379,11 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1558
1379
  // Pulumi owns the servers, firewalls, SSH keys, and networks — `pulumi
1559
1380
  // destroy` tears them all down in one pass.
1560
1381
 
1561
- // For HA, each sub-cluster has its own network (e.g. havibes-prod-primary-network)
1562
- const clusterNames = isHAK8s
1563
- ? [
1564
- `${projectConfig.projectName}-${envName}-primary`,
1565
- `${projectConfig.projectName}-${envName}-standby`,
1566
- ]
1567
- : [`${projectConfig.projectName}-${envName}`];
1568
-
1569
- // Pre-scan: collect volume IDs from ALL servers in the cluster network(s) before deletion.
1570
- // Hetzner CSI driver names volumes with PVC UUIDs (e.g. pvc-xxxxxxxx), not the cluster
1571
- // name, so name/label matching in hetznerCleanupOrphanedVolumes is unreliable. By
1572
- // collecting the IDs now we can delete them reliably after Pulumi tears the servers down.
1382
+ // Pre-scan: collect volume IDs from ALL servers in the cluster network(s)
1383
+ // before deletion. Hetzner CSI driver names volumes with PVC UUIDs (e.g.
1384
+ // pvc-xxxxxxxx), not the cluster name, so name/label matching in
1385
+ // hetznerCleanupOrphanedVolumes is unreliable. By collecting the IDs now we
1386
+ // can delete them reliably after Pulumi tears the servers down.
1573
1387
  s.start('Scanning cluster servers for attached volumes');
1574
1388
  try {
1575
1389
  const networks = await hetznerListNetworks(hetznerToken);
@@ -1601,8 +1415,8 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1601
1415
  }
1602
1416
 
1603
1417
  // Clean up CCM-created load balancers before Pulumi destroys the network.
1604
- // The cluster network still exists at this point, so matchesNetwork is reliable.
1605
- // Once the network is gone, matchesNetwork always returns false.
1418
+ // The cluster network still exists at this point, so matchesNetwork is
1419
+ // reliable. Once the network is gone, matchesNetwork always returns false.
1606
1420
  // HA: fan out across primary + standby — clusters are isolated.
1607
1421
  if (clusterNames.length > 0) {
1608
1422
  console.log(`Cleaning up Hetzner load balancers across ${clusterNames.length} cluster(s)...`);
@@ -1638,10 +1452,10 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1638
1452
  // HA: release CSI volumes on primary + standby in parallel — each
1639
1453
  // cluster's controller is independent.
1640
1454
  console.log(
1641
- `Releasing CSI volumes via cluster finalizers across ${pulumiStackEnvs.length} cluster(s)...`,
1455
+ `Releasing CSI volumes via cluster finalizers across ${stackEnvs.length} cluster(s)...`,
1642
1456
  );
1643
1457
  await Promise.allSettled(
1644
- pulumiStackEnvs.map(async (stackEnv) => {
1458
+ stackEnvs.map(async (stackEnv) => {
1645
1459
  const kubeconfigPath = join(cwd, '.vibecarbon', `kubeconfig-${stackEnv}`);
1646
1460
  const dirLabel = isHAK8s ? ` (${stackEnv})` : '';
1647
1461
  try {
@@ -1672,6 +1486,10 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1672
1486
  bucket: envConfig.s3.bucket,
1673
1487
  region: envConfig.s3.region,
1674
1488
  endpoint: envConfig.s3.endpoint,
1489
+ // Resolve the Pulumi backend to the dedicated state bucket. Falls
1490
+ // back (via resolveBackendUrl's `?? bucket`) to the app bucket for
1491
+ // envs deployed before the state-bucket split.
1492
+ stateBucket: envConfig.s3.stateBucket,
1675
1493
  accessKey: credsForDestroy.s3.accessKey,
1676
1494
  secretKey: credsForDestroy.s3.secretKey,
1677
1495
  }
@@ -1683,11 +1501,9 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1683
1501
  // For HA, sweep both primary and standby cluster networks.
1684
1502
  // HA: sweep CA-spawned workers on primary + standby in parallel — each
1685
1503
  // cluster's CA tags its workers with a distinct cluster name.
1686
- console.log(
1687
- `Sweeping cluster-autoscaler workers across ${pulumiStackEnvs.length} cluster(s)...`,
1688
- );
1504
+ console.log(`Sweeping cluster-autoscaler workers across ${stackEnvs.length} cluster(s)...`);
1689
1505
  await Promise.allSettled(
1690
- pulumiStackEnvs.map(async (stackEnv) => {
1506
+ stackEnvs.map(async (stackEnv) => {
1691
1507
  const dirLabel = isHAK8s ? ` (${stackEnv})` : '';
1692
1508
  const caClusterName = `${projectConfig.projectName}-${stackEnv}`;
1693
1509
  try {
@@ -1717,10 +1533,10 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1717
1533
  // so there's no shared state to race on. Saves ~200s on k8s-ha critical
1718
1534
  // path (Pulumi destroy is ~100s/stack and was strictly sequential).
1719
1535
  console.log(
1720
- `Destroying infrastructure via Pulumi across ${pulumiStackEnvs.length} stack(s) — this may take a few minutes...`,
1536
+ `Destroying infrastructure via Pulumi across ${stackEnvs.length} stack(s) — this may take a few minutes...`,
1721
1537
  );
1722
1538
  await Promise.allSettled(
1723
- pulumiStackEnvs.map(async (stackEnv) => {
1539
+ stackEnvs.map(async (stackEnv) => {
1724
1540
  const dirLabel = isHAK8s ? ` (${stackEnv})` : '';
1725
1541
  try {
1726
1542
  await perfAsync(`destroy.pulumi.${stackEnv}`, async () =>
@@ -1788,7 +1604,7 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1788
1604
  // (RCA from k8s-ha 2026-04-30: post-destroy sweep flagged the same
1789
1605
  // key as REGRESSION every run).
1790
1606
  if (isHAK8s && hetznerToken) {
1791
- const haSshKeyName = `${projectConfig.projectName}-${envName}-ha-key`;
1607
+ const haSshKeyName = `${projectConfig.projectName}-${environment}-ha-key`;
1792
1608
  s.start(`Deleting shared HA SSH key: ${haSshKeyName}`);
1793
1609
  try {
1794
1610
  const deleted = await hetznerDeleteSSHKey(hetznerToken, haSshKeyName);
@@ -1800,183 +1616,448 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1800
1616
  }
1801
1617
  }
1802
1618
 
1803
- cleanupLocalEnvFiles(cwd, envName);
1619
+ // Clean up Kubernetes-managed resources (created by CCM/CSI at runtime,
1620
+ // not by the Pulumi program). These aren't in any stack's resource graph.
1621
+ s.start('Cleaning up orphaned volumes');
1622
+ try {
1623
+ const serverNames = (envConfig.servers || []).map((sv) => sv.name);
1624
+ const knownVolumeIds = [...new Set(allClusterVolumeIds)];
1625
+ const deletedVolumes = await hetznerCleanupOrphanedVolumes(
1626
+ hetznerToken,
1627
+ projectConfig.projectName,
1628
+ environment,
1629
+ serverNames,
1630
+ knownVolumeIds,
1631
+ [...clusterLocations],
1632
+ );
1633
+ results.volumes = deletedVolumes;
1634
+ s.stop(
1635
+ deletedVolumes.length > 0
1636
+ ? `Deleted ${deletedVolumes.length} orphaned volume(s)`
1637
+ : 'No orphaned volumes found',
1638
+ );
1639
+ } catch (error) {
1640
+ s.stop(`Volume cleanup failed: ${error.message}`);
1641
+ }
1642
+ }
1643
+
1644
+ /**
1645
+ * Tier → strategy dispatch table. k8s and k8s-ha share one function; the plan's
1646
+ * stackEnvs/clusterNames already encode the primary+standby fan-out.
1647
+ */
1648
+ const DESTROY_STRATEGIES = {
1649
+ compose: destroyComposeTier,
1650
+ 'compose-ha': destroyComposeHATier,
1651
+ k8s: destroyK8sTier,
1652
+ 'k8s-ha': destroyK8sTier,
1653
+ };
1654
+
1655
+ // ============================================================================
1656
+ // MAIN DESTRUCTION FLOW
1657
+ // ============================================================================
1658
+
1659
+ async function main() {
1660
+ const { values, positional, errors } = parseFlags(process.argv.slice(2), SPEC);
1661
+
1662
+ if (errors.length > 0) {
1663
+ for (const e of errors) {
1664
+ process.stderr.write(`${c.error('✗')} ${e}\n`);
1665
+ }
1666
+ process.stderr.write(`Run ${c.info('vibecarbon destroy -h')} for usage.\n`);
1667
+ process.exit(1);
1668
+ }
1669
+
1670
+ if (values.h) {
1671
+ process.stdout.write(renderHelp(SPEC));
1672
+ process.exit(0);
1673
+ }
1674
+ if (values.v) {
1675
+ console.log(`destroy-vibecarbon v${VERSION}`);
1676
+ process.exit(0);
1677
+ }
1678
+
1679
+ // Build the legacy `args` struct that the orchestration code below
1680
+ // reads (env / yes / destroyOrphans / purgeBackups). Field renames
1681
+ // happen at the boundary so the orchestration stays untouched —
1682
+ // each downstream branch is hundreds of lines and well-tested.
1683
+ const envSeed =
1684
+ /** @type {string|undefined} */ (positional.env) ||
1685
+ /** @type {string|null} */ (values.env) ||
1686
+ null;
1687
+ const args = {
1688
+ env: envSeed,
1689
+ yes: !!values.y,
1690
+ destroyOrphans: !!values.orphans,
1691
+ purgeBackups: !!values.purge,
1692
+ };
1693
+
1694
+ // Check if current working directory exists
1695
+ let cwd;
1696
+ try {
1697
+ cwd = process.cwd();
1698
+ } catch {
1699
+ console.error(`\n${c.error('Error:')} Current working directory does not exist.`);
1700
+ console.error(
1701
+ `This can happen if the project directory was deleted while this command was running.`,
1702
+ );
1703
+ console.error(`\nPlease navigate to a valid directory and try again.`);
1704
+ process.exit(1);
1705
+ }
1706
+
1707
+ // Project guard runs before banner so an accidental `vibecarbon
1708
+ // destroy` from a parent directory emits the canonical message.
1709
+ assertInProjectDir(cwd);
1710
+
1711
+ console.clear();
1712
+ printBanner();
1713
+ p.intro(`${c.bold('vibecarbon destroy')} ${c.dim(`v${VERSION}`)}`);
1714
+
1715
+ const projectConfig = loadProjectConfig(cwd);
1716
+
1717
+ // TTY guard: if the operator seeded an env, no env prompt fires;
1718
+ // otherwise we'd open a picker that hangs off-TTY.
1719
+ const envCount = Object.keys(projectConfig?.environments || {}).length;
1720
+ requireTTYOrFlags({
1721
+ requirements: [
1722
+ {
1723
+ flag: 'env',
1724
+ description: 'name an environment to destroy',
1725
+ satisfied: !!envSeed || envCount <= 1,
1726
+ },
1727
+ ],
1728
+ });
1729
+
1730
+ if (!projectConfig.environments || Object.keys(projectConfig.environments).length === 0) {
1731
+ // Check for orphan Pulumi stacks (deployment interrupted before config save)
1732
+ const orphanScan = p.spinner();
1733
+ orphanScan.start('Scanning for orphan Pulumi stacks...');
1734
+ const orphans = await findOrphanPulumiStacks(projectConfig);
1735
+ orphanScan.stop(
1736
+ orphans.length > 0 ? `Found ${orphans.length} orphan stack(s)` : 'No orphan stacks found',
1737
+ );
1738
+
1739
+ if (orphans.length > 0) {
1740
+ p.log.warn('No environments found in .vibecarbon.json');
1741
+ p.log.info(
1742
+ `Found ${orphans.length} orphan Pulumi stack(s) — likely from interrupted deployments:`,
1743
+ );
1744
+ for (const orphan of orphans) {
1745
+ p.log.info(` • ${c.bold(orphan.name)}`);
1746
+ }
1747
+
1748
+ // Auto-destroying orphans is dangerous: when projectConfig.s3Config is
1749
+ // null (e.g., a typo in the saved config), listStacks falls back to
1750
+ // the local file:// backend at ~/.vibecarbon/pulumi-state/, which is
1751
+ // GLOBAL across projects/scenarios. A scenario's --yes destroy then
1752
+ // nukes other scenarios' stacks (observed 2026-04-26 batch run #5:
1753
+ // compose's final-destroy destroyed compose-ha's e2-primary/e2-standby
1754
+ // mid-deploy because compose's empty config let the orphan path fire).
1755
+ //
1756
+ // The auto-destroy used to honor --yes, but cross-scenario blast is
1757
+ // worse than leaving local state around. Require an explicit flag now.
1758
+ // For interactive use, prompt as before.
1759
+ p.log.info('');
1760
+ if (!args.destroyOrphans) {
1761
+ p.log.warn('Skipping orphan cleanup. Re-run with -orphans to remove these stacks.');
1762
+ p.outro('Done (orphans not destroyed)');
1763
+ process.exit(0);
1764
+ }
1765
+ const shouldDestroy = args.yes
1766
+ ? true
1767
+ : await p.confirm({
1768
+ message: `Destroy these ${orphans.length} orphan stack(s)?`,
1769
+ initialValue: false, // Default to NO for safety
1770
+ });
1771
+
1772
+ if (p.isCancel(shouldDestroy) || !shouldDestroy) {
1773
+ p.cancel('Operation cancelled.');
1774
+ process.exit(0);
1775
+ }
1776
+
1777
+ // Load the HCLOUD token + S3 backend config the same way the
1778
+ // main destroy path does. Pulumi's hcloud provider needs a valid
1779
+ // token to delete resources; an empty token surfaces as
1780
+ // "Missing Hetzner Cloud API token" mid-destroy. The s3Config
1781
+ // must match the one findOrphanPulumiStacks used to discover
1782
+ // these stacks, otherwise resolveBackendUrl picks a different
1783
+ // backend and the destroy can't find them.
1784
+ const orphanCreds = loadCredentials();
1785
+ const orphanApiToken =
1786
+ process.env.HETZNER_API_TOKEN ||
1787
+ orphanCreds?.hetzner?.apiToken ||
1788
+ orphanCreds?.hetznerApiToken ||
1789
+ '';
1790
+ const orphanS3Config =
1791
+ projectConfig?.s3Config && orphanCreds?.s3
1792
+ ? {
1793
+ ...projectConfig.s3Config,
1794
+ accessKey: orphanCreds.s3.accessKey,
1795
+ secretKey: orphanCreds.s3.secretKey,
1796
+ }
1797
+ : null;
1798
+
1799
+ if (!orphanApiToken) {
1800
+ p.log.error(
1801
+ 'No Hetzner API token found in HETZNER_API_TOKEN env var or ~/.vibecarbon/credentials.json — cannot destroy orphan stacks.',
1802
+ );
1803
+ process.exit(1);
1804
+ }
1805
+
1806
+ const orphanSpinner = p.spinner();
1807
+ for (const orphan of orphans) {
1808
+ orphanSpinner.start(`Destroying orphan stack ${c.bold(orphan.name)} (pulumi destroy)...`);
1809
+ try {
1810
+ await destroyOrphanPulumiStack(orphan, {
1811
+ apiToken: orphanApiToken,
1812
+ s3Config: orphanS3Config,
1813
+ });
1814
+ orphanSpinner.stop(`Destroyed: ${orphan.name}`);
1815
+ } catch (error) {
1816
+ orphanSpinner.error(`Failed to destroy ${orphan.name}: ${error.message}`);
1817
+ }
1818
+ }
1819
+
1820
+ p.outro('Orphan cleanup complete');
1821
+ process.exit(0);
1822
+ }
1823
+
1824
+ p.log.error('No environments found in .vibecarbon.json');
1825
+ p.log.info('Nothing to destroy.');
1826
+ process.exit(1);
1827
+ }
1828
+
1829
+ p.log.info(`Project: ${c.bold(projectConfig.projectName)}`);
1804
1830
 
1805
- if (!hasPulumiStack) {
1806
- // Manual API cleanup for compose deployments — k8s mode is fully
1807
- // Pulumi-managed and was already torn down above.
1808
- for (const server of envConfig.servers || []) {
1809
- s.start(`Deleting server: ${server.name} (${server.ip})`);
1810
- try {
1811
- const deleted = await hetznerDeleteServer(hetznerToken, server.id);
1812
- if (deleted) results.servers.push(server.name);
1813
- s.stop(deleted ? `Server ${server.name} deleted` : `Server ${server.name} not found`);
1814
- } catch (error) {
1815
- s.stop(`Failed to delete ${server.name}: ${error.message}`);
1816
- }
1817
- }
1831
+ // Select environment to destroy
1832
+ const envNames = Object.keys(projectConfig.environments);
1833
+ let envName = args.env;
1818
1834
 
1819
- // 3. Delete Hetzner firewalls
1820
- for (const server of envConfig.servers || []) {
1821
- const fwName = `${server.name}-fw`;
1822
- s.start(`Deleting firewall: ${fwName}`);
1823
- try {
1824
- const deleted = await hetznerDeleteFirewall(hetznerToken, fwName);
1825
- if (deleted) results.firewalls.push(fwName);
1826
- s.stop(deleted ? `Firewall ${fwName} deleted` : `Firewall ${fwName} not found`);
1827
- } catch (error) {
1828
- s.stop(`Failed to delete firewall: ${error.message}`);
1829
- }
1835
+ if (!envName) {
1836
+ if (envNames.length === 1) {
1837
+ envName = envNames[0];
1838
+ } else {
1839
+ envName = await p.select({
1840
+ message: 'Which environment do you want to destroy?',
1841
+ options: envNames.map((name) => ({
1842
+ value: name,
1843
+ label: name,
1844
+ hint: projectConfig.environments[name].servers?.map((s) => s.ip).join(', '),
1845
+ })),
1846
+ });
1830
1847
  }
1848
+ }
1831
1849
 
1832
- // 4. Delete Hetzner SSH key.
1833
- // Pulumi (src/lib/iac/programs/hetzner-k8s.js) names the SSH key
1834
- // `${projectName}-${environment}-${location}-key`
1835
- // (`vibecarbon-` prefix dropped — the project name is already unique).
1836
- // This branch only runs when Pulumi state is missing, so we have to
1837
- // know the location to find the key. Fall back to the prefix-only
1838
- // form for older deploys that may not have a region in envConfig.
1839
- const k8sRegion = envConfig.region || envConfig.servers?.[0]?.region;
1840
- const sshKeyName = k8sRegion
1841
- ? `${projectConfig.projectName}-${envName}-${k8sRegion}-key`
1842
- : null;
1843
- if (sshKeyName) {
1844
- s.start(`Deleting SSH key: ${sshKeyName}`);
1845
- try {
1846
- const deleted = await hetznerDeleteSSHKey(hetznerToken, sshKeyName);
1847
- if (deleted) results.sshKeys.push(sshKeyName);
1848
- s.stop(deleted ? 'SSH key deleted' : 'SSH key not found');
1849
- } catch (error) {
1850
- s.stop(`Failed to delete SSH key: ${error.message}`);
1851
- }
1852
- }
1853
- } // end manual API cleanup
1850
+ if (p.isCancel(envName)) {
1851
+ p.cancel('Operation cancelled.');
1852
+ process.exit(0);
1853
+ }
1854
1854
 
1855
- // Clean up Kubernetes-managed resources (created by CCM/CSI at runtime,
1856
- // not by the Pulumi program). These must run for ALL deployments since
1857
- // they aren't in any stack's resource graph.
1855
+ const envConfig = projectConfig.environments[envName];
1856
+ if (!envConfig) {
1857
+ p.log.error(`Environment '${envName}' not found`);
1858
+ p.log.info(`Available environments: ${envNames.join(', ')}`);
1859
+ process.exit(1);
1860
+ }
1858
1861
 
1859
- s.start('Cleaning up orphaned volumes');
1860
- try {
1861
- const serverNames = (envConfig.servers || []).map((sv) => sv.name);
1862
- const knownVolumeIds = [...new Set(allClusterVolumeIds)];
1863
- const deletedVolumes = await hetznerCleanupOrphanedVolumes(
1864
- hetznerToken,
1865
- projectConfig.projectName,
1866
- envName,
1867
- serverNames,
1868
- knownVolumeIds,
1869
- [...clusterLocations],
1870
- );
1871
- results.volumes = deletedVolumes;
1872
- s.stop(
1873
- deletedVolumes.length > 0
1874
- ? `Deleted ${deletedVolumes.length} orphaned volume(s)`
1875
- : 'No orphaned volumes found',
1876
- );
1877
- } catch (error) {
1878
- s.stop(`Volume cleanup failed: ${error.message}`);
1862
+ // Show what will be deleted
1863
+ const serverList =
1864
+ envConfig.servers?.map((s) => ` ${c.error('•')} ${s.name} (${s.ip})`).join('\n') ||
1865
+ ' None';
1866
+
1867
+ p.note(
1868
+ `
1869
+ ${c.danger('This will permanently delete:')}
1870
+
1871
+ ${c.bold('Servers:')}
1872
+ ${serverList}
1873
+
1874
+ ${c.bold('Firewalls:')}
1875
+ ${envConfig.servers?.map((s) => ` ${c.error('•')} ${s.name}-fw`).join('\n') || ' None'}
1876
+
1877
+ ${c.bold('SSH Keys:')}
1878
+ ${c.error('•')} vibecarbon-${projectConfig.projectName}-${envName}
1879
+
1880
+ ${
1881
+ envConfig.dns?.provider === 'cloudflare'
1882
+ ? `${c.bold('Cloudflare Resources:')}
1883
+ ${c.error('•')} DNS record: ${envConfig.domain}
1884
+ ${c.error('•')} Health checks
1885
+ ${envConfig.ha ? `${c.error('•')} Wildcard DNS record` : ''}
1886
+ `
1887
+ : ''
1888
+ }
1889
+ ${
1890
+ envConfig.s3?.bucket
1891
+ ? `${c.bold('S3 Storage:')}
1892
+ ${c.error('•')} Bucket: ${envConfig.s3.bucket} (all objects will be deleted)
1893
+ `
1894
+ : ''
1895
+ }${
1896
+ envConfig.backupS3?.bucket
1897
+ ? `${c.bold('S3 Backups:')}
1898
+ ${args.purgeBackups ? `${c.error('•')} Bucket: ${envConfig.backupS3.bucket} (WILL BE DELETED — --purge-backups)` : `${c.success('•')} Bucket: ${envConfig.backupS3.bucket} (PRESERVED for recovery)`}
1899
+ `
1900
+ : ''
1901
+ }
1902
+ ${c.bold('GitHub:')}
1903
+ ${c.error('•')} Environment: ${envName}
1904
+
1905
+ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1906
+ `,
1907
+ `Destroying: ${envName}`,
1908
+ );
1909
+
1910
+ const needsProdConfirm = requiresProdTypeToConfirm(envName);
1911
+
1912
+ // First prompt: only when not --yes.
1913
+ if (!args.yes) {
1914
+ const confirmed = await p.confirm({
1915
+ message: `Are you sure you want to destroy the ${c.bold(envName)} environment?`,
1916
+ initialValue: false,
1917
+ });
1918
+ if (!confirmed || p.isCancel(confirmed)) {
1919
+ p.cancel('Operation cancelled.');
1920
+ process.exit(0);
1921
+ }
1879
1922
  }
1880
1923
 
1881
- // 4.5 Delete S3 bucket (the Pulumi backend bucket itself, created during
1882
- // deploy bootstrap, not part of any stack's resource graph)
1883
- // Use the saved bucket name from config, or fall back to the predictable deploy-time name.
1884
- // The deploy script creates buckets as sanitizeBucketName(projectName) → e.g. "myapp-storage".
1885
- const s3BucketName = envConfig.s3?.bucket || sanitizeBucketName(projectConfig.projectName);
1886
- const s3Region = envConfig.s3?.region || 'fsn1';
1887
- s.start(`Deleting S3 bucket: ${s3BucketName}`);
1888
- try {
1889
- // S3 credentials from env vars or credentials file (prompts if needed)
1890
- const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
1891
- if (s3Creds) {
1892
- const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, s3Region);
1924
+ // Type-to-confirm: runs whenever NOT --yes, OR the env is prod/production
1925
+ // (even with --yes). Protects against `--env prod --yes` typos in CI/scripts.
1926
+ if (!args.yes || needsProdConfirm) {
1927
+ const confirmSlug = `${projectConfig.projectName}-${envName}`;
1928
+ if (args.yes && needsProdConfirm) {
1929
+ p.log.warn(
1930
+ `Destroying a production environment still requires type-to-confirm, even with --yes.`,
1931
+ );
1932
+ }
1933
+ const doubleConfirm = await p.text({
1934
+ message: `Type "${confirmSlug}" to confirm:`,
1935
+ validate: (v) => (v !== confirmSlug ? `Please type "${confirmSlug}" to confirm` : undefined),
1936
+ });
1937
+ if (p.isCancel(doubleConfirm)) {
1938
+ p.cancel('Operation cancelled.');
1939
+ process.exit(0);
1940
+ }
1941
+ }
1942
+
1943
+ // Offer to create a backup before destroying
1944
+ const serverIp = envConfig.servers?.[0]?.ip;
1945
+ const sshKeyPath = join(cwd, '.vibecarbon', `deploy_key_${envName}`);
1946
+ if (serverIp && existsSync(sshKeyPath) && !args.yes) {
1947
+ const offerBackup = await p.confirm({
1948
+ message: 'Create a backup before destroying? (recommended)',
1949
+ initialValue: true,
1950
+ });
1951
+
1952
+ if (!p.isCancel(offerBackup) && offerBackup) {
1953
+ const backupSpinner = p.spinner();
1954
+ backupSpinner.start('Creating backup...');
1893
1955
  try {
1894
- const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
1895
- if (result.deleted) {
1896
- results.s3Bucket = s3BucketName;
1897
- s.stop(
1898
- result.objectsRemoved > 0
1899
- ? `S3 bucket deleted (${result.objectsRemoved} objects removed)`
1900
- : 'S3 bucket deleted',
1901
- );
1902
- }
1903
- } catch (bucketError) {
1904
- // NoSuchBucket means it was already deleted or never created — not an error
1905
- if (bucketError.name === 'NoSuchBucket' || bucketError.$metadata?.httpStatusCode === 404) {
1906
- s.stop('S3 bucket not found (already deleted or never created)');
1907
- } else {
1908
- throw bucketError;
1956
+ if (isComposeTier(resolveTier(envConfig))) {
1957
+ const { backupCompose } = await import('./lib/deploy/compose/index.js');
1958
+ await backupCompose(serverIp, sshKeyPath, projectConfig.projectName, {
1959
+ retain: envConfig.backup?.retentionDays,
1960
+ });
1961
+ backupSpinner.stop('wal-g base backup pushed to S3');
1909
1962
  }
1963
+ } catch (backupError) {
1964
+ backupSpinner.stop(`Backup failed: ${backupError.message}`);
1965
+ p.log.warn('Continuing with destroy...');
1910
1966
  }
1911
- } else {
1912
- s.stop('S3 bucket skipped (no credentials available)');
1913
- results.issues.push({
1914
- step: 'S3 bucket',
1915
- detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
1916
- hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
1917
- });
1918
1967
  }
1919
- } catch (error) {
1920
- s.stop(`S3 bucket deletion failed: ${error.message}`);
1921
- results.issues.push({
1922
- step: 'S3 bucket',
1923
- detail: `${s3BucketName} (${s3Region}) not deleted: ${error.message}`,
1924
- hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
1925
- });
1926
1968
  }
1927
1969
 
1928
- // Backup bucket: preserved by default, deleted with --purge-backups.
1929
- await handleBackupBucket(envConfig, projectConfig, args, s);
1970
+ // Get API tokens (env var credentials file → interactive prompt)
1971
+ const hetznerToken = await getApiToken(projectConfig.projectName);
1972
+ if (!hetznerToken) {
1973
+ p.log.error('Hetzner API token is required');
1974
+ process.exit(1);
1975
+ }
1930
1976
 
1931
- // 5. Delete GitHub environment
1932
- s.start(`Deleting GitHub environment: ${envName}`);
1933
- try {
1934
- results.github = deleteGitHubEnvironment(envName);
1935
- s.stop(
1936
- results.github ? 'GitHub environment deleted' : 'GitHub environment not found (or no access)',
1937
- );
1938
- } catch (error) {
1939
- s.stop(`Failed to delete GitHub environment: ${error.message}`);
1977
+ let cloudflareToken = null;
1978
+ if (envConfig.dns?.provider === 'cloudflare' && envConfig.dns?.cloudflareZoneId) {
1979
+ // Check env var → credentials file → prompt
1980
+ cloudflareToken = process.env.CLOUDFLARE_API_TOKEN;
1981
+
1982
+ if (!cloudflareToken) {
1983
+ const creds = loadCredentials();
1984
+ if (creds.cloudflare?.apiToken) {
1985
+ cloudflareToken = creds.cloudflare.apiToken;
1986
+ p.log.info('✓ Using Cloudflare API token from ~/.vibecarbon/credentials.json');
1987
+ }
1988
+ }
1989
+
1990
+ if (!cloudflareToken) {
1991
+ cloudflareToken = await p.password({
1992
+ message: 'Enter Cloudflare API token (for DNS cleanup)',
1993
+ validate: (v) => (v.length < 10 ? 'API token is required' : undefined),
1994
+ });
1995
+
1996
+ if (p.isCancel(cloudflareToken)) {
1997
+ p.cancel('Operation cancelled.');
1998
+ process.exit(0);
1999
+ }
2000
+ } else if (process.env.CLOUDFLARE_API_TOKEN) {
2001
+ p.log.info('✓ Using Cloudflare API token from CLOUDFLARE_API_TOKEN environment variable');
2002
+ }
1940
2003
  }
1941
2004
 
1942
- // 6. Update project config
1943
- s.start('Updating project configuration');
1944
- delete projectConfig.environments[envName];
1945
- saveProjectConfig(projectConfig);
1946
- s.stop('Configuration updated');
1947
-
1948
- // Summary
1949
- const deletedCount =
1950
- results.servers.length +
1951
- results.volumes.length +
1952
- results.firewalls.length +
1953
- results.sshKeys.length +
1954
- results.dns.length +
1955
- results.healthChecks.length +
1956
- results.loadBalancers.length +
1957
- (results.s3Bucket ? 1 : 0) +
1958
- (results.github ? 1 : 0);
2005
+ // ========== DESTRUCTION ==========
1959
2006
 
1960
- p.note(
1961
- `
1962
- ${c.success('Deleted resources:')}
1963
- Servers: ${results.servers.length > 0 ? results.servers.join(', ') : 'None'}
1964
- Volumes: ${results.volumes.length > 0 ? results.volumes.join(', ') : 'None'}
1965
- Firewalls: ${results.firewalls.length > 0 ? results.firewalls.join(', ') : 'None'}
1966
- SSH Keys: ${results.sshKeys.length > 0 ? results.sshKeys.join(', ') : 'None'}
1967
- DNS Records: ${results.dns.length > 0 ? results.dns.join(', ') : 'None'}
1968
- Health Checks: ${results.healthChecks.length > 0 ? results.healthChecks.join(', ') : 'None'}
1969
- Load Balancers: ${results.loadBalancers.length > 0 ? results.loadBalancers.join(', ') : 'None'}
1970
- S3 Bucket: ${results.s3Bucket || 'None'}
1971
- GitHub Env: ${results.github ? envName : 'None'}
2007
+ const tracker = createTracker('destroy', { environment: envName });
2008
+ const s = tracker.spinner();
2009
+ const results = {
2010
+ servers: [],
2011
+ volumes: [],
2012
+ firewalls: [],
2013
+ sshKeys: [],
2014
+ dns: [],
2015
+ healthChecks: [],
2016
+ loadBalancers: [],
2017
+ s3Bucket: null,
2018
+ github: false,
2019
+ // Sub-step failures that didn't abort destroy but left state behind
2020
+ // (e.g., S3 bucket couldn't be emptied). The outro surfaces these so the
2021
+ // operator doesn't miss them, and the process exits non-zero so CI sees it.
2022
+ issues: [],
2023
+ };
1972
2024
 
1973
- ${c.dim(`Total: ${deletedCount} resources deleted`)}
1974
- `,
1975
- 'Destruction Complete',
1976
- );
2025
+ // ========== TIER DISPATCH ==========
2026
+ // Pure planner derives every teardown target from persisted config; the
2027
+ // per-tier strategy runs the infra teardown, then a single shared tail
2028
+ // handles buckets/config/summary. k8s and k8s-ha share destroyK8sTier —
2029
+ // the plan's stackEnvs/clusterNames already encode the HA fan-out.
2030
+ const plan = planDestroyTargets(envConfig, projectConfig, envName);
2031
+ const isK8s = isK8sTier(plan.tier);
2032
+
2033
+ await DESTROY_STRATEGIES[plan.tier]({
2034
+ plan,
2035
+ envConfig,
2036
+ projectConfig,
2037
+ environment: envName,
2038
+ args,
2039
+ results,
2040
+ hetznerToken,
2041
+ cloudflareToken,
2042
+ cwd,
2043
+ spinner: s,
2044
+ tracker,
2045
+ });
1977
2046
 
1978
- const { formatted: timeStr } = tracker.finish();
1979
- destroyOutro(envName, timeStr, results);
2047
+ await finishDestroy({
2048
+ envConfig,
2049
+ projectConfig,
2050
+ environment: envName,
2051
+ args,
2052
+ results,
2053
+ spinner: s,
2054
+ tracker,
2055
+ cwd,
2056
+ // k8s-only tail steps (compose/compose-ha never did these):
2057
+ deleteGithubEnv: isK8s,
2058
+ showSummary: isK8s,
2059
+ defaultBucketName: isK8s,
2060
+ });
1980
2061
  }
1981
2062
 
1982
2063
  // ============================================================================
@@ -2002,6 +2083,7 @@ export {
2002
2083
  cloudflareDeleteDNSRecord,
2003
2084
  cloudflareDeleteHealthCheck,
2004
2085
  deleteGitHubEnvironment,
2086
+ deleteStateBucket,
2005
2087
  getApiToken,
2006
2088
  hetznerCleanupAutoscalerWorkers,
2007
2089
  hetznerCleanupLoadBalancers,