tfv 5.0.1 → 6.1.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 (48) hide show
  1. package/.github/workflows/publish.yml +43 -3
  2. package/README.md +253 -122
  3. package/demo.gif +0 -0
  4. package/demo.tape +230 -0
  5. package/index.js +0 -4
  6. package/lib/commands/apply.js +8 -3
  7. package/lib/commands/current.js +25 -0
  8. package/lib/commands/destroy.js +8 -3
  9. package/lib/commands/doctor.js +20 -0
  10. package/lib/commands/exec.js +33 -0
  11. package/lib/commands/fmt.js +26 -0
  12. package/lib/commands/init.js +26 -0
  13. package/lib/commands/install.js +22 -13
  14. package/lib/commands/list.js +20 -11
  15. package/lib/commands/pin.js +26 -0
  16. package/lib/commands/plan.js +8 -3
  17. package/lib/commands/prune.js +41 -0
  18. package/lib/commands/remove.js +17 -12
  19. package/lib/commands/shell-init.js +25 -0
  20. package/lib/commands/switch.js +28 -7
  21. package/lib/commands/upgrade.js +26 -0
  22. package/lib/commands/use.js +17 -13
  23. package/lib/commands/validate.js +21 -0
  24. package/lib/modules/current.js +52 -0
  25. package/lib/modules/doctor.js +160 -0
  26. package/lib/modules/exec.js +36 -0
  27. package/lib/modules/install.js +155 -89
  28. package/lib/modules/list.js +66 -105
  29. package/lib/modules/pin.js +35 -0
  30. package/lib/modules/prune.js +100 -0
  31. package/lib/modules/ps1.js +37 -29
  32. package/lib/modules/remote.js +68 -15
  33. package/lib/modules/remove.js +35 -21
  34. package/lib/modules/shell-init.js +226 -0
  35. package/lib/modules/switch.js +125 -41
  36. package/lib/modules/terraform-command.js +49 -67
  37. package/lib/modules/upgrade.js +93 -0
  38. package/lib/modules/use.js +58 -54
  39. package/lib/utils/formatVersions.js +57 -5
  40. package/lib/utils/paths.js +156 -0
  41. package/lib/utils/postInstall.js +37 -13
  42. package/lib/utils/store.js +17 -6
  43. package/package.json +11 -9
  44. package/test/extractTargets.test.js +75 -0
  45. package/test/formatVersions.test.js +126 -0
  46. package/test/moduleImports.test.js +45 -0
  47. package/test/paths.test.js +69 -0
  48. package/test/versionResolution.test.js +92 -0
@@ -1,38 +1,46 @@
1
- const {spawnSync} = require("child_process");
2
- const {P_END, P_ERROR} = require('../utils/colors');
1
+ const { spawnSync } = require('child_process');
2
+ const { join } = require('path');
3
+ const { homedir } = require('os');
4
+ const { P_END, P_ERROR, P_WARN, P_OK } = require('../utils/colors');
5
+ const { TFV_HOME } = require('../utils/paths');
3
6
 
4
- exports.setWindowsTerraform = async () => {
5
- const getSystemEnv = spawnSync(
6
- "powershell",
7
- ["(Get-ItemProperty -Path 'Registry::HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Control\\Session Manager\\Environment' -Name PATH).path"],
8
- {stdio: 'pipe'}
7
+ const BIN_DIR = join(TFV_HOME, 'bin');
8
+
9
+ /**
10
+ * Ensures ~/.tfv/bin is in the Windows User PATH (no admin required).
11
+ * Uses HKCU via [Environment]::SetEnvironmentVariable with "User" scope.
12
+ */
13
+ exports.ensureWindowsPath = () => {
14
+ const getUserPath = spawnSync(
15
+ 'powershell',
16
+ ['-NoProfile', '-NonInteractive', '-Command',
17
+ '[Environment]::GetEnvironmentVariable("PATH", "User")'],
18
+ { stdio: 'pipe', encoding: 'utf-8' }
9
19
  );
10
20
 
11
- if (!getSystemEnv || getSystemEnv.status === 1) {
12
- console.log(`${P_ERROR}Error ocurred while fetching windows environment${P_END}`);
13
- process.exit(1);
21
+ if (!getUserPath || getUserPath.status !== 0) {
22
+ console.log(`${P_WARN}Could not read Windows User PATH. Please add manually: ${BIN_DIR}${P_END}`);
23
+ return;
14
24
  }
15
25
 
16
- const systemEnv = getSystemEnv.stdout.toString().trim();
17
-
18
- const terraformPath = 'C:\\terraform';
19
- const getTerraformEnv = systemEnv.split(';').find(v => v === terraformPath);
26
+ const currentPath = (getUserPath.stdout || '').trim();
27
+ const pathEntries = currentPath.split(';').map(p => p.trim()).filter(Boolean);
20
28
 
21
- if (!getTerraformEnv) {
22
- const newSystemEnv = `${terraformPath};${systemEnv}`;
29
+ if (pathEntries.includes(BIN_DIR)) return; // Already in PATH
23
30
 
24
- const updateEnv = spawnSync(
25
- "powershell",
26
- [
27
- "-command",
28
- `start-process powershell -verb runas -argumentlist "Set-ItemProperty -Path 'Registry::HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Control\\Session Manager\\Environment' -Name PATH -Value '${newSystemEnv}'"`
29
- ],
30
- {stdio: 'pipe'}
31
- );
31
+ const newPath = `${BIN_DIR};${currentPath}`;
32
+ const updateEnv = spawnSync(
33
+ 'powershell',
34
+ ['-NoProfile', '-NonInteractive', '-Command',
35
+ `[Environment]::SetEnvironmentVariable("PATH", "${newPath}", "User")`],
36
+ { stdio: 'pipe' }
37
+ );
32
38
 
33
- if (!updateEnv || updateEnv.status === 1) {
34
- console.log(`${P_ERROR}Error ocurred while setting windows Environment${P_END}`);
35
- process.exit(1);
36
- }
39
+ if (updateEnv && updateEnv.status === 0) {
40
+ console.log(`${P_OK}Added ${BIN_DIR} to your User PATH.${P_END}`);
41
+ console.log(`${P_WARN}Restart your terminal for the PATH change to take effect.${P_END}`);
42
+ } else {
43
+ console.log(`${P_ERROR}Could not update PATH automatically.${P_END}`);
44
+ console.log(`${P_WARN}Please add manually to your User PATH: ${BIN_DIR}${P_END}`);
37
45
  }
38
- }
46
+ };
@@ -1,18 +1,71 @@
1
1
  const https = require('https');
2
+ const { existsSync, readFileSync, writeFileSync } = require('fs');
3
+ const { join } = require('path');
4
+ const { getPaths, initDirs } = require('../utils/paths');
5
+ const { formatVersions, formatOpenTofuVersions } = require('../utils/formatVersions');
2
6
 
3
- exports.fetchAllVersions = async () => {
4
- try {
5
- return new Promise((resolve, reject) => {
6
- https.get('https://releases.hashicorp.com/terraform/', (resp) => {
7
- let data = '';
8
-
9
- resp.on('data', (chunk) => data += chunk);
10
- resp.on('end', () => resolve(data));
11
- }).on("error", (err) => {
12
- reject(err);
13
- });
14
- });
15
- } catch ({message}) {
16
- console.log('ERROR:', message);
17
- }
7
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
8
+
9
+ const URLS = {
10
+ terraform: 'https://releases.hashicorp.com/terraform/index.json',
11
+ opentofu: 'https://api.github.com/repos/opentofu/opentofu/releases?per_page=100',
12
+ };
13
+
14
+ const fetchUrl = (url) => new Promise((resolve, reject) => {
15
+ const options = { headers: { 'User-Agent': 'tfv-cli' } };
16
+ https.get(url, options, (res) => {
17
+ // Follow single redirect
18
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
19
+ return fetchUrl(res.headers.location).then(resolve).catch(reject);
20
+ }
21
+ let data = '';
22
+ res.on('data', chunk => { data += chunk; });
23
+ res.on('end', () => resolve(data));
24
+ }).on('error', reject);
25
+ });
26
+
27
+ const getCacheFile = (provider) => {
28
+ initDirs();
29
+ return join(getPaths().cache, `${provider}-versions.json`);
30
+ };
31
+
32
+ const readCache = (provider) => {
33
+ const cacheFile = getCacheFile(provider);
34
+ if (!existsSync(cacheFile)) return null;
35
+ try {
36
+ const { timestamp, data } = JSON.parse(readFileSync(cacheFile, 'utf-8'));
37
+ if (Date.now() - timestamp < CACHE_TTL_MS) return data;
38
+ } catch (_) { /* ignore corrupt cache */ }
39
+ return null;
40
+ };
41
+
42
+ const writeCache = (provider, data) => {
43
+ try {
44
+ writeFileSync(getCacheFile(provider), JSON.stringify({ timestamp: Date.now(), data }));
45
+ } catch (_) { /* non-fatal */ }
18
46
  };
47
+
48
+ /**
49
+ * Fetch all stable versions for a given provider.
50
+ * Returns sorted array of version strings (descending).
51
+ */
52
+ exports.fetchAllVersions = async (provider = 'terraform') => {
53
+ const normalized = provider === 'tofu' ? 'opentofu' : provider;
54
+
55
+ const cached = readCache(normalized);
56
+ if (cached) return cached;
57
+
58
+ const raw = await fetchUrl(URLS[normalized]);
59
+ const versions = normalized === 'opentofu'
60
+ ? formatOpenTofuVersions(raw)
61
+ : formatVersions(raw);
62
+
63
+ writeCache(normalized, versions);
64
+ return versions;
65
+ };
66
+
67
+ /**
68
+ * Fetch raw response (used for SHA256 sums and other direct URLs).
69
+ */
70
+ exports.fetchUrl = fetchUrl;
71
+
@@ -1,30 +1,44 @@
1
- const os = require('os')
2
- const fs = require('fs');
3
- const {join} = require('path');
4
- const {P_END, P_OK, P_ERROR} = require('../utils/colors');
1
+ const os = require('os');
2
+ const { existsSync, unlinkSync, readFileSync, writeFileSync } = require('fs');
3
+ const { join } = require('path');
4
+ const { normalizeProvider, getProviderStore, getVersionFile, getPaths } = require('../utils/paths');
5
+ const { P_END, P_OK, P_ERROR, P_WARN } = require('../utils/colors');
5
6
 
6
- exports.remove = async (version) => {
7
+ exports.remove = async (versions, providerArg = 'terraform') => {
7
8
  try {
8
- const store = join(__dirname, '../..', 'store');
9
- const arch = JSON.parse(fs.readFileSync(`${store}/arch.json`, 'utf-8'));
9
+ const provider = normalizeProvider(providerArg);
10
+ const store = getProviderStore(provider);
10
11
 
11
- version.forEach(v => {
12
- const tfVersion = os.platform() === 'win32' ? `${v}.exe` : v;
13
- const file = `${store}/${tfVersion}`;
12
+ const archFile = join(store, 'arch.json');
13
+ const archMap = existsSync(archFile) ? JSON.parse(readFileSync(archFile, 'utf-8')) : {};
14
14
 
15
- if (fs.existsSync(`${file}`)) {
16
- fs.unlinkSync(file);
15
+ // Get active version for warning
16
+ const paths = getPaths();
17
+ let activeVersion = null;
18
+ if (existsSync(paths.active)) {
19
+ const active = JSON.parse(readFileSync(paths.active, 'utf-8'));
20
+ activeVersion = active[provider];
21
+ }
17
22
 
18
- delete arch[v];
23
+ versions.forEach(v => {
24
+ const file = getVersionFile(provider, v);
19
25
 
20
- return console.log(`${P_OK}Terraform ${v} deleted from tfv store${P_END}`);
21
- } else {
22
- return console.log(`${P_ERROR}Terraform ${v} does not exist in tfv store${P_END}`);
26
+ if (!existsSync(file)) {
27
+ return console.log(`${P_ERROR}${provider} ${v} is not in tfv store${P_END}`);
23
28
  }
24
- })
25
29
 
26
- fs.writeFileSync(`${store}/arch.json`, JSON.stringify(arch, null, 2));
27
- } catch ({message}) {
28
- console.log('ERROR:', message)
30
+ if (v === activeVersion) {
31
+ console.log(`${P_WARN}Warning: ${provider} ${v} is currently active. Removing it will break the active symlink.${P_END}`);
32
+ console.log(`${P_WARN}Run ${P_OK}tfv use <other-version>${P_END}${P_WARN} first, or reinstall after removal.${P_END}`);
33
+ }
34
+
35
+ unlinkSync(file);
36
+ delete archMap[v];
37
+ console.log(`${P_OK}Removed ${provider} ${v} from tfv store${P_END}`);
38
+ });
39
+
40
+ writeFileSync(archFile, JSON.stringify(archMap, null, 2));
41
+ } catch (err) {
42
+ console.log(`${P_ERROR}Error: ${err.message}${P_END}`);
29
43
  }
30
- }
44
+ };
@@ -0,0 +1,226 @@
1
+ const { P_END, P_OK, P_ERROR, P_INFO } = require('../utils/colors');
2
+
3
+ const SUPPORTED_SHELLS = ['bash', 'zsh', 'fish', 'powershell', 'pwsh'];
4
+
5
+ const BASH_ZSH_HOOK = `
6
+ # tfv - terraform version manager shell integration
7
+ # Add this to your shell config by running: eval "$(tfv shell-init <shell>)"
8
+ _tfv_auto_switch() {
9
+ if [ -f ".terraform-version" ] || find . -maxdepth 1 -name "*.tf" -print -quit 2>/dev/null | grep -q .; then
10
+ tfv auto-switch --silent 2>/dev/null
11
+ fi
12
+ }
13
+
14
+ _tfv_cd() {
15
+ builtin cd "$@" && _tfv_auto_switch
16
+ }
17
+
18
+ alias cd='_tfv_cd'
19
+
20
+ # Run for the current directory on shell startup
21
+ _tfv_auto_switch
22
+
23
+ # ── Tab completions ────────────────────────────────────────────────────────
24
+ _tfv_versions() {
25
+ ls "\$HOME/.tfv/store/\${1:-terraform}" 2>/dev/null | grep -v arch.json | sed 's/\\.exe\$//'
26
+ }
27
+
28
+ if [ -n "\${BASH_VERSION:-}" ]; then
29
+ _tfv_complete() {
30
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
31
+ local prev="\${COMP_WORDS[COMP_CWORD-1]}"
32
+ local cmds="install i use auto-switch as list ls current which pin upgrade remove rm exec prune doctor shell-init fmt init validate plan apply destroy"
33
+ case "\$prev" in
34
+ use|exec|remove|rm|pin|upgrade|install|i)
35
+ COMPREPLY=(\$(compgen -W "\$(_tfv_versions terraform) latest" -- "\$cur")) ;;
36
+ --provider|-p)
37
+ COMPREPLY=(\$(compgen -W "terraform tofu opentofu" -- "\$cur")) ;;
38
+ shell-init)
39
+ COMPREPLY=(\$(compgen -W "bash zsh fish powershell pwsh" -- "\$cur")) ;;
40
+ *)
41
+ COMPREPLY=(\$(compgen -W "\$cmds" -- "\$cur")) ;;
42
+ esac
43
+ }
44
+ complete -F _tfv_complete tfv
45
+ elif [ -n "\${ZSH_VERSION:-}" ]; then
46
+ _tfv_complete() {
47
+ local cmds
48
+ cmds=(install i use auto-switch as list ls current which pin upgrade remove rm exec prune doctor shell-init fmt init validate plan apply destroy)
49
+ local prev="\${words[CURRENT-1]}"
50
+ case "\$prev" in
51
+ use|exec|remove|rm|pin|upgrade|install|i)
52
+ compadd -- \$(_tfv_versions terraform) latest ;;
53
+ --provider|-p)
54
+ compadd -- terraform tofu opentofu ;;
55
+ shell-init)
56
+ compadd -- bash zsh fish powershell pwsh ;;
57
+ *)
58
+ compadd -- "\$cmds[@]" ;;
59
+ esac
60
+ }
61
+ compdef _tfv_complete tfv 2>/dev/null
62
+ fi
63
+ `.trim();
64
+
65
+ const FISH_HOOK = `
66
+ # tfv - terraform version manager shell integration
67
+ # Add to ~/.config/fish/config.fish: tfv shell-init fish | source
68
+
69
+ function _tfv_auto_switch
70
+ if test -f .terraform-version; or count *.tf >/dev/null 2>&1
71
+ tfv auto-switch --silent 2>/dev/null
72
+ end
73
+ end
74
+
75
+ function cd
76
+ builtin cd $argv
77
+ _tfv_auto_switch
78
+ end
79
+
80
+ _tfv_auto_switch
81
+
82
+ # ── Tab completions ────────────────────────────────────────────────────────
83
+ function __tfv_installed_versions
84
+ set provider terraform
85
+ for i in (seq 1 (count $argv))
86
+ if test "$argv[$i]" = "--provider" -o "$argv[$i]" = "-p"
87
+ set next (math $i + 1)
88
+ if test $next -le (count $argv)
89
+ set p $argv[$next]
90
+ if test "$p" = "tofu" -o "$p" = "opentofu"
91
+ set provider opentofu
92
+ end
93
+ end
94
+ end
95
+ end
96
+ ls $HOME/.tfv/store/$provider 2>/dev/null | grep -v arch.json | string replace -r '\\.exe$' ''
97
+ end
98
+
99
+ set -l tfv_commands install i use auto-switch as list ls current which pin upgrade remove rm exec prune doctor shell-init fmt init validate plan apply destroy
100
+
101
+ complete -c tfv -f
102
+ complete -c tfv -n 'not __fish_seen_subcommand_from $tfv_commands' -a "$tfv_commands"
103
+
104
+ for subcmd in use exec remove rm pin upgrade
105
+ complete -c tfv -n "__fish_seen_subcommand_from $subcmd" -a '(__tfv_installed_versions (commandline -opc))' -d 'Installed version'
106
+ complete -c tfv -n "__fish_seen_subcommand_from $subcmd" -a 'latest' -d 'Latest version'
107
+ end
108
+
109
+ complete -c tfv -n "__fish_seen_subcommand_from install i" -a '(__tfv_installed_versions (commandline -opc))' -d 'Installed version'
110
+ complete -c tfv -n "__fish_seen_subcommand_from install i" -a 'latest' -d 'Latest version'
111
+
112
+ complete -c tfv -l provider -s p -r -a 'terraform tofu opentofu' -d 'Provider'
113
+ complete -c tfv -n "__fish_seen_subcommand_from shell-init" -a 'bash zsh fish powershell pwsh' -d 'Shell'
114
+ `.trim();
115
+
116
+ const POWERSHELL_HOOK = `
117
+ # tfv - terraform version manager shell integration
118
+ # Add to your PowerShell profile ($PROFILE):
119
+ # Invoke-Expression (tfv shell-init powershell | Out-String)
120
+
121
+ function global:Set-LocationWithTfv {
122
+ param([string]$Path = $PWD)
123
+ Set-Location $Path
124
+ if ((Test-Path ".terraform-version") -or (Get-ChildItem -Filter "*.tf" -ErrorAction SilentlyContinue)) {
125
+ tfv auto-switch --silent 2>$null
126
+ }
127
+ }
128
+
129
+ Remove-Item -Path Alias:cd -Force -ErrorAction SilentlyContinue
130
+ Set-Alias -Name cd -Value Set-LocationWithTfv -Scope Global -Force
131
+
132
+ # Run for current directory on shell startup
133
+ Set-LocationWithTfv -Path $PWD
134
+
135
+ # ── Tab completions ────────────────────────────────────────────────────────
136
+ Register-ArgumentCompleter -Native -CommandName tfv -ScriptBlock {
137
+ param($wordToComplete, $commandAst, $cursorPosition)
138
+
139
+ $commands = @('install','i','use','auto-switch','as','list','ls','current','which','pin','upgrade','remove','rm','exec','prune','doctor','shell-init','fmt','init','validate','plan','apply','destroy')
140
+ $elements = $commandAst.CommandElements
141
+ $prev = if ($elements.Count -ge 2) { $elements[$elements.Count - 2].ToString() } else { '' }
142
+
143
+ # Detect --provider / -p anywhere on the line
144
+ $provider = 'terraform'
145
+ for ($i = 1; $i -lt $elements.Count - 1; $i++) {
146
+ if ($elements[$i].ToString() -in '--provider','-p') {
147
+ $pval = $elements[$i+1].ToString()
148
+ if ($pval -in 'tofu','opentofu') { $provider = 'opentofu' }
149
+ }
150
+ }
151
+
152
+ $storeDir = Join-Path $env:USERPROFILE ".tfv\\store\\$provider"
153
+ $installed = if (Test-Path $storeDir) {
154
+ Get-ChildItem $storeDir -ErrorAction SilentlyContinue |
155
+ Where-Object { $_.Name -ne 'arch.json' } |
156
+ ForEach-Object { $_.Name -replace '\\.exe$','' }
157
+ } else { @() }
158
+
159
+ $completions = switch ($prev) {
160
+ { $_ -in 'use','exec','remove','rm','pin','upgrade','install','i' } { @($installed) + 'latest' }
161
+ { $_ -in '--provider','-p' } { 'terraform','tofu','opentofu' }
162
+ 'shell-init' { 'bash','zsh','fish','powershell','pwsh' }
163
+ default { $commands }
164
+ }
165
+
166
+ $completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
167
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
168
+ }
169
+ }
170
+ `.trim();
171
+
172
+ const INSTALL_INSTRUCTIONS = {
173
+ bash: `
174
+ ${'\x1b[32m\x1b[1m'}Shell hook generated for bash.${'\x1b[0m'}
175
+ To activate, add this to your ~/.bashrc:
176
+ eval "$(tfv shell-init bash)"
177
+ Then restart your terminal or run: source ~/.bashrc
178
+ `,
179
+ zsh: `
180
+ ${'\x1b[32m\x1b[1m'}Shell hook generated for zsh.${'\x1b[0m'}
181
+ To activate, add this to your ~/.zshrc:
182
+ eval "$(tfv shell-init zsh)"
183
+ Then restart your terminal or run: source ~/.zshrc
184
+ `,
185
+ fish: `
186
+ ${'\x1b[32m\x1b[1m'}Shell hook generated for fish.${'\x1b[0m'}
187
+ To activate, add this to your ~/.config/fish/config.fish:
188
+ tfv shell-init fish | source
189
+ Then restart your terminal or run: source ~/.config/fish/config.fish
190
+ `,
191
+ powershell: `
192
+ ${'\x1b[32m\x1b[1m'}Shell hook generated for PowerShell.${'\x1b[0m'}
193
+ To activate, add this to your $PROFILE:
194
+ Invoke-Expression (tfv shell-init powershell | Out-String)
195
+ Then restart PowerShell or run: . $PROFILE
196
+ `,
197
+ };
198
+
199
+ exports.shellInit = (shell) => {
200
+ const s = (shell || '').toLowerCase().trim();
201
+
202
+ if (!SUPPORTED_SHELLS.includes(s)) {
203
+ console.error(`${P_ERROR}Unsupported shell: ${shell}${P_END}`);
204
+ console.error(`Supported shells: ${SUPPORTED_SHELLS.join(', ')}`);
205
+ process.exit(1);
206
+ }
207
+
208
+ // Print the hook script to stdout (intended to be eval'd)
209
+ if (s === 'fish') {
210
+ process.stdout.write(FISH_HOOK + '\n');
211
+ } else if (s === 'powershell' || s === 'pwsh') {
212
+ process.stdout.write(POWERSHELL_HOOK + '\n');
213
+ } else {
214
+ // bash and zsh use the same hook
215
+ process.stdout.write(BASH_ZSH_HOOK + '\n');
216
+ }
217
+
218
+ // Only print install instructions when stdout is a TTY (i.e. the user ran
219
+ // tfv shell-init directly). When called inside $() for eval, stdout is a
220
+ // pipe so isTTY is falsy — suppress the message to avoid it printing on
221
+ // every `source ~/.zshrc`.
222
+ const key = s === 'pwsh' ? 'powershell' : s;
223
+ if (INSTALL_INSTRUCTIONS[key] && process.stdout.isTTY) {
224
+ process.stderr.write(INSTALL_INSTRUCTIONS[key] + '\n');
225
+ }
226
+ };
@@ -1,60 +1,144 @@
1
1
  const fs = require('fs');
2
- const {join} = require('path');
3
- const {use} = require('./use');
4
- const {install} = require('./install');
5
- const {P_END, P_ERROR, P_INFO} = require('../utils/colors');
6
- const {checkStore} = require('../utils/store');
2
+ const { join } = require('path');
3
+ const { use } = require('./use');
4
+ const { install } = require('./install');
5
+ const { normalizeProvider, getProviderStore, initDirs } = require('../utils/paths');
6
+ const { P_END, P_ERROR, P_INFO } = require('../utils/colors');
7
+
8
+ /**
9
+ * Resolve required_version constraint to an exact version number.
10
+ * Handles: =, ==, >=, >, ~>, and compound constraints (">= 1.3, < 2.0")
11
+ */
12
+ const resolveConstraint = (constraint, versions) => {
13
+ // Normalize: remove spaces around operators
14
+ const parts = constraint.split(',').map(s => s.trim());
15
+
16
+ return versions.find(v => {
17
+ return parts.every(part => {
18
+ const m = part.match(/^(~>|>=|<=|!=|>|<|=)?\s*(\d+(?:\.\d+)*)/);
19
+ if (!m) return false;
20
+ const [, op = '=', req] = m;
21
+
22
+ const vParts = v.split('.').map(n => parseInt(n, 10) || 0);
23
+ const rParts = req.split('.').map(n => parseInt(n, 10) || 0);
24
+
25
+ const cmp = () => {
26
+ for (let i = 0; i < 3; i++) {
27
+ const diff = (vParts[i] || 0) - (rParts[i] || 0);
28
+ if (diff !== 0) return diff;
29
+ }
30
+ return 0;
31
+ };
32
+
33
+ switch (op) {
34
+ case '~>': {
35
+ // pessimistic: allow patch or minor bumps only at the last specified segment
36
+ const segCount = rParts.length;
37
+ // major.minor segments must match, only patch can increase
38
+ for (let i = 0; i < segCount - 1; i++) {
39
+ if ((vParts[i] || 0) !== (rParts[i] || 0)) return false;
40
+ }
41
+ return (vParts[segCount - 1] || 0) >= (rParts[segCount - 1] || 0);
42
+ }
43
+ case '>=': return cmp() >= 0;
44
+ case '>': return cmp() > 0;
45
+ case '<=': return cmp() <= 0;
46
+ case '<': return cmp() < 0;
47
+ case '!=': return cmp() !== 0;
48
+ default: return cmp() === 0;
49
+ }
50
+ });
51
+ }) || null;
52
+ };
53
+
54
+ exports.autoSwitch = async (options = {}, providerArg = 'terraform') => {
55
+ const { silent = false } = options;
56
+ const log = silent ? () => {} : console.log;
7
57
 
8
- exports.autoSwitch = async () => {
9
58
  try {
59
+ initDirs();
60
+ const provider = normalizeProvider(providerArg);
61
+
10
62
  let versionFile;
11
63
  let tfVersion;
12
- const tfState = `terraform.tfstate`;
13
64
 
14
- if (fs.existsSync(tfState)) {
15
- versionFile = tfState;
16
- tfVersion = JSON.parse(fs.readFileSync(tfState, 'utf-8'))['terraform_version'];
17
- } else {
18
- const getAllFiles = fs.readdirSync(process.cwd());
19
- const likelyVersionFiles = ['main.tf', 'provider.tf', 'providers.tf', 'versions.tf', 'version.tf', 'backend.tf', 'terraform.tf'];
20
- const existingFiles = getAllFiles.filter(f => likelyVersionFiles.indexOf(f) !== -1);
21
- const checkVersionIn = existingFiles.concat(getAllFiles.filter(f => existingFiles.indexOf(f) === -1)).filter(f => f.endsWith('.tf'));
22
-
23
- for (let i = 0; i < checkVersionIn.length; i++) {
24
- versionFile = checkVersionIn[i];
25
- const requiredVersion = fs.readFileSync(versionFile, 'utf-8');
26
- const pattern = /^([\s]{0,}required_version)[\s]{0,}=[\s]{0,}"(>=|=|>|~>)[\s]{0,}\d+.*"/m;
27
- const specified = requiredVersion.match(pattern);
28
-
29
- if (specified) {
30
- const vPattern = /\d+(\.\d+)?(\.\d+)?/;
31
- [tfVersion] = specified[0].trim().match(vPattern);
65
+ // 1. Check .terraform-version file first (tfenv compatibility)
66
+ const dotVersionFile = join(process.cwd(), '.terraform-version');
67
+ if (fs.existsSync(dotVersionFile)) {
68
+ const pinned = fs.readFileSync(dotVersionFile, 'utf-8').trim();
69
+ if (pinned) {
70
+ versionFile = '.terraform-version';
71
+ tfVersion = pinned;
72
+ }
73
+ }
74
+
75
+ // 2. Check terraform.tfstate for version
76
+ if (!tfVersion) {
77
+ const tfState = 'terraform.tfstate';
78
+ if (fs.existsSync(tfState)) {
79
+ versionFile = tfState;
80
+ tfVersion = JSON.parse(fs.readFileSync(tfState, 'utf-8'))['terraform_version'];
81
+ }
82
+ }
83
+
84
+ // 3. Scan .tf files for required_version
85
+ if (!tfVersion) {
86
+ const allFiles = fs.readdirSync(process.cwd());
87
+ const priority = ['main.tf', 'provider.tf', 'providers.tf', 'versions.tf', 'version.tf', 'backend.tf', 'terraform.tf'];
88
+ const tfFiles = [
89
+ ...allFiles.filter(f => priority.includes(f)),
90
+ ...allFiles.filter(f => f.endsWith('.tf') && !priority.includes(f)),
91
+ ];
92
+
93
+ const pattern = /required_version\s*=\s*"([^"]+)"/m;
94
+
95
+ for (const file of tfFiles) {
96
+ const content = fs.readFileSync(file, 'utf-8');
97
+ const match = content.match(pattern);
98
+ if (match) {
99
+ versionFile = file;
100
+ tfVersion = match[1].trim();
32
101
  break;
33
102
  }
34
103
  }
35
104
  }
36
105
 
37
- if (tfVersion) {
38
- console.log('required_version was found in:', versionFile);
39
- const store = join(__dirname, '../..', 'store');
106
+ if (!tfVersion) {
107
+ log(`${P_ERROR}No terraform version found.${P_END}`);
108
+ log(`Create a .terraform-version file or set required_version in a .tf file.`);
109
+ return;
110
+ }
111
+
112
+ log(`${P_INFO}Found version constraint "${tfVersion}" in: ${versionFile}${P_END}`);
40
113
 
41
- checkStore(store);
114
+ const store = getProviderStore(provider);
115
+ const installedFiles = fs.readdirSync(store).filter(f => f !== 'arch.json');
116
+ const installedVersions = installedFiles.map(f => f.replace('.exe', ''));
42
117
 
43
- const inStore = fs.readdirSync(store).find(v => v.startsWith(tfVersion));
118
+ // If exact version (no operators), use directly
119
+ let resolved = tfVersion;
120
+ if (/[><=~!,]/.test(tfVersion)) {
121
+ const { fetchAllVersions } = require('./remote');
122
+ const allVersions = await fetchAllVersions(provider);
123
+ resolved = resolveConstraint(tfVersion, allVersions);
44
124
 
45
- if (inStore) {
46
- await use(inStore.replace('.exe', ''));
47
- } else {
48
- const version = await install(`${tfVersion}^`);
49
- await use(version);
125
+ if (!resolved) {
126
+ log(`${P_ERROR}No ${provider} version satisfies constraint: ${tfVersion}${P_END}`);
127
+ return;
50
128
  }
129
+ log(`${P_INFO}Resolved to: ${resolved}${P_END}`);
130
+ }
131
+
132
+ const inStore = installedVersions.find(v => v === resolved || v.startsWith(resolved));
133
+ if (inStore) {
134
+ await use(inStore, provider);
51
135
  } else {
52
- console.log(`${P_ERROR}NOT FOUND:${P_END}${P_INFO}required_version${P_END}`);
53
- console.log(`${P_INFO}check in your remote state file${P_END}`);
136
+ log(`${P_INFO}${provider} ${resolved} not in store. Installing...${P_END}`);
137
+ const installed = await install(resolved, null, provider);
138
+ if (installed) await use(installed, provider);
54
139
  }
55
140
 
56
- } catch ({message}) {
57
- console.log('ERROR:', message)
141
+ } catch (err) {
142
+ if (!options.silent) console.log(`${P_ERROR}auto-switch error: ${err.message}${P_END}`);
58
143
  }
59
- }
60
-
144
+ };