@timekast/factory 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,8 +13,9 @@
13
13
  * post-bootstrap, and that the patch channel for `src/` is EPIC 2 (agentic
14
14
  * update, not yet available).
15
15
  * - MISSING INITIAL MIGRATION: a derivative that ships a Drizzle schema and no
16
- * generated `.sql`, so its `vercel-build` (`pnpm db:migrate && pnpm build`)
17
- * would migrate an empty database. INFORMATIONAL — see below.
16
+ * generated `.sql`, so its `vercel-build` (`pnpm build && pnpm db:migrate`)
17
+ * has nothing to apply and its first deploy never creates the tables.
18
+ * INFORMATIONAL — see below.
18
19
  *
19
20
  * No lockfile (pre-CLI repo) → delegate to the "run factory:update" advice, exit
20
21
  * 0, never abort (same pattern as `status` / the §8 edge case).
@@ -65,8 +66,8 @@ export const A1_WARNING = 'Aviso de seguridad (A1): el boilerplate `src/` y sus
65
66
  */
66
67
  export const MISSING_INITIAL_MIGRATION_NOTICE = `⚠ Falta la migración inicial: hay schema en \`${SCHEMA_DIR}/\` y ningún \`.sql\` en ` +
67
68
  `\`${MIGRATIONS_DIR}/\`.\n` +
68
- ' El deploy corre `pnpm db:migrate && pnpm build`, así que el primer build migraría una ' +
69
- 'base vacía. Genera la migración con `pnpm db:generate` y commitéala antes del próximo ' +
69
+ ' El deploy corre `pnpm build && pnpm db:migrate`, así que el primer deploy no crearía ' +
70
+ 'ninguna tabla. Genera la migración con `pnpm db:generate` y commitéala antes del próximo ' +
70
71
  'deploy.\n' +
71
72
  ' (Aviso informativo: `factory doctor` no bloquea — sale con éxito de todos modos.)';
72
73
  /** Parse `doctor`'s argv slice into flags. Unknown flags are ignored. */
@@ -13,7 +13,7 @@
13
13
  import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { CLIError } from '../lib/cli-error.js';
16
- import { PROVISION_OWNED, setEnvLine } from '../lib/env-file.js';
16
+ import { PER_ENV_MARKER, PER_ENV_REGISTRY_FILE, PROVISION_OWNED, addToPerEnvRegistry, findInlinePerEnvMarkers, findPerEnvMarkedKeys, listEnvKeys, readPerEnvRegistry, setEnvLine, } from '../lib/env-file.js';
17
17
  import { applyDefaultOverrides, applyWizardAnswers, buildEnvReview, buildQuestionPlan, checkEnvCompleteness, generateVapidKeys, isQuestionActive, parseEnvBody, parseEnvExample, provisionEmailDefaults, } from '../lib/env-wizard.js';
18
18
  import { defaultEnvPath, readVercelToken } from '../lib/provision-preflight.js';
19
19
  import { readProvisionState } from '../lib/provision-state.js';
@@ -30,23 +30,40 @@ export const REDEPLOY_NOTICE = 'ℹ️ Las variables aplican en el próximo dep
30
30
  export function parseEnvPushFlags(argv) {
31
31
  return { dryRun: argv.includes('--dry-run') };
32
32
  }
33
+ /** No extra exclusions — the common case (the derivative declared nothing). */
34
+ const NO_PER_ENV_KEYS = new Set();
33
35
  /**
34
- * Parse `.env.local` into the product vars to push: skip comments/blanks, the
35
- * provision-owned keys, and empty values. First-`=` split; surrounding quotes
36
- * stripped (mirrors the rail reader in provision.ts).
36
+ * Read `.env.local` as text, or `''` when it is absent or unreadable.
37
+ *
38
+ * Fail-SAFE on purpose, and the polarity is the opposite of `readPerEnvRegistry`:
39
+ * this file is the source of VALUES, so an empty body means "there is nothing to
40
+ * push" — the harmless direction. The registry is the source of EXCLUSIONS, where
41
+ * an empty result would mean "push everything", so it throws instead. Do NOT
42
+ * copy this `catch` over there.
37
43
  */
38
- export function readProductVars(filePath) {
39
- const out = {};
44
+ function readEnvBody(filePath) {
40
45
  if (!existsSync(filePath))
41
- return out;
42
- let text;
46
+ return '';
43
47
  try {
44
- text = readFileSync(filePath, 'utf8');
48
+ return readFileSync(filePath, 'utf8');
45
49
  }
46
50
  catch {
47
- return out;
51
+ return '';
48
52
  }
49
- for (const line of text.split('\n')) {
53
+ }
54
+ /**
55
+ * Parse an `.env.local` body into the product vars to push: skip comments/blanks,
56
+ * the provision-owned keys, the per-environment keys the derivative declared, and
57
+ * empty values. First-`=` split; surrounding quotes stripped (mirrors the rail
58
+ * reader in provision.ts).
59
+ *
60
+ * `perEnvKeys` only ever SUBTRACTS from the push — it is applied ALONGSIDE
61
+ * `PROVISION_OWNED` (a union), never instead of it, so a derivative cannot name a
62
+ * provision-owned key to opt it back in.
63
+ */
64
+ export function parseProductVars(body, perEnvKeys = NO_PER_ENV_KEYS) {
65
+ const out = {};
66
+ for (const line of body.split('\n')) {
50
67
  const trimmed = line.trimStart();
51
68
  if (trimmed === '' || trimmed.startsWith('#'))
52
69
  continue;
@@ -54,7 +71,7 @@ export function readProductVars(filePath) {
54
71
  if (eq === -1)
55
72
  continue;
56
73
  const key = line.slice(0, eq).trim();
57
- if (key === '' || PROVISION_OWNED.has(key))
74
+ if (key === '' || PROVISION_OWNED.has(key) || perEnvKeys.has(key))
58
75
  continue;
59
76
  let value = line.slice(eq + 1).replace(/\s+$/, '');
60
77
  if (value.length >= 2 &&
@@ -68,6 +85,10 @@ export function readProductVars(filePath) {
68
85
  }
69
86
  return out;
70
87
  }
88
+ /** {@link parseProductVars} over the file at `filePath`. */
89
+ export function readProductVars(filePath, perEnvKeys = NO_PER_ENV_KEYS) {
90
+ return parseProductVars(readEnvBody(filePath), perEnvKeys);
91
+ }
71
92
  /** Read `{ projectId, teamId }` from `.vercel/project.json` (written by provision). */
72
93
  function readProjectLink(rootDir) {
73
94
  const file = vercelProjectPath(rootDir);
@@ -90,13 +111,65 @@ function readProjectLink(rootDir) {
90
111
  return { projectId, teamId };
91
112
  }
92
113
  export async function runEnvPush(flags, rootDir = process.cwd()) {
93
- const productVars = readProductVars(path.join(rootDir, '.env.local'));
114
+ const body = readEnvBody(path.join(rootDir, '.env.local'));
115
+ // Resolve the derivative's per-environment declaration BEFORE anything can reach
116
+ // Vercel: a present-but-broken registry throws here and aborts the whole run
117
+ // (`--dry-run` included), instead of showing a plan as if nothing were protected.
118
+ const registry = readPerEnvRegistry(rootDir);
119
+ const marked = findPerEnvMarkedKeys(body);
120
+ const pendingAdds = marked.filter((key) => !registry.keys.includes(key));
121
+ // Reconciliation: a marker the registry lacks is APPENDED to it (add-only — see
122
+ // `addToPerEnvRegistry`). `--dry-run` reports it and writes nothing.
123
+ if (pendingAdds.length > 0 && !flags.dryRun) {
124
+ addToPerEnvRegistry(rootDir, registry, pendingAdds);
125
+ }
126
+ // Protected = registry ∪ marker ∪ PROVISION_OWNED. A union, never a replacement:
127
+ // the derivative's declaration can only SUBTRACT variables from the push.
128
+ const perEnvKeys = new Set([...registry.keys, ...marked]);
129
+ const productVars = parseProductVars(body, perEnvKeys);
94
130
  const keys = Object.keys(productVars).sort();
95
- if (keys.length === 0) {
96
- console.log('No hay variables de producto que subir en `.env.local` ' +
97
- '(todo es provision-owned o está vacío).');
98
- return;
131
+ const envKeys = listEnvKeys(body);
132
+ const omittedProvisionOwned = envKeys.filter((key) => PROVISION_OWNED.has(key)).sort();
133
+ // Provision-owned wins the label, so a redundant declaration is listed ONCE.
134
+ const omittedPerEnv = envKeys
135
+ .filter((key) => perEnvKeys.has(key) && !PROVISION_OWNED.has(key))
136
+ .sort();
137
+ const declaredButAbsent = [...perEnvKeys].filter((key) => !envKeys.includes(key)).sort();
138
+ if (pendingAdds.length > 0) {
139
+ const names = pendingAdds.join(', ');
140
+ console.log(flags.dryRun
141
+ ? `ℹ️ Agregaría a \`${PER_ENV_REGISTRY_FILE}\` por el marcador ` +
142
+ `\`${PER_ENV_MARKER}\`: ${names} (--dry-run: no escribí nada).`
143
+ : `✔ Agregué a \`${PER_ENV_REGISTRY_FILE}\` (marcador \`${PER_ENV_MARKER}\`): ${names}.`);
144
+ }
145
+ // 🔴 An INLINE marker ABORTS, it does not warn. Warning and pushing anyway shipped a value the
146
+ // code had ALREADY PROVEN corrupt: `parseProductVars` splits on the first `=` and strips no
147
+ // inline comment, so `KEY="x" # @per-env` uploads the literal `"x" # @per-env`. Aborting is
148
+ // exactly as loud as the warning — the dev sees the wrong form and fixes it — and it is the
149
+ // only option that also protects. Filtered to keys the registry does NOT already cover: for a
150
+ // protected one the diagnosis would be false (it does not go up at all), and a message that
151
+ // sometimes lies trains everyone to skip reading it.
152
+ const inlineMarked = findInlinePerEnvMarkers(body).filter((key) => !perEnvKeys.has(key));
153
+ if (inlineMarked.length > 0) {
154
+ throw new CLIError(`${inlineMarked.map((key) => `\`${key}\``).join(', ')} lleva \`${PER_ENV_MARKER}\` en la MISMA ` +
155
+ 'línea de la asignación. Ahí no protege nada, y además el valor subiría con el comentario ' +
156
+ 'pegado (a Vercel llegaría el texto literal, comillas incluidas).\n' +
157
+ ` El marcador va en un comentario en la línea de ARRIBA (\`SK.md §7.2\`). Aborté el push.`);
158
+ }
159
+ for (const key of declaredButAbsent) {
160
+ console.log(`⚠ \`${key}\` está declarada por ambiente pero no aparece en \`.env.local\` ` +
161
+ '— revisa si es un typo: así no está protegiendo nada.');
99
162
  }
163
+ /** Names + reason, NEVER values: the omitted vars are the secret-bearing ones. */
164
+ const printOmitted = () => {
165
+ if (omittedProvisionOwned.length === 0 && omittedPerEnv.length === 0)
166
+ return;
167
+ console.log('\nOmitidas (no se envían):');
168
+ for (const key of omittedProvisionOwned)
169
+ console.log(` ${key} — provision-owned`);
170
+ for (const key of omittedPerEnv)
171
+ console.log(` ${key} — declarada por ambiente`);
172
+ };
100
173
  const targets = ['production', 'preview'];
101
174
  const plan = keys.map((key) => ({
102
175
  key,
@@ -104,12 +177,23 @@ export async function runEnvPush(flags, rootDir = process.cwd()) {
104
177
  target: targets,
105
178
  }));
106
179
  if (flags.dryRun) {
107
- console.log(`Plan (${plan.length} vars → production, preview):`);
108
- for (const key of keys)
109
- console.log(` ${key}`);
180
+ if (keys.length === 0) {
181
+ console.log('No hay variables de producto que subir en `.env.local`.');
182
+ }
183
+ else {
184
+ console.log(`Plan (${plan.length} vars → production, preview):`);
185
+ for (const key of keys)
186
+ console.log(` ${key}`);
187
+ }
188
+ printOmitted();
110
189
  console.log('\n(--dry-run: nada se envió a Vercel.)');
111
190
  return;
112
191
  }
192
+ if (keys.length === 0) {
193
+ console.log('No hay variables de producto que subir en `.env.local` ' +
194
+ '(todo es provision-owned, está declarado por ambiente, o está vacío).');
195
+ return;
196
+ }
113
197
  const { projectId, teamId } = readProjectLink(rootDir);
114
198
  const token = readVercelToken(defaultEnvPath());
115
199
  if (!token) {
@@ -219,9 +303,11 @@ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {
219
303
  }
220
304
  /**
221
305
  * `factory env wizard --review [--json]` — a secret-safe summary of the resolved
222
- * `.env.local`. Non-secret values are shown; secret values are masked; empty ones
223
- * are flagged. This is what the guided flow shows the dev at the end (the agent
224
- * NEVER echoes raw secrets) — the dev opens `.env.local` to see the full values.
306
+ * `.env.local`. Non-secret values are shown; secret values are masked down to their
307
+ * last four characters, enough to tell WHICH value is in place while rotating and
308
+ * useless on their own; empty ones are flagged. This is what the guided flow shows
309
+ * the dev at the end (the agent NEVER echoes a raw secret) — the dev opens
310
+ * `.env.local` to see the full values.
225
311
  */
226
312
  export function runEnvWizardReview(flags, rootDir = process.cwd()) {
227
313
  const localPath = path.join(rootDir, '.env.local');
@@ -252,7 +338,7 @@ export function runEnvWizardReview(flags, rootDir = process.cwd()) {
252
338
  for (const w of warnings)
253
339
  console.log(` ⚠ ${w}`);
254
340
  }
255
- console.log('\nAbre `.env.local` para ver los valores completos (los secretos no se imprimen).');
341
+ console.log('\nAbre `.env.local` para ver los valores completos (de un secreto solo se ven sus últimos 4).');
256
342
  }
257
343
  /**
258
344
  * `factory env wizard --gen-vapid` — generate a VAPID keypair and write it to