tfv 5.0.0 → 6.0.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 (42) hide show
  1. package/.github/workflows/publish.yml +27 -3
  2. package/README.md +190 -129
  3. package/demo.gif +0 -0
  4. package/demo.tape +230 -0
  5. package/index.js +0 -4
  6. package/lib/commands/apply.js +9 -4
  7. package/lib/commands/current.js +25 -0
  8. package/lib/commands/destroy.js +9 -4
  9. package/lib/commands/fmt.js +26 -0
  10. package/lib/commands/init.js +26 -0
  11. package/lib/commands/install.js +22 -13
  12. package/lib/commands/list.js +20 -11
  13. package/lib/commands/pin.js +26 -0
  14. package/lib/commands/plan.js +9 -4
  15. package/lib/commands/remove.js +17 -12
  16. package/lib/commands/shell-init.js +25 -0
  17. package/lib/commands/switch.js +28 -7
  18. package/lib/commands/upgrade.js +26 -0
  19. package/lib/commands/use.js +17 -13
  20. package/lib/commands/validate.js +21 -0
  21. package/lib/modules/current.js +52 -0
  22. package/lib/modules/install.js +155 -89
  23. package/lib/modules/list.js +66 -105
  24. package/lib/modules/pin.js +35 -0
  25. package/lib/modules/ps1.js +37 -29
  26. package/lib/modules/remote.js +68 -15
  27. package/lib/modules/remove.js +35 -21
  28. package/lib/modules/shell-init.js +113 -0
  29. package/lib/modules/switch.js +125 -41
  30. package/lib/modules/terraform-command.js +49 -67
  31. package/lib/modules/upgrade.js +93 -0
  32. package/lib/modules/use.js +58 -54
  33. package/lib/utils/formatVersions.js +57 -5
  34. package/lib/utils/paths.js +156 -0
  35. package/lib/utils/postInstall.js +37 -13
  36. package/lib/utils/store.js +17 -6
  37. package/package.json +11 -9
  38. package/test/extractTargets.test.js +75 -0
  39. package/test/formatVersions.test.js +126 -0
  40. package/test/moduleImports.test.js +45 -0
  41. package/test/paths.test.js +69 -0
  42. package/test/versionResolution.test.js +92 -0
@@ -1,19 +1,17 @@
1
1
  const fs = require('fs');
2
- const {spawn} = require('child_process');
3
- const {P_END, P_OK, P_ERROR, P_WARN, P_INFO} = require('../utils/colors');
2
+ const { spawn } = require('child_process');
3
+ const { normalizeProvider, getCliName, getBinTarget } = require('../utils/paths');
4
+ const { P_END, P_OK, P_ERROR, P_WARN, P_INFO } = require('../utils/colors');
4
5
 
5
6
  /**
6
- * Removes comments from Terraform content
7
- * Handles: # comments, // comments, and block comments
7
+ * Removes comments from Terraform content.
8
+ * Handles: # comments, // comments, and block comments.
8
9
  */
9
10
  const removeComments = (content) => {
10
- // Remove block comments /* */
11
11
  content = content.replace(/\/\*[\s\S]*?\*\//g, '');
12
12
 
13
- // Remove single line comments (# and //)
14
13
  const lines = content.split('\n');
15
14
  const cleanedLines = lines.map(line => {
16
- // Find position of # or // that's not inside a string
17
15
  let inString = false;
18
16
  let stringChar = '';
19
17
  let commentStart = -1;
@@ -35,70 +33,47 @@ const removeComments = (content) => {
35
33
  }
36
34
  }
37
35
 
38
- if (commentStart !== -1) {
39
- return line.substring(0, commentStart);
40
- }
41
- return line;
36
+ return commentStart !== -1 ? line.substring(0, commentStart) : line;
42
37
  });
43
38
 
44
39
  return cleanedLines.join('\n');
45
40
  };
46
41
 
47
42
  /**
48
- * Extracts terraform targets from file content
49
- * Returns array of target strings like "aws_instance.example", "module.vpc", etc.
43
+ * Extracts terraform targets from file content.
50
44
  */
51
45
  const extractTargets = (content) => {
52
46
  const targets = [];
47
+ const clean = removeComments(content);
53
48
 
54
- // Remove comments first
55
- const cleanContent = removeComments(content);
56
-
57
- // Pattern for resource blocks: resource "type" "name"
58
49
  const resourcePattern = /resource\s+"([^"]+)"\s+"([^"]+)"/g;
59
- let match;
60
- while ((match = resourcePattern.exec(cleanContent)) !== null) {
61
- targets.push(`${match[1]}.${match[2]}`);
62
- }
63
-
64
- // Pattern for data blocks: data "type" "name"
65
50
  const dataPattern = /data\s+"([^"]+)"\s+"([^"]+)"/g;
66
- while ((match = dataPattern.exec(cleanContent)) !== null) {
67
- targets.push(`data.${match[1]}.${match[2]}`);
68
- }
69
-
70
- // Pattern for module blocks: module "name"
71
51
  const modulePattern = /module\s+"([^"]+)"/g;
72
- while ((match = modulePattern.exec(cleanContent)) !== null) {
73
- targets.push(`module.${match[1]}`);
74
- }
52
+
53
+ let match;
54
+ while ((match = resourcePattern.exec(clean)) !== null) targets.push(`${match[1]}.${match[2]}`);
55
+ while ((match = dataPattern.exec(clean)) !== null) targets.push(`data.${match[1]}.${match[2]}`);
56
+ while ((match = modulePattern.exec(clean)) !== null) targets.push(`module.${match[1]}`);
75
57
 
76
58
  return targets;
77
59
  };
78
60
 
79
- /**
80
- * Extracts targets from one or more terraform files
81
- */
82
61
  const extractTargetsFromFiles = (files) => {
83
62
  const allTargets = [];
84
63
  const fileList = Array.isArray(files) ? files : [files];
85
64
 
86
65
  fileList.forEach(filename => {
87
66
  if (!fs.existsSync(filename)) {
88
- console.log(`${P_WARN}Warning: File '${filename}' not found, skipping${P_END}`);
67
+ console.log(`${P_WARN}Warning: '${filename}' not found, skipping${P_END}`);
89
68
  return;
90
69
  }
91
70
 
92
- const content = fs.readFileSync(filename, 'utf8');
93
- const targets = extractTargets(content);
94
-
71
+ const targets = extractTargets(fs.readFileSync(filename, 'utf8'));
95
72
  if (targets.length > 0) {
96
73
  console.log(`${P_INFO}Found ${targets.length} target(s) in '${filename}':${P_END}`);
97
- targets.forEach(target => {
98
- console.log(` - ${target}`);
99
- if (!allTargets.includes(target)) {
100
- allTargets.push(target);
101
- }
74
+ targets.forEach(t => {
75
+ console.log(` - ${t}`);
76
+ if (!allTargets.includes(t)) allTargets.push(t);
102
77
  });
103
78
  } else {
104
79
  console.log(`${P_WARN}No targets found in '${filename}'${P_END}`);
@@ -109,54 +84,61 @@ const extractTargetsFromFiles = (files) => {
109
84
  };
110
85
 
111
86
  /**
112
- * Runs a terraform command (plan, apply, destroy) with optional file-based targets
113
- * @param {string} command - The terraform command (plan, apply, destroy)
114
- * @param {string|string[]} files - Optional file(s) to extract targets from
115
- * @param {string[]} extraArgs - Additional arguments to pass to terraform
87
+ * Runs a terraform/tofu command with optional file-based targets.
88
+ * Uses the active tfv-managed binary from ~/.tfv/bin so that `tfv plan`
89
+ * and a bare `terraform plan` always use the exact same binary.
90
+ *
91
+ * @param {string} command - terraform subcommand (plan, apply, destroy, init, fmt, validate…)
92
+ * @param {string|string[]} files - optional file(s) to extract -target args from
93
+ * @param {string[]} extraArgs - additional flags passed after --
94
+ * @param {string} providerArg - 'terraform' (default) or 'tofu'/'opentofu'
116
95
  */
117
- exports.runTerraformCommand = async (command, files, extraArgs = []) => {
96
+ exports.runTerraformCommand = async (command, files, extraArgs = [], providerArg = 'terraform') => {
118
97
  try {
98
+ const provider = normalizeProvider(providerArg);
99
+ const cli = getCliName(provider);
100
+
101
+ // Resolve the exact binary tfv manages; fall back to PATH-resolved cli name
102
+ // This guarantees tfv commands and bare `terraform`/`tofu` point to the same binary.
103
+ const binaryPath = getBinTarget(provider);
104
+ const binary = fs.existsSync(binaryPath) ? binaryPath : cli;
105
+
119
106
  const args = [command];
120
107
 
121
- // Extract targets from files if provided
122
108
  if (files && (Array.isArray(files) ? files.length > 0 : true)) {
123
109
  console.log(`${P_OK}Extracting targets from file(s)...${P_END}\n`);
124
110
  const targets = extractTargetsFromFiles(files);
125
111
 
126
112
  if (targets.length > 0) {
127
113
  console.log(`\n${P_OK}Total unique targets: ${targets.length}${P_END}\n`);
128
- targets.forEach(target => {
129
- args.push('-target', target);
130
- });
114
+ targets.forEach(t => args.push('-target', t));
131
115
  } else {
132
- console.log(`${P_WARN}No targets extracted from files. Running without file-based targets.${P_END}\n`);
116
+ console.log(`${P_WARN}No targets extracted. Running without file-based targets.${P_END}\n`);
133
117
  }
134
118
  }
135
119
 
136
- // Add any extra arguments passed through
137
- if (extraArgs && extraArgs.length > 0) {
138
- args.push(...extraArgs);
139
- }
120
+ if (extraArgs && extraArgs.length > 0) args.push(...extraArgs);
140
121
 
141
- console.log(`${P_OK}Running: terraform ${args.join(' ')}${P_END}\n`);
122
+ console.log(`${P_OK}Running: ${cli} ${args.join(' ')}${P_END}\n`);
142
123
 
143
- // Execute terraform command
144
- const tf = spawn('terraform', args, {
145
- stdio: 'inherit',
146
- cwd: process.cwd()
147
- });
124
+ const tf = spawn(binary, args, { stdio: 'inherit', cwd: process.cwd() });
148
125
 
149
126
  tf.on('error', (err) => {
150
- console.log(`${P_ERROR}Error executing terraform: ${err.message}${P_END}`);
127
+ console.log(`${P_ERROR}Error executing ${cli}: ${err.message}${P_END}`);
128
+ if (err.code === 'ENOENT') {
129
+ console.log(`${P_WARN}${cli} not found. Run: tfv install latest${providerArg === 'terraform' ? '' : ` --provider ${providerArg}`}${P_END}`);
130
+ }
151
131
  process.exit(1);
152
132
  });
153
133
 
154
- tf.on('close', (code) => {
155
- process.exit(code);
156
- });
134
+ tf.on('close', code => process.exit(code));
157
135
 
158
136
  } catch (err) {
159
137
  console.log(`${P_ERROR}Error: ${err.message}${P_END}`);
160
138
  process.exit(1);
161
139
  }
162
140
  };
141
+
142
+ // Export helpers for testing
143
+ exports.removeComments = removeComments;
144
+ exports.extractTargets = extractTargets;
@@ -0,0 +1,93 @@
1
+ const { existsSync, readFileSync, readdirSync } = require('fs');
2
+ const { normalizeProvider, getProviderStore, getPaths, fixPathConflict } = require('../utils/paths');
3
+ const { fetchAllVersions } = require('./remote');
4
+ const { install } = require('./install');
5
+ const { use } = require('./use');
6
+ const { P_END, P_OK, P_INFO, P_WARN, P_ERROR } = require('../utils/colors');
7
+
8
+ /**
9
+ * Upgrade to the latest available patch within a version series.
10
+ *
11
+ * tfv upgrade → upgrades active version's patch (e.g. 1.6.3 → 1.6.7)
12
+ * tfv upgrade 1.8 → installs + uses latest 1.8.x
13
+ * tfv upgrade latest → installs + uses absolute latest version
14
+ */
15
+ exports.upgrade = async (target, providerArg = 'terraform') => {
16
+ try {
17
+ const provider = normalizeProvider(providerArg);
18
+ const allVersions = await fetchAllVersions(provider);
19
+
20
+ let seriesPrefix;
21
+
22
+ if (!target || target === 'current') {
23
+ // Determine from active version
24
+ const paths = getPaths();
25
+ if (!existsSync(paths.active)) {
26
+ console.log(`${P_ERROR}No active ${provider} version. Run: tfv use <version> first.${P_END}`);
27
+ process.exit(1);
28
+ }
29
+ const active = JSON.parse(readFileSync(paths.active, 'utf-8'));
30
+ const activeVersion = active[provider];
31
+ if (!activeVersion) {
32
+ console.log(`${P_ERROR}No active ${provider} version set.${P_END}`);
33
+ process.exit(1);
34
+ }
35
+ const parts = activeVersion.split('.');
36
+ seriesPrefix = `${parts[0]}.${parts[1]}`;
37
+ console.log(`${P_INFO}Upgrading ${provider} ${activeVersion} (${seriesPrefix}.x series)...${P_END}`);
38
+ } else if (target === 'latest') {
39
+ const latest = allVersions[0];
40
+ console.log(`${P_INFO}Latest available ${provider} version: ${latest}${P_END}`);
41
+ const installed = await install(latest, null, providerArg);
42
+ if (installed) await use(installed, provider);
43
+ return;
44
+ } else {
45
+ // Target is a series like "1.8" or "1.8.0"
46
+ const parts = target.split('.');
47
+ seriesPrefix = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : `${parts[0]}`;
48
+ console.log(`${P_INFO}Finding latest ${provider} in ${seriesPrefix}.x series...${P_END}`);
49
+ }
50
+
51
+ const seriesVersions = allVersions.filter(v => v.startsWith(`${seriesPrefix}.`));
52
+ if (seriesVersions.length === 0) {
53
+ console.log(`${P_ERROR}No versions found in ${seriesPrefix}.x series${P_END}`);
54
+ process.exit(1);
55
+ }
56
+
57
+ const latestInSeries = seriesVersions[0]; // Already sorted descending
58
+
59
+ // Check if already on latest
60
+ const paths = getPaths();
61
+ if (existsSync(paths.active)) {
62
+ const active = JSON.parse(readFileSync(paths.active, 'utf-8'));
63
+ if (active[provider] === latestInSeries) {
64
+ console.log(`${P_OK}Already on latest ${provider} in ${seriesPrefix}.x series: ${latestInSeries}${P_END}`);
65
+ return;
66
+ }
67
+ }
68
+
69
+ // Check if already in store
70
+ const store = getProviderStore(provider);
71
+ const inStore = readdirSync(store)
72
+ .filter(f => f !== 'arch.json')
73
+ .map(f => f.replace('.exe', ''))
74
+ .includes(latestInSeries);
75
+
76
+ if (inStore) {
77
+ console.log(`${P_INFO}${provider} ${latestInSeries} already installed, switching...${P_END}`);
78
+ await use(latestInSeries, provider);
79
+ } else {
80
+ console.log(`${P_INFO}Installing ${provider} ${latestInSeries}...${P_END}`);
81
+ const installed = await install(latestInSeries, null, providerArg);
82
+ if (installed) await use(installed, provider);
83
+ }
84
+
85
+ // Re-run PATH setup on upgrade in case a system tool (brew/apt) was
86
+ // installed after tfv and is now shadowing ~/.tfv/bin
87
+ fixPathConflict();
88
+
89
+ } catch (err) {
90
+ console.log(`${P_ERROR}Upgrade failed: ${err.message}${P_END}`);
91
+ process.exit(1);
92
+ }
93
+ };
@@ -1,71 +1,75 @@
1
1
  const os = require('os');
2
- const {join} = require('path');
3
- const {spawnSync} = require('child_process');
4
- const {readdirSync, mkdirSync, copyFileSync, existsSync} = require('fs');
5
- const {setWindowsTerraform} = require('./ps1');
6
- const {P_END, P_OK, P_INFO, P_ERROR} = require('../utils/colors');
2
+ const { existsSync, readdirSync, copyFileSync, readFileSync, writeFileSync } = require('fs');
3
+ const {
4
+ normalizeProvider, getCliName, getProviderStore,
5
+ getBinTarget, getVersionFile, getPaths, initDirs
6
+ } = require('../utils/paths');
7
7
  const { checkStore } = require('../utils/store');
8
+ const { ensureWindowsPath } = require('./ps1');
9
+ const { P_END, P_OK, P_INFO, P_ERROR } = require('../utils/colors');
8
10
 
9
- exports.use = async (tfVer) => {
11
+ exports.use = async (tfVer, providerArg = 'terraform') => {
10
12
  try {
11
- const store = join(__dirname, '../..', 'store');
12
-
13
- checkStore(store);
14
-
15
- let version = os.platform() === 'win32' ? `${tfVer}.exe` : tfVer;
16
-
17
- if (tfVer === 'latest') {
18
- [version] = readdirSync(store).sort((a, b) => {
19
- let aVer = a.replace(/(.exe)/g, '');
20
- let bVer = b.replace(/(.exe)/g, '');
21
-
22
- const major = /.*\./g;
23
-
24
- const [aMajor] = aVer.match(major);
25
- const aMin = aVer.replace(aMajor, '');
26
- const aMinUpdate = aMin.length === 1 ? `0${aMin}` : aMin;
27
- aVer = `${aMajor}${aMinUpdate}`.replace(/\.(?!.*\.)/g, '');
28
-
29
- const [bMajor] = bVer.match(major);
30
- const bMin = bVer.replace(bMajor, '');
31
- const bMinUpdate = bMin.length === 1 ? `0${bMin}` : bMin;
32
- bVer = `${bMajor}${bMinUpdate}`.replace(/\.(?!.*\.)/g, '');
33
-
34
- return Number(bVer) - Number(aVer);
35
- });
36
-
37
- console.log(`${P_INFO}Your latest installed version is ${version}${P_END}`);
13
+ initDirs();
14
+
15
+ const provider = normalizeProvider(providerArg);
16
+ checkStore(provider);
17
+
18
+ const store = getProviderStore(provider);
19
+ const isWin = os.platform() === 'win32';
20
+ let version = tfVer;
21
+
22
+ if (version === 'latest') {
23
+ const files = readdirSync(store)
24
+ .filter(f => f !== 'arch.json')
25
+ .map(f => f.replace('.exe', ''));
26
+
27
+ version = files.sort((a, b) => {
28
+ const aParts = a.split('.').map(n => parseInt(n, 10) || 0);
29
+ const bParts = b.split('.').map(n => parseInt(n, 10) || 0);
30
+ for (let i = 0; i < 3; i++) {
31
+ const diff = (bParts[i] || 0) - (aParts[i] || 0);
32
+ if (diff !== 0) return diff;
33
+ }
34
+ return 0;
35
+ })[0];
36
+
37
+ console.log(`${P_INFO}Latest installed ${provider} version: ${version}${P_END}`);
38
38
  }
39
39
 
40
- console.log(`${P_INFO}Switching to terraform ${version.replace('.exe', '')}${P_END}`);
41
-
42
- const source = `${store}/${version}`;
40
+ const source = getVersionFile(provider, version);
43
41
 
44
42
  if (!existsSync(source)) {
45
- console.log(`${P_ERROR}terraform ${version} is not installed${P_END}`);
46
- return console.log(`To install this version Run: ${P_OK}tfv install ${version}${P_END}`);
43
+ console.log(`${P_ERROR}${provider} ${version} is not installed${P_END}`);
44
+ const pFlag = providerArg === 'terraform' ? '' : ` --provider ${providerArg}`;
45
+ return console.log(`To install, run: ${P_OK}tfv install ${version}${pFlag}${P_END}`);
47
46
  }
48
47
 
49
- let destination;
48
+ console.log(`${P_INFO}Switching to ${provider} ${version}...${P_END}`);
50
49
 
51
- if (['darwin', 'linux'].includes(os.platform())) {
52
- const bin = os.platform() === 'darwin' ? '/usr/local/bin' : `${os.homedir()}/bin`;
50
+ const destination = getBinTarget(provider);
51
+ copyFileSync(source, destination);
53
52
 
54
- destination = `${bin}/terraform`;
55
- if (existsSync(destination)) spawnSync('sudo', ['rm', '-f', `${destination}`], {stdio: 'inherit', shell: true});
56
- spawnSync('sudo', ['cp', `${source}`, `${destination}`], {stdio: 'inherit', shell: true});
53
+ if (!isWin) {
54
+ const { chmodSync } = require('fs');
55
+ chmodSync(destination, '755');
57
56
  }
58
57
 
59
- if (os.platform() === 'win32') {
60
- mkdirSync('C:\\terraform', { recursive: true });
61
- destination = 'C:\\terraform\\terraform.exe';
58
+ // Update active.json
59
+ const paths = getPaths();
60
+ const active = existsSync(paths.active)
61
+ ? JSON.parse(readFileSync(paths.active, 'utf-8'))
62
+ : { terraform: null, opentofu: null };
63
+ active[provider] = version;
64
+ writeFileSync(paths.active, JSON.stringify(active, null, 2));
62
65
 
63
- copyFileSync(source, destination);
64
- setWindowsTerraform();
65
- }
66
+ // Ensure ~/.tfv/bin is on PATH for Windows (idempotent)
67
+ if (isWin) ensureWindowsPath();
68
+
69
+ console.log(`${P_OK}Now using ${provider} ${version}${P_END}`);
66
70
 
67
- console.log(`${P_OK}Successful!${P_END}`)
68
- } catch ({message}) {
69
- console.log('ERROR:', message)
71
+ } catch (err) {
72
+ console.log(`${P_ERROR}Error: ${err.message}${P_END}`);
73
+ process.exit(1);
70
74
  }
71
- }
75
+ };
@@ -1,6 +1,58 @@
1
- exports.formatVersions = (content) => {
2
- const pattern = /(>terraform_).*(?=<\/a>)/g;
3
- const versions = content.match(pattern).map(v => v.replace('>terraform_', ''));
1
+ /**
2
+ * Parse Terraform versions from HashiCorp JSON API response.
3
+ * API: https://releases.hashicorp.com/terraform/index.json
4
+ * Returns sorted array of STABLE version strings only (descending).
5
+ * Use formatAllVersions() to include pre-releases.
6
+ */
7
+ exports.formatVersions = (jsonData) => {
8
+ const data = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData;
9
+ return Object.keys(data.versions || {})
10
+ .filter(v => !v.includes('-')) // stable only: exclude 1.8.0-beta1, rc1, alpha etc.
11
+ .sort(semverDesc);
12
+ };
4
13
 
5
- return versions;
6
- }
14
+ /**
15
+ * Same as formatVersions but includes pre-release builds.
16
+ * Used when user explicitly requests a pre-release version.
17
+ */
18
+ exports.formatAllVersions = (jsonData) => {
19
+ const data = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData;
20
+ return Object.keys(data.versions || {}).sort(semverDesc);
21
+ };
22
+
23
+ /**
24
+ * Parse OpenTofu versions from GitHub releases API response.
25
+ * API: https://api.github.com/repos/opentofu/opentofu/releases
26
+ * Returns sorted array of STABLE version strings only (descending).
27
+ */
28
+ exports.formatOpenTofuVersions = (jsonData) => {
29
+ const releases = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData;
30
+ return releases
31
+ .filter(r => !r.prerelease && !r.draft)
32
+ .map(r => r.tag_name.replace(/^v/, ''))
33
+ .filter(v => !v.includes('-')) // extra guard for stable-only
34
+ .sort(semverDesc);
35
+ };
36
+
37
+ /**
38
+ * Same as formatOpenTofuVersions but includes pre-release builds.
39
+ */
40
+ exports.formatAllOpenTofuVersions = (jsonData) => {
41
+ const releases = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData;
42
+ return releases
43
+ .filter(r => !r.draft)
44
+ .map(r => r.tag_name.replace(/^v/, ''))
45
+ .sort(semverDesc);
46
+ };
47
+
48
+ const semverDesc = (a, b) => {
49
+ const aParts = a.split('.').map(n => parseInt(n, 10) || 0);
50
+ const bParts = b.split('.').map(n => parseInt(n, 10) || 0);
51
+ for (let i = 0; i < 3; i++) {
52
+ const diff = (bParts[i] || 0) - (aParts[i] || 0);
53
+ if (diff !== 0) return diff;
54
+ }
55
+ return 0;
56
+ };
57
+
58
+ exports.semverDesc = semverDesc;
@@ -0,0 +1,156 @@
1
+ const { homedir, platform } = require('os');
2
+ const { join } = require('path');
3
+ const { mkdirSync, existsSync, writeFileSync, readFileSync } = require('fs');
4
+
5
+ const TFV_HOME = join(homedir(), '.tfv');
6
+
7
+ const PROVIDERS = { terraform: 'terraform', tf: 'terraform', tofu: 'opentofu', opentofu: 'opentofu' };
8
+
9
+ const normalizeProvider = (provider = 'terraform') => PROVIDERS[provider] || 'terraform';
10
+
11
+ const getCliName = (provider) => normalizeProvider(provider) === 'opentofu' ? 'tofu' : 'terraform';
12
+
13
+ const getPaths = () => ({
14
+ home: TFV_HOME,
15
+ bin: join(TFV_HOME, 'bin'),
16
+ store: join(TFV_HOME, 'store'),
17
+ cache: join(TFV_HOME, 'cache'),
18
+ terraform: join(TFV_HOME, 'store', 'terraform'),
19
+ opentofu: join(TFV_HOME, 'store', 'opentofu'),
20
+ active: join(TFV_HOME, 'active.json'),
21
+ });
22
+
23
+ const getProviderStore = (provider = 'terraform') => {
24
+ const normalized = normalizeProvider(provider);
25
+ return join(TFV_HOME, 'store', normalized);
26
+ };
27
+
28
+ const getBinTarget = (provider = 'terraform') => {
29
+ const cli = getCliName(provider);
30
+ const isWin = platform() === 'win32';
31
+ return join(TFV_HOME, 'bin', isWin ? `${cli}.exe` : cli);
32
+ };
33
+
34
+ const getVersionFile = (provider, version) => {
35
+ const store = getProviderStore(provider);
36
+ const isWin = platform() === 'win32';
37
+ return join(store, isWin ? `${version}.exe` : version);
38
+ };
39
+
40
+ const initDirs = () => {
41
+ const paths = getPaths();
42
+ [paths.home, paths.bin, paths.terraform, paths.opentofu, paths.cache].forEach(dir => {
43
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
44
+ });
45
+
46
+ ['terraform', 'opentofu'].forEach(p => {
47
+ const archFile = join(paths[p], 'arch.json');
48
+ if (!existsSync(archFile)) writeFileSync(archFile, '{}');
49
+ });
50
+
51
+ if (!existsSync(paths.active)) {
52
+ writeFileSync(paths.active, JSON.stringify({ terraform: null, opentofu: null }, null, 2));
53
+ }
54
+ };
55
+
56
+ const SHELL_CONFIGS = [
57
+ join(homedir(), '.zshrc'),
58
+ join(homedir(), '.bashrc'),
59
+ join(homedir(), '.bash_profile'),
60
+ join(homedir(), '.profile'),
61
+ ];
62
+ const PATH_MARKER = '# tfv - terraform version manager';
63
+ const PATH_LINE = 'export PATH="$HOME/.tfv/bin:$PATH"';
64
+
65
+ /**
66
+ * Checks whether the shell-resolved binary (via `which`/`where`) matches
67
+ * the tfv-managed binary at ~/.tfv/bin/<cli>.
68
+ * Returns { ok: true } or { ok: false, resolved, expected }.
69
+ */
70
+ const checkPathConflict = (provider = 'terraform') => {
71
+ const { spawnSync } = require('child_process');
72
+ const isWin = platform() === 'win32';
73
+ const cli = getCliName(provider);
74
+ const expected = getBinTarget(provider);
75
+
76
+ const result = isWin
77
+ ? spawnSync('where', [cli], { stdio: 'pipe', encoding: 'utf-8', shell: true })
78
+ : spawnSync('which', [cli], { stdio: 'pipe', encoding: 'utf-8' });
79
+
80
+ if (!result || result.status !== 0 || !result.stdout.trim()) {
81
+ return { ok: true }; // not found anywhere else — no conflict
82
+ }
83
+
84
+ const resolved = result.stdout.trim().split('\n')[0].trim();
85
+ if (resolved === expected) return { ok: true };
86
+
87
+ return { ok: false, resolved, expected };
88
+ };
89
+
90
+ /**
91
+ * Fixes a PATH conflict automatically.
92
+ *
93
+ * macOS / Linux:
94
+ * Removes any existing tfv PATH block from shell configs, then re-appends
95
+ * it at the very END so it runs after brew/apt/other tools and always wins.
96
+ *
97
+ * Windows:
98
+ * Moves ~/.tfv/bin to the front of the User PATH in the registry (no admin needed).
99
+ */
100
+ const fixPathConflict = () => {
101
+ const isWin = platform() === 'win32';
102
+
103
+ if (isWin) {
104
+ const { spawnSync } = require('child_process');
105
+ const BIN_DIR = join(TFV_HOME, 'bin');
106
+
107
+ const get = spawnSync(
108
+ 'powershell',
109
+ ['-NoProfile', '-NonInteractive', '-Command',
110
+ '[Environment]::GetEnvironmentVariable("PATH", "User")'],
111
+ { stdio: 'pipe', encoding: 'utf-8' }
112
+ );
113
+
114
+ const current = (get.stdout || '').trim();
115
+ const entries = current.split(';').map(p => p.trim()).filter(p => p && p !== BIN_DIR);
116
+ const fixed = [BIN_DIR, ...entries].join(';');
117
+
118
+ spawnSync(
119
+ 'powershell',
120
+ ['-NoProfile', '-NonInteractive', '-Command',
121
+ `[Environment]::SetEnvironmentVariable("PATH", "${fixed}", "User")`],
122
+ { stdio: 'pipe' }
123
+ );
124
+ return;
125
+ }
126
+
127
+ // macOS / Linux — update each shell config file
128
+ const block = `\n${PATH_MARKER}\n${PATH_LINE}\n`;
129
+
130
+ SHELL_CONFIGS.forEach(file => {
131
+ if (!existsSync(file)) return;
132
+
133
+ // Strip any existing tfv PATH block
134
+ let content = readFileSync(file, 'utf-8');
135
+ content = content.replace(
136
+ new RegExp(`\\n?${PATH_MARKER}\\n${PATH_LINE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n?`, 'g'),
137
+ ''
138
+ );
139
+
140
+ // Append at the very end so it always runs last and wins
141
+ writeFileSync(file, content.trimEnd() + block);
142
+ });
143
+ };
144
+
145
+ module.exports = {
146
+ TFV_HOME,
147
+ getPaths,
148
+ getProviderStore,
149
+ getBinTarget,
150
+ getVersionFile,
151
+ normalizeProvider,
152
+ getCliName,
153
+ initDirs,
154
+ checkPathConflict,
155
+ fixPathConflict,
156
+ };