vibecarbon 0.10.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.
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) {
@@ -817,710 +880,402 @@ function destroyOutro(envName, timeStr, results) {
817
880
  }
818
881
 
819
882
  // ============================================================================
820
- // MAIN DESTRUCTION FLOW
883
+ // SHARED TEARDOWN TAIL
821
884
  // ============================================================================
822
885
 
823
- async function main() {
824
- const { values, positional, errors } = parseFlags(process.argv.slice(2), SPEC);
825
-
826
- if (errors.length > 0) {
827
- for (const e of errors) {
828
- 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
+ });
829
963
  }
830
- process.stderr.write(`Run ${c.info('vibecarbon destroy -h')} for usage.\n`);
831
- process.exit(1);
832
964
  }
833
965
 
834
- if (values.h) {
835
- process.stdout.write(renderHelp(SPEC));
836
- process.exit(0);
837
- }
838
- if (values.v) {
839
- console.log(`destroy-vibecarbon v${VERSION}`);
840
- 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
+ }
841
987
  }
842
988
 
843
- // Build the legacy `args` struct that the orchestration code below
844
- // reads (env / yes / destroyOrphans / purgeBackups). Field renames
845
- // happen at the boundary so the orchestration stays untouched —
846
- // each downstream branch is hundreds of lines and well-tested.
847
- const envSeed =
848
- /** @type {string|undefined} */ (positional.env) ||
849
- /** @type {string|null} */ (values.env) ||
850
- null;
851
- const args = {
852
- env: envSeed,
853
- yes: !!values.y,
854
- destroyOrphans: !!values.orphans,
855
- purgeBackups: !!values.purge,
856
- };
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'}
857
1026
 
858
- // Check if current working directory exists
859
- let cwd;
860
- try {
861
- cwd = process.cwd();
862
- } catch {
863
- console.error(`\n${c.error('Error:')} Current working directory does not exist.`);
864
- console.error(
865
- `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',
866
1030
  );
867
- console.error(`\nPlease navigate to a valid directory and try again.`);
868
- process.exit(1);
869
1031
  }
870
1032
 
871
- // Project guard runs before banner so an accidental `vibecarbon
872
- // destroy` from a parent directory emits the canonical message.
873
- assertInProjectDir(cwd);
1033
+ const { formatted: timeStr } = tracker.finish();
1034
+ destroyOutro(environment, timeStr, results);
1035
+ }
874
1036
 
875
- console.clear();
876
- printBanner();
877
- p.intro(`${c.bold('vibecarbon destroy')} ${c.dim(`v${VERSION}`)}`);
1037
+ // ============================================================================
1038
+ // PER-TIER DESTROY STRATEGIES
1039
+ // ============================================================================
878
1040
 
879
- 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
+ }
880
1071
 
881
- // TTY guard: if the operator seeded an env, no env prompt fires;
882
- // otherwise we'd open a picker that hangs off-TTY.
883
- const envCount = Object.keys(projectConfig?.environments || {}).length;
884
- requireTTYOrFlags({
885
- requirements: [
886
- {
887
- flag: 'env',
888
- description: 'name an environment to destroy',
889
- satisfied: !!envSeed || envCount <= 1,
890
- },
891
- ],
892
- });
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}`);
893
1090
 
894
- if (!projectConfig.environments || Object.keys(projectConfig.environments).length === 0) {
895
- // Check for orphan Pulumi stacks (deployment interrupted before config save)
896
- const orphanScan = p.spinner();
897
- orphanScan.start('Scanning for orphan Pulumi stacks...');
898
- const orphans = await findOrphanPulumiStacks(projectConfig);
899
- orphanScan.stop(
900
- orphans.length > 0 ? `Found ${orphans.length} orphan stack(s)` : 'No orphan stacks found',
901
- );
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
+ }
902
1103
 
903
- if (orphans.length > 0) {
904
- p.log.warn('No environments found in .vibecarbon.json');
905
- p.log.info(
906
- `Found ${orphans.length} orphan Pulumi stack(s) likely from interrupted deployments:`,
907
- );
908
- for (const orphan of orphans) {
909
- 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');
910
1135
  }
1136
+ } catch (error) {
1137
+ s.stop(`Failed to delete VPS: ${error.message}`);
1138
+ }
1139
+ }
911
1140
 
912
- // Auto-destroying orphans is dangerous: when projectConfig.s3Config is
913
- // null (e.g., a typo in the saved config), listStacks falls back to
914
- // the local file:// backend at ~/.vibecarbon/pulumi-state/, which is
915
- // GLOBAL across projects/scenarios. A scenario's --yes destroy then
916
- // nukes other scenarios' stacks (observed 2026-04-26 batch run #5:
917
- // compose's final-destroy destroyed compose-ha's e2-primary/e2-standby
918
- // mid-deploy because compose's empty config let the orphan path fire).
919
- //
920
- // The auto-destroy used to honor --yes, but cross-scenario blast is
921
- // worse than leaving local state around. Require an explicit flag now.
922
- // For interactive use, prompt as before.
923
- p.log.info('');
924
- if (!args.destroyOrphans) {
925
- p.log.warn('Skipping orphan cleanup. Re-run with -orphans to remove these stacks.');
926
- p.outro('Done (orphans not destroyed)');
927
- 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');
928
1170
  }
929
- const shouldDestroy = args.yes
930
- ? true
931
- : await p.confirm({
932
- message: `Destroy these ${orphans.length} orphan stack(s)?`,
933
- initialValue: false, // Default to NO for safety
934
- });
1171
+ } catch (error) {
1172
+ s.stop(`Failed to delete firewall: ${error.message}`);
1173
+ }
1174
+ }
935
1175
 
936
- if (p.isCancel(shouldDestroy) || !shouldDestroy) {
937
- p.cancel('Operation cancelled.');
938
- process.exit(0);
939
- }
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
+ }
940
1195
 
941
- // Load the HCLOUD token + S3 backend config the same way the
942
- // main destroy path does. Pulumi's hcloud provider needs a valid
943
- // token to delete resources; an empty token surfaces as
944
- // "Missing Hetzner Cloud API token" mid-destroy. The s3Config
945
- // must match the one findOrphanPulumiStacks used to discover
946
- // these stacks, otherwise resolveBackendUrl picks a different
947
- // backend and the destroy can't find them.
948
- const orphanCreds = loadCredentials();
949
- const orphanApiToken =
950
- process.env.HETZNER_API_TOKEN ||
951
- orphanCreds?.hetzner?.apiToken ||
952
- orphanCreds?.hetznerApiToken ||
953
- '';
954
- const orphanS3Config =
955
- projectConfig?.s3Config && orphanCreds?.s3
956
- ? {
957
- ...projectConfig.s3Config,
958
- accessKey: orphanCreds.s3.accessKey,
959
- secretKey: orphanCreds.s3.secretKey,
960
- }
961
- : null;
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.
962
1199
 
963
- if (!orphanApiToken) {
964
- p.log.error(
965
- 'No Hetzner API token found in HETZNER_API_TOKEN env var or ~/.vibecarbon/credentials.json — cannot destroy orphan stacks.',
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(', ')})`,
966
1216
  );
967
- process.exit(1);
1217
+ } else {
1218
+ s.stop(result.deleted > 0 ? 'DNS record deleted' : 'DNS record not found');
968
1219
  }
1220
+ } catch (error) {
1221
+ s.stop(`DNS deletion failed: ${error.message}`);
1222
+ }
1223
+ }
969
1224
 
970
- const orphanSpinner = p.spinner();
971
- for (const orphan of orphans) {
972
- orphanSpinner.start(`Destroying orphan stack ${c.bold(orphan.name)} (pulumi destroy)...`);
973
- try {
974
- await destroyOrphanPulumiStack(orphan, {
975
- apiToken: orphanApiToken,
976
- s3Config: orphanS3Config,
977
- });
978
- orphanSpinner.stop(`Destroyed: ${orphan.name}`);
979
- } catch (error) {
980
- orphanSpinner.error(`Failed to destroy ${orphan.name}: ${error.message}`);
981
- }
982
- }
983
-
984
- p.outro('Orphan cleanup complete');
985
- process.exit(0);
986
- }
987
-
988
- p.log.error('No environments found in .vibecarbon.json');
989
- p.log.info('Nothing to destroy.');
990
- process.exit(1);
991
- }
992
-
993
- p.log.info(`Project: ${c.bold(projectConfig.projectName)}`);
994
-
995
- // Select environment to destroy
996
- const envNames = Object.keys(projectConfig.environments);
997
- let envName = args.env;
998
-
999
- if (!envName) {
1000
- if (envNames.length === 1) {
1001
- envName = envNames[0];
1002
- } else {
1003
- envName = await p.select({
1004
- message: 'Which environment do you want to destroy?',
1005
- options: envNames.map((name) => ({
1006
- value: name,
1007
- label: name,
1008
- hint: projectConfig.environments[name].servers?.map((s) => s.ip).join(', '),
1009
- })),
1010
- });
1011
- }
1012
- }
1013
-
1014
- if (p.isCancel(envName)) {
1015
- p.cancel('Operation cancelled.');
1016
- process.exit(0);
1017
- }
1018
-
1019
- const envConfig = projectConfig.environments[envName];
1020
- if (!envConfig) {
1021
- p.log.error(`Environment '${envName}' not found`);
1022
- p.log.info(`Available environments: ${envNames.join(', ')}`);
1023
- process.exit(1);
1024
- }
1025
-
1026
- // Show what will be deleted
1027
- const serverList =
1028
- envConfig.servers?.map((s) => ` ${c.error('•')} ${s.name} (${s.ip})`).join('\n') ||
1029
- ' None';
1030
-
1031
- p.note(
1032
- `
1033
- ${c.danger('This will permanently delete:')}
1034
-
1035
- ${c.bold('Servers:')}
1036
- ${serverList}
1037
-
1038
- ${c.bold('Firewalls:')}
1039
- ${envConfig.servers?.map((s) => ` ${c.error('•')} ${s.name}-fw`).join('\n') || ' None'}
1040
-
1041
- ${c.bold('SSH Keys:')}
1042
- ${c.error('•')} vibecarbon-${projectConfig.projectName}-${envName}
1043
-
1044
- ${
1045
- envConfig.dns?.provider === 'cloudflare'
1046
- ? `${c.bold('Cloudflare Resources:')}
1047
- ${c.error('•')} DNS record: ${envConfig.domain}
1048
- ${c.error('•')} Health checks
1049
- ${envConfig.ha ? `${c.error('•')} Wildcard DNS record` : ''}
1050
- `
1051
- : ''
1052
- }
1053
- ${
1054
- envConfig.s3?.bucket
1055
- ? `${c.bold('S3 Storage:')}
1056
- ${c.error('•')} Bucket: ${envConfig.s3.bucket} (all objects will be deleted)
1057
- `
1058
- : ''
1059
- }${
1060
- envConfig.backupS3?.bucket
1061
- ? `${c.bold('S3 Backups:')}
1062
- ${args.purgeBackups ? `${c.error('•')} Bucket: ${envConfig.backupS3.bucket} (WILL BE DELETED — --purge-backups)` : `${c.success('•')} Bucket: ${envConfig.backupS3.bucket} (PRESERVED for recovery)`}
1063
- `
1064
- : ''
1065
- }
1066
- ${c.bold('GitHub:')}
1067
- ${c.error('•')} Environment: ${envName}
1068
-
1069
- ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1070
- `,
1071
- `Destroying: ${envName}`,
1072
- );
1073
-
1074
- const needsProdConfirm = requiresProdTypeToConfirm(envName);
1075
-
1076
- // First prompt: only when not --yes.
1077
- if (!args.yes) {
1078
- const confirmed = await p.confirm({
1079
- message: `Are you sure you want to destroy the ${c.bold(envName)} environment?`,
1080
- initialValue: false,
1081
- });
1082
- if (!confirmed || p.isCancel(confirmed)) {
1083
- p.cancel('Operation cancelled.');
1084
- process.exit(0);
1085
- }
1086
- }
1087
-
1088
- // Type-to-confirm: runs whenever NOT --yes, OR the env is prod/production
1089
- // (even with --yes). Protects against `--env prod --yes` typos in CI/scripts.
1090
- if (!args.yes || needsProdConfirm) {
1091
- const confirmSlug = `${projectConfig.projectName}-${envName}`;
1092
- if (args.yes && needsProdConfirm) {
1093
- p.log.warn(
1094
- `Destroying a production environment still requires type-to-confirm, even with --yes.`,
1095
- );
1096
- }
1097
- const doubleConfirm = await p.text({
1098
- message: `Type "${confirmSlug}" to confirm:`,
1099
- validate: (v) => (v !== confirmSlug ? `Please type "${confirmSlug}" to confirm` : undefined),
1100
- });
1101
- if (p.isCancel(doubleConfirm)) {
1102
- p.cancel('Operation cancelled.');
1103
- process.exit(0);
1104
- }
1105
- }
1106
-
1107
- // Offer to create a backup before destroying
1108
- const serverIp = envConfig.servers?.[0]?.ip;
1109
- const sshKeyPath = join(cwd, '.vibecarbon', `deploy_key_${envName}`);
1110
- if (serverIp && existsSync(sshKeyPath) && !args.yes) {
1111
- const offerBackup = await p.confirm({
1112
- message: 'Create a backup before destroying? (recommended)',
1113
- initialValue: true,
1114
- });
1115
-
1116
- if (!p.isCancel(offerBackup) && offerBackup) {
1117
- const backupSpinner = p.spinner();
1118
- backupSpinner.start('Creating backup...');
1119
- try {
1120
- if (envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha') {
1121
- const { backupCompose } = await import('./lib/deploy/compose/index.js');
1122
- await backupCompose(serverIp, sshKeyPath, projectConfig.projectName, {
1123
- retain: envConfig.backup?.retentionDays,
1124
- });
1125
- backupSpinner.stop('wal-g base backup pushed to S3');
1126
- }
1127
- } catch (backupError) {
1128
- backupSpinner.stop(`Backup failed: ${backupError.message}`);
1129
- p.log.warn('Continuing with destroy...');
1130
- }
1131
- }
1132
- }
1133
-
1134
- // Get API tokens (env var → credentials file → interactive prompt)
1135
- const hetznerToken = await getApiToken(projectConfig.projectName);
1136
- if (!hetznerToken) {
1137
- p.log.error('Hetzner API token is required');
1138
- process.exit(1);
1139
- }
1140
-
1141
- let cloudflareToken = null;
1142
- if (envConfig.dns?.provider === 'cloudflare' && envConfig.dns?.cloudflareZoneId) {
1143
- // Check env var → credentials file → prompt
1144
- cloudflareToken = process.env.CLOUDFLARE_API_TOKEN;
1145
-
1146
- if (!cloudflareToken) {
1147
- const creds = loadCredentials();
1148
- if (creds.cloudflare?.apiToken) {
1149
- cloudflareToken = creds.cloudflare.apiToken;
1150
- p.log.info('✓ Using Cloudflare API token from ~/.vibecarbon/credentials.json');
1151
- }
1152
- }
1153
-
1154
- if (!cloudflareToken) {
1155
- cloudflareToken = await p.password({
1156
- message: 'Enter Cloudflare API token (for DNS cleanup)',
1157
- validate: (v) => (v.length < 10 ? 'API token is required' : undefined),
1158
- });
1159
-
1160
- if (p.isCancel(cloudflareToken)) {
1161
- p.cancel('Operation cancelled.');
1162
- process.exit(0);
1163
- }
1164
- } else if (process.env.CLOUDFLARE_API_TOKEN) {
1165
- p.log.info('✓ Using Cloudflare API token from CLOUDFLARE_API_TOKEN environment variable');
1166
- }
1167
- }
1168
-
1169
- // ========== DESTRUCTION ==========
1170
-
1171
- const tracker = createTracker('destroy', { environment: envName });
1172
- const s = tracker.spinner();
1173
- const results = {
1174
- servers: [],
1175
- volumes: [],
1176
- firewalls: [],
1177
- sshKeys: [],
1178
- dns: [],
1179
- healthChecks: [],
1180
- loadBalancers: [],
1181
- s3Bucket: null,
1182
- github: false,
1183
- // Sub-step failures that didn't abort destroy but left state behind
1184
- // (e.g., S3 bucket couldn't be emptied). The outro surfaces these so the
1185
- // operator doesn't miss them, and the process exits non-zero so CI sees it.
1186
- issues: [],
1187
- };
1188
-
1189
- // ========== COMPOSE-HA DESTRUCTION (if deployed via Docker Compose HA) ==========
1190
- if (envConfig.deployMode === 'compose-ha') {
1191
- const { destroyComposeHA } = await import('./lib/deploy/compose/ha.js');
1192
-
1193
- const s2 = tracker.spinner();
1194
- s2.start('Destroying Compose HA environment...');
1195
- try {
1196
- await destroyComposeHA({
1197
- projectName: projectConfig.projectName,
1198
- environment: envName,
1199
- envConfig,
1200
- hetznerToken,
1201
- cloudflareToken,
1202
- onProgress: (msg) => s2.message(msg),
1203
- });
1204
- s2.stop('Compose HA environment destroyed');
1205
- } catch (error) {
1206
- s2.stop(`Compose HA destruction failed: ${error.message}`);
1207
- }
1208
-
1209
- // Delete S3 bucket if configured
1210
- if (envConfig.s3?.bucket) {
1211
- const s3BucketName = envConfig.s3.bucket;
1212
- const s3Region = envConfig.s3?.region || 'fsn1';
1213
- s2.start(`Deleting S3 bucket: ${s3BucketName}`);
1214
- try {
1215
- const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
1216
- if (s3Creds) {
1217
- const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, s3Region);
1218
- try {
1219
- const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
1220
- s2.stop(result.deleted ? 'S3 bucket deleted' : 'S3 bucket not found');
1221
- } catch (bucketError) {
1222
- if (
1223
- bucketError.name === 'NoSuchBucket' ||
1224
- bucketError.$metadata?.httpStatusCode === 404
1225
- ) {
1226
- s2.stop('S3 bucket not found (already deleted)');
1227
- } else {
1228
- s2.stop(`S3 bucket deletion failed: ${bucketError.message}`);
1229
- results.issues.push({
1230
- step: 'S3 bucket',
1231
- detail: `${s3BucketName} (${s3Region}) not deleted: ${bucketError.message}`,
1232
- hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
1233
- });
1234
- }
1235
- }
1236
- } else {
1237
- s2.stop('S3 bucket skipped (no credentials available)');
1238
- results.issues.push({
1239
- step: 'S3 bucket',
1240
- detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
1241
- hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
1242
- });
1243
- }
1244
- } catch (error) {
1245
- s2.stop(`S3 bucket deletion failed: ${error.message}`);
1246
- results.issues.push({
1247
- step: 'S3 bucket',
1248
- detail: `${s3BucketName} not deleted: ${error.message}`,
1249
- hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket.',
1250
- });
1251
- }
1252
- }
1253
-
1254
- // State bucket: deleted AFTER Compose-HA teardown (both stacks are torn
1255
- // down inside destroyComposeHA above), so it can't yank the backend early.
1256
- await deleteStateBucket(envConfig, projectConfig, s2, results);
1257
-
1258
- // Backup bucket: preserved by default, deleted with --purge-backups.
1259
- await handleBackupBucket(envConfig, projectConfig, args, s2);
1260
-
1261
- // Update project config to remove environment
1262
- const updatedConfig = { ...projectConfig };
1263
- if (updatedConfig.environments) {
1264
- delete updatedConfig.environments[envName];
1265
- }
1266
- saveProjectConfig(updatedConfig);
1267
-
1268
- cleanupLocalEnvFiles(cwd, envName);
1269
-
1270
- const { formatted: timeStr } = tracker.finish();
1271
- destroyOutro(envName, timeStr, results);
1272
- return;
1273
- }
1274
-
1275
- // ========== COMPOSE DESTRUCTION (if deployed via Docker Compose) ==========
1276
- if (envConfig.deployMode === 'compose') {
1277
- const serverIp = envConfig.servers?.[0]?.ip;
1278
- const sshKeyPath = join(cwd, '.vibecarbon', `deploy_key_${envName}`);
1279
-
1280
- if (serverIp && existsSync(sshKeyPath)) {
1281
- // Stop and remove Docker Compose services
1282
- s.start('Stopping Docker Compose services...');
1283
- try {
1284
- const { destroyCompose } = await import('./lib/deploy/compose/index.js');
1285
- await destroyCompose(serverIp, sshKeyPath, projectConfig.projectName);
1286
- s.stop('Docker Compose services removed');
1287
- results.servers.push(`vps-${serverIp}`);
1288
- } catch (error) {
1289
- s.stop(`Failed to stop services: ${error.message}`);
1290
- }
1291
- }
1292
-
1293
- // Delete Hetzner VPS if auto-provisioned (has server ID in config)
1294
- const serverId = envConfig.servers?.[0]?.id;
1295
- // The `name` field in envConfig.servers is a role label ("master") set by
1296
- // the orchestrator, not the Hetzner-assigned hostname. Pulumi's compose
1297
- // program names the server `${projectName}-${environment}`. The
1298
- // orchestrator persists that under `hetznerName`; configs older than
1299
- // that change only have role-style names, so derive the Pulumi name as
1300
- // a deterministic fallback — looking up "master" in Hetzner never
1301
- // matches and leaks the VPS.
1302
- const serverHetznerName =
1303
- envConfig.servers?.[0]?.hetznerName || `${projectConfig.projectName}-${envName}`;
1304
- if (serverId && hetznerToken) {
1305
- s.start(`Deleting VPS (${serverIp})...`);
1306
- try {
1307
- const deleted = await hetznerDeleteServer(hetznerToken, serverId);
1308
- s.stop(deleted ? 'VPS deleted' : 'VPS not found');
1309
- } catch (error) {
1310
- s.stop(`Failed to delete VPS: ${error.message}`);
1311
- }
1312
- } else if (!serverId && hetznerToken && serverHetznerName) {
1313
- // Fallback: find server by name if ID wasn't saved (older deploys
1314
- // or runs where Pulumi didn't propagate serverId).
1315
- s.start(`Looking up VPS by name: ${serverHetznerName}...`);
1316
- try {
1317
- const servers = await hetznerListServers(hetznerToken);
1318
- const server = servers.find((sv) => sv.name === serverHetznerName);
1319
- if (server) {
1320
- const deleted = await hetznerDeleteServer(hetznerToken, server.id);
1321
- s.stop(deleted ? 'VPS deleted' : 'VPS not found');
1322
- } else {
1323
- s.stop('VPS not found');
1324
- }
1325
- } catch (error) {
1326
- s.stop(`Failed to delete VPS: ${error.message}`);
1327
- }
1328
- }
1329
-
1330
- // Delete Hetzner firewall.
1331
- // Pulumi (src/lib/iac/programs/hetzner-compose.js) names the firewall
1332
- // `${projectName}-${environment}-firewall`
1333
- // Earlier this used `vibecarbon-${projectName}-${envName}` which never
1334
- // matched anything Pulumi created — the firewall always leaked, and
1335
- // the next compose restore re-deploy hit "name is already used
1336
- // (uniqueness_error)" on Pulumi up. Match the actual Pulumi naming
1337
- // convention (the equivalent fix exists in destroyComposeHA).
1338
- const fwName = `${projectConfig.projectName}-${envName}-firewall`;
1339
- if (hetznerToken) {
1340
- s.start(`Deleting firewall: ${fwName}`);
1341
- try {
1342
- const deleted = await hetznerDeleteFirewall(hetznerToken, fwName);
1343
- if (deleted) results.firewalls.push(fwName);
1344
- s.stop(deleted ? 'Firewall deleted' : 'Firewall not found');
1345
- } catch (error) {
1346
- s.stop(`Failed to delete firewall: ${error.message}`);
1347
- }
1348
- }
1349
-
1350
- // Delete Hetzner SSH key.
1351
- // Pulumi names the key
1352
- // `${projectName}-${environment}-${location}-key`
1353
- // — the location suffix is what kept the old `vibecarbon-` prefix from
1354
- // ever matching. Same root cause as the firewall above.
1355
- const composeRegion = envConfig.region || envConfig.servers?.[0]?.region;
1356
- const hetznerSshKeyName = composeRegion
1357
- ? `${projectConfig.projectName}-${envName}-${composeRegion}-key`
1358
- : null;
1359
- if (hetznerToken && hetznerSshKeyName) {
1360
- s.start(`Deleting SSH key: ${hetznerSshKeyName}`);
1361
- try {
1362
- const deleted = await hetznerDeleteSSHKey(hetznerToken, hetznerSshKeyName);
1363
- if (deleted) results.sshKeys.push(hetznerSshKeyName);
1364
- s.stop(deleted ? 'SSH key deleted' : 'SSH key not found');
1365
- } catch (error) {
1366
- s.stop(`Failed to delete SSH key: ${error.message}`);
1367
- }
1368
- }
1369
-
1370
- // DNS cleanup is ownership-filtered: only records pointing at this env's
1371
- // server IPs are deleted. Shared zones (e.g. parallel e2e scenarios
1372
- // on the same root domain) used to lose neighbours' records here — see
1373
- // the deleteDNSRecord doc for context.
1374
- const ownedIps = (envConfig.servers || []).map((srv) => srv.ip).filter(Boolean);
1375
-
1376
- // Delete Cloudflare DNS if configured
1377
- if (cloudflareToken && envConfig.dns?.cloudflareZoneId && envConfig.domain) {
1378
- const zoneId = envConfig.dns.cloudflareZoneId;
1379
-
1380
- s.start(`Deleting DNS record: ${envConfig.domain}`);
1381
- try {
1382
- const result = await cloudflareDeleteDNSRecord(
1383
- cloudflareToken,
1384
- zoneId,
1385
- envConfig.domain,
1386
- ownedIps,
1387
- );
1388
- if (result.deleted > 0) results.dns.push(envConfig.domain);
1389
- if (result.skipped > 0) {
1390
- s.stop(
1391
- `DNS record preserved (${result.skipped} unowned target(s): ${result.skippedTargets.join(', ')})`,
1392
- );
1393
- } else {
1394
- s.stop(result.deleted > 0 ? 'DNS record deleted' : 'DNS record not found');
1395
- }
1396
- } catch (error) {
1397
- s.stop(`DNS deletion failed: ${error.message}`);
1398
- }
1399
- }
1400
-
1401
- // Delete Hetzner DNS if configured
1402
- if (envConfig.dns?.provider === 'hetzner' && envConfig.dns?.hetznerZoneId && envConfig.domain) {
1403
- const zoneId = envConfig.dns.hetznerZoneId;
1404
- s.start(`Deleting Hetzner DNS record: ${envConfig.domain}`);
1405
- try {
1406
- const zone = await hetznerDnsGetZone(hetznerToken, zoneId);
1407
- const [root, wildcard] = await Promise.all([
1408
- hetznerDnsDeleteRecord(hetznerToken, zoneId, zone.name, envConfig.domain, ownedIps),
1409
- hetznerDnsDeleteRecord(
1410
- hetznerToken,
1411
- zoneId,
1412
- zone.name,
1413
- `*.${envConfig.domain}`,
1414
- ownedIps,
1415
- ),
1416
- ]);
1417
- if (root.deleted || wildcard.deleted) results.dns.push(envConfig.domain);
1418
- const preserved = [root, wildcard].filter((r) => r.skipped);
1419
- if (preserved.length > 0) {
1420
- const targets = preserved.flatMap((p) => p.skippedTargets);
1421
- s.stop(`Hetzner DNS records preserved (unowned targets: ${targets.join(', ')})`);
1422
- } else {
1423
- s.stop('Hetzner DNS records deleted');
1424
- }
1425
- } catch (error) {
1426
- s.stop(`Hetzner DNS cleanup failed: ${error.message}`);
1427
- }
1428
- }
1429
-
1430
- // Delete S3 bucket if configured
1431
- if (envConfig.s3?.bucket) {
1432
- const s3BucketName = envConfig.s3.bucket;
1433
- const s3Region = envConfig.s3?.region || 'fsn1';
1434
- s.start(`Deleting S3 bucket: ${s3BucketName}`);
1435
- try {
1436
- const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
1437
- if (s3Creds) {
1438
- const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, s3Region);
1439
- try {
1440
- const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
1441
- if (result.deleted) results.s3Bucket = s3BucketName;
1442
- s.stop(result.deleted ? 'S3 bucket deleted' : 'S3 bucket not found');
1443
- } catch (bucketError) {
1444
- if (
1445
- bucketError.name === 'NoSuchBucket' ||
1446
- bucketError.$metadata?.httpStatusCode === 404
1447
- ) {
1448
- s.stop('S3 bucket not found (already deleted or never created)');
1449
- } else {
1450
- throw bucketError;
1451
- }
1452
- }
1453
- } else {
1454
- s.stop('S3 bucket skipped (no credentials available)');
1455
- results.issues.push({
1456
- step: 'S3 bucket',
1457
- detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
1458
- hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
1459
- });
1460
- }
1461
- } catch (error) {
1462
- s.stop(`S3 bucket deletion failed: ${error.message}`);
1463
- results.issues.push({
1464
- step: 'S3 bucket',
1465
- detail: `${s3BucketName} (${s3Region}) not deleted: ${error.message}`,
1466
- hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
1467
- });
1468
- }
1469
- }
1470
-
1471
- // State bucket: deleted after the compose teardown above. (Compose destroy
1472
- // reaps resources via the Hetzner API rather than `pulumi destroy`, so
1473
- // there's no in-flight stack op to protect — but ordering it here keeps the
1474
- // "state bucket last" invariant uniform across all destroy paths.)
1475
- await deleteStateBucket(envConfig, projectConfig, s, results);
1476
-
1477
- // Backup bucket: preserved by default, deleted with --purge-backups.
1478
- await handleBackupBucket(envConfig, projectConfig, args, s);
1479
-
1480
- // Clean up local SSH key files
1481
- const sshKeyFiles = [sshKeyPath, `${sshKeyPath}.pub`];
1482
- for (const f of sshKeyFiles) {
1483
- if (existsSync(f)) {
1484
- try {
1485
- rmSync(f);
1486
- } catch {
1487
- // Non-fatal
1488
- }
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');
1489
1242
  }
1243
+ } catch (error) {
1244
+ s.stop(`Hetzner DNS cleanup failed: ${error.message}`);
1490
1245
  }
1491
-
1492
- // Update project config to remove environment
1493
- const updatedConfig = { ...projectConfig };
1494
- if (updatedConfig.environments) {
1495
- delete updatedConfig.environments[envName];
1496
- }
1497
- saveProjectConfig(updatedConfig);
1498
-
1499
- cleanupLocalEnvFiles(cwd, envName);
1500
-
1501
- const { formatted: timeStr } = tracker.finish();
1502
- destroyOutro(envName, timeStr, results);
1503
- return;
1504
1246
  }
1247
+ }
1505
1248
 
1506
- // Identify Pulumi stacks to destroy. HA k8s uses two stacks:
1507
- // {env}-primary + {env}-standby; single k8s uses just {env}.
1508
- const isHAK8s = envConfig.ha?.enabled && envConfig.deployMode === 'kubernetes';
1509
- const pulumiStackEnvs = isHAK8s ? [`${envName}-primary`, `${envName}-standby`] : [envName];
1510
- 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';
1511
1269
 
1512
1270
  // Volume IDs collected from ALL cluster servers (master, supabase, workers).
1513
- // Hetzner CSI names volumes pvc-<uuid> — not the cluster name — so we must collect IDs
1514
- // 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.
1515
1274
  const allClusterVolumeIds = [];
1516
- // Datacenter location names for all servers in the cluster (e.g. "fsn1", "nbg1").
1517
- // 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.
1518
1277
  const clusterLocations = new Set();
1519
1278
 
1520
- // Owned IPs gate every DNS delete — only records pointing at this env's
1521
- // servers are removed. See deleteDNSRecord doc in lib/cloudflare.js.
1522
- const ownedIps = (envConfig.servers || []).map((srv) => srv.ip).filter(Boolean);
1523
-
1524
1279
  // 1. Delete Cloudflare resources first (while servers still exist for reference)
1525
1280
  if (cloudflareToken && envConfig.dns?.cloudflareZoneId) {
1526
1281
  const zoneId = envConfig.dns.cloudflareZoneId;
@@ -1624,18 +1379,11 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1624
1379
  // Pulumi owns the servers, firewalls, SSH keys, and networks — `pulumi
1625
1380
  // destroy` tears them all down in one pass.
1626
1381
 
1627
- // For HA, each sub-cluster has its own network (e.g. havibes-prod-primary-network)
1628
- const clusterNames = isHAK8s
1629
- ? [
1630
- `${projectConfig.projectName}-${envName}-primary`,
1631
- `${projectConfig.projectName}-${envName}-standby`,
1632
- ]
1633
- : [`${projectConfig.projectName}-${envName}`];
1634
-
1635
- // Pre-scan: collect volume IDs from ALL servers in the cluster network(s) before deletion.
1636
- // Hetzner CSI driver names volumes with PVC UUIDs (e.g. pvc-xxxxxxxx), not the cluster
1637
- // name, so name/label matching in hetznerCleanupOrphanedVolumes is unreliable. By
1638
- // 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.
1639
1387
  s.start('Scanning cluster servers for attached volumes');
1640
1388
  try {
1641
1389
  const networks = await hetznerListNetworks(hetznerToken);
@@ -1667,8 +1415,8 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1667
1415
  }
1668
1416
 
1669
1417
  // Clean up CCM-created load balancers before Pulumi destroys the network.
1670
- // The cluster network still exists at this point, so matchesNetwork is reliable.
1671
- // 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.
1672
1420
  // HA: fan out across primary + standby — clusters are isolated.
1673
1421
  if (clusterNames.length > 0) {
1674
1422
  console.log(`Cleaning up Hetzner load balancers across ${clusterNames.length} cluster(s)...`);
@@ -1704,10 +1452,10 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1704
1452
  // HA: release CSI volumes on primary + standby in parallel — each
1705
1453
  // cluster's controller is independent.
1706
1454
  console.log(
1707
- `Releasing CSI volumes via cluster finalizers across ${pulumiStackEnvs.length} cluster(s)...`,
1455
+ `Releasing CSI volumes via cluster finalizers across ${stackEnvs.length} cluster(s)...`,
1708
1456
  );
1709
1457
  await Promise.allSettled(
1710
- pulumiStackEnvs.map(async (stackEnv) => {
1458
+ stackEnvs.map(async (stackEnv) => {
1711
1459
  const kubeconfigPath = join(cwd, '.vibecarbon', `kubeconfig-${stackEnv}`);
1712
1460
  const dirLabel = isHAK8s ? ` (${stackEnv})` : '';
1713
1461
  try {
@@ -1753,11 +1501,9 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1753
1501
  // For HA, sweep both primary and standby cluster networks.
1754
1502
  // HA: sweep CA-spawned workers on primary + standby in parallel — each
1755
1503
  // cluster's CA tags its workers with a distinct cluster name.
1756
- console.log(
1757
- `Sweeping cluster-autoscaler workers across ${pulumiStackEnvs.length} cluster(s)...`,
1758
- );
1504
+ console.log(`Sweeping cluster-autoscaler workers across ${stackEnvs.length} cluster(s)...`);
1759
1505
  await Promise.allSettled(
1760
- pulumiStackEnvs.map(async (stackEnv) => {
1506
+ stackEnvs.map(async (stackEnv) => {
1761
1507
  const dirLabel = isHAK8s ? ` (${stackEnv})` : '';
1762
1508
  const caClusterName = `${projectConfig.projectName}-${stackEnv}`;
1763
1509
  try {
@@ -1787,10 +1533,10 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1787
1533
  // so there's no shared state to race on. Saves ~200s on k8s-ha critical
1788
1534
  // path (Pulumi destroy is ~100s/stack and was strictly sequential).
1789
1535
  console.log(
1790
- `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...`,
1791
1537
  );
1792
1538
  await Promise.allSettled(
1793
- pulumiStackEnvs.map(async (stackEnv) => {
1539
+ stackEnvs.map(async (stackEnv) => {
1794
1540
  const dirLabel = isHAK8s ? ` (${stackEnv})` : '';
1795
1541
  try {
1796
1542
  await perfAsync(`destroy.pulumi.${stackEnv}`, async () =>
@@ -1858,7 +1604,7 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1858
1604
  // (RCA from k8s-ha 2026-04-30: post-destroy sweep flagged the same
1859
1605
  // key as REGRESSION every run).
1860
1606
  if (isHAK8s && hetznerToken) {
1861
- const haSshKeyName = `${projectConfig.projectName}-${envName}-ha-key`;
1607
+ const haSshKeyName = `${projectConfig.projectName}-${environment}-ha-key`;
1862
1608
  s.start(`Deleting shared HA SSH key: ${haSshKeyName}`);
1863
1609
  try {
1864
1610
  const deleted = await hetznerDeleteSSHKey(hetznerToken, haSshKeyName);
@@ -1870,190 +1616,448 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1870
1616
  }
1871
1617
  }
1872
1618
 
1873
- 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)}`);
1874
1830
 
1875
- if (!hasPulumiStack) {
1876
- // Manual API cleanup for compose deployments — k8s mode is fully
1877
- // Pulumi-managed and was already torn down above.
1878
- for (const server of envConfig.servers || []) {
1879
- s.start(`Deleting server: ${server.name} (${server.ip})`);
1880
- try {
1881
- const deleted = await hetznerDeleteServer(hetznerToken, server.id);
1882
- if (deleted) results.servers.push(server.name);
1883
- s.stop(deleted ? `Server ${server.name} deleted` : `Server ${server.name} not found`);
1884
- } catch (error) {
1885
- s.stop(`Failed to delete ${server.name}: ${error.message}`);
1886
- }
1887
- }
1831
+ // Select environment to destroy
1832
+ const envNames = Object.keys(projectConfig.environments);
1833
+ let envName = args.env;
1888
1834
 
1889
- // 3. Delete Hetzner firewalls
1890
- for (const server of envConfig.servers || []) {
1891
- const fwName = `${server.name}-fw`;
1892
- s.start(`Deleting firewall: ${fwName}`);
1893
- try {
1894
- const deleted = await hetznerDeleteFirewall(hetznerToken, fwName);
1895
- if (deleted) results.firewalls.push(fwName);
1896
- s.stop(deleted ? `Firewall ${fwName} deleted` : `Firewall ${fwName} not found`);
1897
- } catch (error) {
1898
- s.stop(`Failed to delete firewall: ${error.message}`);
1899
- }
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
+ });
1900
1847
  }
1848
+ }
1901
1849
 
1902
- // 4. Delete Hetzner SSH key.
1903
- // Pulumi (src/lib/iac/programs/hetzner-k8s.js) names the SSH key
1904
- // `${projectName}-${environment}-${location}-key`
1905
- // (`vibecarbon-` prefix dropped — the project name is already unique).
1906
- // This branch only runs when Pulumi state is missing, so we have to
1907
- // know the location to find the key. Fall back to the prefix-only
1908
- // form for older deploys that may not have a region in envConfig.
1909
- const k8sRegion = envConfig.region || envConfig.servers?.[0]?.region;
1910
- const sshKeyName = k8sRegion
1911
- ? `${projectConfig.projectName}-${envName}-${k8sRegion}-key`
1912
- : null;
1913
- if (sshKeyName) {
1914
- s.start(`Deleting SSH key: ${sshKeyName}`);
1915
- try {
1916
- const deleted = await hetznerDeleteSSHKey(hetznerToken, sshKeyName);
1917
- if (deleted) results.sshKeys.push(sshKeyName);
1918
- s.stop(deleted ? 'SSH key deleted' : 'SSH key not found');
1919
- } catch (error) {
1920
- s.stop(`Failed to delete SSH key: ${error.message}`);
1921
- }
1922
- }
1923
- } // end manual API cleanup
1850
+ if (p.isCancel(envName)) {
1851
+ p.cancel('Operation cancelled.');
1852
+ process.exit(0);
1853
+ }
1924
1854
 
1925
- // Clean up Kubernetes-managed resources (created by CCM/CSI at runtime,
1926
- // not by the Pulumi program). These must run for ALL deployments since
1927
- // 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
+ }
1928
1861
 
1929
- s.start('Cleaning up orphaned volumes');
1930
- try {
1931
- const serverNames = (envConfig.servers || []).map((sv) => sv.name);
1932
- const knownVolumeIds = [...new Set(allClusterVolumeIds)];
1933
- const deletedVolumes = await hetznerCleanupOrphanedVolumes(
1934
- hetznerToken,
1935
- projectConfig.projectName,
1936
- envName,
1937
- serverNames,
1938
- knownVolumeIds,
1939
- [...clusterLocations],
1940
- );
1941
- results.volumes = deletedVolumes;
1942
- s.stop(
1943
- deletedVolumes.length > 0
1944
- ? `Deleted ${deletedVolumes.length} orphaned volume(s)`
1945
- : 'No orphaned volumes found',
1946
- );
1947
- } catch (error) {
1948
- 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
+ }
1949
1922
  }
1950
1923
 
1951
- // 4.5 Delete S3 bucket (the Pulumi backend bucket itself, created during
1952
- // deploy bootstrap, not part of any stack's resource graph)
1953
- // Use the saved bucket name from config, or fall back to the predictable deploy-time name.
1954
- // The deploy script creates buckets as sanitizeBucketName(projectName) → e.g. "myapp-storage".
1955
- const s3BucketName = envConfig.s3?.bucket || sanitizeBucketName(projectConfig.projectName);
1956
- const s3Region = envConfig.s3?.region || 'fsn1';
1957
- s.start(`Deleting S3 bucket: ${s3BucketName}`);
1958
- try {
1959
- // S3 credentials from env vars or credentials file (prompts if needed)
1960
- const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
1961
- if (s3Creds) {
1962
- 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...');
1963
1955
  try {
1964
- const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
1965
- if (result.deleted) {
1966
- results.s3Bucket = s3BucketName;
1967
- s.stop(
1968
- result.objectsRemoved > 0
1969
- ? `S3 bucket deleted (${result.objectsRemoved} objects removed)`
1970
- : 'S3 bucket deleted',
1971
- );
1972
- }
1973
- } catch (bucketError) {
1974
- // NoSuchBucket means it was already deleted or never created — not an error
1975
- if (bucketError.name === 'NoSuchBucket' || bucketError.$metadata?.httpStatusCode === 404) {
1976
- s.stop('S3 bucket not found (already deleted or never created)');
1977
- } else {
1978
- 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');
1979
1962
  }
1963
+ } catch (backupError) {
1964
+ backupSpinner.stop(`Backup failed: ${backupError.message}`);
1965
+ p.log.warn('Continuing with destroy...');
1980
1966
  }
1981
- } else {
1982
- s.stop('S3 bucket skipped (no credentials available)');
1983
- results.issues.push({
1984
- step: 'S3 bucket',
1985
- detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
1986
- hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
1987
- });
1988
1967
  }
1989
- } catch (error) {
1990
- s.stop(`S3 bucket deletion failed: ${error.message}`);
1991
- results.issues.push({
1992
- step: 'S3 bucket',
1993
- detail: `${s3BucketName} (${s3Region}) not deleted: ${error.message}`,
1994
- hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
1995
- });
1996
1968
  }
1997
1969
 
1998
- // State bucket: deleted LAST, only after the Pulumi stack(s) were destroyed
1999
- // and removed (the destroyK8sStack loop above runs `pulumi destroy` +
2000
- // `removeStack`). Deleting the app storage bucket above no longer touches
2001
- // Pulumi state, so tearing the state bucket down here is safe — this is the
2002
- // fix for the documented destroy-time circular hazard.
2003
- await deleteStateBucket(envConfig, projectConfig, s, results);
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
+ }
1976
+
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;
2004
1981
 
2005
- // Backup bucket: preserved by default, deleted with --purge-backups.
2006
- await handleBackupBucket(envConfig, projectConfig, args, s);
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
+ }
2007
1989
 
2008
- // 5. Delete GitHub environment
2009
- s.start(`Deleting GitHub environment: ${envName}`);
2010
- try {
2011
- results.github = deleteGitHubEnvironment(envName);
2012
- s.stop(
2013
- results.github ? 'GitHub environment deleted' : 'GitHub environment not found (or no access)',
2014
- );
2015
- } catch (error) {
2016
- s.stop(`Failed to delete GitHub environment: ${error.message}`);
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
+ }
2017
2003
  }
2018
2004
 
2019
- // 6. Update project config
2020
- s.start('Updating project configuration');
2021
- delete projectConfig.environments[envName];
2022
- saveProjectConfig(projectConfig);
2023
- s.stop('Configuration updated');
2024
-
2025
- // Summary
2026
- const deletedCount =
2027
- results.servers.length +
2028
- results.volumes.length +
2029
- results.firewalls.length +
2030
- results.sshKeys.length +
2031
- results.dns.length +
2032
- results.healthChecks.length +
2033
- results.loadBalancers.length +
2034
- (results.s3Bucket ? 1 : 0) +
2035
- (results.github ? 1 : 0);
2005
+ // ========== DESTRUCTION ==========
2036
2006
 
2037
- p.note(
2038
- `
2039
- ${c.success('Deleted resources:')}
2040
- Servers: ${results.servers.length > 0 ? results.servers.join(', ') : 'None'}
2041
- Volumes: ${results.volumes.length > 0 ? results.volumes.join(', ') : 'None'}
2042
- Firewalls: ${results.firewalls.length > 0 ? results.firewalls.join(', ') : 'None'}
2043
- SSH Keys: ${results.sshKeys.length > 0 ? results.sshKeys.join(', ') : 'None'}
2044
- DNS Records: ${results.dns.length > 0 ? results.dns.join(', ') : 'None'}
2045
- Health Checks: ${results.healthChecks.length > 0 ? results.healthChecks.join(', ') : 'None'}
2046
- Load Balancers: ${results.loadBalancers.length > 0 ? results.loadBalancers.join(', ') : 'None'}
2047
- S3 Bucket: ${results.s3Bucket || 'None'}
2048
- 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
+ };
2049
2024
 
2050
- ${c.dim(`Total: ${deletedCount} resources deleted`)}
2051
- `,
2052
- 'Destruction Complete',
2053
- );
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
+ });
2054
2046
 
2055
- const { formatted: timeStr } = tracker.finish();
2056
- 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
+ });
2057
2061
  }
2058
2062
 
2059
2063
  // ============================================================================