@timekast/factory 1.6.0 → 1.7.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.
@@ -87,6 +87,9 @@ export async function runAdd(flags = { core: false, full: false }) {
87
87
  verify: false,
88
88
  commit: false,
89
89
  noCommit: false,
90
+ // A legacy `add` redirect is never a beta channel operation.
91
+ beta: false,
92
+ stable: false,
90
93
  });
91
94
  return;
92
95
  }
@@ -22,6 +22,7 @@ import { collectClaudePaths } from '../lib/claude-paths.js';
22
22
  import { CLAUDE_MD_FILE } from '../lib/constants.js';
23
23
  import { hasLockfile, normalizeThenHash, readLockfile } from '../lib/lockfile.js';
24
24
  import { detectRepo } from '../lib/repo-detection.js';
25
+ import { BETA_BANNER } from './status.js';
25
26
  /** Matches a flat always-on rule file shipped by the kit (`.claude/rules/<name>.md`). */
26
27
  const RULE_PATH_RE = /^\.claude\/rules\/[^/]+\.md$/;
27
28
  /**
@@ -112,9 +113,12 @@ export function computeDoctor(deps = {}) {
112
113
  claudeMdMissing: false,
113
114
  a1Warning: A1_WARNING,
114
115
  noLockfile: true,
116
+ beta: false,
115
117
  };
116
118
  }
117
119
  const lock = readLockfile(rootDir);
120
+ // Beta channel marker (BETA-002). Absent / non-boolean → false (back-compat).
121
+ const beta = lock.beta === true;
118
122
  const manifestPaths = new Set(lock.files.map((f) => f.path));
119
123
  // Orphans: files under `.claude/` on disk that the manifest does not track.
120
124
  const claudePaths = collectClaudePaths(rootDir);
@@ -162,6 +166,7 @@ export function computeDoctor(deps = {}) {
162
166
  claudeMdMissing,
163
167
  a1Warning: A1_WARNING,
164
168
  noLockfile: false,
169
+ beta,
165
170
  };
166
171
  }
167
172
  /** Render the doctor result to the console. The A1 notice is ALWAYS last. */
@@ -173,6 +178,11 @@ function renderDoctor(result) {
173
178
  console.log(`\n${result.a1Warning}`);
174
179
  return;
175
180
  }
181
+ // Beta banner first (BETA-005): the most important state when on a pre-release
182
+ // brain. Shown regardless of orphans/conflicts so it never gets buried.
183
+ if (result.beta) {
184
+ console.log(BETA_BANNER);
185
+ }
176
186
  if (result.orphans.length === 0 &&
177
187
  result.conflicts.length === 0 &&
178
188
  result.unimportedRules.length === 0 &&
@@ -17,6 +17,14 @@
17
17
  import { execa } from 'execa';
18
18
  import { FACTORY_REPO } from '../lib/constants.js';
19
19
  import { hasLockfile, readLockfile } from '../lib/lockfile.js';
20
+ import { compare } from '../lib/semver.js';
21
+ /**
22
+ * The BETA channel banner (BETA-005). Emitted by `status` and `doctor` when the
23
+ * lockfile is on the beta channel (`lockfile.beta === true`) so the dev always
24
+ * sees they are on a pre-release brain and how to leave the channel. Exported so
25
+ * the tests assert on the exact rendered text (no magic string in the spec).
26
+ */
27
+ export const BETA_BANNER = '⚠️ cerebro BETA — corre `factory update --stable` para salir del canal';
20
28
  /**
21
29
  * Query the Factory repo for the latest published release, IGNORING prereleases
22
30
  * (`-rc`, `-test`, etc.) so a release candidate is never reported as latest.
@@ -52,24 +60,6 @@ async function fetchLatestRelease() {
52
60
  function stripVersionPrefix(tag) {
53
61
  return tag.startsWith('v') ? tag.slice(1) : tag;
54
62
  }
55
- /**
56
- * Compare two semver-ish strings. Returns 1 when `a` > `b`, -1 when `a` < `b`,
57
- * 0 when equal. Non-numeric / malformed segments compare as 0 so a parse
58
- * surprise never flips `hasUpdate` to a false positive.
59
- */
60
- function compareVersions(a, b) {
61
- const pa = a.split('.').map((n) => Number.parseInt(n, 10) || 0);
62
- const pb = b.split('.').map((n) => Number.parseInt(n, 10) || 0);
63
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
64
- const da = pa[i] ?? 0;
65
- const db = pb[i] ?? 0;
66
- if (da > db)
67
- return 1;
68
- if (da < db)
69
- return -1;
70
- }
71
- return 0;
72
- }
73
63
  /**
74
64
  * Compute the status result. Pure given its inputs — the only side effect is the
75
65
  * lockfile read (FS), and `fetchLatest` (network). Both are graceful: a missing
@@ -86,6 +76,7 @@ export async function computeStatus(deps = {}) {
86
76
  hasUpdate: false,
87
77
  noLockfile: true,
88
78
  networkError: false,
79
+ beta: false,
89
80
  };
90
81
  }
91
82
  const lock = readLockfile(rootDir);
@@ -94,6 +85,8 @@ export async function computeStatus(deps = {}) {
94
85
  // the top-level `factoryVersion` is the birth stamp. A legacy lockfile without
95
86
  // a birth stamp degrades to null (it is reported as "unknown", never crashes).
96
87
  const factoryVersion = lock.factoryVersion ?? null;
88
+ // Beta channel marker (BETA-002). Absent / non-boolean → false (back-compat).
89
+ const beta = lock.beta === true;
97
90
  const latest = await fetchLatest();
98
91
  if (latest === null) {
99
92
  return {
@@ -103,15 +96,22 @@ export async function computeStatus(deps = {}) {
103
96
  hasUpdate: false,
104
97
  noLockfile: false,
105
98
  networkError: true,
99
+ beta,
106
100
  };
107
101
  }
108
102
  return {
109
103
  installed,
110
104
  factoryVersion,
111
105
  latest,
112
- hasUpdate: compareVersions(latest, installed) > 0,
106
+ // `latest` is the newest STABLE release (prereleases are filtered out by
107
+ // `fetchLatest`). With the BETA-001 comparator, a beta install newer than the
108
+ // latest stable (e.g. installed `10.9.0-beta.0`, latest `10.8.0`) yields
109
+ // `compare('10.8.0', '10.9.0-beta.0') < 0` → hasUpdate:false: there is no
110
+ // stable update to offer, so the render does not contradict the BETA banner.
111
+ hasUpdate: compare(latest, installed) > 0,
113
112
  noLockfile: false,
114
113
  networkError: false,
114
+ beta,
115
115
  };
116
116
  }
117
117
  /** Render the status result to the console. */
@@ -121,6 +121,11 @@ function renderStatus(result) {
121
121
  console.log('Ejecuta `factory:update` para inicializar el tracking de versión.');
122
122
  return;
123
123
  }
124
+ // Beta banner first so it leads the report — it is the most important state
125
+ // when on a pre-release brain (BETA-005).
126
+ if (result.beta) {
127
+ console.log(BETA_BANNER);
128
+ }
124
129
  console.log(`Instalado: v${result.installed}`);
125
130
  if (result.factoryVersion) {
126
131
  console.log(`Sello de nacimiento (factoryVersion): v${result.factoryVersion}`);
@@ -133,7 +138,9 @@ function renderStatus(result) {
133
138
  if (result.hasUpdate) {
134
139
  console.log('⬆ Actualización disponible — ejecuta factory:update');
135
140
  }
136
- else {
141
+ else if (!result.beta) {
142
+ // Suppress "estás en la última versión" on the beta channel: with no stable
143
+ // update to offer, that line would contradict the BETA banner above (B3b).
137
144
  console.log('✔ Estás en la última versión.');
138
145
  }
139
146
  }
@@ -48,7 +48,7 @@ import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLo
48
48
  import { detectProfile, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
49
49
  import { runPreflight } from '../lib/preflight.js';
50
50
  import { detectRepo } from '../lib/repo-detection.js';
51
- import { downloadProfileTarball, stageProfileTarball } from '../lib/unpack.js';
51
+ import { downloadBetaProfileTarball, downloadProfileTarball, stageProfileTarball, } from '../lib/unpack.js';
52
52
  /** Parse `update`'s argv slice into flags. Unknown flags are ignored here. */
53
53
  export function parseUpdateFlags(argv) {
54
54
  return {
@@ -60,6 +60,8 @@ export function parseUpdateFlags(argv) {
60
60
  full: argv.includes('--full'),
61
61
  commit: argv.includes('--commit'),
62
62
  noCommit: argv.includes('--no-commit'),
63
+ beta: argv.includes('--beta'),
64
+ stable: argv.includes('--stable'),
63
65
  };
64
66
  }
65
67
  /** Default acquire: download the sticky-profile tarball, stage + validate it. */
@@ -70,6 +72,20 @@ async function acquireFromRelease(profile) {
70
72
  const manifest = validateStagedManifest(stagedDir, manifestRaw);
71
73
  return { stagedDir, manifest, tmpRoot };
72
74
  }
75
+ /**
76
+ * Beta acquire: parallel to {@link acquireFromRelease} but resolves the latest
77
+ * `-beta.N` pre-release (BETA-003's `downloadBetaProfileTarball`) instead of the
78
+ * `gh`-latest stable. The staged tarball passes through the SAME engine (diff /
79
+ * apply / lockfile) — only the SOURCE differs. The `-beta.N` version lands in the
80
+ * lockfile via the manifest; `stampBeta` adds `beta:true` at the write step.
81
+ */
82
+ async function acquireFromBeta(profile) {
83
+ const tmpRoot = mkdtempSync(path.join(tmpdir(), 'tk-update-beta-'));
84
+ const tarball = await downloadBetaProfileTarball(profile, tmpRoot);
85
+ const { stagedDir, manifestRaw } = await stageProfileTarball(tarball, tmpRoot);
86
+ const manifest = validateStagedManifest(stagedDir, manifestRaw);
87
+ return { stagedDir, manifest, tmpRoot };
88
+ }
73
89
  /**
74
90
  * Hash the disk content (normalized) of every path in `paths` that exists.
75
91
  * A missing file is simply absent from the returned map.
@@ -223,9 +239,21 @@ async function runResume(rootDir, flags) {
223
239
  // clean apply), so it carries the birth stamp to preserve. A legacy resume (no
224
240
  // prior lockfile) seals the birth at the persisted manifest's version.
225
241
  const priorLock = hasLockfile(rootDir) ? readLockfile(rootDir) : undefined;
242
+ // Channel re-stamp (Edge-1): an interrupted beta update would otherwise lose
243
+ // `beta:true`, since `state.newManifest` is the stable-shaped embedded manifest.
244
+ // The channel is recovered from the persisted state (the fresh run stamps
245
+ // `beta:true` into it for a `--beta` update) OR, as a fallback for a state
246
+ // persisted before this field existed, from the prior lockfile's channel. An
247
+ // explicit `--stable` resume always exits the channel (the flag is the intent),
248
+ // so it overrides the fallback. A stable resume has no signal → `beta` omitted.
249
+ const resumeBeta = !flags.stable && (state.newManifest.beta === true || priorLock?.beta === true);
226
250
  const newLock = priorLock
227
251
  ? { ...state.newManifest, factoryVersion: priorLock.factoryVersion ?? priorLock.version }
228
252
  : { ...state.newManifest, factoryVersion: state.newManifest.version };
253
+ if (resumeBeta)
254
+ newLock.beta = true;
255
+ else
256
+ delete newLock.beta;
229
257
  applyPlan(rootDir, state.stagedDir, state.plan, state.backupDir);
230
258
  writeLockfile(rootDir, newLock);
231
259
  clearUpdateState(rootDir);
@@ -245,6 +273,18 @@ async function runResume(rootDir, flags) {
245
273
  function stampBirth(oldLock, newManifest) {
246
274
  return { ...newManifest, factoryVersion: oldLock.factoryVersion ?? oldLock.version };
247
275
  }
276
+ /**
277
+ * Stamp the beta channel onto the new lockfile. COMPOSES `stampBirth` (never
278
+ * replaces it): the birth seal is preserved exactly as for a stable update, then
279
+ * `beta: true` is layered on top. A beta→beta update therefore keeps
280
+ * `factoryVersion` frozen (it flows through `stampBirth`) AND re-marks the channel
281
+ * (H4). A STABLE update uses `stampBirth` directly: the stable manifest carries no
282
+ * `beta`, so the field is simply absent from the new lockfile — the channel is
283
+ * cleared by omission (no explicit `beta:false` needed).
284
+ */
285
+ function stampBeta(oldLock, newManifest) {
286
+ return { ...stampBirth(oldLock, newManifest), beta: true };
287
+ }
248
288
  /** Warn (never fail) when package.json holds a divergent factory:update value. */
249
289
  function warnOnScriptConflict(action, _pkgPath) {
250
290
  if (action === 'conflict') {
@@ -384,6 +424,9 @@ export async function runUpdate(flags, deps = {}) {
384
424
  if (flags.core && flags.full) {
385
425
  throw new CLIError('No puedes combinar `--core` con `--full`.');
386
426
  }
427
+ if (flags.beta && flags.stable) {
428
+ throw new CLIError('No puedes combinar `--beta` con `--stable`.');
429
+ }
387
430
  // --verify and --resume short-circuit (no download).
388
431
  if (flags.verify) {
389
432
  runVerify(rootDir);
@@ -415,6 +458,10 @@ export async function runUpdate(flags, deps = {}) {
415
458
  }
416
459
  else {
417
460
  oldLock = readLockfile(rootDir);
461
+ // Channel-change guard (B3a): a plain/stable update over a beta lockfile must
462
+ // be confirmed (or rejected headless) BEFORE any download. Runs after the
463
+ // lockfile read (it needs `oldLock.beta`) and before acquire.
464
+ await guardChannelChange(oldLock, flags);
418
465
  if (flags.full && oldLock.profile === PROFILES.core) {
419
466
  profile = PROFILES.full; // cross-grade core → full (additive)
420
467
  }
@@ -426,7 +473,10 @@ export async function runUpdate(flags, deps = {}) {
426
473
  profile = oldLock.profile; // sticky (--full on full / --core on core = refresh)
427
474
  }
428
475
  }
429
- const acquire = deps.acquire ?? acquireFromRelease;
476
+ // Acquire source: `--beta` pulls the latest `-beta.N` pre-release; everything
477
+ // else pulls the latest stable. The injected `deps.acquire` (tests) wins over
478
+ // both. Both sources feed the SAME engine downstream.
479
+ const acquire = deps.acquire ?? (flags.beta ? acquireFromBeta : acquireFromRelease);
430
480
  const { stagedDir, manifest, tmpRoot } = await acquire(profile);
431
481
  try {
432
482
  // Guard: the downloaded tarball must match the resolved profile. A mispublished
@@ -464,12 +514,18 @@ export async function runUpdate(flags, deps = {}) {
464
514
  const plan = buildPlan(diff, theirsConflicts);
465
515
  const backupDir = defaultBackupDir(tmpRoot);
466
516
  // Persist resume state BEFORE applying, so a crash mid-apply is recoverable
467
- // without re-downloading (design §7.5).
468
- writeUpdateState(rootDir, { stagedDir, backupDir, plan, newManifest: manifest });
517
+ // without re-downloading (design §7.5). The CHANNEL is persisted here (Edge-1):
518
+ // `--beta` stamps `beta:true` onto the state's manifest so a `--resume` of an
519
+ // interrupted beta keeps the channel even when the on-disk lockfile predates it
520
+ // (first entry from a stable lockfile, where `priorLock.beta` is absent).
521
+ const stateManifest = flags.beta ? { ...manifest, beta: true } : manifest;
522
+ writeUpdateState(rootDir, { stagedDir, backupDir, plan, newManifest: stateManifest });
469
523
  applyPlan(rootDir, stagedDir, plan, backupDir);
470
524
  // Lockfile written ONLY after a clean apply. The birth stamp is preserved
471
- // (agentKitVersion advances; factoryVersion stays frozen).
472
- writeLockfile(rootDir, stampBirth(oldLock, manifest));
525
+ // (agentKitVersion advances; factoryVersion stays frozen). With `--beta`,
526
+ // `stampBeta` composes `stampBirth` and adds `beta:true`; a stable/plain
527
+ // update uses `stampBirth` alone, so a prior `beta` is cleared by omission.
528
+ writeLockfile(rootDir, flags.beta ? stampBeta(oldLock, manifest) : stampBirth(oldLock, manifest));
473
529
  clearUpdateState(rootDir);
474
530
  maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
475
531
  await maybeCommitBrain(rootDir, manifest.version, [...plan.writes, ...plan.deletes], flags);
@@ -485,6 +541,40 @@ export async function runUpdate(flags, deps = {}) {
485
541
  }
486
542
  }
487
543
  }
544
+ /**
545
+ * Channel-change guard (finding B3a). A plain/stable `update` over a beta lockfile
546
+ * is a CHANNEL CHANGE (beta → stable) and must NEVER happen silently:
547
+ * - `--beta` / `--stable` explicit → no guard (the flag IS the intent).
548
+ * - interactive (TTY) → ask for confirmation; abort if declined.
549
+ * - headless (no TTY) → REJECT with a clear message + clean exit. It does NOT
550
+ * degrade or auto-proceed (auto-proceeding would reintroduce the very silent
551
+ * downgrade this kills); the repo stays on beta. Use `--stable` to opt out.
552
+ *
553
+ * `oldLock.beta === false` (explicit) or absent ≡ a stable lockfile → no guard.
554
+ */
555
+ async function guardChannelChange(oldLock, flags) {
556
+ if (oldLock.beta !== true)
557
+ return; // stable lockfile → nothing to guard
558
+ if (flags.beta || flags.stable)
559
+ return; // explicit intent → no prompt
560
+ if (!process.stdout.isTTY) {
561
+ // Headless: reject, do not degrade. Exit code 0 — this is a refusal to change
562
+ // channels, not a failure (the brain is untouched, the repo stays on beta).
563
+ throw new CLIError('Este repo está en el canal beta. Un `update` sin `--beta` cambiaría de canal ' +
564
+ '(beta → estable) y en modo headless eso se rechaza para no degradar en silencio.\n' +
565
+ 'Usa `factory update --beta` para seguir en beta, o `factory update --stable` para salir al estable.', 0);
566
+ }
567
+ const { proceed } = await prompts({
568
+ type: 'confirm',
569
+ name: 'proceed',
570
+ message: 'Este repo está en el canal beta. Un `update` sin `--beta` te saca del canal (beta → estable). ' +
571
+ '¿Salir del canal beta y bajar al estable?',
572
+ initial: false,
573
+ });
574
+ if (!proceed) {
575
+ throw new CLIError('Operación cancelada; sigues en el canal beta. Usa `factory update --beta` para actualizar dentro de beta.', 130);
576
+ }
577
+ }
488
578
  /** Warn the dev before a legacy auto-register sync (design §8 edge case). */
489
579
  async function confirmLegacy(profile) {
490
580
  const detected = profile === PROFILES.full
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ Comandos:
27
27
  new <Nombre> Crea un proyecto derivado nuevo (repo + perfil + git init)
28
28
  add Instala el cerebro (.claude/) en el repo actual
29
29
  update Actualiza el cerebro al día sin pisar tu trabajo local
30
+ beta Atajo de \`update --beta\`: instala el cerebro del canal beta
30
31
  status Reporta la versión instalada vs. la última disponible
31
32
  doctor Detecta huérfanos, conflictos y rules sin importar + aviso de seguridad
32
33
  publish <qué> Publica el entregable (proposal|mockup|both) a proposals.timekast.mx
@@ -52,6 +53,10 @@ Sobre \`update\`:
52
53
  --mine-all Conserva tu versión en todos los conflictos
53
54
  --resume Retoma un update interrumpido sin re-descargar
54
55
  --verify Reporta diferencias disco-vs-lockfile sin modificar nada
56
+ --beta Entra al canal beta (instala el último pre-release \`-beta.N\`)
57
+ --stable Sale del canal beta y baja al estable (limpia \`beta\` del lockfile)
58
+ Un \`update\` sin \`--beta\` sobre un lockfile beta pide confirmación (y en headless
59
+ rechaza, sin degradar en silencio).
55
60
 
56
61
  Sobre \`status\` y \`doctor\`:
57
62
  Solo diagnostican — nunca modifican archivos. \`status\` lee el lockfile y
@@ -103,6 +108,12 @@ async function main(argv) {
103
108
  await runUpdate(parseUpdateFlags(rest));
104
109
  return;
105
110
  }
111
+ case 'beta': {
112
+ // Ad-hoc alias of `update --beta` (NOT injected into derivatives). Forces the
113
+ // beta channel while still honoring the other update flags the dev may pass.
114
+ await runUpdate(parseUpdateFlags([...rest, '--beta']));
115
+ return;
116
+ }
106
117
  case 'status': {
107
118
  await runStatus();
108
119
  return;
@@ -133,6 +133,9 @@ export function parseLockfile(raw, source = 'lockfile') {
133
133
  // Optional birth stamp — present in stamped lockfiles, absent in the
134
134
  // builder's embedded manifest and in legacy lockfiles (back-compat).
135
135
  ...(typeof obj.factoryVersion === 'string' ? { factoryVersion: obj.factoryVersion } : {}),
136
+ // Optional beta marker — only a boolean survives; null/string/missing are
137
+ // dropped (defensive whitelist), so legacy lockfiles stay back-compat.
138
+ ...(typeof obj.beta === 'boolean' ? { beta: obj.beta } : {}),
136
139
  };
137
140
  }
138
141
  /** Read + validate the lockfile from a derived project root. */
@@ -102,9 +102,34 @@ export function kitLocalScripts(rootDir) {
102
102
  : {};
103
103
  }
104
104
  /**
105
- * Rename `package.json.name` and ensure the `factory:*` scripts (+ the
106
- * file-gated kit-local `extraScripts`, see `kitLocalScripts`) exist, preserving
107
- * everything else. Returns the serialized document (with the original
105
+ * Source dirs that never ship to a derivative — excluded from BOTH dist profiles
106
+ * (`distribution/`, `cli/`). A boilerplate script that shells one of these (e.g.
107
+ * `dist:beta` → `tsx distribution/cut-beta.ts`) would be dead noise in a
108
+ * derivative, which has no such dir. Path-based (not a name list) so a future
109
+ * origin-only script is stripped by construction, never silently leaked.
110
+ */
111
+ const ORIGIN_ONLY_SCRIPT_PATHS = ['distribution/', 'cli/'];
112
+ /**
113
+ * Remove boilerplate scripts that shell an origin-only path (see
114
+ * `ORIGIN_ONLY_SCRIPT_PATHS`). Mutates `pkg.scripts` in place. Runs ONLY on the
115
+ * `new` flow (boilerplate seeding from the Factory's own package.json); the
116
+ * `update`/`add` flows touch a derivative's OWN package.json and never add these.
117
+ */
118
+ function stripOriginOnlyScripts(pkg) {
119
+ const scripts = pkg.scripts;
120
+ if (!scripts)
121
+ return;
122
+ for (const [scriptName, cmd] of Object.entries(scripts)) {
123
+ if (ORIGIN_ONLY_SCRIPT_PATHS.some((p) => cmd.includes(p))) {
124
+ delete scripts[scriptName];
125
+ }
126
+ }
127
+ }
128
+ /**
129
+ * Rename `package.json.name`, strip origin-only scripts that would be dead in a
130
+ * derivative (see `stripOriginOnlyScripts`), and ensure the `factory:*` scripts
131
+ * (+ the file-gated kit-local `extraScripts`, see `kitLocalScripts`) exist,
132
+ * preserving everything else. Returns the serialized document (with the original
108
133
  * indentation + trailing newline).
109
134
  *
110
135
  * If `factory:update` already exists with a different value it is left intact;
@@ -114,6 +139,7 @@ export function applyPackageJsonEdits(raw, name, extraScripts = {}) {
114
139
  const indent = detectIndent(raw);
115
140
  const pkg = parsePackageJson(raw);
116
141
  pkg.name = name;
142
+ stripOriginOnlyScripts(pkg);
117
143
  const { primary } = ensureFactoryScripts(pkg, extraScripts);
118
144
  const content = `${JSON.stringify(pkg, null, indent)}\n`;
119
145
  return { content, scriptAlreadyPresent: primary.action === 'conflict' };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Minimal, total semver comparator for the version strings the CLI actually
3
+ * sees (`X.Y.Z` releases and `X.Y.Z-beta.N` pre-releases). It implements ONLY
4
+ * the three rules the beta channel needs — NOT a full semver range engine
5
+ * (no `^`/`~`/`>`/coercion). The CLI keeps ≤3 runtime deps and deliberately
6
+ * does not pull in the `semver` package for this (DIST BETA-001).
7
+ *
8
+ * The three rules (design §11 / B3b):
9
+ * 1. Numeric core, field by field (`major.minor.patch`). Missing fields → 0.
10
+ * 2. Same core → a pre-release is LOWER than the bare release
11
+ * (`10.9.0-beta.0` < `10.9.0`). This is the heart of B3b: the previous
12
+ * naive comparator (`status.ts` `compareVersions`) parsed `0-beta` as `0`
13
+ * and treated the pre-release as EQUAL to the release.
14
+ * 3. Two pre-releases of the same core → compare their numeric suffix N
15
+ * (`-beta.1` > `-beta.0`).
16
+ *
17
+ * 🔒 TOTAL (never throws). A malformed input (e.g. `"latest"`, `""`, `"x.y"`)
18
+ * must never crash: non-numeric segments collapse to 0, so the function still
19
+ * returns a deterministic order. This preserves the existing contract — `status`
20
+ * guarantees exit 0 and its old comparator was tolerant (`parseInt(...) || 0`).
21
+ */
22
+ /** Coerce a string segment to a finite integer; anything non-numeric → 0 (total contract). */
23
+ function toInt(segment) {
24
+ const n = Number.parseInt(segment, 10);
25
+ return Number.isNaN(n) ? 0 : n;
26
+ }
27
+ /**
28
+ * Parse a version string into its core + pre-release parts. Tolerant by design:
29
+ * strips an optional leading `v`, splits on the FIRST `-` into core vs suffix,
30
+ * and never throws. Any pre-release suffix (`-beta.N`, `-rc.1`, …) marks the
31
+ * version as a pre-release; only the trailing numeric component is read as N.
32
+ */
33
+ function parse(version) {
34
+ const raw = version.startsWith('v') ? version.slice(1) : version;
35
+ const dashIdx = raw.indexOf('-');
36
+ const corePart = dashIdx === -1 ? raw : raw.slice(0, dashIdx);
37
+ const suffix = dashIdx === -1 ? '' : raw.slice(dashIdx + 1);
38
+ const core = corePart.split('.').map(toInt);
39
+ const isPrerelease = suffix.length > 0;
40
+ // N = last dotted component of the suffix (`beta.3` → `3`, `rc.1` → `1`).
41
+ // A non-numeric tail (`beta.foo`) collapses to 0 (total contract).
42
+ const suffixParts = suffix.split('.');
43
+ const prereleaseN = isPrerelease ? toInt(suffixParts[suffixParts.length - 1]) : 0;
44
+ return { core, isPrerelease, prereleaseN };
45
+ }
46
+ /**
47
+ * Compare two version strings.
48
+ *
49
+ * @returns negative when `a < b`, `0` when equal, positive when `a > b`.
50
+ * Never throws; malformed input yields a deterministic order (0 for the
51
+ * unparseable segments).
52
+ *
53
+ * PARITY: `distribution/cut-beta.ts#compareSemver` is a deliberate copy of these
54
+ * 3 §11 rules (kept out of a cross-package import). If you change the rules here,
55
+ * mirror them there and re-run both suites (semver.test.ts + cut-beta.test.ts).
56
+ */
57
+ export function compare(a, b) {
58
+ const pa = parse(a);
59
+ const pb = parse(b);
60
+ // Rule 1: numeric core, field by field. Missing fields compare as 0.
61
+ const len = Math.max(pa.core.length, pb.core.length);
62
+ for (let i = 0; i < len; i++) {
63
+ const da = pa.core[i] ?? 0;
64
+ const db = pb.core[i] ?? 0;
65
+ if (da !== db)
66
+ return da - db;
67
+ }
68
+ // Same core. Rule 2: a pre-release is LOWER than the bare release.
69
+ if (pa.isPrerelease !== pb.isPrerelease) {
70
+ // a is the pre-release → a < b → negative; otherwise positive.
71
+ return pa.isPrerelease ? -1 : 1;
72
+ }
73
+ // Rule 3: both pre-release (or both stable) → compare numeric suffix N.
74
+ // Two stable versions of the same core both have N = 0 → equal (returns 0).
75
+ return pa.prereleaseN - pb.prereleaseN;
76
+ }
@@ -19,6 +19,7 @@ import { execa } from 'execa';
19
19
  import { extract } from 'tar';
20
20
  import { CLIError } from './cli-error.js';
21
21
  import { FACTORY_REPO, MANIFEST_FILE, TIMEKAST_DIR } from './constants.js';
22
+ import { compare } from './semver.js';
22
23
  /** Download the profile tarball into `tmpDir`; returns the tarball path. */
23
24
  export async function downloadProfileTarball(profile, tmpDir) {
24
25
  try {
@@ -44,6 +45,89 @@ export async function downloadProfileTarball(profile, tmpDir) {
44
45
  }
45
46
  return path.join(tmpDir, downloaded[0]);
46
47
  }
48
+ /**
49
+ * Marker that a tag belongs to the beta channel. The filter matches on this
50
+ * substring of the `tagName` ONLY — never the release body — so a release whose
51
+ * notes happen to mention `-beta.` (but whose tag is a stable `vX.Y.Z`) is never
52
+ * picked up (§8 edge case). Other pre-release channels (`-rc.`, `-test`) carry a
53
+ * different marker, so they are excluded by construction.
54
+ */
55
+ const BETA_TAG_MARKER = '-beta.';
56
+ /** No beta release exists in the Factory repo — a defined, user-facing failure. */
57
+ export class BetaNotFoundError extends CLIError {
58
+ constructor(message, exitCode = 1) {
59
+ super(message, exitCode);
60
+ this.name = 'BetaNotFoundError';
61
+ Object.setPrototypeOf(this, new.target.prototype);
62
+ }
63
+ }
64
+ /**
65
+ * Pick the latest beta release out of a list of releases.
66
+ *
67
+ * Filters to entries whose `tagName` carries the `-beta.` marker, then selects
68
+ * the highest by **semver-max** (BETA-001's `compare`), NOT by GitHub's
69
+ * newest-first ordering: `gh release list` sorts by publish date and interleaves
70
+ * `-rc.`/`-test` tags, and a `-beta.1` can be published before a `-beta.0`. Only
71
+ * the semver suffix N decides the winner.
72
+ *
73
+ * @throws {BetaNotFoundError} when no release carries the `-beta.` marker.
74
+ */
75
+ export function resolveLatestBeta(releases) {
76
+ const betas = releases.filter((r) => r.tagName.includes(BETA_TAG_MARKER));
77
+ if (betas.length === 0) {
78
+ throw new BetaNotFoundError(`No hay un beta publicado en \`${FACTORY_REPO}\`.\n` +
79
+ 'Verifica que exista un pre-release con tag `-beta.N` antes de usar el canal beta.');
80
+ }
81
+ // semver-max: `compare` strips the leading `v` itself, so tags pass verbatim.
82
+ return betas.reduce((max, r) => (compare(r.tagName, max.tagName) > 0 ? r : max));
83
+ }
84
+ /**
85
+ * Download the latest beta profile tarball into `tmpDir`; returns the tarball
86
+ * path. Beta counterpart of {@link downloadProfileTarball}: instead of letting
87
+ * `gh` resolve "latest" (which excludes pre-releases), it lists the releases,
88
+ * resolves the highest `-beta.N` by semver, and downloads that release BY TAG.
89
+ *
90
+ * A network/`gh` failure surfaces as-is — only an empty beta list (a successful
91
+ * query that returns no beta) yields {@link BetaNotFoundError}, so a real outage
92
+ * is never masked as "no beta published" (§8 edge case).
93
+ */
94
+ export async function downloadBetaProfileTarball(profile, tmpDir) {
95
+ const { stdout } = await execa('gh', [
96
+ 'release',
97
+ 'list',
98
+ '--repo',
99
+ FACTORY_REPO,
100
+ '--json',
101
+ 'tagName,isPrerelease',
102
+ '--limit',
103
+ '30',
104
+ ]);
105
+ const releases = JSON.parse(stdout);
106
+ const latestBeta = resolveLatestBeta(releases);
107
+ try {
108
+ await execa('gh', [
109
+ 'release',
110
+ 'download',
111
+ latestBeta.tagName,
112
+ '--repo',
113
+ FACTORY_REPO,
114
+ '--pattern',
115
+ `tk-${profile}-*.tgz`,
116
+ '--dir',
117
+ tmpDir,
118
+ ]);
119
+ }
120
+ catch {
121
+ throw new CLIError(`No se pudo descargar el asset \`${profile}\` del beta \`${latestBeta.tagName}\` en \`${FACTORY_REPO}\`.\n` +
122
+ 'Verifica que el pre-release contenga los assets de distribución.');
123
+ }
124
+ const downloaded = readdirSync(tmpDir).filter((f) => f.endsWith('.tgz'));
125
+ if (downloaded.length === 0) {
126
+ throw new CLIError(`El beta \`${latestBeta.tagName}\` no contiene un asset para el perfil \`${profile}\`. ` +
127
+ 'Verifica el pre-release del Factory.');
128
+ }
129
+ return path.join(tmpDir, downloaded[0]);
130
+ }
47
131
  /**
48
132
  * Extract `tarball` into a fresh staging dir under `tmpDir` and validate that it
49
133
  * carries an embedded `.timekast/manifest.json`. Returns the staging dir + the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",