@timekast/factory 1.18.0 → 1.20.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.
@@ -41,6 +41,12 @@ const DIVIDER = /^#\s*-{3,}\s*$/;
41
41
  const ACTIVE = /^([A-Z][A-Z0-9_]*)=(.*)$/;
42
42
  const COMMENTED = /^#\s*([A-Z][A-Z0-9_]*)=(.*)$/;
43
43
  const SECRET_KEY = /(_SECRET|_KEY|_TOKEN|_PASSWORD)$/;
44
+ /**
45
+ * `NEXT_PUBLIC_*` vars are public by Next.js convention — they're inlined into
46
+ * the client bundle, so they can never be secrets regardless of their suffix
47
+ * (e.g. NEXT_PUBLIC_AUTH_PASSWORD is a boolean feature flag, not a credential).
48
+ */
49
+ const PUBLIC_PREFIX = /^NEXT_PUBLIC_/;
44
50
  function unquote(raw) {
45
51
  const v = raw.trim();
46
52
  if (v.length >= 2 &&
@@ -104,10 +110,16 @@ export function parseEnvExample(text) {
104
110
  }
105
111
  return entries;
106
112
  }
107
- function questionKind(key, defaultValue) {
113
+ /**
114
+ * Classify a var into the widget the wizard renders. Secret detection delegates
115
+ * to `isSecretKey` so classification, masking and review all share one rule
116
+ * (secret suffix AND not `NEXT_PUBLIC_*` — public-by-convention vars are never
117
+ * secrets, e.g. NEXT_PUBLIC_AUTH_PASSWORD="true" is a boolean flag).
118
+ */
119
+ export function questionKind(key, defaultValue) {
108
120
  if (SELECTS[key])
109
121
  return 'select';
110
- if (SECRET_KEY.test(key))
122
+ if (isSecretKey(key))
111
123
  return 'secret';
112
124
  if (defaultValue === 'true' || defaultValue === 'false')
113
125
  return 'boolean';
@@ -189,9 +201,13 @@ export function applyWizardAnswers(rootDir, answers) {
189
201
  // secret values, (c) auto-generate what a dev can't type, and (d) catch broken
190
202
  // setups (a provider flag on with empty creds). All pure over their inputs.
191
203
  // ─────────────────────────────────────────────────────────────────────────────
192
- /** True when `key`'s value is a secret (never printed in review/summary output). */
204
+ /**
205
+ * True when `key`'s value is a secret (never printed in review/summary output).
206
+ * `NEXT_PUBLIC_*` keys are excluded: Next.js inlines them into the client bundle,
207
+ * so they're public by convention even when their suffix looks secret-ish.
208
+ */
193
209
  export function isSecretKey(key) {
194
- return SECRET_KEY.test(key);
210
+ return !PUBLIC_PREFIX.test(key) && SECRET_KEY.test(key);
195
211
  }
196
212
  /** Parse an env-file body into `{ KEY: value }` (unquoted), skipping comments/blanks. */
197
213
  export function parseEnvBody(body) {
@@ -247,8 +263,12 @@ const OAUTH_PROVIDERS = [
247
263
  ];
248
264
  /**
249
265
  * Warn (do NOT block) about half-configured setups that would ship a broken app:
250
- * an OAuth provider enabled with empty creds → broken login button; notifications
251
- * enabled with no VAPID keys → dead push. Pure over the resolved env map.
266
+ * an OAuth flag on with missing creds → `next build` FAILS (BOOT-005: auth.ts
267
+ * resolves the provider credentials at module load, i.e. during the build — a
268
+ * deploy that never ships on derivatives whose frozen src/ predates the auth.ts
269
+ * guard); notifications enabled with no VAPID keys → dead push. Pure over the
270
+ * resolved env map. Warn-never-block by contract (headless-safe): the wizard
271
+ * stays pure + non-interactive, so escalation is message-only.
252
272
  */
253
273
  export function checkEnvCompleteness(env) {
254
274
  const warnings = [];
@@ -257,8 +277,10 @@ export function checkEnvCompleteness(env) {
257
277
  continue;
258
278
  const missing = [p.id, p.secret].filter((k) => !env[k]);
259
279
  if (missing.length) {
260
- warnings.push(`Login con ${p.label} activado pero falta ${missing.join(' + ')} → el botón de login ` +
261
- `quedará roto. Complétalas o pon ${p.flag}="false".`);
280
+ warnings.push(`Login con ${p.label} activado pero falta ${missing.join(' + ')} → EL BUILD FALLARÁ ` +
281
+ `mientras ${p.flag} quede en "true" sin credenciales (next build resuelve las ` +
282
+ `credenciales del provider al compilar — el deploy nunca sale). ` +
283
+ `Complétalas o pon ${p.flag}="false".`);
262
284
  }
263
285
  }
264
286
  if (env['NEXT_PUBLIC_NOTIFICATIONS_ENABLED'] === 'true' &&
@@ -8,7 +8,7 @@
8
8
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { CLIError } from './cli-error.js';
11
- import { FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, UPDATE_SCRIPT_NAME, } from './constants.js';
11
+ import { FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, } from './constants.js';
12
12
  /**
13
13
  * Auto-detect the profile to install into an existing repo from its
14
14
  * `package.json`: a `factoryVersion` field means the repo was born from the
@@ -197,6 +197,64 @@ export function insertFactoryUpdateScript(pkgPath) {
197
197
  }
198
198
  return primary;
199
199
  }
200
+ /**
201
+ * Retire the E2E port the kit used to ship in a derivative's `package.json`: KEEP the key,
202
+ * EMPTY the value (`"e2e": null`), and add the `//e2e` sibling that explains why.
203
+ *
204
+ * WHY EMPTY RATHER THAN DELETE. `null` is a signal the e2e runner reads: with the key
205
+ * present and empty it derives a port from the checkout's absolute path, so two worktrees of
206
+ * one repo can run their suites at the same time. Deleting the key would say something
207
+ * different — "this project never had one" — which is the state that keeps a project on the
208
+ * runner's hardcoded fallback. So the key is the contract; only the value retires.
209
+ *
210
+ * WHY ONLY EXACTLY `3006`. Same discipline as `ensureFactoryScripts`: a KNOWN-OBSOLETE kit
211
+ * value is healed, a divergent one is never touched. A dev who pinned `4100` on purpose
212
+ * keeps `4100` through every future `factory update`; anything that is not the shipped
213
+ * number — including a `null` already written by an earlier update — is left alone, which is
214
+ * also what makes this idempotent (a second run writes nothing and cannot duplicate the
215
+ * comment).
216
+ *
217
+ * Surgical like the rest of this module: reads, mutates only inside `ports`, re-serializes
218
+ * with the original indentation + trailing newline, and writes ONLY on a real change. Key
219
+ * order inside `ports` is preserved and the comment is inserted immediately BEFORE `e2e`, so
220
+ * what a derivative sees in `git diff` is two lines: the emptied value and its explanation.
221
+ *
222
+ * @param pkgPath Absolute path to the derived project's `package.json`.
223
+ */
224
+ export function emptyShippedE2EPort(pkgPath) {
225
+ const raw = readFileSync(pkgPath, 'utf8');
226
+ const indent = detectIndent(raw);
227
+ const pkg = parsePackageJson(raw);
228
+ const ports = pkg[PORTS_KEY];
229
+ if (typeof ports !== 'object' || ports === null || Array.isArray(ports))
230
+ return 'absent';
231
+ const block = ports;
232
+ const current = block[PORTS_E2E_KEY];
233
+ if (current === undefined)
234
+ return 'absent';
235
+ if (current === null)
236
+ return 'already-empty';
237
+ if (current !== SHIPPED_E2E_PORT)
238
+ return 'preserved';
239
+ // Rebuilt rather than assigned in place: an object literal's key order is its insertion
240
+ // order, so this is what puts the comment immediately before the key it describes while
241
+ // leaving every sibling (`dev`, whatever else the project keeps here) where it was. An
242
+ // existing `//e2e` is dropped on the way through and re-inserted once, never twice.
243
+ const rebuilt = {};
244
+ for (const [key, value] of Object.entries(block)) {
245
+ if (key === PORTS_E2E_COMMENT_KEY)
246
+ continue;
247
+ if (key === PORTS_E2E_KEY) {
248
+ rebuilt[PORTS_E2E_COMMENT_KEY] = PORTS_E2E_COMMENT;
249
+ rebuilt[PORTS_E2E_KEY] = null;
250
+ continue;
251
+ }
252
+ rebuilt[key] = value;
253
+ }
254
+ pkg[PORTS_KEY] = rebuilt;
255
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
256
+ return 'emptied';
257
+ }
200
258
  /**
201
259
  * Surgically set the derived project's `package.json.agentKitVersion` to the
202
260
  * installed brain version, mirroring the lockfile's `version`. The Factory's own
@@ -45,6 +45,14 @@ const SERVICE_TOKENS = {
45
45
  // at the scaffold stage.
46
46
  github: 'VERCEL_TOKEN',
47
47
  dns: 'CLOUDFLARE_API_TOKEN',
48
+ // `backlog` (BSYNC-003) registers the project in backlog.timekast.mx with the org
49
+ // `admin` token. NOTE: unlike the other gate tokens (fail-closed here), a missing
50
+ // `BACKLOG_ADMIN_TOKEN` degrades GRACEFULLY at the command layer — WARN + skip when
51
+ // backlog is one of several services (`resolveBacklogService` in provision.ts strips
52
+ // it BEFORE this preflight runs), a hard error only when it is the sole service. So
53
+ // by the time the preflight sees `backlog` in `services`, the token is present; this
54
+ // entry is the defense-in-depth double-check.
55
+ backlog: 'BACKLOG_ADMIN_TOKEN',
48
56
  };
49
57
  /** True when the thrown error is execa's "command not found" (ENOENT). */
50
58
  function isCommandNotFound(error) {
@@ -247,6 +247,12 @@ export function isServiceComplete(state, service) {
247
247
  return state.github?.done === true;
248
248
  case 'dns':
249
249
  return state.dns !== undefined;
250
+ case 'backlog':
251
+ // Complete only when the per-project key was minted AND written — NOT on the
252
+ // mere presence of state.backlog (its `projectId` is recorded before the mint,
253
+ // so a run that created the project but failed to mint would be wrongly skipped
254
+ // on resume, and the show-once key would never reach `.env.local`).
255
+ return state.backlog?.keyMinted === true;
250
256
  default:
251
257
  return false;
252
258
  }
@@ -348,6 +354,9 @@ export function readDestroyTarget(rootDir) {
348
354
  neonBranchDevId: realId(adopted.neonBranchDevId),
349
355
  vercelProjectId: realId(adopted.vercelProjectId),
350
356
  githubRepo: realRepo(adopted.githubRepo),
357
+ // The adopt backfill (PROV-007) does not reconstruct backlog central — nothing
358
+ // to tear down from an adopted stack; a normal provision state carries it.
359
+ backlogProjectId: null,
351
360
  domains,
352
361
  };
353
362
  }
@@ -380,6 +389,7 @@ export function readDestroyTarget(rootDir) {
380
389
  neonBranchDevId: realId(step.neon?.developBranchId),
381
390
  vercelProjectId: realId(step.vercel?.projectId),
382
391
  githubRepo: realRepo(step.github?.repo),
392
+ backlogProjectId: realId(step.backlog?.projectId),
383
393
  domains,
384
394
  };
385
395
  }
@@ -334,19 +334,53 @@ export async function listEnvVars(projectId, token, teamId, deps = {}) {
334
334
  return data.envs ?? [];
335
335
  }
336
336
  /**
337
- * Idempotently set ONE env var for its targets (issue §4 + §8). If an existing
338
- * var already covers any of the requested targets, it is PATCHed
339
- * (`/v10/projects/{id}/env/{envId}`); otherwise it is POSTed
340
- * (`/v10/projects/{id}/env`). Either way the call threads `?teamId=`.
337
+ * Idempotently set ONE env var for its targets (issue §4 + §8). If existing vars
338
+ * already cover any of the requested targets, they are RECONCILED into a single
339
+ * record (PATCH `/v10/projects/{id}/env/{envId}`, DELETEing any surplus record —
340
+ * see below); otherwise the var is POSTed (`/v10/projects/{id}/env`). Either way
341
+ * every call threads `?teamId=`.
341
342
  *
342
343
  * Matching is by `key` + target overlap — a var pushed for `production` does not
343
344
  * clobber the same key's `preview` entry (they are distinct Vercel env records,
344
- * which is exactly how `AUTH_SECRET` can differ per environment).
345
+ * which is exactly how `AUTH_SECRET` can differ per environment). Same-key
346
+ * records whose target does NOT overlap the plan are never touched, so the
347
+ * intentional split pairs `buildEnvPlan` creates (distinct values per env) are
348
+ * never collapsed by a per-target push.
349
+ *
350
+ * Split-target reconciliation (BOOT-002): `buildEnvPlan` intentionally creates
351
+ * SPLIT records per target for some keys (e.g. `NEXT_PUBLIC_APP_URL` as
352
+ * `['production']` + `['preview']`). When a later plan re-points that key with a
353
+ * COMBINED target (`['production','preview']` — e.g. the `--domain` go-live),
354
+ * ALL overlapping records are gathered, the surplus ones are DELETEd FIRST
355
+ * (Vercel rejects a PATCH whose expanded target collides with a sibling record),
356
+ * and the first record is then PATCHed with the plan's value + target — leaving
357
+ * ONE consistent record for the key. A re-run over the already-reconciled state
358
+ * finds a single record and simply re-PATCHes it in place: no duplicate, no 400.
359
+ *
360
+ * @throws {CLIError} when a surplus-record DELETE fails mid-reconciliation — the
361
+ * message names the key and the step stays re-runnable with `--resume` (the next
362
+ * run re-lists the records and retries the cleanup).
363
+ * @throws {VercelApiError} on any other non-2xx (the caller maps it).
345
364
  */
346
365
  export async function upsertEnvVar(projectId, token, teamId, plan, existing, deps = {}) {
347
- const match = existing.find((e) => e.key === plan.key && (e.target ?? []).some((t) => plan.target.includes(t)));
348
- if (match) {
349
- await vercelApi('PATCH', `/v10/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(match.id)}`, token, teamId, deps, {
366
+ // EVERY record of the key whose target overlaps the plan — not just the first
367
+ // (a combined-target plan over split records must reconcile the whole set).
368
+ const matches = existing.filter((e) => e.key === plan.key && (e.target ?? []).some((t) => plan.target.includes(t)));
369
+ if (matches.length > 0) {
370
+ const [survivor, ...surplus] = matches;
371
+ // DELETE the surplus records BEFORE the PATCH: expanding the survivor's
372
+ // target while a sibling still owns one of those targets 400s on Vercel.
373
+ for (const record of surplus) {
374
+ try {
375
+ await vercelApi('DELETE', `/v10/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(record.id)}`, token, teamId, deps);
376
+ }
377
+ catch (error) {
378
+ throw new CLIError(`No se pudo eliminar el record duplicado de \`${plan.key}\` en Vercel al reconciliar ` +
379
+ `sus targets (${error?.message ?? 'error desconocido'}).\n` +
380
+ 'La variable quedó a medio reconciliar; reintenta con `--resume` para completar la limpieza.');
381
+ }
382
+ }
383
+ await vercelApi('PATCH', `/v10/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(survivor.id)}`, token, teamId, deps, {
350
384
  value: plan.value,
351
385
  target: plan.target,
352
386
  type: 'encrypted',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.18.0",
3
+ "version": "1.20.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",