@ngocsangairvds/vsaf 4.1.7 → 4.1.8
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/package.json +1 -1
- package/skills/vds-skill/install-deps.mjs +160 -3
- package/skills/vds-skill/vds-scripts/vds_cli/src/vds_cli/cli.py +32 -0
- package/skills/vds-skill/vds-scripts/vds_cli/tests/unit/test_cli.py +23 -0
- package/skills/vds-skill/vds-scripts/vds_cli_common/src/vds_cli_common/migrate_sdlc_config.py +210 -0
- package/skills/vds-skill/vds-scripts/vds_cli_common/tests/test_migrate_sdlc_config.py +257 -0
package/package.json
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
* node install-deps.mjs [projectPath]
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, chmodSync, unlinkSync } from 'fs';
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, chmodSync, unlinkSync, renameSync } from 'fs';
|
|
14
14
|
import { join, dirname, delimiter } from 'path';
|
|
15
15
|
import { homedir } from 'os';
|
|
16
16
|
|
|
17
17
|
const projectPath = process.argv[2] || process.cwd();
|
|
18
18
|
const packDir = process.argv[3] || dirname(new URL(import.meta.url).pathname);
|
|
19
|
+
const skipMigration = process.argv.includes('--skip-migration');
|
|
19
20
|
|
|
20
21
|
// ── Config ──
|
|
21
22
|
|
|
@@ -57,13 +58,169 @@ function parseExistingConfig(filePath) {
|
|
|
57
58
|
return vars;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
// ──
|
|
61
|
+
// ── Migration: sdlc-config.env → .env ──
|
|
62
|
+
|
|
63
|
+
const LEGACY_CONFIG = join(VDS_DIR, 'sdlc-config.env');
|
|
64
|
+
|
|
65
|
+
const VAR_MAPPING = {
|
|
66
|
+
'VDS_CONFLUENCE_TOKEN': 'INTERNAL_CONFLUENCE_TOKEN',
|
|
67
|
+
'VDS_JIRA_TOKEN': 'JIRA_TOKEN',
|
|
68
|
+
'VDS_BITBUCKET_TOKEN': 'BITBUCKET_TOKEN',
|
|
69
|
+
'VDS_USERNAME': 'VDS_USERNAME',
|
|
70
|
+
'VDS_PASSWORD': 'VDS_PASSWORD',
|
|
71
|
+
'VDS_CONFLUENCE_SPACE_DEFAULT': 'VDS_CONFLUENCE_SPACE_DEFAULT',
|
|
72
|
+
'VDS_JIRA_PROJECT_DEFAULT': 'VDS_JIRA_PROJECT_DEFAULT',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const PLACEHOLDERS = new Set([
|
|
76
|
+
'changeme', '<your-token>', 'xxx', 'todo',
|
|
77
|
+
'your-confluence-token', 'your-jira-token', 'your-bitbucket-token',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
function parseLegacyEnv(filePath) {
|
|
81
|
+
if (!existsSync(filePath)) return {};
|
|
82
|
+
let raw = readFileSync(filePath);
|
|
83
|
+
// Strip UTF-8 BOM
|
|
84
|
+
if (raw[0] === 0xEF && raw[1] === 0xBB && raw[2] === 0xBF) {
|
|
85
|
+
raw = raw.subarray(3);
|
|
86
|
+
}
|
|
87
|
+
const text = raw.toString('utf-8');
|
|
88
|
+
const vars = {};
|
|
89
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
90
|
+
let line = rawLine.trim();
|
|
91
|
+
if (!line || line.startsWith('#')) continue;
|
|
92
|
+
if (line.startsWith('export ')) line = line.slice(7).trimStart();
|
|
93
|
+
const eq = line.indexOf('=');
|
|
94
|
+
if (eq <= 0) continue;
|
|
95
|
+
const key = line.slice(0, eq).trim();
|
|
96
|
+
let val = line.slice(eq + 1).trim();
|
|
97
|
+
if (val.length >= 2 && val[0] === val[val.length - 1] && (val[0] === "'" || val[0] === '"')) {
|
|
98
|
+
val = val.slice(1, -1);
|
|
99
|
+
}
|
|
100
|
+
if (val) vars[key] = val;
|
|
101
|
+
}
|
|
102
|
+
return vars;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function migrateSdlcConfig() {
|
|
106
|
+
if (!existsSync(LEGACY_CONFIG)) {
|
|
107
|
+
log('ℹ️', 'No legacy sdlc-config.env found — skip migration.');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const legacy = parseLegacyEnv(LEGACY_CONFIG);
|
|
112
|
+
if (Object.keys(legacy).length === 0) {
|
|
113
|
+
log('ℹ️', 'sdlc-config.env is empty — skip migration.');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const target = parseExistingConfig(CONFIG_FILE);
|
|
118
|
+
|
|
119
|
+
const toWrite = {};
|
|
120
|
+
const migrated = [];
|
|
121
|
+
const skipped = [];
|
|
122
|
+
const warnings = [];
|
|
123
|
+
|
|
124
|
+
for (const [oldKey, value] of Object.entries(legacy)) {
|
|
125
|
+
if (!(oldKey in VAR_MAPPING)) {
|
|
126
|
+
warnings.push(oldKey);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const newKey = VAR_MAPPING[oldKey];
|
|
130
|
+
if (PLACEHOLDERS.has(value.toLowerCase())) {
|
|
131
|
+
skipped.push(oldKey);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// Don't overwrite real values
|
|
135
|
+
const existing = target[newKey] || '';
|
|
136
|
+
if (existing && !PLACEHOLDERS.has(existing.toLowerCase())) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
toWrite[newKey] = value;
|
|
140
|
+
migrated.push(newKey);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (migrated.length === 0) {
|
|
144
|
+
log('ℹ️', 'Nothing to migrate — target already has all values.');
|
|
145
|
+
if (warnings.length > 0) {
|
|
146
|
+
log('⚠️', `Unknown keys in sdlc-config.env: ${warnings.join(', ')}`);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Backup existing .env
|
|
152
|
+
if (existsSync(CONFIG_FILE)) {
|
|
153
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
154
|
+
const backup = join(VDS_DIR, `.env.bak.${ts}`);
|
|
155
|
+
writeFileSync(backup, readFileSync(CONFIG_FILE));
|
|
156
|
+
log('📋', `Backup: ${backup}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Read existing content or start fresh
|
|
160
|
+
let lines = existsSync(CONFIG_FILE)
|
|
161
|
+
? readFileSync(CONFIG_FILE, 'utf-8').split('\n')
|
|
162
|
+
: [];
|
|
163
|
+
|
|
164
|
+
// Update in-place where key exists with empty value
|
|
165
|
+
const updated = new Set();
|
|
166
|
+
lines = lines.map(line => {
|
|
167
|
+
const stripped = line.trim();
|
|
168
|
+
if (!stripped || stripped.startsWith('#')) return line;
|
|
169
|
+
const eq = stripped.indexOf('=');
|
|
170
|
+
if (eq <= 0) return line;
|
|
171
|
+
const key = stripped.slice(0, eq).trim();
|
|
172
|
+
if (key in toWrite) {
|
|
173
|
+
updated.add(key);
|
|
174
|
+
return `${key}=${toWrite[key]}`;
|
|
175
|
+
}
|
|
176
|
+
return line;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Append remaining
|
|
180
|
+
const remaining = Object.entries(toWrite).filter(([k]) => !updated.has(k));
|
|
181
|
+
if (remaining.length > 0) {
|
|
182
|
+
if (lines.length > 0 && lines[lines.length - 1].trim() !== '') lines.push('');
|
|
183
|
+
lines.push('# --- Migrated from sdlc-config.env ---');
|
|
184
|
+
for (const [key, val] of remaining) {
|
|
185
|
+
lines.push(`${key}=${val}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Atomic write
|
|
190
|
+
const tmp = join(VDS_DIR, '.env.tmp');
|
|
191
|
+
writeFileSync(tmp, lines.join('\n') + '\n');
|
|
192
|
+
if (process.platform !== 'win32') {
|
|
193
|
+
chmodSync(tmp, 0o600);
|
|
194
|
+
}
|
|
195
|
+
renameSync(tmp, CONFIG_FILE);
|
|
196
|
+
|
|
197
|
+
log('✅', `Migrated ${migrated.length} credential(s) from sdlc-config.env:`);
|
|
198
|
+
for (const name of migrated) {
|
|
199
|
+
log(' ', `+ ${name}`);
|
|
200
|
+
}
|
|
201
|
+
if (skipped.length > 0) {
|
|
202
|
+
log('ℹ️', `Skipped ${skipped.length} placeholder(s).`);
|
|
203
|
+
}
|
|
204
|
+
if (warnings.length > 0) {
|
|
205
|
+
log('⚠️', `Unknown keys (not migrated): ${warnings.join(', ')}`);
|
|
206
|
+
}
|
|
207
|
+
console.log('');
|
|
208
|
+
log('💡', `Consider removing ${LEGACY_CONFIG} to avoid confusion.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Step 0: Migrate legacy sdlc-config.env (if exists) ──
|
|
61
212
|
|
|
62
213
|
console.log('\n[vds-skill] Setting up VDS credentials...\n');
|
|
63
214
|
|
|
64
215
|
mkdirSync(VDS_DIR, { recursive: true });
|
|
65
216
|
|
|
66
|
-
|
|
217
|
+
if (!skipMigration) {
|
|
218
|
+
migrateSdlcConfig();
|
|
219
|
+
} else {
|
|
220
|
+
log('ℹ️', 'Migration skipped (--skip-migration).');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Step 1: Create or merge credential template ──
|
|
67
224
|
|
|
68
225
|
const existing = parseExistingConfig(CONFIG_FILE);
|
|
69
226
|
const missing = [];
|
|
@@ -116,6 +116,38 @@ def env_status() -> None:
|
|
|
116
116
|
console.print(" 4. Set INTERNAL_CONFLUENCE_TOKEN/EXTERNAL_CONFLUENCE_TOKEN (or VDS credentials).")
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
@env_app.command("migrate-sdlc-config")
|
|
120
|
+
def env_migrate_sdlc_config() -> None:
|
|
121
|
+
"""Migrate credentials from legacy ~/.vds/sdlc-config.env to ~/.vds/.env."""
|
|
122
|
+
from vds_cli_common.migrate_sdlc_config import migrate_sdlc_config
|
|
123
|
+
|
|
124
|
+
legacy_path = get_shared_env_path().parent / "sdlc-config.env"
|
|
125
|
+
target_path = get_shared_env_path()
|
|
126
|
+
|
|
127
|
+
result = migrate_sdlc_config(legacy_path, target_path)
|
|
128
|
+
|
|
129
|
+
if result.legacy_not_found:
|
|
130
|
+
console.print("[dim]No legacy sdlc-config.env found — nothing to migrate.[/dim]")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
if result.migrated:
|
|
134
|
+
console.print("[green]Migrated credentials:[/green]")
|
|
135
|
+
for name in result.migrated:
|
|
136
|
+
console.print(f" [green]+[/green] {name}")
|
|
137
|
+
|
|
138
|
+
if result.skipped:
|
|
139
|
+
console.print(f"\n[dim]Skipped {len(result.skipped)} placeholder(s).[/dim]")
|
|
140
|
+
|
|
141
|
+
for warning in result.warnings:
|
|
142
|
+
console.print(f"[yellow]Warning: {warning}[/yellow]")
|
|
143
|
+
|
|
144
|
+
if result.migrated:
|
|
145
|
+
console.print(f"\n[yellow]Suggestion: rename or remove {legacy_path}[/yellow]")
|
|
146
|
+
console.print("[yellow] to avoid confusion on next install.[/yellow]")
|
|
147
|
+
else:
|
|
148
|
+
console.print("[dim]Nothing to migrate — target already has all values.[/dim]")
|
|
149
|
+
|
|
150
|
+
|
|
119
151
|
@env_app.command("install-git-helper")
|
|
120
152
|
def env_install_git_helper(
|
|
121
153
|
force: bool = typer.Option(
|
|
@@ -203,6 +203,29 @@ def test_cli_status_command(mock_load: Mock, mock_script_dir: Path) -> None:
|
|
|
203
203
|
assert "Orchestrator" in result.stdout or "Environment" in result.stdout
|
|
204
204
|
|
|
205
205
|
|
|
206
|
+
def test_cli_env_migrate_sdlc_config_no_legacy(mock_home_dir: Path) -> None:
|
|
207
|
+
"""When no sdlc-config.env exists, command exits 0 with skip message."""
|
|
208
|
+
result = runner.invoke(app, ["env", "migrate-sdlc-config"])
|
|
209
|
+
assert result.exit_code == 0
|
|
210
|
+
assert "No legacy" in result.stdout or "not found" in result.stdout
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_cli_env_migrate_sdlc_config_with_legacy(mock_home_dir: Path) -> None:
|
|
214
|
+
"""When sdlc-config.env exists, command migrates and reports."""
|
|
215
|
+
legacy = mock_home_dir / ".vds" / "sdlc-config.env"
|
|
216
|
+
legacy.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
legacy.write_text("VDS_CONFLUENCE_TOKEN=test-token\n")
|
|
218
|
+
|
|
219
|
+
result = runner.invoke(app, ["env", "migrate-sdlc-config"])
|
|
220
|
+
assert result.exit_code == 0
|
|
221
|
+
assert "INTERNAL_CONFLUENCE_TOKEN" in result.stdout
|
|
222
|
+
|
|
223
|
+
# Verify target file was created
|
|
224
|
+
target = mock_home_dir / ".vds" / ".env"
|
|
225
|
+
assert target.exists()
|
|
226
|
+
assert "INTERNAL_CONFLUENCE_TOKEN=test-token" in target.read_text()
|
|
227
|
+
|
|
228
|
+
|
|
206
229
|
@patch("vds_cli.cli._env_git_helper")
|
|
207
230
|
@patch("vds_cli.cli.validate_orchestrator")
|
|
208
231
|
@patch("vds_cli.cli.load_environment")
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Migrate credentials from legacy ~/.vds/sdlc-config.env to ~/.vds/.env.
|
|
2
|
+
|
|
3
|
+
The old installer wrote credentials with wrong variable names to a file
|
|
4
|
+
that vds-cli never reads. This module:
|
|
5
|
+
1. Parses sdlc-config.env
|
|
6
|
+
2. Maps old var names → names the orchestrators actually expect
|
|
7
|
+
3. Merges into ~/.vds/.env without overwriting user-set values
|
|
8
|
+
4. Creates a timestamped backup of .env before modifying
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── Mapping table ──
|
|
20
|
+
# Source: orchestrator config.py files (pydantic alias= fields)
|
|
21
|
+
|
|
22
|
+
MAPPING: dict[str, str] = {
|
|
23
|
+
"VDS_CONFLUENCE_TOKEN": "INTERNAL_CONFLUENCE_TOKEN",
|
|
24
|
+
"VDS_JIRA_TOKEN": "JIRA_TOKEN",
|
|
25
|
+
"VDS_BITBUCKET_TOKEN": "BITBUCKET_TOKEN",
|
|
26
|
+
# These pass through unchanged
|
|
27
|
+
"VDS_USERNAME": "VDS_USERNAME",
|
|
28
|
+
"VDS_PASSWORD": "VDS_PASSWORD",
|
|
29
|
+
"VDS_CONFLUENCE_SPACE_DEFAULT": "VDS_CONFLUENCE_SPACE_DEFAULT",
|
|
30
|
+
"VDS_JIRA_PROJECT_DEFAULT": "VDS_JIRA_PROJECT_DEFAULT",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
PLACEHOLDERS = frozenset({
|
|
34
|
+
"changeme",
|
|
35
|
+
"<your-token>",
|
|
36
|
+
"xxx",
|
|
37
|
+
"todo",
|
|
38
|
+
"your-confluence-token",
|
|
39
|
+
"your-jira-token",
|
|
40
|
+
"your-bitbucket-token",
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class MigrationResult:
|
|
46
|
+
migrated: list[str] = field(default_factory=list)
|
|
47
|
+
skipped: list[str] = field(default_factory=list)
|
|
48
|
+
warnings: list[str] = field(default_factory=list)
|
|
49
|
+
legacy_not_found: bool = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_legacy_env(path: Path) -> dict[str, str]:
|
|
53
|
+
"""Parse a shell-style .env file into {key: value}.
|
|
54
|
+
|
|
55
|
+
Handles: comments, blank lines, export prefix, single/double quotes,
|
|
56
|
+
CRLF line endings, UTF-8 BOM, equals signs in values.
|
|
57
|
+
Skips entries with empty/whitespace-only values.
|
|
58
|
+
"""
|
|
59
|
+
if not path.exists():
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
raw = path.read_bytes()
|
|
63
|
+
# Strip UTF-8 BOM
|
|
64
|
+
if raw.startswith(b"\xef\xbb\xbf"):
|
|
65
|
+
raw = raw[3:]
|
|
66
|
+
text = raw.decode("utf-8")
|
|
67
|
+
|
|
68
|
+
result: dict[str, str] = {}
|
|
69
|
+
for line in text.splitlines():
|
|
70
|
+
line = line.strip()
|
|
71
|
+
if not line or line.startswith("#"):
|
|
72
|
+
continue
|
|
73
|
+
if line.startswith("export "):
|
|
74
|
+
line = line[7:].lstrip()
|
|
75
|
+
eq = line.find("=")
|
|
76
|
+
if eq <= 0:
|
|
77
|
+
continue
|
|
78
|
+
key = line[:eq].strip()
|
|
79
|
+
val = line[eq + 1:].strip()
|
|
80
|
+
# Strip matching quotes
|
|
81
|
+
if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
|
|
82
|
+
val = val[1:-1]
|
|
83
|
+
if not val:
|
|
84
|
+
continue
|
|
85
|
+
result[key] = val
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_placeholder(value: str) -> bool:
|
|
90
|
+
return value.lower() in PLACEHOLDERS
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_target_active_keys(path: Path) -> dict[str, str]:
|
|
94
|
+
"""Parse target .env, returning only active (uncommented, non-empty) keys."""
|
|
95
|
+
if not path.exists():
|
|
96
|
+
return {}
|
|
97
|
+
result: dict[str, str] = {}
|
|
98
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
99
|
+
stripped = line.strip()
|
|
100
|
+
if not stripped or stripped.startswith("#"):
|
|
101
|
+
continue
|
|
102
|
+
if stripped.startswith("export "):
|
|
103
|
+
stripped = stripped[7:].lstrip()
|
|
104
|
+
eq = stripped.find("=")
|
|
105
|
+
if eq <= 0:
|
|
106
|
+
continue
|
|
107
|
+
key = stripped[:eq].strip()
|
|
108
|
+
val = stripped[eq + 1:].strip()
|
|
109
|
+
if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
|
|
110
|
+
val = val[1:-1]
|
|
111
|
+
result[key] = val
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def migrate_sdlc_config(
|
|
116
|
+
legacy_path: Path,
|
|
117
|
+
target_path: Path,
|
|
118
|
+
) -> MigrationResult:
|
|
119
|
+
"""Migrate credentials from sdlc-config.env → .env.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
legacy_path: Path to ~/.vds/sdlc-config.env
|
|
123
|
+
target_path: Path to ~/.vds/.env
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
MigrationResult with lists of migrated, skipped, and warning entries.
|
|
127
|
+
"""
|
|
128
|
+
result = MigrationResult()
|
|
129
|
+
|
|
130
|
+
if not legacy_path.exists():
|
|
131
|
+
result.legacy_not_found = True
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
legacy_vars = parse_legacy_env(legacy_path)
|
|
135
|
+
if not legacy_vars:
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
target_active = _parse_target_active_keys(target_path)
|
|
139
|
+
|
|
140
|
+
# Build list of vars to write
|
|
141
|
+
to_write: dict[str, str] = {}
|
|
142
|
+
|
|
143
|
+
for old_key, value in legacy_vars.items():
|
|
144
|
+
if old_key not in MAPPING:
|
|
145
|
+
result.warnings.append(f"Unknown key '{old_key}' in sdlc-config.env — not migrated")
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
new_key = MAPPING[old_key]
|
|
149
|
+
|
|
150
|
+
if _is_placeholder(value):
|
|
151
|
+
result.skipped.append(old_key)
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# Check if target already has a real value for this key
|
|
155
|
+
existing = target_active.get(new_key, "")
|
|
156
|
+
if existing and not _is_placeholder(existing):
|
|
157
|
+
# Target has real value — do not overwrite
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
to_write[new_key] = value
|
|
161
|
+
result.migrated.append(new_key)
|
|
162
|
+
|
|
163
|
+
if not to_write:
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
# Backup existing target if it exists
|
|
167
|
+
if target_path.exists():
|
|
168
|
+
ts = str(int(time.time()))
|
|
169
|
+
backup = target_path.parent / f".env.bak.{ts}"
|
|
170
|
+
backup.write_text(target_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
171
|
+
|
|
172
|
+
# Read existing content (or start fresh)
|
|
173
|
+
if target_path.exists():
|
|
174
|
+
lines = target_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
175
|
+
else:
|
|
176
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
lines = []
|
|
178
|
+
|
|
179
|
+
# Update existing lines in-place where key exists with empty/placeholder value
|
|
180
|
+
updated_keys: set[str] = set()
|
|
181
|
+
new_lines: list[str] = []
|
|
182
|
+
for line in lines:
|
|
183
|
+
stripped = line.strip()
|
|
184
|
+
if stripped and not stripped.startswith("#"):
|
|
185
|
+
eq = stripped.find("=")
|
|
186
|
+
if eq > 0:
|
|
187
|
+
key = stripped[:eq].strip()
|
|
188
|
+
if key in to_write:
|
|
189
|
+
new_lines.append(f"{key}={to_write[key]}\n")
|
|
190
|
+
updated_keys.add(key)
|
|
191
|
+
continue
|
|
192
|
+
new_lines.append(line if line.endswith("\n") else line + "\n")
|
|
193
|
+
|
|
194
|
+
# Append remaining keys not already in file
|
|
195
|
+
remaining = {k: v for k, v in to_write.items() if k not in updated_keys}
|
|
196
|
+
if remaining:
|
|
197
|
+
if new_lines and not new_lines[-1].strip() == "":
|
|
198
|
+
new_lines.append("\n")
|
|
199
|
+
new_lines.append("# --- Migrated from sdlc-config.env ---\n")
|
|
200
|
+
for key, val in remaining.items():
|
|
201
|
+
new_lines.append(f"{key}={val}\n")
|
|
202
|
+
|
|
203
|
+
# Atomic write via temp file
|
|
204
|
+
tmp_path = target_path.parent / ".env.tmp"
|
|
205
|
+
tmp_path.write_text("".join(new_lines), encoding="utf-8")
|
|
206
|
+
if os.name != "nt":
|
|
207
|
+
os.chmod(tmp_path, 0o600)
|
|
208
|
+
tmp_path.rename(target_path)
|
|
209
|
+
|
|
210
|
+
return result
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Unit tests for sdlc-config.env → .env migration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ── Fixtures ──
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def vds_dir(tmp_path: Path) -> Path:
|
|
17
|
+
"""Create a temporary ~/.vds/ directory."""
|
|
18
|
+
d = tmp_path / ".vds"
|
|
19
|
+
d.mkdir()
|
|
20
|
+
return d
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def legacy_file(vds_dir: Path) -> Path:
|
|
25
|
+
"""Create a populated sdlc-config.env."""
|
|
26
|
+
f = vds_dir / "sdlc-config.env"
|
|
27
|
+
f.write_text(
|
|
28
|
+
"# VDS Skill Pack — credential config\n"
|
|
29
|
+
"VDS_CONFLUENCE_TOKEN=my-confluence-pat\n"
|
|
30
|
+
"VDS_JIRA_TOKEN=my-jira-pat\n"
|
|
31
|
+
"VDS_BITBUCKET_TOKEN=my-bitbucket-pat\n"
|
|
32
|
+
"VDS_USERNAME=myuser\n"
|
|
33
|
+
"VDS_PASSWORD=mypass\n"
|
|
34
|
+
"VDS_CONFLUENCE_SPACE_DEFAULT=CEP\n"
|
|
35
|
+
"VDS_JIRA_PROJECT_DEFAULT=NTTC\n",
|
|
36
|
+
encoding="utf-8",
|
|
37
|
+
)
|
|
38
|
+
return f
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def target_file(vds_dir: Path) -> Path:
|
|
43
|
+
"""Return path to target .env (does not create it)."""
|
|
44
|
+
return vds_dir / ".env"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── Tests ──
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestParseEnvFile:
|
|
51
|
+
def test_basic_parse(self, vds_dir: Path) -> None:
|
|
52
|
+
from vds_cli_common.migrate_sdlc_config import parse_legacy_env
|
|
53
|
+
|
|
54
|
+
f = vds_dir / "test.env"
|
|
55
|
+
f.write_text("KEY1=value1\nKEY2=value2\n")
|
|
56
|
+
result = parse_legacy_env(f)
|
|
57
|
+
assert result == {"KEY1": "value1", "KEY2": "value2"}
|
|
58
|
+
|
|
59
|
+
def test_skips_comments_and_blanks(self, vds_dir: Path) -> None:
|
|
60
|
+
from vds_cli_common.migrate_sdlc_config import parse_legacy_env
|
|
61
|
+
|
|
62
|
+
f = vds_dir / "test.env"
|
|
63
|
+
f.write_text("# comment\n\nKEY=val\n # indented comment\n")
|
|
64
|
+
result = parse_legacy_env(f)
|
|
65
|
+
assert result == {"KEY": "val"}
|
|
66
|
+
|
|
67
|
+
def test_handles_quoted_values(self, vds_dir: Path) -> None:
|
|
68
|
+
from vds_cli_common.migrate_sdlc_config import parse_legacy_env
|
|
69
|
+
|
|
70
|
+
f = vds_dir / "test.env"
|
|
71
|
+
f.write_text('SINGLE=\'hello world\'\nDOUBLE="foo bar"\n')
|
|
72
|
+
result = parse_legacy_env(f)
|
|
73
|
+
assert result == {"SINGLE": "hello world", "DOUBLE": "foo bar"}
|
|
74
|
+
|
|
75
|
+
def test_handles_equals_in_value(self, vds_dir: Path) -> None:
|
|
76
|
+
from vds_cli_common.migrate_sdlc_config import parse_legacy_env
|
|
77
|
+
|
|
78
|
+
f = vds_dir / "test.env"
|
|
79
|
+
f.write_text("TOKEN=abc123==\n") # base64 padding
|
|
80
|
+
result = parse_legacy_env(f)
|
|
81
|
+
assert result == {"TOKEN": "abc123=="}
|
|
82
|
+
|
|
83
|
+
def test_handles_export_prefix(self, vds_dir: Path) -> None:
|
|
84
|
+
from vds_cli_common.migrate_sdlc_config import parse_legacy_env
|
|
85
|
+
|
|
86
|
+
f = vds_dir / "test.env"
|
|
87
|
+
f.write_text("export KEY=val\n")
|
|
88
|
+
result = parse_legacy_env(f)
|
|
89
|
+
assert result == {"KEY": "val"}
|
|
90
|
+
|
|
91
|
+
def test_handles_crlf(self, vds_dir: Path) -> None:
|
|
92
|
+
from vds_cli_common.migrate_sdlc_config import parse_legacy_env
|
|
93
|
+
|
|
94
|
+
f = vds_dir / "test.env"
|
|
95
|
+
f.write_bytes(b"KEY1=a\r\nKEY2=b\r\n")
|
|
96
|
+
result = parse_legacy_env(f)
|
|
97
|
+
assert result == {"KEY1": "a", "KEY2": "b"}
|
|
98
|
+
|
|
99
|
+
def test_handles_bom(self, vds_dir: Path) -> None:
|
|
100
|
+
from vds_cli_common.migrate_sdlc_config import parse_legacy_env
|
|
101
|
+
|
|
102
|
+
f = vds_dir / "test.env"
|
|
103
|
+
f.write_bytes(b"\xef\xbb\xbfKEY=val\n")
|
|
104
|
+
result = parse_legacy_env(f)
|
|
105
|
+
assert result == {"KEY": "val"}
|
|
106
|
+
|
|
107
|
+
def test_nonexistent_file_returns_empty(self, vds_dir: Path) -> None:
|
|
108
|
+
from vds_cli_common.migrate_sdlc_config import parse_legacy_env
|
|
109
|
+
|
|
110
|
+
result = parse_legacy_env(vds_dir / "nope.env")
|
|
111
|
+
assert result == {}
|
|
112
|
+
|
|
113
|
+
def test_skips_empty_values(self, vds_dir: Path) -> None:
|
|
114
|
+
from vds_cli_common.migrate_sdlc_config import parse_legacy_env
|
|
115
|
+
|
|
116
|
+
f = vds_dir / "test.env"
|
|
117
|
+
f.write_text("FILLED=ok\nEMPTY=\nALSO_EMPTY= \n")
|
|
118
|
+
result = parse_legacy_env(f)
|
|
119
|
+
assert result == {"FILLED": "ok"}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TestMigration:
|
|
123
|
+
def test_basic_migration(self, legacy_file: Path, target_file: Path) -> None:
|
|
124
|
+
from vds_cli_common.migrate_sdlc_config import migrate_sdlc_config
|
|
125
|
+
|
|
126
|
+
result = migrate_sdlc_config(legacy_file, target_file)
|
|
127
|
+
assert result.migrated == [
|
|
128
|
+
"INTERNAL_CONFLUENCE_TOKEN",
|
|
129
|
+
"JIRA_TOKEN",
|
|
130
|
+
"BITBUCKET_TOKEN",
|
|
131
|
+
"VDS_USERNAME",
|
|
132
|
+
"VDS_PASSWORD",
|
|
133
|
+
"VDS_CONFLUENCE_SPACE_DEFAULT",
|
|
134
|
+
"VDS_JIRA_PROJECT_DEFAULT",
|
|
135
|
+
]
|
|
136
|
+
assert result.skipped == []
|
|
137
|
+
assert result.warnings == []
|
|
138
|
+
|
|
139
|
+
# Verify file content
|
|
140
|
+
content = target_file.read_text(encoding="utf-8")
|
|
141
|
+
assert "INTERNAL_CONFLUENCE_TOKEN=my-confluence-pat" in content
|
|
142
|
+
assert "JIRA_TOKEN=my-jira-pat" in content
|
|
143
|
+
assert "BITBUCKET_TOKEN=my-bitbucket-pat" in content
|
|
144
|
+
assert "VDS_USERNAME=myuser" in content
|
|
145
|
+
|
|
146
|
+
def test_skips_placeholders(self, vds_dir: Path, target_file: Path) -> None:
|
|
147
|
+
from vds_cli_common.migrate_sdlc_config import migrate_sdlc_config
|
|
148
|
+
|
|
149
|
+
legacy = vds_dir / "sdlc-config.env"
|
|
150
|
+
legacy.write_text(
|
|
151
|
+
"VDS_CONFLUENCE_TOKEN=changeme\n"
|
|
152
|
+
"VDS_JIRA_TOKEN=<your-token>\n"
|
|
153
|
+
"VDS_BITBUCKET_TOKEN=xxx\n"
|
|
154
|
+
"VDS_USERNAME=realuser\n",
|
|
155
|
+
)
|
|
156
|
+
result = migrate_sdlc_config(legacy, target_file)
|
|
157
|
+
assert "VDS_USERNAME" in result.migrated
|
|
158
|
+
assert len(result.skipped) == 3
|
|
159
|
+
content = target_file.read_text(encoding="utf-8")
|
|
160
|
+
assert "VDS_USERNAME=realuser" in content
|
|
161
|
+
assert "INTERNAL_CONFLUENCE_TOKEN" not in content
|
|
162
|
+
|
|
163
|
+
def test_no_overwrite_existing_real_values(self, legacy_file: Path, target_file: Path) -> None:
|
|
164
|
+
from vds_cli_common.migrate_sdlc_config import migrate_sdlc_config
|
|
165
|
+
|
|
166
|
+
# Pre-populate target with a real value
|
|
167
|
+
target_file.write_text(
|
|
168
|
+
"INTERNAL_CONFLUENCE_TOKEN=already-set-by-user\n"
|
|
169
|
+
"JIRA_TOKEN=\n", # empty — should be overwritten
|
|
170
|
+
)
|
|
171
|
+
result = migrate_sdlc_config(legacy_file, target_file)
|
|
172
|
+
content = target_file.read_text(encoding="utf-8")
|
|
173
|
+
# Real value preserved
|
|
174
|
+
assert "INTERNAL_CONFLUENCE_TOKEN=already-set-by-user" in content
|
|
175
|
+
# Empty value overwritten
|
|
176
|
+
assert "JIRA_TOKEN=my-jira-pat" in content
|
|
177
|
+
|
|
178
|
+
def test_overwrites_commented_var(self, legacy_file: Path, target_file: Path) -> None:
|
|
179
|
+
from vds_cli_common.migrate_sdlc_config import migrate_sdlc_config
|
|
180
|
+
|
|
181
|
+
target_file.write_text("# JIRA_TOKEN=old-commented-out\n")
|
|
182
|
+
result = migrate_sdlc_config(legacy_file, target_file)
|
|
183
|
+
content = target_file.read_text(encoding="utf-8")
|
|
184
|
+
assert "JIRA_TOKEN=my-jira-pat" in content
|
|
185
|
+
|
|
186
|
+
def test_idempotent(self, legacy_file: Path, target_file: Path) -> None:
|
|
187
|
+
from vds_cli_common.migrate_sdlc_config import migrate_sdlc_config
|
|
188
|
+
|
|
189
|
+
result1 = migrate_sdlc_config(legacy_file, target_file)
|
|
190
|
+
content_after_first = target_file.read_text(encoding="utf-8")
|
|
191
|
+
assert len(result1.migrated) == 7
|
|
192
|
+
|
|
193
|
+
result2 = migrate_sdlc_config(legacy_file, target_file)
|
|
194
|
+
content_after_second = target_file.read_text(encoding="utf-8")
|
|
195
|
+
assert result2.migrated == []
|
|
196
|
+
assert content_after_first == content_after_second
|
|
197
|
+
|
|
198
|
+
def test_warns_on_unknown_keys(self, vds_dir: Path, target_file: Path) -> None:
|
|
199
|
+
from vds_cli_common.migrate_sdlc_config import migrate_sdlc_config
|
|
200
|
+
|
|
201
|
+
legacy = vds_dir / "sdlc-config.env"
|
|
202
|
+
legacy.write_text("UNKNOWN_VAR=hello\nVDS_USERNAME=user\n")
|
|
203
|
+
result = migrate_sdlc_config(legacy, target_file)
|
|
204
|
+
assert "UNKNOWN_VAR" in result.warnings[0]
|
|
205
|
+
assert "VDS_USERNAME" in result.migrated
|
|
206
|
+
|
|
207
|
+
def test_creates_backup(self, legacy_file: Path, target_file: Path) -> None:
|
|
208
|
+
from vds_cli_common.migrate_sdlc_config import migrate_sdlc_config
|
|
209
|
+
|
|
210
|
+
target_file.write_text("EXISTING=value\n")
|
|
211
|
+
migrate_sdlc_config(legacy_file, target_file)
|
|
212
|
+
backups = list(target_file.parent.glob(".env.bak.*"))
|
|
213
|
+
assert len(backups) == 1
|
|
214
|
+
assert "EXISTING=value" in backups[0].read_text()
|
|
215
|
+
|
|
216
|
+
def test_no_backup_when_target_missing(self, legacy_file: Path, target_file: Path) -> None:
|
|
217
|
+
from vds_cli_common.migrate_sdlc_config import migrate_sdlc_config
|
|
218
|
+
|
|
219
|
+
assert not target_file.exists()
|
|
220
|
+
migrate_sdlc_config(legacy_file, target_file)
|
|
221
|
+
backups = list(target_file.parent.glob(".env.bak.*"))
|
|
222
|
+
assert len(backups) == 0
|
|
223
|
+
|
|
224
|
+
def test_file_permissions_0600(self, legacy_file: Path, target_file: Path) -> None:
|
|
225
|
+
from vds_cli_common.migrate_sdlc_config import migrate_sdlc_config
|
|
226
|
+
|
|
227
|
+
if os.name == "nt":
|
|
228
|
+
pytest.skip("Unix permissions not applicable on Windows")
|
|
229
|
+
migrate_sdlc_config(legacy_file, target_file)
|
|
230
|
+
mode = stat.S_IMODE(target_file.stat().st_mode)
|
|
231
|
+
assert mode == 0o600
|
|
232
|
+
|
|
233
|
+
def test_nonexistent_legacy_returns_skip(self, vds_dir: Path, target_file: Path) -> None:
|
|
234
|
+
from vds_cli_common.migrate_sdlc_config import migrate_sdlc_config
|
|
235
|
+
|
|
236
|
+
result = migrate_sdlc_config(vds_dir / "nope.env", target_file)
|
|
237
|
+
assert result.migrated == []
|
|
238
|
+
assert result.skipped == []
|
|
239
|
+
assert result.warnings == []
|
|
240
|
+
assert result.legacy_not_found is True
|
|
241
|
+
|
|
242
|
+
def test_preserves_existing_lines_and_comments(self, legacy_file: Path, target_file: Path) -> None:
|
|
243
|
+
from vds_cli_common.migrate_sdlc_config import migrate_sdlc_config
|
|
244
|
+
|
|
245
|
+
target_file.write_text(
|
|
246
|
+
"# My hand-written header\n"
|
|
247
|
+
"CUSTOM_VAR=keep-me\n"
|
|
248
|
+
"\n"
|
|
249
|
+
"# Another section\n"
|
|
250
|
+
"ANOTHER=also-keep\n",
|
|
251
|
+
)
|
|
252
|
+
migrate_sdlc_config(legacy_file, target_file)
|
|
253
|
+
content = target_file.read_text(encoding="utf-8")
|
|
254
|
+
assert "# My hand-written header" in content
|
|
255
|
+
assert "CUSTOM_VAR=keep-me" in content
|
|
256
|
+
assert "ANOTHER=also-keep" in content
|
|
257
|
+
assert "INTERNAL_CONFLUENCE_TOKEN=my-confluence-pat" in content
|