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.
- package/.github/workflows/publish.yml +43 -3
- package/README.md +253 -122
- package/demo.gif +0 -0
- package/demo.tape +230 -0
- package/index.js +0 -4
- package/lib/commands/apply.js +8 -3
- package/lib/commands/current.js +25 -0
- package/lib/commands/destroy.js +8 -3
- package/lib/commands/doctor.js +20 -0
- package/lib/commands/exec.js +33 -0
- package/lib/commands/fmt.js +26 -0
- package/lib/commands/init.js +26 -0
- package/lib/commands/install.js +22 -13
- package/lib/commands/list.js +20 -11
- package/lib/commands/pin.js +26 -0
- package/lib/commands/plan.js +8 -3
- package/lib/commands/prune.js +41 -0
- package/lib/commands/remove.js +17 -12
- package/lib/commands/shell-init.js +25 -0
- package/lib/commands/switch.js +28 -7
- package/lib/commands/upgrade.js +26 -0
- package/lib/commands/use.js +17 -13
- package/lib/commands/validate.js +21 -0
- package/lib/modules/current.js +52 -0
- package/lib/modules/doctor.js +160 -0
- package/lib/modules/exec.js +36 -0
- package/lib/modules/install.js +155 -89
- package/lib/modules/list.js +66 -105
- package/lib/modules/pin.js +35 -0
- package/lib/modules/prune.js +100 -0
- package/lib/modules/ps1.js +37 -29
- package/lib/modules/remote.js +68 -15
- package/lib/modules/remove.js +35 -21
- package/lib/modules/shell-init.js +226 -0
- package/lib/modules/switch.js +125 -41
- package/lib/modules/terraform-command.js +49 -67
- package/lib/modules/upgrade.js +93 -0
- package/lib/modules/use.js +58 -54
- package/lib/utils/formatVersions.js +57 -5
- package/lib/utils/paths.js +156 -0
- package/lib/utils/postInstall.js +37 -13
- package/lib/utils/store.js +17 -6
- package/package.json +11 -9
- package/test/extractTargets.test.js +75 -0
- package/test/formatVersions.test.js +126 -0
- package/test/moduleImports.test.js +45 -0
- package/test/paths.test.js +69 -0
- package/test/versionResolution.test.js +92 -0
package/lib/modules/ps1.js
CHANGED
|
@@ -1,38 +1,46 @@
|
|
|
1
|
-
const {spawnSync} = require(
|
|
2
|
-
const {
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 (!
|
|
12
|
-
console.log(`${
|
|
13
|
-
|
|
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
|
|
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 (
|
|
22
|
-
const newSystemEnv = `${terraformPath};${systemEnv}`;
|
|
29
|
+
if (pathEntries.includes(BIN_DIR)) return; // Already in PATH
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
};
|
package/lib/modules/remote.js
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
|
package/lib/modules/remove.js
CHANGED
|
@@ -1,30 +1,44 @@
|
|
|
1
|
-
const os = require('os')
|
|
2
|
-
const
|
|
3
|
-
const {join} = require('path');
|
|
4
|
-
const {
|
|
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 (
|
|
7
|
+
exports.remove = async (versions, providerArg = 'terraform') => {
|
|
7
8
|
try {
|
|
8
|
-
const
|
|
9
|
-
const
|
|
9
|
+
const provider = normalizeProvider(providerArg);
|
|
10
|
+
const store = getProviderStore(provider);
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
23
|
+
versions.forEach(v => {
|
|
24
|
+
const file = getVersionFile(provider, v);
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
};
|
package/lib/modules/switch.js
CHANGED
|
@@ -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 {
|
|
6
|
-
const {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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 (
|
|
57
|
-
console.log(
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (!options.silent) console.log(`${P_ERROR}auto-switch error: ${err.message}${P_END}`);
|
|
58
143
|
}
|
|
59
|
-
}
|
|
60
|
-
|
|
144
|
+
};
|