@ngocsangairvds/vsaf 4.1.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngocsangairvds/vsaf",
3
- "version": "4.1.6",
3
+ "version": "4.1.8",
4
4
  "description": "logging step",
5
5
  "main": "packages/core/dist/index.js",
6
6
  "types": "packages/core/dist/index.d.ts",
@@ -5,11 +5,11 @@
5
5
  * config-check.js — reusable pre-flight checker for vds-skill-* SKILL.md files.
6
6
  *
7
7
  * Usage:
8
- * node config-check.js --cmd vds-cli --env VDS_CONFLUENCE_TOKEN,VDS_CONFLUENCE_SPACE_DEFAULT
8
+ * node config-check.js --cmd vds-cli --env INTERNAL_CONFLUENCE_TOKEN
9
9
  *
10
10
  * Output (matches legacy bash format):
11
11
  * OK — all checks pass
12
- * BLOCKED — missing: vds-cli VDS_JIRA_TOKEN — something missing
12
+ * BLOCKED — missing: vds-cli JIRA_TOKEN — something missing
13
13
  * NOTE: /path/to/cmd exists but fails to run — broken shim detected
14
14
  */
15
15
 
@@ -72,5 +72,5 @@ if (missing.length === 0) {
72
72
  console.log('OK');
73
73
  } else {
74
74
  console.log(`BLOCKED — missing: ${missing.join(' ')}`);
75
- console.log('Fix: edit ~/.vds/sdlc-config.env (or run: vsaf install vds-skill)');
75
+ console.log('Fix: edit ~/.vds/.env (or run: vsaf install vds-skill)');
76
76
  }
@@ -9,7 +9,7 @@ const { execFileSync } = require('child_process');
9
9
  const IS_WIN = process.platform === 'win32';
10
10
 
11
11
  function getConfigPath() {
12
- return process.env.VSAF_CONFIG_FILE || join(homedir(), '.vds', 'sdlc-config.env');
12
+ return process.env.VSAF_CONFIG_FILE || join(homedir(), '.vds', '.env');
13
13
  }
14
14
 
15
15
  function parseEnvFile(filePath) {
@@ -12,7 +12,7 @@ Create a Bitbucket PR on Viettel internal Bitbucket via `vds-cli`.
12
12
  Before doing anything, run this check via Bash tool:
13
13
 
14
14
  ```bash
15
- node .claude/skills/_shared/vds-skill/config-check.js --cmd vds-cli --env VDS_BITBUCKET_TOKEN
15
+ node .claude/skills/_shared/vds-skill/config-check.js --cmd vds-cli --env BITBUCKET_TOKEN
16
16
  ```
17
17
 
18
18
  If BLOCKED: tell the user exactly what's missing and how to fix, then STOP. Do NOT fabricate output. `--dry-run` mode skips credential + vds-cli checks.
@@ -22,7 +22,7 @@ If BLOCKED: tell the user exactly what's missing and how to fix, then STOP. Do N
22
22
  - `vds-cli` installed (for non-dry-run)
23
23
  - Inside a git repo with remote pointing to `bitbucket.digital.vn`
24
24
  - On a named branch (not detached HEAD)
25
- - `VDS_BITBUCKET_TOKEN` in `~/.vds/sdlc-config.env`
25
+ - `BITBUCKET_TOKEN` in `~/.vds/.env`
26
26
 
27
27
  ## Usage
28
28
 
@@ -40,7 +40,7 @@ If BLOCKED: tell the user exactly what's missing and how to fix, then STOP. Do N
40
40
  2. Extract PROJECT/REPO from remote URL
41
41
  3. Determine source branch (current) + target (default `master`)
42
42
  4. Auto-detect description from `.vsaf/docs/features/*/09-ship.md` or `.vsaf/docs/hotfixes/*/03-ship.md`
43
- 5. Lazy-prompt `VDS_BITBUCKET_TOKEN` if not set
43
+ 5. Lazy-prompt `BITBUCKET_TOKEN` if not set
44
44
  6. Confirm with user before executing
45
45
  7. Run `vds-cli bitbucket pr create $PROJECT/$REPO --source ... --target ... --title ... --description-file ... --yes --json-only`
46
46
 
@@ -106,7 +106,7 @@ if (!cmdCheck.found) {
106
106
  }
107
107
 
108
108
  (async () => {
109
- if (!await ensureEnv('VDS_BITBUCKET_TOKEN', 'Enter VDS Bitbucket personal access token')) process.exit(1);
109
+ if (!await ensureEnv('BITBUCKET_TOKEN', 'Enter Bitbucket personal access token')) process.exit(1);
110
110
 
111
111
  console.log('About to create PR:');
112
112
  console.log(` Project/Repo: ${project}/${repo}`);
@@ -12,7 +12,7 @@ Create a Jira Epic on Viettel Jira based on a PRD markdown file.
12
12
  Before doing anything, run this check via Bash tool:
13
13
 
14
14
  ```bash
15
- node .claude/skills/_shared/vds-skill/config-check.js --cmd vds-cli --env VDS_JIRA_TOKEN,VDS_JIRA_PROJECT_DEFAULT
15
+ node .claude/skills/_shared/vds-skill/config-check.js --cmd vds-cli --env JIRA_TOKEN,VDS_JIRA_PROJECT_DEFAULT
16
16
  ```
17
17
 
18
18
  If BLOCKED: tell the user exactly what's missing and how to fix, then STOP. Do NOT fabricate output. `--dry-run` mode skips credential + vds-cli checks.
@@ -21,7 +21,7 @@ If BLOCKED: tell the user exactly what's missing and how to fix, then STOP. Do N
21
21
 
22
22
  - `vds-cli` installed (for non-dry-run)
23
23
  - A PRD file exists at `.vsaf/docs/features/{feature}/02-prd.md`
24
- - `VDS_JIRA_TOKEN` + `VDS_JIRA_PROJECT_DEFAULT` in `~/.vds/sdlc-config.env`
24
+ - `JIRA_TOKEN` + `VDS_JIRA_PROJECT_DEFAULT` in `~/.vds/.env`
25
25
 
26
26
  ## Usage
27
27
 
@@ -37,7 +37,7 @@ If BLOCKED: tell the user exactly what's missing and how to fix, then STOP. Do N
37
37
 
38
38
  1. Locate PRD file (or use `--description-file`)
39
39
  2. Extract summary from PRD's first H1 heading
40
- 3. Lazy-prompt `VDS_JIRA_TOKEN` + `VDS_JIRA_PROJECT_DEFAULT` if missing
40
+ 3. Lazy-prompt `JIRA_TOKEN` + `VDS_JIRA_PROJECT_DEFAULT` if missing
41
41
  4. Confirm with user
42
42
  5. Run `vds-cli jira create --project KEY --issuetype Epic --summary ... --description-file ... --yes --json-only`
43
43
  6. Save returned epic key to `<feature-dir>/jira-epic-key.txt`
@@ -76,7 +76,7 @@ if (!cmdCheck.found) {
76
76
  }
77
77
 
78
78
  (async () => {
79
- if (!await ensureEnv('VDS_JIRA_TOKEN', 'Enter VDS Jira personal access token')) process.exit(1);
79
+ if (!await ensureEnv('JIRA_TOKEN', 'Enter Jira personal access token')) process.exit(1);
80
80
  if (!await ensureEnv('VDS_JIRA_PROJECT_DEFAULT', 'Enter default Jira project key (e.g. NTTC)', false)) process.exit(1);
81
81
 
82
82
  projectKey = projectKey || process.env.VDS_JIRA_PROJECT_DEFAULT;
@@ -3,31 +3,37 @@
3
3
  /**
4
4
  * VDS Skill Pack — credential & config setup
5
5
  *
6
- * Creates ~/.vds/sdlc-config.env template with all required
7
- * environment variables for vds-skill-* skills.
6
+ * Merges credential stubs into ~/.vds/.env (the shared VDS env file
7
+ * that all orchestrators read via pydantic-settings).
8
8
  *
9
9
  * Usage:
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
 
22
23
  const VDS_DIR = join(homedir(), '.vds');
23
- const CONFIG_FILE = join(VDS_DIR, 'sdlc-config.env');
24
+ const CONFIG_FILE = join(VDS_DIR, '.env');
24
25
 
25
26
  const REQUIRED_VARS = [
26
- { name: 'VDS_CONFLUENCE_TOKEN', desc: 'Confluence personal access token', skills: 'push-prd, push-srs, search-confluence' },
27
- { name: 'VDS_JIRA_TOKEN', desc: 'Jira personal access token', skills: 'create-jira-epic, search-confluence' },
28
- { name: 'VDS_BITBUCKET_TOKEN', desc: 'Bitbucket personal access token', skills: 'create-bitbucket-pr' },
29
- { name: 'VDS_CONFLUENCE_SPACE_DEFAULT', desc: 'Default Confluence space key (e.g. CEP)', skills: 'push-prd, push-srs, search-confluence' },
30
- { name: 'VDS_JIRA_PROJECT_DEFAULT', desc: 'Default Jira project key (e.g. NTTC)', skills: 'create-jira-epic, search-confluence' },
27
+ // Personal Access Tokens primary auth (one token per service)
28
+ { name: 'INTERNAL_CONFLUENCE_TOKEN', desc: 'Confluence internal server PAT', skills: 'push-prd, push-srs, search-confluence' },
29
+ { name: 'JIRA_TOKEN', desc: 'Jira personal access token', skills: 'create-jira-epic, search-confluence' },
30
+ { name: 'BITBUCKET_TOKEN', desc: 'Bitbucket personal access token', skills: 'create-bitbucket-pr' },
31
+ ];
32
+
33
+ // Optional — basic auth fallback (only needed if PATs not available)
34
+ const OPTIONAL_VARS = [
35
+ { name: 'VDS_USERNAME', desc: 'VDS username (basic auth fallback)', skills: 'confluence, jira, bitbucket' },
36
+ { name: 'VDS_PASSWORD', desc: 'VDS password (basic auth fallback)', skills: 'confluence, jira, bitbucket' },
31
37
  ];
32
38
 
33
39
  // ── Helpers ──
@@ -52,13 +58,169 @@ function parseExistingConfig(filePath) {
52
58
  return vars;
53
59
  }
54
60
 
55
- // ── Step 1: Ensure ~/.vds/ directory ──
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) ──
56
212
 
57
213
  console.log('\n[vds-skill] Setting up VDS credentials...\n');
58
214
 
59
215
  mkdirSync(VDS_DIR, { recursive: true });
60
216
 
61
- // ── Step 2: Create or merge config template ──
217
+ if (!skipMigration) {
218
+ migrateSdlcConfig();
219
+ } else {
220
+ log('ℹ️', 'Migration skipped (--skip-migration).');
221
+ }
222
+
223
+ // ── Step 1: Create or merge credential template ──
62
224
 
63
225
  const existing = parseExistingConfig(CONFIG_FILE);
64
226
  const missing = [];
@@ -75,12 +237,14 @@ for (const v of REQUIRED_VARS) {
75
237
  if (!existsSync(CONFIG_FILE)) {
76
238
  // Create fresh template
77
239
  const lines = [
78
- '# VDS Skill Pack — credential config',
79
- '# File: ~/.vds/sdlc-config.env',
80
- '# Permissions: 600 (auto-set by credentials.js)',
240
+ '# VDS shared environment — credential config',
241
+ '# File: ~/.vds/.env',
242
+ '# Permissions: 600 (auto-set by installer)',
81
243
  '#',
82
- '# Fill in the values below. Skills will use these automatically.',
83
- '# To rotate a token: update the value here, skills pick it up next run.',
244
+ '# Set per-service Personal Access Tokens (recommended):',
245
+ '# INTERNAL_CONFLUENCE_TOKEN, JIRA_TOKEN, BITBUCKET_TOKEN',
246
+ '# Or basic auth fallback: VDS_USERNAME + VDS_PASSWORD (works for all services)',
247
+ '# To rotate a credential: update the value here, orchestrators pick it up next run.',
84
248
  '',
85
249
  ];
86
250
 
@@ -91,6 +255,14 @@ if (!existsSync(CONFIG_FILE)) {
91
255
  lines.push('');
92
256
  }
93
257
 
258
+ lines.push('# --- Basic auth fallback (optional, only if PATs not available) ---');
259
+ lines.push('');
260
+ for (const v of OPTIONAL_VARS) {
261
+ lines.push(`# ${v.desc}`);
262
+ lines.push(`# ${v.name}=`);
263
+ }
264
+ lines.push('');
265
+
94
266
  writeFileSync(CONFIG_FILE, lines.join('\n'));
95
267
  if (process.platform !== 'win32') {
96
268
  chmodSync(CONFIG_FILE, 0o600);
@@ -17,7 +17,7 @@ Publish the current PRD to Viettel Confluence via `vds-cli` — create a new pag
17
17
  Before doing anything, run this check via Bash tool:
18
18
 
19
19
  ```bash
20
- node .claude/skills/_shared/vds-skill/config-check.js --cmd vds-cli --env VDS_CONFLUENCE_TOKEN,VDS_CONFLUENCE_SPACE_DEFAULT
20
+ node .claude/skills/_shared/vds-skill/config-check.js --cmd vds-cli --env INTERNAL_CONFLUENCE_TOKEN,VDS_CONFLUENCE_SPACE_DEFAULT
21
21
  ```
22
22
 
23
23
  If BLOCKED: tell the user exactly what's missing and how to fix, then STOP. Do NOT fabricate output.
@@ -26,7 +26,7 @@ If BLOCKED: tell the user exactly what's missing and how to fix, then STOP. Do N
26
26
 
27
27
  - `vds-cli` installed
28
28
  - A PRD markdown file must exist
29
- - `VDS_CONFLUENCE_TOKEN` + `VDS_CONFLUENCE_SPACE_DEFAULT` in `~/.vds/sdlc-config.env`
29
+ - `INTERNAL_CONFLUENCE_TOKEN` + `VDS_CONFLUENCE_SPACE_DEFAULT` in `~/.vds/.env`
30
30
  - Confluence parent page configured in `.vsaf/config.yaml` (or lazy-prompted)
31
31
 
32
32
  ## Steps
@@ -17,7 +17,7 @@ Publish the current SRS to Viettel Confluence via `vds-cli` — create or update
17
17
  Before doing anything, run this check via Bash tool:
18
18
 
19
19
  ```bash
20
- node .claude/skills/_shared/vds-skill/config-check.js --cmd vds-cli --env VDS_CONFLUENCE_TOKEN,VDS_CONFLUENCE_SPACE_DEFAULT
20
+ node .claude/skills/_shared/vds-skill/config-check.js --cmd vds-cli --env INTERNAL_CONFLUENCE_TOKEN,VDS_CONFLUENCE_SPACE_DEFAULT
21
21
  ```
22
22
 
23
23
  If BLOCKED: tell the user exactly what's missing and how to fix, then STOP. Do NOT fabricate output.
@@ -26,7 +26,7 @@ If BLOCKED: tell the user exactly what's missing and how to fix, then STOP. Do N
26
26
 
27
27
  - `vds-cli` installed
28
28
  - An SRS markdown file must exist
29
- - `VDS_CONFLUENCE_TOKEN` + `VDS_CONFLUENCE_SPACE_DEFAULT` in `~/.vds/sdlc-config.env`
29
+ - `INTERNAL_CONFLUENCE_TOKEN` + `VDS_CONFLUENCE_SPACE_DEFAULT` in `~/.vds/.env`
30
30
  - Confluence parent page in config or lazy-prompted
31
31
 
32
32
  ## Steps
@@ -12,7 +12,7 @@ Search Viettel Confluence + Jira for pages/tickets related to a feature, then wr
12
12
  Before doing anything, run this check via Bash tool:
13
13
 
14
14
  ```bash
15
- node .claude/skills/_shared/vds-skill/config-check.js --cmd vds-cli --env VDS_CONFLUENCE_TOKEN,VDS_JIRA_TOKEN,VDS_CONFLUENCE_SPACE_DEFAULT,VDS_JIRA_PROJECT_DEFAULT
15
+ node .claude/skills/_shared/vds-skill/config-check.js --cmd vds-cli --env INTERNAL_CONFLUENCE_TOKEN,JIRA_TOKEN,VDS_CONFLUENCE_SPACE_DEFAULT,VDS_JIRA_PROJECT_DEFAULT
16
16
  ```
17
17
 
18
18
  If BLOCKED: tell the user exactly what's missing and how to fix, then STOP. Do NOT fabricate output. `--dry-run` mode skips vds-cli check.
@@ -21,7 +21,7 @@ If BLOCKED: tell the user exactly what's missing and how to fix, then STOP. Do N
21
21
 
22
22
  - `vds-cli` installed
23
23
  - Inside a project root with `.vsaf/docs/features/`
24
- - `VDS_CONFLUENCE_TOKEN`, `VDS_JIRA_TOKEN`, `VDS_CONFLUENCE_SPACE_DEFAULT`, `VDS_JIRA_PROJECT_DEFAULT` in `~/.vds/sdlc-config.env`
24
+ - `INTERNAL_CONFLUENCE_TOKEN`, `JIRA_TOKEN`, `VDS_CONFLUENCE_SPACE_DEFAULT`, `VDS_JIRA_PROJECT_DEFAULT` in `~/.vds/.env`
25
25
 
26
26
  ## Usage
27
27
 
@@ -57,8 +57,8 @@ if (!cmdCheck.found) {
57
57
  }
58
58
 
59
59
  (async () => {
60
- if (!await ensureEnv('VDS_CONFLUENCE_TOKEN', 'Enter VDS Confluence personal access token')) process.exit(1);
61
- if (!await ensureEnv('VDS_JIRA_TOKEN', 'Enter VDS Jira personal access token')) process.exit(1);
60
+ if (!await ensureEnv('INTERNAL_CONFLUENCE_TOKEN', 'Enter Confluence internal server PAT')) process.exit(1);
61
+ if (!await ensureEnv('JIRA_TOKEN', 'Enter Jira personal access token')) process.exit(1);
62
62
  if (!await ensureEnv('VDS_CONFLUENCE_SPACE_DEFAULT', 'Enter default Confluence space key (e.g. ENG)', false)) process.exit(1);
63
63
  if (!await ensureEnv('VDS_JIRA_PROJECT_DEFAULT', 'Enter default Jira project key (e.g. NTTC)', false)) process.exit(1);
64
64
 
@@ -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