tfv 6.0.0 → 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.
@@ -10,7 +10,23 @@ permissions:
10
10
  id-token: write
11
11
 
12
12
  jobs:
13
+ verify-branch:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ fetch-depth: 0
19
+
20
+ - name: Verify tag is on main branch
21
+ run: |
22
+ git fetch origin main
23
+ if ! git merge-base --is-ancestor ${{ github.sha }} origin/main; then
24
+ echo "Error: tags must only be pushed on commits that exist on the main branch."
25
+ exit 1
26
+ fi
27
+
13
28
  test:
29
+ needs: verify-branch
14
30
  runs-on: ubuntu-latest
15
31
  steps:
16
32
  - uses: actions/checkout@v4
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # tfv — Terraform & OpenTofu Version Manager
1
+ # tfv — Terraform & OpenTofu Version Manager for macOS, Linux & Windows
2
2
 
3
- ```
3
+ ```text
4
4
  _ ________ __
5
5
  _| |__ / _____|\ \ / /
6
6
  |_ ___\ | |___ \ \ / /
@@ -30,7 +30,7 @@ npm install -g tfv
30
30
 
31
31
  ## How it works
32
32
 
33
- ```
33
+ ```text
34
34
  npm install -g tfv
35
35
 
36
36
 
@@ -63,7 +63,7 @@ Both `terraform` and `tfv` commands always use the **exact same binary**.
63
63
 
64
64
  ## Store layout
65
65
 
66
- ```
66
+ ```text
67
67
  ~/.tfv/
68
68
  bin/
69
69
  terraform ← active terraform binary (no sudo, no symlinks)
@@ -137,6 +137,7 @@ Reads version from (in priority order):
137
137
  Wraps `cd` so that entering a directory automatically switches the terraform version — but **only if the directory looks like a terraform project**. Non-terraform folders have zero overhead (one filesystem check, no subprocess).
138
138
 
139
139
  A directory is considered a terraform project if it contains:
140
+
140
141
  - a `.terraform-version` file, **or**
141
142
  - any `*.tf` files
142
143
 
@@ -151,20 +152,22 @@ Setup (one-time, add to your shell config):
151
152
 
152
153
  ```sh
153
154
  # Bash
154
- echo 'eval "$(tfv shell-init bash)"' >> ~/.bashrc
155
+ printf '\neval "$(tfv shell-init bash)"\n' >> ~/.bashrc
155
156
 
156
157
  # Zsh
157
- echo 'eval "$(tfv shell-init zsh)"' >> ~/.zshrc
158
+ printf '\neval "$(tfv shell-init zsh)"\n' >> ~/.zshrc
158
159
 
159
160
  # Fish
160
- echo 'tfv shell-init fish | source' >> ~/.config/fish/config.fish
161
+ printf '\ntfv shell-init fish | source\n' >> ~/.config/fish/config.fish
161
162
 
162
163
  # PowerShell
163
- echo 'Invoke-Expression (tfv shell-init powershell | Out-String)' >> $PROFILE
164
+ Add-Content $PROFILE "`nInvoke-Expression (tfv shell-init powershell | Out-String)"
164
165
  ```
165
166
 
166
167
  After setup, entering a terraform project switches the version automatically — no manual `tfv as` needed.
167
168
 
169
+ `shell-init` also installs **tab completions** for all `tfv` commands and installed versions. After running the setup command above, `tfv <tab>` will complete commands, `tfv use <tab>` will complete installed versions, and `tfv --provider <tab>` will complete provider names.
170
+
168
171
  ---
169
172
 
170
173
  ### List versions
@@ -188,7 +191,8 @@ tfv current --provider tofu
188
191
  ```
189
192
 
190
193
  Example output:
191
- ```
194
+
195
+ ```text
192
196
  Active terraform version: 1.9.0
193
197
  Binary: /Users/you/.tfv/bin/terraform
194
198
  Reported: Terraform v1.9.0
@@ -234,6 +238,75 @@ Warns if you're removing the currently active version.
234
238
 
235
239
  ---
236
240
 
241
+ ### Prune
242
+
243
+ Remove all non-active versions at once to free disk space.
244
+
245
+ ```sh
246
+ tfv prune # remove everything except the active version
247
+ tfv prune --keep 2 # keep the 2 most recent + the active version
248
+ tfv prune --provider tofu
249
+ tfv prune --yes # skip confirmation prompt
250
+ ```
251
+
252
+ The active version is always kept regardless of `--keep`.
253
+
254
+ ---
255
+
256
+ ### Exec
257
+
258
+ Run a single command with a specific installed version **without** changing the active version. Useful for cross-version testing and CI.
259
+
260
+ ```sh
261
+ tfv exec 1.9.0 -- version
262
+ tfv exec 1.8.0 -- plan -var="env=prod"
263
+ tfv exec 1.7.3 --provider tofu -- validate
264
+ ```
265
+
266
+ Pass all terraform/tofu flags after `--`.
267
+
268
+ ---
269
+
270
+ ### Doctor
271
+
272
+ Run a full health check: store directories, active binaries, PATH order, PATH conflicts, and shell config.
273
+
274
+ ```sh
275
+ tfv doctor
276
+ ```
277
+
278
+ Example output:
279
+
280
+ ```text
281
+ tfv doctor
282
+
283
+ Store
284
+ ✔ ~/.tfv/store/terraform/ exists
285
+ ✔ ~/.tfv/store/opentofu/ exists
286
+
287
+ Active versions
288
+ ✔ terraform: active version is 1.9.0
289
+ ✔ terraform: binary exists
290
+ ✔ terraform: executes (Terraform v1.9.0)
291
+ – tofu: not set up (no versions installed)
292
+
293
+ PATH
294
+ ✔ ~/.tfv/bin is in PATH
295
+ ✔ ~/.tfv/bin precedes system dirs in PATH
296
+ ✔ 'terraform' resolves to tfv-managed binary
297
+ ✔ PATH block found in shell config (~/.zshrc)
298
+
299
+ Cache
300
+ ✔ terraform version cache exists
301
+ – opentofu version cache not yet created
302
+
303
+ All checks passed. tfv is healthy.
304
+ ```
305
+
306
+ Exits with code 1 if any issue is found.
307
+
308
+ ---
309
+
237
310
  ### Terraform commands (via tfv)
238
311
 
239
312
  All commands use the tfv-managed binary and accept extra terraform flags after `--`:
@@ -0,0 +1,20 @@
1
+ 'use strict'
2
+
3
+ const { doctor } = require('../modules/doctor');
4
+
5
+ exports.command = 'doctor'
6
+ exports.desc = 'Run diagnostics: PATH, binaries, store, shell config'
7
+ exports.builder = (yargs) => {
8
+ return yargs
9
+ .epilog([
10
+ 'Checks PATH order, active binaries, store directories,',
11
+ 'shell config, and PATH conflicts.',
12
+ '',
13
+ 'Example:',
14
+ ' tfv doctor',
15
+ ].join('\n'))
16
+ }
17
+
18
+ exports.handler = async () => {
19
+ await doctor();
20
+ }
@@ -0,0 +1,33 @@
1
+ 'use strict'
2
+
3
+ const { exec } = require('../modules/exec');
4
+
5
+ exports.command = 'exec <ver>'
6
+ exports.desc = 'Run a terraform/tofu command with a specific version without switching the active version'
7
+ exports.builder = (yargs) => {
8
+ return yargs
9
+ .positional('ver', {
10
+ describe: 'Installed version to use for this run',
11
+ type: 'string',
12
+ })
13
+ .option('provider', {
14
+ alias: 'p',
15
+ describe: 'Provider: terraform (default) or tofu/opentofu',
16
+ type: 'string',
17
+ default: 'terraform',
18
+ })
19
+ .epilog([
20
+ 'Pass terraform/tofu args after --',
21
+ '',
22
+ 'Examples:',
23
+ ' tfv exec 1.9.0 -- version',
24
+ ' tfv exec 1.8.0 -- plan -var="env=prod"',
25
+ ' tfv exec 1.7.3 --provider tofu -- validate',
26
+ ].join('\n'))
27
+ }
28
+
29
+ exports.handler = async (argv) => {
30
+ const { ver, provider, _ } = argv;
31
+ const extraArgs = _.slice(1);
32
+ await exec(ver, extraArgs, provider);
33
+ }
@@ -0,0 +1,41 @@
1
+ 'use strict'
2
+
3
+ const { prune } = require('../modules/prune');
4
+
5
+ exports.command = 'prune'
6
+ exports.desc = 'Remove all non-active installed versions to free disk space'
7
+ exports.builder = (yargs) => {
8
+ return yargs
9
+ .option('keep', {
10
+ alias: 'k',
11
+ describe: 'Keep the N most recent versions in addition to the active one',
12
+ type: 'number',
13
+ default: 0,
14
+ })
15
+ .option('provider', {
16
+ alias: 'p',
17
+ describe: 'Provider: terraform (default) or tofu/opentofu',
18
+ type: 'string',
19
+ default: 'terraform',
20
+ })
21
+ .option('yes', {
22
+ alias: 'y',
23
+ describe: 'Skip confirmation prompt',
24
+ type: 'boolean',
25
+ default: false,
26
+ })
27
+ .epilog([
28
+ 'Always keeps the currently active version.',
29
+ '',
30
+ 'Examples:',
31
+ ' tfv prune # remove all but active',
32
+ ' tfv prune --keep 2 # remove all but active + 2 most recent',
33
+ ' tfv prune --provider tofu',
34
+ ' tfv prune --yes # skip confirmation',
35
+ ].join('\n'))
36
+ }
37
+
38
+ exports.handler = async (argv) => {
39
+ const { provider, keep, yes } = argv;
40
+ await prune(provider, keep, yes);
41
+ }
@@ -0,0 +1,160 @@
1
+ const { existsSync, readFileSync, readdirSync } = require('fs');
2
+ const { join } = require('path');
3
+ const { spawnSync } = require('child_process');
4
+ const { platform, homedir } = require('os');
5
+ const { getPaths, getBinTarget, getProviderStore, getCliName, normalizeProvider, checkPathConflict } = require('../utils/paths');
6
+ const { P_END, P_OK, P_ERROR, P_WARN, P_INFO } = require('../utils/colors');
7
+
8
+ const SHELL_CONFIGS = [
9
+ join(homedir(), '.zshrc'),
10
+ join(homedir(), '.bashrc'),
11
+ join(homedir(), '.bash_profile'),
12
+ join(homedir(), '.profile'),
13
+ ];
14
+ const PATH_MARKER = '# tfv - terraform version manager';
15
+
16
+ const ok = (label, detail) => { console.log(` ${P_OK}✔${P_END} ${label}`); if (detail) console.log(` ${P_INFO}${detail}${P_END}`); };
17
+ const fail = (label, hint) => { console.log(` ${P_ERROR}✘${P_END} ${label}`); if (hint) console.log(` ${P_WARN}${hint}${P_END}`); };
18
+ const info = (label, detail) => { console.log(` ${P_INFO}–${P_END} ${label}`); if (detail) console.log(` ${P_INFO}${detail}${P_END}`); };
19
+
20
+ exports.doctor = async () => {
21
+ const paths = getPaths();
22
+ const isWin = platform() === 'win32';
23
+ let issues = 0;
24
+
25
+ console.log(`\n${P_INFO}tfv doctor${P_END}\n`);
26
+
27
+ // ── Store ──────────────────────────────────────────────────────────────────
28
+ console.log(`${P_OK}Store${P_END}`);
29
+ for (const p of ['terraform', 'opentofu']) {
30
+ const store = getProviderStore(p);
31
+ if (existsSync(store)) {
32
+ ok(`~/.tfv/store/${p}/ exists`);
33
+ } else {
34
+ fail(`~/.tfv/store/${p}/ missing`, 'Run: tfv install latest' + (p === 'opentofu' ? ' --provider tofu' : ''));
35
+ issues++;
36
+ }
37
+ }
38
+
39
+ // ── Active versions & binaries ─────────────────────────────────────────────
40
+ console.log(`\n${P_OK}Active versions${P_END}`);
41
+ let activeData = { terraform: null, opentofu: null };
42
+ if (existsSync(paths.active)) {
43
+ try { activeData = JSON.parse(readFileSync(paths.active, 'utf-8')); } catch {}
44
+ }
45
+
46
+ for (const p of ['terraform', 'opentofu']) {
47
+ const cli = getCliName(p);
48
+ const binary = getBinTarget(p);
49
+ const active = activeData[p];
50
+
51
+ const store = getProviderStore(p);
52
+ const hasInstalled = existsSync(store) &&
53
+ readdirSync(store).filter(f => f !== 'arch.json').length > 0;
54
+
55
+ if (!active) {
56
+ if (hasInstalled) {
57
+ fail(`${cli}: no active version set`, `Run: tfv use <version>${p === 'opentofu' ? ' --provider tofu' : ''}`);
58
+ issues++;
59
+ } else {
60
+ info(`${cli}: not set up (no versions installed)`);
61
+ }
62
+ continue;
63
+ }
64
+
65
+ ok(`${cli}: active version is ${active}`);
66
+
67
+ if (!existsSync(binary)) {
68
+ fail(`${cli}: binary missing at ${binary}`, `Run: tfv use ${active}${p === 'opentofu' ? ' --provider tofu' : ''}`);
69
+ issues++;
70
+ continue;
71
+ }
72
+
73
+ ok(`${cli}: binary exists`);
74
+
75
+ const result = spawnSync(binary, ['version'], { stdio: 'pipe', encoding: 'utf-8' });
76
+ if (result.status === 0 && result.stdout) {
77
+ ok(`${cli}: executes (${result.stdout.split('\n')[0].trim()})`);
78
+ } else {
79
+ fail(`${cli}: binary failed to execute`, result.stderr ? result.stderr.trim() : 'Unknown error');
80
+ issues++;
81
+ }
82
+ }
83
+
84
+ // ── PATH ──────────────────────────────────────────────────────────────────
85
+ console.log(`\n${P_OK}PATH${P_END}`);
86
+
87
+ if (isWin) {
88
+ info('PATH check skipped on Windows (managed via User PATH registry key)');
89
+ } else {
90
+ const pathEntries = (process.env.PATH || '').split(':');
91
+ const tfvBin = paths.bin;
92
+
93
+ if (pathEntries.includes(tfvBin)) {
94
+ ok('~/.tfv/bin is in PATH');
95
+
96
+ const tfvIdx = pathEntries.indexOf(tfvBin);
97
+ const systemDirs = ['/usr/bin', '/usr/local/bin', '/opt/homebrew/bin'];
98
+ const systemIdxs = systemDirs
99
+ .map(d => pathEntries.indexOf(d))
100
+ .filter(i => i >= 0);
101
+ const firstSystem = systemIdxs.length ? Math.min(...systemIdxs) : Infinity;
102
+
103
+ if (firstSystem < Infinity) {
104
+ if (tfvIdx < firstSystem) {
105
+ ok('~/.tfv/bin precedes system dirs in PATH');
106
+ } else {
107
+ fail('~/.tfv/bin comes AFTER system dirs in PATH', 'Run: tfv upgrade to re-anchor PATH');
108
+ issues++;
109
+ }
110
+ }
111
+ } else {
112
+ fail('~/.tfv/bin is not in PATH', 'Run: tfv upgrade to re-anchor PATH');
113
+ issues++;
114
+ }
115
+
116
+ for (const p of ['terraform', 'opentofu']) {
117
+ const active = activeData[p];
118
+ if (!active) continue;
119
+ const cli = getCliName(p);
120
+ const conflict = checkPathConflict(p);
121
+ if (conflict.ok) {
122
+ ok(`'${cli}' resolves to tfv-managed binary`);
123
+ } else {
124
+ fail(`'${cli}' resolves to wrong binary`, `Got: ${conflict.resolved} Expected: ${conflict.expected}`);
125
+ issues++;
126
+ }
127
+ }
128
+
129
+ const configsWithMarker = SHELL_CONFIGS.filter(f => {
130
+ if (!existsSync(f)) return false;
131
+ try { return readFileSync(f, 'utf-8').includes(PATH_MARKER); } catch { return false; }
132
+ });
133
+ if (configsWithMarker.length > 0) {
134
+ ok(`PATH block found in shell config`, configsWithMarker.map(f => f.replace(homedir(), '~')).join(', '));
135
+ } else {
136
+ fail('PATH block not found in any shell config', `Add: export PATH="$HOME/.tfv/bin:$PATH" to ~/.zshrc or ~/.bashrc`);
137
+ issues++;
138
+ }
139
+ }
140
+
141
+ // ── Cache ─────────────────────────────────────────────────────────────────
142
+ console.log(`\n${P_OK}Cache${P_END}`);
143
+ for (const p of ['terraform', 'opentofu']) {
144
+ const cacheFile = join(paths.cache, `${p}-versions.json`);
145
+ if (existsSync(cacheFile)) {
146
+ ok(`${p} version cache exists`);
147
+ } else {
148
+ info(`${p} version cache not yet created`, 'Will be built on next: tfv list --remote' + (p === 'opentofu' ? ' --provider tofu' : ''));
149
+ }
150
+ }
151
+
152
+ // ── Summary ───────────────────────────────────────────────────────────────
153
+ console.log();
154
+ if (issues === 0) {
155
+ console.log(`${P_OK}All checks passed. tfv is healthy.${P_END}\n`);
156
+ } else {
157
+ console.log(`${P_ERROR}${issues} issue(s) found. See details above.${P_END}\n`);
158
+ process.exit(1);
159
+ }
160
+ };
@@ -0,0 +1,36 @@
1
+ const { existsSync } = require('fs');
2
+ const { spawn } = require('child_process');
3
+ const { normalizeProvider, getCliName, getVersionFile } = require('../utils/paths');
4
+ const { P_END, P_OK, P_ERROR, P_WARN, P_INFO } = require('../utils/colors');
5
+
6
+ exports.exec = async (version, extraArgs = [], providerArg = 'terraform') => {
7
+ try {
8
+ const provider = normalizeProvider(providerArg);
9
+ const cli = getCliName(provider);
10
+ const binary = getVersionFile(provider, version);
11
+
12
+ if (!existsSync(binary)) {
13
+ console.log(`${P_ERROR}${provider} ${version} is not installed.${P_END}`);
14
+ console.log(`Run: ${P_OK}tfv install ${version}${provider === 'opentofu' ? ' --provider tofu' : ''}${P_END}`);
15
+ process.exit(1);
16
+ }
17
+
18
+ console.log(`${P_INFO}Using ${provider} ${version} (active version unchanged)${P_END}`);
19
+ if (extraArgs.length > 0) {
20
+ console.log(`${P_OK}Running: ${cli} ${extraArgs.join(' ')}${P_END}\n`);
21
+ }
22
+
23
+ const tf = spawn(binary, extraArgs, { stdio: 'inherit', cwd: process.cwd() });
24
+
25
+ tf.on('error', (err) => {
26
+ console.log(`${P_ERROR}Error executing ${cli} ${version}: ${err.message}${P_END}`);
27
+ process.exit(1);
28
+ });
29
+
30
+ tf.on('close', code => process.exit(code ?? 0));
31
+
32
+ } catch (err) {
33
+ console.log(`${P_ERROR}Error: ${err.message}${P_END}`);
34
+ process.exit(1);
35
+ }
36
+ };
@@ -0,0 +1,100 @@
1
+ const { existsSync, readdirSync, unlinkSync, readFileSync, writeFileSync } = require('fs');
2
+ const { join } = require('path');
3
+ const readline = require('readline');
4
+ const { normalizeProvider, getProviderStore, getVersionFile, getPaths } = require('../utils/paths');
5
+ const { P_END, P_OK, P_ERROR, P_WARN, P_INFO } = require('../utils/colors');
6
+
7
+ const sortVersionsDesc = (versions) => versions.sort((a, b) => {
8
+ const ap = a.split('.').map(n => parseInt(n, 10) || 0);
9
+ const bp = b.split('.').map(n => parseInt(n, 10) || 0);
10
+ for (let i = 0; i < 3; i++) {
11
+ const d = (bp[i] || 0) - (ap[i] || 0);
12
+ if (d !== 0) return d;
13
+ }
14
+ return 0;
15
+ });
16
+
17
+ const confirm = (question) => new Promise(resolve => {
18
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
19
+ rl.question(question, ans => { rl.close(); resolve(ans.trim().toLowerCase()); });
20
+ });
21
+
22
+ exports.prune = async (providerArg = 'terraform', keep = 0, yes = false) => {
23
+ try {
24
+ const provider = normalizeProvider(providerArg);
25
+ const store = getProviderStore(provider);
26
+
27
+ if (!existsSync(store)) {
28
+ console.log(`${P_ERROR}No store found for ${provider}.${P_END}`);
29
+ process.exit(1);
30
+ }
31
+
32
+ const archFile = join(store, 'arch.json');
33
+ const archMap = existsSync(archFile) ? JSON.parse(readFileSync(archFile, 'utf-8')) : {};
34
+
35
+ const paths = getPaths();
36
+ let activeVersion = null;
37
+ if (existsSync(paths.active)) {
38
+ const active = JSON.parse(readFileSync(paths.active, 'utf-8'));
39
+ activeVersion = active[provider];
40
+ }
41
+
42
+ const installed = sortVersionsDesc(
43
+ readdirSync(store)
44
+ .filter(f => f !== 'arch.json')
45
+ .map(f => f.replace(/\.exe$/, ''))
46
+ );
47
+
48
+ if (installed.length === 0) {
49
+ console.log(`${P_WARN}No ${provider} versions installed.${P_END}`);
50
+ return;
51
+ }
52
+
53
+ // Build the keep set: active version is always kept; top N by semver if --keep N
54
+ const keepSet = new Set();
55
+ if (activeVersion) keepSet.add(activeVersion);
56
+ if (keep > 0) installed.slice(0, keep).forEach(v => keepSet.add(v));
57
+
58
+ const toRemove = installed.filter(v => !keepSet.has(v));
59
+
60
+ if (toRemove.length === 0) {
61
+ console.log(`${P_OK}Nothing to prune for ${provider}.${P_END}`);
62
+ if (activeVersion) console.log(`${P_INFO}Active version ${activeVersion} is kept.${P_END}`);
63
+ return;
64
+ }
65
+
66
+ console.log(`${P_INFO}Versions to remove (${provider}):${P_END}`);
67
+ toRemove.forEach(v => console.log(` ${P_WARN}${v}${P_END}`));
68
+ console.log();
69
+ if (activeVersion) console.log(`${P_OK}Keeping (active): ${activeVersion}${P_END}`);
70
+ if (keep > 0) {
71
+ const kept = installed.filter(v => keepSet.has(v) && v !== activeVersion);
72
+ if (kept.length) console.log(`${P_OK}Keeping (--keep ${keep}): ${kept.join(', ')}${P_END}`);
73
+ }
74
+
75
+ if (!yes) {
76
+ const ans = await confirm(`\nRemove ${toRemove.length} version(s)? [y/N] `);
77
+ if (ans !== 'y' && ans !== 'yes') {
78
+ console.log(`${P_WARN}Aborted.${P_END}`);
79
+ return;
80
+ }
81
+ }
82
+
83
+ console.log();
84
+ toRemove.forEach(v => {
85
+ const file = getVersionFile(provider, v);
86
+ if (existsSync(file)) {
87
+ unlinkSync(file);
88
+ delete archMap[v];
89
+ console.log(`${P_OK}Removed ${provider} ${v}${P_END}`);
90
+ }
91
+ });
92
+
93
+ writeFileSync(archFile, JSON.stringify(archMap, null, 2));
94
+ console.log(`\n${P_OK}Done. Pruned ${toRemove.length} version(s).${P_END}`);
95
+
96
+ } catch (err) {
97
+ console.log(`${P_ERROR}Error: ${err.message}${P_END}`);
98
+ process.exit(1);
99
+ }
100
+ };
@@ -6,7 +6,7 @@ const BASH_ZSH_HOOK = `
6
6
  # tfv - terraform version manager shell integration
7
7
  # Add this to your shell config by running: eval "$(tfv shell-init <shell>)"
8
8
  _tfv_auto_switch() {
9
- if [ -f ".terraform-version" ] || ls *.tf 2>/dev/null | head -1 >/dev/null 2>&1; then
9
+ if [ -f ".terraform-version" ] || find . -maxdepth 1 -name "*.tf" -print -quit 2>/dev/null | grep -q .; then
10
10
  tfv auto-switch --silent 2>/dev/null
11
11
  fi
12
12
  }
@@ -19,6 +19,47 @@ alias cd='_tfv_cd'
19
19
 
20
20
  # Run for the current directory on shell startup
21
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
22
63
  `.trim();
23
64
 
24
65
  const FISH_HOOK = `
@@ -37,6 +78,39 @@ function cd
37
78
  end
38
79
 
39
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'
40
114
  `.trim();
41
115
 
42
116
  const POWERSHELL_HOOK = `
@@ -57,6 +131,42 @@ Set-Alias -Name cd -Value Set-LocationWithTfv -Scope Global -Force
57
131
 
58
132
  # Run for current directory on shell startup
59
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
+ }
60
170
  `.trim();
61
171
 
62
172
  const INSTALL_INSTRUCTIONS = {
@@ -105,9 +215,12 @@ exports.shellInit = (shell) => {
105
215
  process.stdout.write(BASH_ZSH_HOOK + '\n');
106
216
  }
107
217
 
108
- // Print install instructions to stderr so they don't get eval'd
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`.
109
222
  const key = s === 'pwsh' ? 'powershell' : s;
110
- if (INSTALL_INSTRUCTIONS[key]) {
223
+ if (INSTALL_INSTRUCTIONS[key] && process.stdout.isTTY) {
111
224
  process.stderr.write(INSTALL_INSTRUCTIONS[key] + '\n');
112
225
  }
113
226
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tfv",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true