@timekast/factory 1.5.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.
- package/dist/commands/add.js +3 -0
- package/dist/commands/doctor.js +10 -0
- package/dist/commands/new.js +2 -2
- package/dist/commands/status.js +27 -20
- package/dist/commands/update.js +96 -6
- package/dist/index.js +11 -0
- package/dist/lib/constants.js +11 -0
- package/dist/lib/lockfile.js +3 -0
- package/dist/lib/package-json.js +53 -13
- package/dist/lib/semver.js +76 -0
- package/dist/lib/unpack.js +84 -0
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
package/dist/commands/doctor.js
CHANGED
|
@@ -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 &&
|
package/dist/commands/new.js
CHANGED
|
@@ -18,7 +18,7 @@ import prompts from 'prompts';
|
|
|
18
18
|
import { CLIError } from '../lib/cli-error.js';
|
|
19
19
|
import { FACTORY_ORG, PROFILES } from '../lib/constants.js';
|
|
20
20
|
import { parseLockfile, writeInitialLockfile } from '../lib/lockfile.js';
|
|
21
|
-
import { applyPackageJsonEdits } from '../lib/package-json.js';
|
|
21
|
+
import { applyPackageJsonEdits, kitLocalScripts } from '../lib/package-json.js';
|
|
22
22
|
import { renderDerivedReadme } from '../lib/readme.js';
|
|
23
23
|
import { runPreflight } from '../lib/preflight.js';
|
|
24
24
|
import { downloadProfileTarball, moveContentsInto, stageProfileTarball } from '../lib/unpack.js';
|
|
@@ -103,7 +103,7 @@ export async function runNew(name) {
|
|
|
103
103
|
const pkgPath = path.join(destDir, 'package.json');
|
|
104
104
|
const hasPackageJson = existsSync(pkgPath);
|
|
105
105
|
if (hasPackageJson) {
|
|
106
|
-
const { content, scriptAlreadyPresent } = applyPackageJsonEdits(readFileSync(pkgPath, 'utf8'), validName);
|
|
106
|
+
const { content, scriptAlreadyPresent } = applyPackageJsonEdits(readFileSync(pkgPath, 'utf8'), validName, kitLocalScripts(destDir));
|
|
107
107
|
writeFileSync(pkgPath, content, 'utf8');
|
|
108
108
|
if (scriptAlreadyPresent) {
|
|
109
109
|
console.warn('Aviso: `factory:update` ya existía en package.json con otro valor; no se sobrescribió.');
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/lib/constants.js
CHANGED
|
@@ -83,3 +83,14 @@ export const FACTORY_SCRIPTS = {
|
|
|
83
83
|
[PUBLISH_SCRIPT_NAME]: PUBLISH_SCRIPT_CMD,
|
|
84
84
|
[UNPUBLISH_SCRIPT_NAME]: UNPUBLISH_SCRIPT_CMD,
|
|
85
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* Kit-local convenience script: the readiness sweep (`pnpm preflight`, consumed
|
|
88
|
+
* by the /deploy gate and /preflight). Unlike the npx-based `factory:*` scripts
|
|
89
|
+
* it shells a file that ships with the FULL brain, so the installer ensures it
|
|
90
|
+
* only when that file exists in the repo (file-gated — a core/non-Factory repo
|
|
91
|
+
* never gets a broken entry). Insert-if-missing like the rest: a dev's own
|
|
92
|
+
* `preflight` script is never overwritten.
|
|
93
|
+
*/
|
|
94
|
+
export const PREFLIGHT_SCRIPT_NAME = 'preflight';
|
|
95
|
+
export const PREFLIGHT_SCRIPT_CMD = 'tsx scripts/tools/preflight.ts';
|
|
96
|
+
export const PREFLIGHT_SCRIPT_FILE = 'scripts/tools/preflight.ts';
|
package/dist/lib/lockfile.js
CHANGED
|
@@ -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. */
|
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, PROFILES, UPDATE_SCRIPT_NAME } from './constants.js';
|
|
11
|
+
import { FACTORY_SCRIPTS, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, 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
|
|
@@ -66,12 +66,12 @@ export function parsePackageJson(raw) {
|
|
|
66
66
|
* only on a real edit) + the result for the primary `factory:update` script
|
|
67
67
|
* (which drives the install messaging).
|
|
68
68
|
*/
|
|
69
|
-
function ensureFactoryScripts(pkg) {
|
|
69
|
+
function ensureFactoryScripts(pkg, extraScripts = {}) {
|
|
70
70
|
const scripts = pkg.scripts ?? {};
|
|
71
71
|
pkg.scripts = scripts;
|
|
72
72
|
let changed = false;
|
|
73
73
|
let primary = { action: 'already-correct' };
|
|
74
|
-
for (const [name, cmd] of Object.entries(FACTORY_SCRIPTS)) {
|
|
74
|
+
for (const [name, cmd] of Object.entries({ ...FACTORY_SCRIPTS, ...extraScripts })) {
|
|
75
75
|
const existing = scripts[name];
|
|
76
76
|
let result;
|
|
77
77
|
if (existing === undefined) {
|
|
@@ -91,27 +91,67 @@ function ensureFactoryScripts(pkg) {
|
|
|
91
91
|
return { changed, primary };
|
|
92
92
|
}
|
|
93
93
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
94
|
+
* The kit-local scripts to ensure for THIS repo: `preflight` only when its
|
|
95
|
+
* backing file ships here (full brain). File-gated so a core/non-Factory repo
|
|
96
|
+
* never gets an entry that shells a file it does not have — and it self-heals:
|
|
97
|
+
* the entry appears on the first install/update after the file lands.
|
|
98
|
+
*/
|
|
99
|
+
export function kitLocalScripts(rootDir) {
|
|
100
|
+
return existsSync(path.join(rootDir, PREFLIGHT_SCRIPT_FILE))
|
|
101
|
+
? { [PREFLIGHT_SCRIPT_NAME]: PREFLIGHT_SCRIPT_CMD }
|
|
102
|
+
: {};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
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
|
|
133
|
+
* indentation + trailing newline).
|
|
97
134
|
*
|
|
98
135
|
* If `factory:update` already exists with a different value it is left intact;
|
|
99
136
|
* `scriptAlreadyPresent` reports that so the caller can warn the user.
|
|
100
137
|
*/
|
|
101
|
-
export function applyPackageJsonEdits(raw, name) {
|
|
138
|
+
export function applyPackageJsonEdits(raw, name, extraScripts = {}) {
|
|
102
139
|
const indent = detectIndent(raw);
|
|
103
140
|
const pkg = parsePackageJson(raw);
|
|
104
141
|
pkg.name = name;
|
|
105
|
-
|
|
142
|
+
stripOriginOnlyScripts(pkg);
|
|
143
|
+
const { primary } = ensureFactoryScripts(pkg, extraScripts);
|
|
106
144
|
const content = `${JSON.stringify(pkg, null, indent)}\n`;
|
|
107
145
|
return { content, scriptAlreadyPresent: primary.action === 'conflict' };
|
|
108
146
|
}
|
|
109
147
|
/**
|
|
110
148
|
* Surgically ensure the `factory:*` scripts (`update` / `doctor` / `status`)
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
149
|
+
* plus the file-gated kit-local scripts (`preflight` when its file ships —
|
|
150
|
+
* `kitLocalScripts` on the package.json's own directory) exist in the
|
|
151
|
+
* `package.json` at `pkgPath` (design §7.4). Reads, mutates only those keys,
|
|
152
|
+
* and re-serializes with the original indentation + trailing newline. NEVER
|
|
153
|
+
* overwrites a divergent value and NEVER touches `name`, deps, or any other
|
|
154
|
+
* script.
|
|
115
155
|
*
|
|
116
156
|
* Writes the file only when at least one script was added; if all are already
|
|
117
157
|
* present (correct or divergent) the file is left byte-identical.
|
|
@@ -123,7 +163,7 @@ export function insertFactoryUpdateScript(pkgPath) {
|
|
|
123
163
|
const raw = readFileSync(pkgPath, 'utf8');
|
|
124
164
|
const indent = detectIndent(raw);
|
|
125
165
|
const pkg = parsePackageJson(raw);
|
|
126
|
-
const { changed, primary } = ensureFactoryScripts(pkg);
|
|
166
|
+
const { changed, primary } = ensureFactoryScripts(pkg, kitLocalScripts(path.dirname(pkgPath)));
|
|
127
167
|
if (changed) {
|
|
128
168
|
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
|
|
129
169
|
}
|
|
@@ -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
|
+
}
|
package/dist/lib/unpack.js
CHANGED
|
@@ -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
|