@timekast/factory 1.28.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/commands/backlog.js +52 -15
  2. package/dist/commands/doctor.js +210 -29
  3. package/dist/commands/env.js +154 -18
  4. package/dist/commands/invite-admin.js +62 -15
  5. package/dist/commands/provision.js +5815 -763
  6. package/dist/commands/status.js +12 -2
  7. package/dist/commands/ticket.js +4 -4
  8. package/dist/commands/update.js +97 -12
  9. package/dist/commands/vault.js +2627 -0
  10. package/dist/index.js +97 -33
  11. package/dist/lib/backlog-mapping.js +111 -18
  12. package/dist/lib/backlog-parse.js +1 -1
  13. package/dist/lib/claude-md.js +320 -0
  14. package/dist/lib/cloudflare-api.js +213 -37
  15. package/dist/lib/constants.js +70 -6
  16. package/dist/lib/env-file.js +45 -7
  17. package/dist/lib/env-wizard.js +66 -2
  18. package/dist/lib/initial-migration.js +3 -2
  19. package/dist/lib/neon-api.js +145 -10
  20. package/dist/lib/package-json.js +37 -16
  21. package/dist/lib/preflight.js +92 -38
  22. package/dist/lib/provision-preflight.js +158 -100
  23. package/dist/lib/provision-state.js +652 -20
  24. package/dist/lib/publish-engine.js +1 -1
  25. package/dist/lib/rail.js +400 -0
  26. package/dist/lib/railway-api.js +851 -0
  27. package/dist/lib/vault-adopt.js +1495 -0
  28. package/dist/lib/vault-api.js +1106 -0
  29. package/dist/lib/vault-config.js +21 -0
  30. package/dist/lib/vault-deploys.js +898 -0
  31. package/dist/lib/vercel-env.js +251 -31
  32. package/dist/lib/wrapper-wiring.js +160 -0
  33. package/dist/tui/env-wizard-flow.js +17 -3
  34. package/dist/tui/menu.js +4 -22
  35. package/dist/tui/provision-flow.js +68 -13
  36. package/package.json +1 -1
  37. package/dist/commands/secrets.js +0 -71
  38. package/dist/lib/env-merge.js +0 -238
@@ -29,11 +29,42 @@ import { CLIError } from '../lib/cli-error.js';
29
29
  * The services this flow can request, in canonical order. The TUI offers the
30
30
  * full ephemeral-safe set as the default; there is intentionally NO live-only or
31
31
  * destructive option here (issue §4 + §9). Kept as a literal so the test can
32
- * assert the exact, bounded surface.
32
+ * assert the exact, bounded surface. `vault` goes FIRST (VAULT-002): the repo's
33
+ * project in the vault is where every later step writes what it mints. (`provision`
34
+ * drops it on its own in a `core` repo, and stops a repo already live without one.)
35
+ * `vercel` and `railway` are the two destinations' deploy steps (RWAY-004): an alta
36
+ * uses the one of the destination it picked.
33
37
  */
34
- export const TUI_PROVISION_SERVICES = ['neon', 'github', 'vercel', 'dns'];
35
- /** The default `--services` string the flow seeds (all ephemeral-safe services). */
36
- export const TUI_PROVISION_DEFAULT_SERVICES = TUI_PROVISION_SERVICES.join(',');
38
+ export const TUI_PROVISION_SERVICES = [
39
+ 'vault',
40
+ 'neon',
41
+ 'github',
42
+ 'vercel',
43
+ 'railway',
44
+ 'dns',
45
+ ];
46
+ /** The destinations the flow offers (`--target`), Vercel first (the default). */
47
+ export const TUI_PROVISION_TARGETS = ['vercel', 'railway'];
48
+ /**
49
+ * The default `--services` string per destination: each one's deploy step plus `dns`.
50
+ */
51
+ export const TUI_PROVISION_DEFAULT_SERVICES_BY_TARGET = {
52
+ vercel: 'vault,neon,github,vercel,dns',
53
+ railway: 'vault,neon,github,railway,dns',
54
+ };
55
+ /** The default `--services` string the flow seeds (the default destination, Vercel). */
56
+ export const TUI_PROVISION_DEFAULT_SERVICES = TUI_PROVISION_DEFAULT_SERVICES_BY_TARGET.vercel;
57
+ /** The destination a TUI alta passes when none was picked (`--target`). */
58
+ export const TUI_PROVISION_TARGET = 'vercel';
59
+ /** The `--services` tokens that deploy — an alta with one of them needs `--target`. */
60
+ const TUI_DEPLOY_SERVICES = ['github', 'vercel', 'railway', 'dns'];
61
+ /** Whether a comma-separated `--services` value includes a deploy step. */
62
+ function hasDeployStep(services) {
63
+ return services
64
+ .split(',')
65
+ .map((token) => token.trim())
66
+ .some((token) => TUI_DEPLOY_SERVICES.includes(token));
67
+ }
37
68
  /**
38
69
  * Build the `--ephemeral` flag set the TUI passes to `runProvision`. The slug is
39
70
  * threaded through `--slug` (the canonical stack identifier) so `deriveProjectName`
@@ -47,6 +78,9 @@ export const TUI_PROVISION_DEFAULT_SERVICES = TUI_PROVISION_SERVICES.join(',');
47
78
  */
48
79
  export function buildEphemeralFlags(inputs, dryRun) {
49
80
  const argv = [`--services=${inputs.services}`, `--slug=${inputs.slug}`, '--ephemeral'];
81
+ // RWAY-003: an alta with a deploy step must name its destination — the one the person picked.
82
+ if (hasDeployStep(inputs.services))
83
+ argv.push(`--target=${inputs.target ?? TUI_PROVISION_TARGET}`);
50
84
  if (inputs.team)
51
85
  argv.push(`--team=${inputs.team}`);
52
86
  if (dryRun)
@@ -78,7 +112,8 @@ export function validateSlug(value) {
78
112
  * completed provision — and NEVER throws to the caller (the menu stays alive).
79
113
  *
80
114
  * Order is load-bearing and asserted by the unit test:
81
- * 1. prompt slug (inline-validated) → team (optional) → services (defaulted)
115
+ * 1. prompt slug (inline-validated) → destination → team (Vercel only, optional) →
116
+ * services (defaulted per destination)
82
117
  * 2. `runProvision({ ..., ephemeral: true, dryRun: true })` — show the plan
83
118
  * 3. `confirm` — only on "sí":
84
119
  * 4. `runProvision({ ..., ephemeral: true })` — the real run
@@ -103,20 +138,41 @@ export async function provisionFlow(deps = {}) {
103
138
  console.log('\nProvision cancelado.');
104
139
  return;
105
140
  }
106
- const { team } = await prompts({
107
- type: 'text',
108
- name: 'team',
109
- message: 'Vercel team id (opcional — enter para omitir):',
141
+ // RWAY-004 — the destination: where the app deploys (one per repo).
142
+ const { target: picked } = await prompts({
143
+ type: 'select',
144
+ name: 'target',
145
+ message: '¿A qué plataforma se despliega?',
146
+ choices: [
147
+ { title: 'Vercel', value: 'vercel' },
148
+ { title: 'Railway (la bóveda le lleva las variables)', value: 'railway' },
149
+ ],
150
+ initial: 0,
110
151
  }, { onCancel });
111
152
  if (cancelled) {
112
153
  console.log('\nProvision cancelado.');
113
154
  return;
114
155
  }
156
+ const target = picked === 'railway' ? 'railway' : 'vercel';
157
+ // The Vercel team only matters with destination Vercel: a Railway alta never asks for it.
158
+ let team;
159
+ if (target === 'vercel') {
160
+ ({ team } = await prompts({
161
+ type: 'text',
162
+ name: 'team',
163
+ message: 'Vercel team id (opcional — enter para omitir):',
164
+ }, { onCancel }));
165
+ if (cancelled) {
166
+ console.log('\nProvision cancelado.');
167
+ return;
168
+ }
169
+ }
170
+ const defaultServices = TUI_PROVISION_DEFAULT_SERVICES_BY_TARGET[target];
115
171
  const { services } = await prompts({
116
172
  type: 'text',
117
173
  name: 'services',
118
174
  message: 'Servicios a aprovisionar:',
119
- initial: TUI_PROVISION_DEFAULT_SERVICES,
175
+ initial: defaultServices,
120
176
  }, { onCancel });
121
177
  if (cancelled) {
122
178
  console.log('\nProvision cancelado.');
@@ -125,9 +181,8 @@ export async function provisionFlow(deps = {}) {
125
181
  const inputs = {
126
182
  slug: slug.trim(),
127
183
  team: typeof team === 'string' && team.trim() !== '' ? team.trim() : undefined,
128
- services: typeof services === 'string' && services.trim() !== ''
129
- ? services.trim()
130
- : TUI_PROVISION_DEFAULT_SERVICES,
184
+ services: typeof services === 'string' && services.trim() !== '' ? services.trim() : defaultServices,
185
+ target,
131
186
  };
132
187
  // 2. ALWAYS dry-run first — show the plan BEFORE asking to proceed (issue §4).
133
188
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.28.1",
3
+ "version": "2.0.1",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -1,71 +0,0 @@
1
- /**
2
- * `factory secrets <file>` — distribute methodology secrets safely (SEC-001).
3
- *
4
- * Reads a master `KEY=value` file and merges it idempotently into the
5
- * per-user secrets rail `~/.claude/.env`, creating it with `chmod 600` if it
6
- * does not exist. Two hard guarantees:
7
- *
8
- * 1. Fail-closed gate — `runPreflight()` (gh installed + authed + org member)
9
- * runs FIRST, before any I/O on the target. A non-member, an expired
10
- * session, or `gh` missing from PATH aborts with a non-zero exit and the
11
- * target is never created or modified. In headless mode with no resolvable
12
- * auth, preflight throws too → the command is a no-op fail-closed.
13
- * 2. No-echo — stdout lists only the KEY NAMES processed (added / updated /
14
- * skipped), never their values. A secret value never reaches the terminal.
15
- *
16
- * The org gate is `runPreflight()` from `lib/preflight.ts` — the public API.
17
- * `preflight()` / `assertOrgMembership` are NOT public and are not called here.
18
- *
19
- * NOTE: the device-flow `secrets pull` + admin `secrets sync` (the org secrets
20
- * gate, SECGATE-*) were REMOVED — that gate/droplet effort is parked. The interim
21
- * model is this shared-file import; the future is Infisical (a fresh branch).
22
- */
23
- import { homedir } from 'node:os';
24
- import path from 'node:path';
25
- import { mergeEnvFile } from '../lib/env-merge.js';
26
- import { runPreflight } from '../lib/preflight.js';
27
- /** Absolute path to the per-user secrets rail (`~/.claude/.env`). */
28
- export function defaultSecretsTarget() {
29
- return path.join(homedir(), '.claude', '.env');
30
- }
31
- /** Render the merge outcome to stdout — KEY NAMES only, never values (no-echo). */
32
- function reportMerge(result, targetPath) {
33
- // Surface ignored lines first (P2-2): only the candidate name (left of `=`) is
34
- // shown — the value of an invalid line is never echoed.
35
- if (result.invalid.length > 0) {
36
- console.warn(`⚠ Ignoré ${result.invalid.length} línea(s) con nombre de clave inválido: ` +
37
- `${result.invalid.join(', ')} (solo se permite [A-Za-z_][A-Za-z0-9_]*).`);
38
- }
39
- const total = result.added.length + result.updated.length + result.skipped.length;
40
- if (total === 0) {
41
- console.log('No había claves que procesar en el archivo fuente.');
42
- return;
43
- }
44
- console.log(`✔ Secretos sincronizados en \`${targetPath}\` (permisos 600).`);
45
- if (result.added.length > 0) {
46
- console.log(` Agregadas: ${result.added.join(', ')}`);
47
- }
48
- if (result.updated.length > 0) {
49
- console.log(` Actualizadas: ${result.updated.join(', ')}`);
50
- }
51
- if (result.skipped.length > 0) {
52
- console.log(` Sin cambios: ${result.skipped.join(', ')}`);
53
- }
54
- // Reaffirm the no-echo guarantee so the operator knows values were never shown.
55
- console.log('ℹ Solo se muestran NOMBRES de clave; los valores nunca se imprimen.');
56
- }
57
- /**
58
- * Run `factory secrets <file>`. Order is deliberate and security-relevant:
59
- * preflight gate first (fail-closed — no target I/O on failure), then the merge.
60
- */
61
- export async function secretsCommand(file, deps = {}) {
62
- const preflight = deps.preflight ?? runPreflight;
63
- const targetPath = deps.targetPath ?? defaultSecretsTarget();
64
- // 1. Fail-closed gate. Throws BEFORE any read/write of the source or target —
65
- // a non-member / unauthenticated / headless-no-token run touches nothing.
66
- await preflight();
67
- // 2. Merge the source into the rail (creates it 0600 if absent, idempotent).
68
- const result = await mergeEnvFile(file, targetPath);
69
- // 3. Report — KEY NAMES only.
70
- reportMerge(result, targetPath);
71
- }
@@ -1,238 +0,0 @@
1
- /**
2
- * Non-naive `.env` parser + merger for the `factory secrets` rail (SEC-001).
3
- *
4
- * Two entry points, one engine:
5
- * - `mergeEnvFile(sourcePath, target)` — merge a `.env` FILE on disk (the local
6
- * master of `factory secrets <file>`).
7
- * - `mergeEnvText(text, target)` — merge an in-memory `KEY=value` BLOB (a
8
- * caller that already holds the text can merge without writing a temp file).
9
- * `mergeEnvFile` reads its file and delegates to `mergeEnvText`; the merge logic
10
- * below is written once.
11
- *
12
- * Merges a SOURCE `.env` blob into a TARGET (`~/.claude/.env` in production),
13
- * idempotently and additively:
14
- * - keys in source that are absent in target → ADDED
15
- * - keys in source that already exist with a different value → UPDATED
16
- * - keys in source whose value already matches target → SKIPPED (no churn)
17
- * - keys in target NOT mentioned in source → preserved verbatim
18
- * - comment lines (`# ...`) + blank lines in target → preserved verbatim
19
- * - the target's trailing newline → preserved
20
- * - target absent → created with `chmod 600` BEFORE the first write
21
- *
22
- * "Non-naive" parsing rules (the sharp cases the tests pin):
23
- * - split a `KEY=value` line on the FIRST `=` only — a value may contain `=`
24
- * (`KEY=a=b=c` → value `a=b=c`).
25
- * - a value wrapped in single or double quotes keeps its quotes literally
26
- * (`KEY="val"` and `KEY='val'` round-trip byte-for-byte).
27
- * - a `#` INSIDE a quoted value is literal, NOT a comment start
28
- * (`KEY="val#hash"` keeps `#hash`). A `#` at column 0 (after optional
29
- * whitespace) is a comment line and is never parsed as a key.
30
- *
31
- * The merge is line-oriented and surgical: an updated key is rewritten in place
32
- * (its original line position is kept), so re-running with the same source is a
33
- * no-op on disk (idempotent — every key SKIPs the second time).
34
- *
35
- * Known limitation (documented, not blocked — SEC-001 §8): concurrent writers
36
- * on the same target may corrupt it (no file lock). The command is manual.
37
- */
38
- import { chmodSync, existsSync, lstatSync, readFileSync, writeFileSync } from 'node:fs';
39
- /**
40
- * True when a line is a comment or blank — never parsed as a key. A comment is
41
- * a `#` at the first non-whitespace column; a blank line is empty/whitespace.
42
- */
43
- function isCommentOrBlank(line) {
44
- const trimmed = line.trimStart();
45
- return trimmed === '' || trimmed.startsWith('#');
46
- }
47
- /**
48
- * A valid env-var name: a letter or underscore, then letters/digits/underscores
49
- * (POSIX shell name shape). The source file is the one fully-untrusted input the
50
- * tool otherwise trusts, so a line whose key fails this shape (`export FOO`,
51
- * `; rm -rf`, `FOO BAR`) is NOT written to the rail — it's reported as invalid
52
- * (P2-2 hardening). Inert as `.env` data, but we refuse to write surprising keys.
53
- */
54
- function isValidKey(key) {
55
- return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
56
- }
57
- /**
58
- * Parse a single `KEY=value` line, splitting on the FIRST `=` only. Returns null
59
- * for comment/blank lines, lines with no `=`, and lines whose key is not a valid
60
- * env-var name (all preserved as-is by the target rewrite, never treated as a
61
- * key). The key is trimmed; the value keeps its quotes and inner characters
62
- * literally (only trailing whitespace, which a real `.env` loader ignores, is
63
- * stripped so re-runs stay idempotent).
64
- */
65
- function parseLine(line) {
66
- if (isCommentOrBlank(line))
67
- return null;
68
- const eq = line.indexOf('=');
69
- if (eq === -1)
70
- return null;
71
- const key = line.slice(0, eq).trim();
72
- if (key === '')
73
- return null;
74
- if (!isValidKey(key))
75
- return null;
76
- const value = line.slice(eq + 1).replace(/\s+$/, '');
77
- return { key, value };
78
- }
79
- /**
80
- * Build the canonical `KEY=value` line for a pair. Centralized so source parse
81
- * and target rewrite emit identical text (the idempotency contract depends on
82
- * byte-equality between a freshly written line and the one parsed back).
83
- */
84
- function renderLine(pair) {
85
- return `${pair.key}=${pair.value}`;
86
- }
87
- /**
88
- * Read `.env` text, failing closed with a clear error on non-UTF-8 input (SEC-001
89
- * §8 edge case). `readFileSync(..., 'utf8')` does not throw on invalid bytes — it
90
- * substitutes U+FFFD — so we detect the replacement character and reject rather
91
- * than silently corrupt a secret. An empty/missing target is the caller's
92
- * concern; this only runs on files known to exist.
93
- */
94
- function readEnvText(filePath) {
95
- const text = readFileSync(filePath, 'utf8');
96
- if (text.includes('�')) {
97
- throw new Error(`El archivo \`${filePath}\` no es UTF-8 válido (bytes no decodificables). ` +
98
- 'Revisa su codificación; no se modificó nada.');
99
- }
100
- return text;
101
- }
102
- /**
103
- * Parse the SOURCE file's key/value pairs, last-write-wins on duplicate keys (a
104
- * source listing a key twice resolves to its final value — mirrors how a real
105
- * loader reads top-to-bottom). Order of first appearance is preserved for the
106
- * keys that get appended to the target. Lines whose key fails `isValidKey` are
107
- * NOT merged — their candidate name (value-free) is collected in `invalid` so
108
- * the command can warn the operator (P2-2).
109
- */
110
- function parseSource(text) {
111
- const byKey = new Map();
112
- const order = [];
113
- const invalid = [];
114
- for (const line of text.split('\n')) {
115
- if (isCommentOrBlank(line))
116
- continue;
117
- const eq = line.indexOf('=');
118
- if (eq === -1)
119
- continue; // no '=' — not a key assignment; ignored in a source file
120
- const key = line.slice(0, eq).trim();
121
- if (key === '')
122
- continue;
123
- if (!isValidKey(key)) {
124
- if (!invalid.includes(key))
125
- invalid.push(key); // candidate only — never the value
126
- continue;
127
- }
128
- const value = line.slice(eq + 1).replace(/\s+$/, '');
129
- if (!byKey.has(key))
130
- order.push(key);
131
- byKey.set(key, value);
132
- }
133
- return { pairs: order.map((key) => ({ key, value: byKey.get(key) })), invalid };
134
- }
135
- /**
136
- * Merge `sourcePath` into `targetPath`. See the module docstring for the full
137
- * contract. Async to match the CLI's command surface (`mergeEnvFile(...)` is
138
- * awaited), though the FS ops are synchronous under the hood — the cost is a
139
- * couple of small reads/writes, and sync keeps the chmod-before-write ordering
140
- * race-free.
141
- *
142
- * Thin wrapper over {@link mergeEnvText}: reads + validates the SOURCE FILE, then
143
- * delegates the merge to the text-level engine. `factory secrets <file>` (a local
144
- * master on disk) uses this; a caller that already holds the text in memory uses
145
- * `mergeEnvText` directly.
146
- *
147
- * @throws when the source is missing/unreadable, or either file is not UTF-8.
148
- */
149
- export async function mergeEnvFile(sourcePath, targetPath) {
150
- // Source must exist and be readable — fail closed with a clear message (the
151
- // command never half-writes a target off a missing/garbled source).
152
- if (!existsSync(sourcePath)) {
153
- throw new Error(`No encontré el archivo fuente \`${sourcePath}\`.`);
154
- }
155
- // The only difference from a `pull` is WHERE the source text comes from (a file
156
- // here, an HTTP body there). Once read, the merge is identical — so read here
157
- // and hand the text to the shared engine. No reimplementation of the merge.
158
- return mergeEnvText(readEnvText(sourcePath), targetPath);
159
- }
160
- /**
161
- * Merge SOURCE TEXT (a `KEY=value` blob already in memory) into `targetPath`.
162
- *
163
- * This is the engine `mergeEnvFile` delegates to, kept as a separate text-level
164
- * entry point so a caller that already holds the blob in memory can merge it
165
- * straight in, so a secret value NEVER lands in a temp file or any intermediate
166
- * path on the way to `~/.claude/.env`.
167
- *
168
- * Same contract as `mergeEnvFile` for everything downstream of the source read:
169
- * additive/idempotent merge, first-`=` split, quoted values preserved, comments +
170
- * untouched target keys preserved, trailing newline preserved, target created
171
- * `0600` if absent and re-asserted `0600` if it already existed (Edge case §8 —
172
- * a `0644` file edited by hand is corrected before any secret is written).
173
- *
174
- * @throws when the target is a symlink or not UTF-8. (The source is in-memory
175
- * text, so there is no "source missing" failure mode here — the caller owns
176
- * validating that the HTTP body is well-formed before calling.)
177
- */
178
- export async function mergeEnvText(text, targetPath) {
179
- const { pairs: sourcePairs, invalid } = parseSource(text);
180
- // Reject a symlink target explicitly (SEC-001 §8): writing through a symlink
181
- // could redirect secrets outside `~/.claude/`. We refuse rather than follow.
182
- if (existsSync(targetPath) && lstatSync(targetPath).isSymbolicLink()) {
183
- throw new Error(`El destino \`${targetPath}\` es un symlink. Por seguridad no escribo a través de symlinks; ` +
184
- 'apunta a un archivo regular.');
185
- }
186
- // Target absent → create empty with 0600 BEFORE writing any secret, so the
187
- // file is never world-readable even for the instant between create and chmod.
188
- const targetExists = existsSync(targetPath);
189
- if (!targetExists) {
190
- writeFileSync(targetPath, '', { mode: 0o600 });
191
- }
192
- const targetText = targetExists ? readEnvText(targetPath) : '';
193
- // Preserve the trailing-newline shape of the original target: an empty file and
194
- // a file ending in `\n` both round-trip; a file without a final newline gets one
195
- // (a well-formed `.env` ends in a newline — POSIX text-file convention).
196
- const hadTrailingNewline = targetText.endsWith('\n');
197
- const lines = targetText.length === 0 ? [] : targetText.replace(/\n$/, '').split('\n');
198
- const sourceByKey = new Map(sourcePairs.map((p) => [p.key, p.value]));
199
- const seenInTarget = new Set();
200
- const added = [];
201
- const updated = [];
202
- const skipped = [];
203
- // Rewrite existing target lines in place: a key present in source is updated
204
- // (or skipped if unchanged); comments, blanks, and untouched keys pass through.
205
- const rewritten = lines.map((line) => {
206
- const pair = parseLine(line);
207
- if (!pair)
208
- return line; // comment / blank / malformed — preserve verbatim
209
- seenInTarget.add(pair.key);
210
- if (!sourceByKey.has(pair.key))
211
- return line; // key not in source — preserve
212
- const nextValue = sourceByKey.get(pair.key);
213
- if (nextValue === pair.value) {
214
- skipped.push(pair.key);
215
- return line;
216
- }
217
- updated.push(pair.key);
218
- return renderLine({ key: pair.key, value: nextValue });
219
- });
220
- // Append source keys that were not already in the target, in source order.
221
- for (const pair of sourcePairs) {
222
- if (seenInTarget.has(pair.key))
223
- continue;
224
- added.push(pair.key);
225
- rewritten.push(renderLine(pair));
226
- }
227
- // Reassemble. Restore the trailing newline when the original had one (or when
228
- // we created/append-grew the file — a `.env` should end in `\n`).
229
- const body = rewritten.join('\n');
230
- const shouldEndWithNewline = hadTrailingNewline || !targetExists || rewritten.length > lines.length;
231
- const finalText = shouldEndWithNewline && rewritten.length > 0 ? `${body}\n` : body;
232
- writeFileSync(targetPath, finalText, { mode: 0o600 });
233
- // Re-assert 0600 even when the file already existed (it may have been created
234
- // with looser perms before this rail existed — SEC-001 §8). `writeFileSync`'s
235
- // `mode` is ignored for an existing file, so chmod explicitly.
236
- chmodSync(targetPath, 0o600);
237
- return { added, updated, skipped, invalid };
238
- }