@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.
- package/dist/commands/add.js +6 -1
- package/dist/commands/backlog.js +706 -0
- package/dist/commands/env.js +9 -0
- package/dist/commands/provision.js +689 -42
- package/dist/commands/update.js +45 -1
- package/dist/index.js +43 -2
- package/dist/lib/atomic-swap.js +13 -6
- package/dist/lib/backlog-api.js +302 -0
- package/dist/lib/backlog-mapping.js +175 -0
- package/dist/lib/backlog-parse.js +208 -0
- package/dist/lib/cloudflare-api.js +112 -2
- package/dist/lib/constants.js +23 -0
- package/dist/lib/env-file.js +51 -6
- package/dist/lib/env-wizard.js +30 -8
- package/dist/lib/package-json.js +59 -1
- package/dist/lib/provision-preflight.js +8 -0
- package/dist/lib/provision-state.js +10 -0
- package/dist/lib/vercel-env.js +42 -8
- package/package.json +1 -1
package/dist/lib/env-wizard.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
|
251
|
-
*
|
|
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(' + ')} →
|
|
261
|
-
`
|
|
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' &&
|
package/dist/lib/package-json.js
CHANGED
|
@@ -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
|
}
|
package/dist/lib/vercel-env.js
CHANGED
|
@@ -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
|
|
338
|
-
*
|
|
339
|
-
* (`/v10/projects/{id}/env/{envId}
|
|
340
|
-
* (`/v10/projects/{id}/env`). Either way
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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',
|