tfv 6.0.0 → 6.1.1
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 +16 -0
- package/README.md +82 -9
- package/lib/commands/doctor.js +20 -0
- package/lib/commands/exec.js +33 -0
- package/lib/commands/prune.js +41 -0
- package/lib/modules/doctor.js +160 -0
- package/lib/modules/exec.js +36 -0
- package/lib/modules/prune.js +100 -0
- package/lib/modules/shell-init.js +117 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
155
|
+
printf '\neval "$(tfv shell-init bash)"\n' >> ~/.bashrc
|
|
155
156
|
|
|
156
157
|
# Zsh
|
|
157
|
-
|
|
158
|
+
printf '\neval "$(tfv shell-init zsh)"\n' >> ~/.zshrc
|
|
158
159
|
|
|
159
160
|
# Fish
|
|
160
|
-
|
|
161
|
+
printf '\ntfv shell-init fish | source\n' >> ~/.config/fish/config.fish
|
|
161
162
|
|
|
162
163
|
# PowerShell
|
|
163
|
-
|
|
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" ] ||
|
|
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,48 @@ 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
|
+
compdef _tfv_cd=cd 2>/dev/null
|
|
63
|
+
fi
|
|
22
64
|
`.trim();
|
|
23
65
|
|
|
24
66
|
const FISH_HOOK = `
|
|
@@ -37,6 +79,39 @@ function cd
|
|
|
37
79
|
end
|
|
38
80
|
|
|
39
81
|
_tfv_auto_switch
|
|
82
|
+
|
|
83
|
+
# ── Tab completions ────────────────────────────────────────────────────────
|
|
84
|
+
function __tfv_installed_versions
|
|
85
|
+
set provider terraform
|
|
86
|
+
for i in (seq 1 (count $argv))
|
|
87
|
+
if test "$argv[$i]" = "--provider" -o "$argv[$i]" = "-p"
|
|
88
|
+
set next (math $i + 1)
|
|
89
|
+
if test $next -le (count $argv)
|
|
90
|
+
set p $argv[$next]
|
|
91
|
+
if test "$p" = "tofu" -o "$p" = "opentofu"
|
|
92
|
+
set provider opentofu
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
ls $HOME/.tfv/store/$provider 2>/dev/null | grep -v arch.json | string replace -r '\\.exe$' ''
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
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
|
|
101
|
+
|
|
102
|
+
complete -c tfv -f
|
|
103
|
+
complete -c tfv -n 'not __fish_seen_subcommand_from $tfv_commands' -a "$tfv_commands"
|
|
104
|
+
|
|
105
|
+
for subcmd in use exec remove rm pin upgrade
|
|
106
|
+
complete -c tfv -n "__fish_seen_subcommand_from $subcmd" -a '(__tfv_installed_versions (commandline -opc))' -d 'Installed version'
|
|
107
|
+
complete -c tfv -n "__fish_seen_subcommand_from $subcmd" -a 'latest' -d 'Latest version'
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
complete -c tfv -n "__fish_seen_subcommand_from install i" -a '(__tfv_installed_versions (commandline -opc))' -d 'Installed version'
|
|
111
|
+
complete -c tfv -n "__fish_seen_subcommand_from install i" -a 'latest' -d 'Latest version'
|
|
112
|
+
|
|
113
|
+
complete -c tfv -l provider -s p -r -a 'terraform tofu opentofu' -d 'Provider'
|
|
114
|
+
complete -c tfv -n "__fish_seen_subcommand_from shell-init" -a 'bash zsh fish powershell pwsh' -d 'Shell'
|
|
40
115
|
`.trim();
|
|
41
116
|
|
|
42
117
|
const POWERSHELL_HOOK = `
|
|
@@ -57,6 +132,42 @@ Set-Alias -Name cd -Value Set-LocationWithTfv -Scope Global -Force
|
|
|
57
132
|
|
|
58
133
|
# Run for current directory on shell startup
|
|
59
134
|
Set-LocationWithTfv -Path $PWD
|
|
135
|
+
|
|
136
|
+
# ── Tab completions ────────────────────────────────────────────────────────
|
|
137
|
+
Register-ArgumentCompleter -Native -CommandName tfv -ScriptBlock {
|
|
138
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
139
|
+
|
|
140
|
+
$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')
|
|
141
|
+
$elements = $commandAst.CommandElements
|
|
142
|
+
$prev = if ($elements.Count -ge 2) { $elements[$elements.Count - 2].ToString() } else { '' }
|
|
143
|
+
|
|
144
|
+
# Detect --provider / -p anywhere on the line
|
|
145
|
+
$provider = 'terraform'
|
|
146
|
+
for ($i = 1; $i -lt $elements.Count - 1; $i++) {
|
|
147
|
+
if ($elements[$i].ToString() -in '--provider','-p') {
|
|
148
|
+
$pval = $elements[$i+1].ToString()
|
|
149
|
+
if ($pval -in 'tofu','opentofu') { $provider = 'opentofu' }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
$storeDir = Join-Path $env:USERPROFILE ".tfv\\store\\$provider"
|
|
154
|
+
$installed = if (Test-Path $storeDir) {
|
|
155
|
+
Get-ChildItem $storeDir -ErrorAction SilentlyContinue |
|
|
156
|
+
Where-Object { $_.Name -ne 'arch.json' } |
|
|
157
|
+
ForEach-Object { $_.Name -replace '\\.exe$','' }
|
|
158
|
+
} else { @() }
|
|
159
|
+
|
|
160
|
+
$completions = switch ($prev) {
|
|
161
|
+
{ $_ -in 'use','exec','remove','rm','pin','upgrade','install','i' } { @($installed) + 'latest' }
|
|
162
|
+
{ $_ -in '--provider','-p' } { 'terraform','tofu','opentofu' }
|
|
163
|
+
'shell-init' { 'bash','zsh','fish','powershell','pwsh' }
|
|
164
|
+
default { $commands }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
$completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
|
|
168
|
+
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
60
171
|
`.trim();
|
|
61
172
|
|
|
62
173
|
const INSTALL_INSTRUCTIONS = {
|
|
@@ -105,9 +216,12 @@ exports.shellInit = (shell) => {
|
|
|
105
216
|
process.stdout.write(BASH_ZSH_HOOK + '\n');
|
|
106
217
|
}
|
|
107
218
|
|
|
108
|
-
//
|
|
219
|
+
// Only print install instructions when stdout is a TTY (i.e. the user ran
|
|
220
|
+
// tfv shell-init directly). When called inside $() for eval, stdout is a
|
|
221
|
+
// pipe so isTTY is falsy — suppress the message to avoid it printing on
|
|
222
|
+
// every `source ~/.zshrc`.
|
|
109
223
|
const key = s === 'pwsh' ? 'powershell' : s;
|
|
110
|
-
if (INSTALL_INSTRUCTIONS[key]) {
|
|
224
|
+
if (INSTALL_INSTRUCTIONS[key] && process.stdout.isTTY) {
|
|
111
225
|
process.stderr.write(INSTALL_INSTRUCTIONS[key] + '\n');
|
|
112
226
|
}
|
|
113
227
|
};
|