@timekast/factory 1.25.0 β 1.27.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 +2 -0
- package/dist/commands/doctor.js +5 -4
- package/dist/commands/env.js +110 -24
- package/dist/commands/provision.js +24 -17
- package/dist/commands/ticket.js +487 -0
- package/dist/commands/update.js +13 -2
- package/dist/index.js +28 -1
- package/dist/lib/constants.js +172 -0
- package/dist/lib/env-file.js +239 -0
- package/dist/lib/env-wizard.js +110 -21
- package/dist/lib/exit-code.js +31 -0
- package/dist/lib/initial-migration.js +1 -1
- package/dist/lib/package-json.js +70 -2
- package/dist/lib/public-env-keys.json +27 -0
- package/dist/lib/retrofit-reminder.js +46 -3
- package/package.json +1 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the CLI turns a resolved `main()` into a process exit code.
|
|
3
|
+
*
|
|
4
|
+
* π΄ The bug this exists to fix, stated plainly: `main(...).then(() => process.exit(0))`
|
|
5
|
+
* passes an EXPLICIT code, and an explicit argument to `process.exit` overrides whatever a
|
|
6
|
+
* command assigned to `process.exitCode`. So a command that finished its work successfully
|
|
7
|
+
* but wanted to report a non-zero *outcome* had its signal silently dropped.
|
|
8
|
+
*
|
|
9
|
+
* That is not hypothetical. `update --dry-run` sets `process.exitCode = 2` when it finds
|
|
10
|
+
* conflicts, and the GUI launcher chains the real apply (`--theirs-all --yes`) only when the
|
|
11
|
+
* preview exited 0 β its comment says "never clobber local edits with `--theirs-all` behind
|
|
12
|
+
* the dev's back". With the 2 discarded, that guard was dead: the launcher read 0 on every
|
|
13
|
+
* preview and applied unconditionally, overwriting local edits to tracked kit files.
|
|
14
|
+
*
|
|
15
|
+
* Note the distinction this function keeps: a **thrown** `CLIError` is a failure and still
|
|
16
|
+
* exits with its own code (that path never went through here). `process.exitCode` is for a
|
|
17
|
+
* command that succeeded and is reporting a RESULT β the dry-run's "there are conflicts" β
|
|
18
|
+
* which is exactly the case that was being swallowed.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveSuccessExitCode(pending) {
|
|
21
|
+
// Node types `process.exitCode` as `number | string | null | undefined` β `null` included,
|
|
22
|
+
// which is why this takes the whole union instead of narrowing at the call site. A string is
|
|
23
|
+
// only reachable if something assigned one; coerce rather than forward it, because
|
|
24
|
+
// `process.exit('2')` is not the same call as `process.exit(2)`.
|
|
25
|
+
if (typeof pending === 'number' && Number.isInteger(pending))
|
|
26
|
+
return pending;
|
|
27
|
+
if (typeof pending === 'string' && /^\d+$/.test(pending))
|
|
28
|
+
return Number(pending);
|
|
29
|
+
// Unset (the overwhelmingly common case) or unusable β success.
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* WHY THIS MODULE EXISTS. `factory provision` never ran `db:generate`, so a fresh
|
|
6
6
|
* derivative was born with a schema and no migrations while its `vercel-build`
|
|
7
|
-
* (`pnpm
|
|
7
|
+
* (`pnpm build && pnpm db:migrate`) migrated an empty database on the first deploy.
|
|
8
8
|
* The provision step that closes that gap has to decide, from disk alone, whether
|
|
9
9
|
* generating and committing a `0000` is the right thing to do β and the cost of a
|
|
10
10
|
* wrong "yes" is asymmetric.
|
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 { DEV_NEXT_GATE_FILE, DEV_NEXT_SCRIPT_CMD, DEV_NEXT_SCRIPT_NAME, 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, RETIRED_SCRIPT_CMDS, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, } from './constants.js';
|
|
11
|
+
import { DEV_NEXT_GATE_FILE, DEV_NEXT_SCRIPT_CMD, DEV_NEXT_SCRIPT_NAME, E2E_SETUP_SCRIPT_CMD, E2E_SETUP_SCRIPT_FILE, E2E_SETUP_SCRIPT_NAME, EVIDENCE_VISUAL_SCRIPT_CMD, EVIDENCE_VISUAL_SCRIPT_FILE, EVIDENCE_VISUAL_SCRIPT_NAME, 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, VERIFY_QUICK_SCRIPT_CMD, VERIFY_QUICK_SCRIPT_NAME, VERIFY_SCRIPT_CMD, VERIFY_SCRIPT_FILE, VERIFY_SCRIPT_NAME, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, RETIRED_SCRIPT_CMDS, SETUP_E2E_VARS_SCRIPT_CMD, SETUP_E2E_VARS_SCRIPT_FILE, SETUP_E2E_VARS_SCRIPT_NAME, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, VERCEL_BUILD_OBSOLETE_CMDS, VERCEL_BUILD_SCRIPT_CMD, VERCEL_BUILD_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
|
|
@@ -111,6 +111,18 @@ function ensureFactoryScripts(pkg, extraScripts = {}) {
|
|
|
111
111
|
* backing file lands.
|
|
112
112
|
*
|
|
113
113
|
* - `preflight` β when its sweep script ships (full brain).
|
|
114
|
+
* - `setup:e2e:vars` β publishes the CI E2E auth env vars (Fase 1 of the
|
|
115
|
+
* `e2e-ci-auth-env` retrofit), when `scripts/tools/setup-e2e.ts` ships (full brain).
|
|
116
|
+
* - `evidence:visual` β the visual-evidence harness (`fx-visual-evidence`), gated on the
|
|
117
|
+
* harness itself (`scripts/tools/visual-evidence/`) rather than on the runner: it is what
|
|
118
|
+
* this alias needs, and a `core` derivative receives neither. Forwards to `test:e2e`, so the
|
|
119
|
+
* capture pass goes through the ephemeral Neon branch like every other phase.
|
|
120
|
+
* - `e2e:setup` β installs the Playwright browsers, gated on the e2e runner that
|
|
121
|
+
* refuses to start without them and names this alias in its message. A repo
|
|
122
|
+
* whose runner ships but whose `package.json` lacks the alias would be told to
|
|
123
|
+
* run a script it does not have β which is why it is maintained here rather
|
|
124
|
+
* than only in the Factory's own `package.json` (frozen at bootstrap in a
|
|
125
|
+
* derivative, so it is unreachable any other way).
|
|
114
126
|
* - `generate:*` β one alias per reference generator whose `scripts/tools/
|
|
115
127
|
* generate-*.mjs` ships (full brain). These arrive via the `scripts/**`
|
|
116
128
|
* tracked set on `update`/`beta`, so an existing derivative self-heals the
|
|
@@ -140,12 +152,22 @@ export function kitLocalScripts(rootDir) {
|
|
|
140
152
|
scripts[PREFLIGHT_SCRIPT_NAME] = PREFLIGHT_SCRIPT_CMD;
|
|
141
153
|
if (has(INVITE_ADMIN_SCRIPT_FILE))
|
|
142
154
|
scripts[INVITE_ADMIN_SCRIPT_NAME] = INVITE_ADMIN_SCRIPT_CMD;
|
|
155
|
+
if (has(SETUP_E2E_VARS_SCRIPT_FILE))
|
|
156
|
+
scripts[SETUP_E2E_VARS_SCRIPT_NAME] = SETUP_E2E_VARS_SCRIPT_CMD;
|
|
157
|
+
if (has(E2E_SETUP_SCRIPT_FILE))
|
|
158
|
+
scripts[E2E_SETUP_SCRIPT_NAME] = E2E_SETUP_SCRIPT_CMD;
|
|
159
|
+
if (has(EVIDENCE_VISUAL_SCRIPT_FILE))
|
|
160
|
+
scripts[EVIDENCE_VISUAL_SCRIPT_NAME] = EVIDENCE_VISUAL_SCRIPT_CMD;
|
|
143
161
|
for (const gen of GENERATOR_SCRIPTS) {
|
|
144
162
|
if (has(gen.file))
|
|
145
163
|
scripts[gen.name] = gen.cmd;
|
|
146
164
|
}
|
|
147
165
|
if (has(GENERATE_REFERENCE_GATE_FILE))
|
|
148
166
|
scripts[GENERATE_REFERENCE_NAME] = GENERATE_REFERENCE_CMD;
|
|
167
|
+
if (has(VERIFY_SCRIPT_FILE))
|
|
168
|
+
scripts[VERIFY_SCRIPT_NAME] = VERIFY_SCRIPT_CMD;
|
|
169
|
+
if (has(VERIFY_SCRIPT_FILE))
|
|
170
|
+
scripts[VERIFY_QUICK_SCRIPT_NAME] = VERIFY_QUICK_SCRIPT_CMD;
|
|
149
171
|
if (has(DEV_NEXT_GATE_FILE))
|
|
150
172
|
scripts[DEV_NEXT_SCRIPT_NAME] = DEV_NEXT_SCRIPT_CMD;
|
|
151
173
|
return scripts;
|
|
@@ -351,6 +373,48 @@ export function removeRetiredScripts(pkgPath) {
|
|
|
351
373
|
}
|
|
352
374
|
return report;
|
|
353
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Reorder a derivative's `vercel-build` so the production build runs BEFORE the migration.
|
|
378
|
+
*
|
|
379
|
+
* WHY THIS IS ITS OWN FUNCTION AND NOT AN ENTRY IN `FACTORY_SCRIPTS`. Everything in that
|
|
380
|
+
* registry is INSERTED when missing, because those are the CLI's own `factory:*` aliases and a
|
|
381
|
+
* repo without them is a repo that is missing them. `vercel-build` is the opposite kind of key:
|
|
382
|
+
* it belongs to the boilerplate, arrives frozen with `src/` at `factory new` (BR-FACTORY-006),
|
|
383
|
+
* and in a `core`/non-Factory repo its absence is CORRECT β inserting it there would point a
|
|
384
|
+
* build command at a stack that repo does not have. So this heals in place and never inserts,
|
|
385
|
+
* the same shape as `emptyShippedE2EPort`.
|
|
386
|
+
*
|
|
387
|
+
* The damage it repairs is in `VERCEL_BUILD_SCRIPT_NAME`'s docstring: with the migration first,
|
|
388
|
+
* a build that fails AFTER it leaves the production database migrated while Vercel keeps
|
|
389
|
+
* serving the previous build β new schema, old code, and a deployment rollback cannot undo it.
|
|
390
|
+
*
|
|
391
|
+
* Surgical like the rest of this module: reads, mutates a single key, re-serializes with the
|
|
392
|
+
* original indentation + trailing newline, and writes ONLY on a real change β so a second run
|
|
393
|
+
* finds `already-correct` and writes nothing.
|
|
394
|
+
*
|
|
395
|
+
* @param pkgPath Absolute path to the derived project's `package.json`.
|
|
396
|
+
*/
|
|
397
|
+
export function healVercelBuildOrder(pkgPath) {
|
|
398
|
+
const raw = readFileSync(pkgPath, 'utf8');
|
|
399
|
+
const indent = detectIndent(raw);
|
|
400
|
+
const pkg = parsePackageJson(raw);
|
|
401
|
+
const scripts = pkg.scripts;
|
|
402
|
+
if (typeof scripts !== 'object' || scripts === null || Array.isArray(scripts))
|
|
403
|
+
return 'absent';
|
|
404
|
+
const current = scripts[VERCEL_BUILD_SCRIPT_NAME];
|
|
405
|
+
if (current === undefined)
|
|
406
|
+
return 'absent';
|
|
407
|
+
if (current === VERCEL_BUILD_SCRIPT_CMD)
|
|
408
|
+
return 'already-correct';
|
|
409
|
+
// Exact match against what the kit actually shipped, byte for byte. A dev who appended a flag
|
|
410
|
+
// β or wrote the same two halves with different spacing β keeps their command through every
|
|
411
|
+
// future update: an unlisted variant erring toward "leave it alone" is the safe side here.
|
|
412
|
+
if (!VERCEL_BUILD_OBSOLETE_CMDS.includes(current))
|
|
413
|
+
return 'preserved';
|
|
414
|
+
scripts[VERCEL_BUILD_SCRIPT_NAME] = VERCEL_BUILD_SCRIPT_CMD;
|
|
415
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
|
|
416
|
+
return 'reordered';
|
|
417
|
+
}
|
|
354
418
|
/**
|
|
355
419
|
* The kit-owned maintenance of a derivative's `package.json`, in the ONE order that is correct.
|
|
356
420
|
*
|
|
@@ -391,6 +455,10 @@ export function maintainKitScripts(pkgPath) {
|
|
|
391
455
|
// `scripts/**` can derive one per checkout. Conditional: it only ever touches that exact
|
|
392
456
|
// value, so a dev-chosen port survives untouched.
|
|
393
457
|
const portAction = emptyShippedE2EPort(pkgPath);
|
|
458
|
+
// Reorder `vercel-build` so the build runs before the migration. Conditional in the same way:
|
|
459
|
+
// it only ever rewrites a value the kit itself shipped, and never creates the key β a repo
|
|
460
|
+
// without it is a repo that should not have one.
|
|
461
|
+
const vercelBuildAction = healVercelBuildOrder(pkgPath);
|
|
394
462
|
const removedKeys = new Set(Object.entries(retiredReport)
|
|
395
463
|
.filter(([, action]) => action === 'removed')
|
|
396
464
|
.map(([name]) => name));
|
|
@@ -398,7 +466,7 @@ export function maintainKitScripts(pkgPath) {
|
|
|
398
466
|
.filter(([name, r]) => (r.action === 'added' || r.action === 'healed') && !removedKeys.has(name))
|
|
399
467
|
.map(([name]) => name);
|
|
400
468
|
const retired = [...removedKeys].filter((name) => insert.perScript[name]?.action !== 'added');
|
|
401
|
-
return { insert, retiredReport, portAction, inserted, retired };
|
|
469
|
+
return { insert, retiredReport, portAction, vercelBuildAction, inserted, retired };
|
|
402
470
|
}
|
|
403
471
|
/**
|
|
404
472
|
* Surgically set the derived project's `package.json.agentKitVersion` to the
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"prefixes": ["NEXT_PUBLIC_", "RATE_LIMIT_"],
|
|
3
|
+
"keys": [
|
|
4
|
+
"APP_URL",
|
|
5
|
+
"AUTH_DEBUG",
|
|
6
|
+
"AUTH_GITHUB_ID",
|
|
7
|
+
"AUTH_GOOGLE_ID",
|
|
8
|
+
"AUTH_TRUST_HOST",
|
|
9
|
+
"EMAIL_FROM",
|
|
10
|
+
"EMAIL_LOGO_URL",
|
|
11
|
+
"EMAIL_PROVIDER",
|
|
12
|
+
"EMAIL_SERVER_HOST",
|
|
13
|
+
"EMAIL_SERVER_PORT",
|
|
14
|
+
"EMAIL_SERVER_SECURE",
|
|
15
|
+
"EMAIL_SERVER_USER",
|
|
16
|
+
"MFA_ENABLED",
|
|
17
|
+
"MFA_REQUIRED_ALL",
|
|
18
|
+
"NEXTAUTH_URL",
|
|
19
|
+
"NODE_ENV",
|
|
20
|
+
"SENTRY_ORG",
|
|
21
|
+
"SENTRY_PROJECT",
|
|
22
|
+
"SUPPORT_EMAIL",
|
|
23
|
+
"VAPID_SUBJECT",
|
|
24
|
+
"WEBAUTHN_RP_ID",
|
|
25
|
+
"WEBAUTHN_RP_NAME"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -216,13 +216,56 @@ export function renderRetrofitReminder(entries, fromVersion = null) {
|
|
|
216
216
|
lines.push(` No todas te aplican: cada una abre diciendo a quiΓ©n le toca. En ${RETROFITS_DIR}/`, ' Si trabajas con un agente, pΓdele que las lea y te diga cuΓ‘les te aplican.', rule, '');
|
|
217
217
|
return lines.join('\n');
|
|
218
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* π΄ FROZEN WIRE FORMAT β see ADR-004 Β§1/Β§4. Once a launcher is installed on someone else's
|
|
221
|
+
* machine, renaming this marker leaves it mute forever, silently: it does not error, it just
|
|
222
|
+
* stops seeing the signal. Never rename it; the pin in the test suite is what makes that
|
|
223
|
+
* mistake loud instead of silent. Adding NEW fields to the payload is fine (ADR-004 Β§3) β
|
|
224
|
+
* this is about the marker string itself and the shape it prefixes.
|
|
225
|
+
*/
|
|
226
|
+
export const RETROFIT_SIGNAL_MARKER = '::timekast:retrofit-signal::';
|
|
227
|
+
/**
|
|
228
|
+
* Render the machine-readable signal line, or `null` under the same gate as the banner
|
|
229
|
+
* (`renderRetrofitReminder` β no guides shipped, nothing to signal either).
|
|
230
|
+
*
|
|
231
|
+
* Reuses `splitByArrival` rather than recomputing the arrival cut β that logic already lives
|
|
232
|
+
* there and this must never drift from what the banner counts.
|
|
233
|
+
*
|
|
234
|
+
* π΄ One line, no pretty-printing: `JSON.stringify` with no `indent` arg. The consumer detects
|
|
235
|
+
* this by line PREFIX (ADR-004 Β§1); a payload that spans multiple lines breaks that detection.
|
|
236
|
+
*/
|
|
237
|
+
export function renderRetrofitSignal(entries, fromVersion = null) {
|
|
238
|
+
if (renderRetrofitReminder(entries, fromVersion) === null)
|
|
239
|
+
return null;
|
|
240
|
+
const { arrived, carried } = splitByArrival(entries, fromVersion);
|
|
241
|
+
const payload = {
|
|
242
|
+
dir: RETROFITS_DIR,
|
|
243
|
+
arrived: arrived.length,
|
|
244
|
+
carried: carried.length,
|
|
245
|
+
};
|
|
246
|
+
return `${RETROFIT_SIGNAL_MARKER}${JSON.stringify(payload)}`;
|
|
247
|
+
}
|
|
219
248
|
/**
|
|
220
249
|
* Print the reminder. Called on EVERY applied update β see the module header for why this one
|
|
221
250
|
* has no headless guard and no outcome condition.
|
|
251
|
+
*
|
|
252
|
+
* π΄ With `guiSignal: true`, the signal line is written FIRST, the banner second β not the
|
|
253
|
+
* other way around. `cli/src/index.ts` calls `process.exit(0)` right after `main` resolves, and
|
|
254
|
+
* writes to a pipe are async on Windows, where `process.exit` can truncate the tail. Under
|
|
255
|
+
* `--yes` the signal would otherwise be the LAST line written; writing it first leaves the
|
|
256
|
+
* banner (cosmetic text) as the one at risk of truncation instead of the machine-readable line.
|
|
222
257
|
*/
|
|
223
258
|
export function printRetrofitReminder(opts) {
|
|
224
259
|
const log = opts.log ?? ((m) => console.log(m));
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
260
|
+
const entries = listRetrofits(opts.rootDir, opts);
|
|
261
|
+
const fromVersion = opts.fromVersion ?? null;
|
|
262
|
+
const rendered = renderRetrofitReminder(entries, fromVersion);
|
|
263
|
+
if (!rendered)
|
|
264
|
+
return;
|
|
265
|
+
if (opts.guiSignal) {
|
|
266
|
+
const signal = renderRetrofitSignal(entries, fromVersion);
|
|
267
|
+
if (signal)
|
|
268
|
+
log(signal);
|
|
269
|
+
}
|
|
270
|
+
log(rendered);
|
|
228
271
|
}
|