@timekast/factory 1.25.0 → 1.26.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.
@@ -94,6 +94,8 @@ export async function runAdd(flags = { core: false, full: false }) {
94
94
  // `add` is interactive: keep the legacy confirm prompt. Only the GUI/headless
95
95
  // caller passes `--yes` (via `update` directly), never this redirect.
96
96
  yes: false,
97
+ // The legacy `add` redirect never requests the GUI signal — only `update --launcher` does.
98
+ launcher: false,
97
99
  });
98
100
  return;
99
101
  }
@@ -67,6 +67,7 @@ export function parseUpdateFlags(argv) {
67
67
  beta: argv.includes('--beta'),
68
68
  stable: argv.includes('--stable'),
69
69
  yes: argv.includes('--yes') || argv.includes('-y'),
70
+ launcher: argv.includes('--launcher'),
70
71
  };
71
72
  }
72
73
  /** Default acquire: download the sticky-profile tarball, stage + validate it. */
@@ -566,7 +567,17 @@ function syncDerivedPrettierignore(rootDir, stagedDir) {
566
567
  const KIT_TESTS_DIR = 'scripts/tools/__tests__/';
567
568
  /** Exit code of `--dry-run` when the preview found conflicts (0 = none). Lets a
568
569
  * non-interactive caller (the GUI) branch on the result instead of clobbering. */
569
- const DRY_RUN_CONFLICTS_EXIT = 2;
570
+ /**
571
+ * 🔴 FROZEN WIRE FORMAT — the same class of contract as the retrofit signal marker
572
+ * (ADR-004 §1/§4), and for the same reason. `update --dry-run` reports "the preview found
573
+ * conflicts" with this code, and the GUI launcher reads it to decide whether it may chain the
574
+ * real apply. Its twin is calqued BY VALUE in `desktop/renderer/panels/update-project.tsx`
575
+ * (`DRY_RUN_CONFLICTS_EXIT`) — two npm packages with no dependency edge, so no compiler will
576
+ * ever catch a drift. Changing this number silently kills the launcher's clobber guard, which
577
+ * is exactly the failure that shipped once already (see `cli/src/lib/exit-code.ts`).
578
+ * Exported so the golden pin in the test suite can assert it.
579
+ */
580
+ export const DRY_RUN_CONFLICTS_EXIT = 2;
570
581
  /**
571
582
  * `--dry-run`: preview the plan (buckets + the explicit conflict list) and apply
572
583
  * NOTHING. Prints the conflicting paths so a human or agent sees exactly what
@@ -859,7 +870,7 @@ export async function runUpdate(flags, deps = {}) {
859
870
  // `oldLock.version` is the version BEFORE this update, which is what separates the guides
860
871
  // that just arrived from the ones that were already sitting there. A legacy repo carries
861
872
  // `0.0.0`, so every guide reads as new — correct: it has seen none of them.
862
- printRetrofitReminder({ rootDir, fromVersion: oldLock.version });
873
+ printRetrofitReminder({ rootDir, fromVersion: oldLock.version, guiSignal: flags.launcher });
863
874
  // End-pointer to the next step (PVSKILL-002). Reached only after a clean apply;
864
875
  // `--verify`/`--resume` short-circuit above (verify NEVER prints), an error path
865
876
  // throws earlier, and headless `--yes` is suppressed (CI noise).
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { readFileSync } from 'node:fs';
10
10
  import { dirname, join } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { CLIError } from './lib/cli-error.js';
13
+ import { resolveSuccessExitCode } from './lib/exit-code.js';
13
14
  import { parseAddFlags, runAdd } from './commands/add.js';
14
15
  import { parseBacklogPushFlags, parseBacklogSyncFlags, runBacklogPush, runBacklogSync, } from './commands/backlog.js';
15
16
  import { parseEnvPushFlags, parseEnvWizardFlags, runEnvPush, runEnvWizardApply, runEnvWizardGenVapid, runEnvWizardPlan, runEnvWizardReview, } from './commands/env.js';
@@ -328,7 +329,11 @@ async function main(argv) {
328
329
  }
329
330
  }
330
331
  main(process.argv.slice(2)).then(() => {
331
- process.exit(0);
332
+ // NOT a bare `process.exit(0)`. An explicit argument overrides `process.exitCode`, so
333
+ // hardcoding 0 threw away the outcome code of any command that succeeded but had
334
+ // something to report — `update --dry-run`'s "there are conflicts" (exit 2) among them,
335
+ // which left the launcher's clobber guard dead. See `resolveSuccessExitCode`.
336
+ process.exit(resolveSuccessExitCode(process.exitCode));
332
337
  }, (error) => {
333
338
  if (error instanceof CLIError) {
334
339
  // An empty message is intentional (the command already rendered its own
@@ -214,6 +214,44 @@ export const GENERATE_REFERENCE_GATE_FILE = 'scripts/tools/generate-schema.ts';
214
214
  * FILE-GATED on the launcher, like `GENERATOR_SCRIPTS`: a `core`/non-Next derivative has no
215
215
  * `dev.mjs` and never receives an alias for a dev server it does not run.
216
216
  */
217
+ /**
218
+ * `verify` — la compuerta de calidad, movida de una cadena de shell a un script del cerebro.
219
+ *
220
+ * WHY THE CLI OWNS IT. Mismo razonamiento que `dev:next` justo abajo, y por la misma causa:
221
+ * el valor vive en `package.json`, que nace congelado en el derivado y no viaja en el set
222
+ * trackeado, así que `factory update` no lo alcanza por sí solo. La diferencia es QUÉ se gana
223
+ * al alcanzarlo. Aquí el valor nuevo no es otro parámetro: es un PUNTERO a un archivo que sí
224
+ * viaja (`scripts/**`). Sanar esta clave una vez es lo que vuelve viva a la compuerta — de ahí
225
+ * en adelante el Factory la mejora editando el script, y la flota lo recibe en el update
226
+ * siguiente sin que nadie abra su repo. Es la única entrada de este registro cuyo objetivo es
227
+ * dejar de necesitar entradas en este registro.
228
+ *
229
+ * FILE-GATED sobre el script mismo: `applyPlan` escribe `scripts/**` ANTES de que corra el
230
+ * mantenimiento del `package.json`, así que en la misma pasada el archivo ya está en disco
231
+ * cuando se evalúa el gate. Un derivado `core` que no reciba el archivo nunca recibe un alias
232
+ * apuntando a un script que no tiene.
233
+ */
234
+ export const VERIFY_SCRIPT_NAME = 'verify';
235
+ export const VERIFY_SCRIPT_FILE = 'scripts/tools/verify.mjs';
236
+ export const VERIFY_SCRIPT_CMD = `node ${VERIFY_SCRIPT_FILE}`;
237
+ /**
238
+ * La cadena de shell que el kit shippeó antes del script. Listada como COMANDO COMPLETO —
239
+ * igual que los heaps de `dev:next`— para que el sanado sea exact-match: un derivado que le
240
+ * agregó un paso propio (`&& pnpm format:check`, un `test:e2e`) tiene un valor que el kit
241
+ * nunca shippeó, y se queda EXACTAMENTE como está. Esa es la mitad importante del contrato:
242
+ * la compuerta se moderniza sola donde nadie la tocó, y no pisa a quien la hizo suya.
243
+ */
244
+ /**
245
+ * `verify:quick` — el bucle de trabajo, NO la compuerta.
246
+ *
247
+ * Comparte archivo y gate con `verify` (mismo script, distinto flag), así que un derivado que
248
+ * recibe uno recibe el otro. No tiene entrada en `OBSOLETE_SCRIPT_CMDS` y no la va a tener: es
249
+ * un alias que el kit nunca shippeó antes, así que no hay valor viejo que sanar — solo
250
+ * insert-if-missing. Si un equipo ya tenía un `verify:quick` propio, ese gana y se queda.
251
+ */
252
+ export const VERIFY_QUICK_SCRIPT_NAME = 'verify:quick';
253
+ export const VERIFY_QUICK_SCRIPT_CMD = `node ${VERIFY_SCRIPT_FILE} --quick`;
254
+ export const VERIFY_OBSOLETE_CMDS = ['pnpm lint && pnpm typecheck && pnpm test'];
217
255
  export const DEV_NEXT_SCRIPT_NAME = 'dev:next';
218
256
  export const DEV_NEXT_GATE_FILE = 'scripts/tools/dev.mjs';
219
257
  export const DEV_NEXT_HEAP_MB = 4096;
@@ -269,6 +307,7 @@ export const OBSOLETE_SCRIPT_CMDS = {
269
307
  // Every heap ceiling the kit shipped for the dev server before the current one. Listed as
270
308
  // whole COMMANDS (not just the number) so healing stays exact-match: a derivative that
271
309
  // appended a flag of its own, or picked a ceiling the kit never shipped, is left alone.
310
+ [VERIFY_SCRIPT_NAME]: VERIFY_OBSOLETE_CMDS,
272
311
  [DEV_NEXT_SCRIPT_NAME]: DEV_NEXT_OBSOLETE_HEAPS_MB.map((mb) => `NODE_OPTIONS='--max-old-space-size=${mb}' next dev --turbopack`),
273
312
  };
274
313
  /**
@@ -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
+ }
@@ -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, 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, 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
@@ -146,6 +146,10 @@ export function kitLocalScripts(rootDir) {
146
146
  }
147
147
  if (has(GENERATE_REFERENCE_GATE_FILE))
148
148
  scripts[GENERATE_REFERENCE_NAME] = GENERATE_REFERENCE_CMD;
149
+ if (has(VERIFY_SCRIPT_FILE))
150
+ scripts[VERIFY_SCRIPT_NAME] = VERIFY_SCRIPT_CMD;
151
+ if (has(VERIFY_SCRIPT_FILE))
152
+ scripts[VERIFY_QUICK_SCRIPT_NAME] = VERIFY_QUICK_SCRIPT_CMD;
149
153
  if (has(DEV_NEXT_GATE_FILE))
150
154
  scripts[DEV_NEXT_SCRIPT_NAME] = DEV_NEXT_SCRIPT_CMD;
151
155
  return scripts;
@@ -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 rendered = renderRetrofitReminder(listRetrofits(opts.rootDir, opts), opts.fromVersion ?? null);
226
- if (rendered)
227
- log(rendered);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",