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