@timekast/factory 0.1.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.
@@ -0,0 +1,344 @@
1
+ /**
2
+ * `factory update` — refresh the brain (`.claude/` + tracked scripts) of a
3
+ * derived project without clobbering local work (design §6.3, §7.0–§7.6).
4
+ *
5
+ * Flow:
6
+ * 1. Preflight (gh) — reused from DIST-003, never reimplemented.
7
+ * 2. Read the lockfile → the STICKY profile (`core` | `full`). Download the
8
+ * tarball of the SAME profile (never cross-grade). A `core` repo therefore
9
+ * never receives `sk-*` (not in the core manifest); a new `kb-*` in the
10
+ * core manifest does land.
11
+ * 3. Stage + validate the embedded manifest (the swap preflight rejects a
12
+ * corrupt/empty manifest before touching the FS).
13
+ * 4. Diff the new manifest against the lockfile → the 4 buckets + conflicts.
14
+ * 5. Resolve conflicts: `--theirs-all` / `--mine-all`, else prompt PER FILE
15
+ * with NO default option (design §7.3) — the file is not modified until the
16
+ * dev chooses.
17
+ * 6. Apply atomically (backup + rollback guard, design §7.5).
18
+ * 7. Write the lockfile (= the new manifest) ONLY after a clean apply.
19
+ * 8. Surgically ensure `factory:update` is in package.json (design §7.4).
20
+ *
21
+ * Auto-register (no lockfile, pre-CLI repo, design §6.3): path-match — repo
22
+ * paths matching the manifest are kit-owned (overwrite); `.claude/` paths absent
23
+ * from the manifest are logged "ambiguous" and NOT deleted on the first sync.
24
+ * The dev is warned before proceeding.
25
+ *
26
+ * `--verify`: compares disk vs lockfile and reports drift WITHOUT modifying
27
+ * anything. `--resume`: re-applies a persisted mid-flight state without
28
+ * re-downloading.
29
+ *
30
+ * `update` NEVER touches `src/` — files outside the lockfile/manifest are
31
+ * invisible (design §9 out-of-scope).
32
+ */
33
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
34
+ import { tmpdir } from 'node:os';
35
+ import path from 'node:path';
36
+ import prompts from 'prompts';
37
+ import { applyPlan, clearUpdateState, defaultBackupDir, hasUpdateState, readUpdateState, validateStagedManifest, writeUpdateState, } from '../lib/atomic-swap.js';
38
+ import { CLIError } from '../lib/cli-error.js';
39
+ import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
40
+ import { insertFactoryUpdateScript } from '../lib/package-json.js';
41
+ import { runPreflight } from '../lib/preflight.js';
42
+ import { downloadProfileTarball, stageProfileTarball } from '../lib/unpack.js';
43
+ /** Parse `update`'s argv slice into flags. Unknown flags are ignored here. */
44
+ export function parseUpdateFlags(argv) {
45
+ return {
46
+ theirsAll: argv.includes('--theirs-all'),
47
+ mineAll: argv.includes('--mine-all'),
48
+ resume: argv.includes('--resume'),
49
+ verify: argv.includes('--verify'),
50
+ };
51
+ }
52
+ /** Default acquire: download the sticky-profile tarball, stage + validate it. */
53
+ async function acquireFromRelease(profile) {
54
+ const tmpRoot = mkdtempSync(path.join(tmpdir(), 'tk-update-'));
55
+ const tarball = await downloadProfileTarball(profile, tmpRoot);
56
+ const { stagedDir, manifestRaw } = await stageProfileTarball(tarball, tmpRoot);
57
+ const manifest = validateStagedManifest(stagedDir, manifestRaw);
58
+ return { stagedDir, manifest, tmpRoot };
59
+ }
60
+ /**
61
+ * Recursively collect repo-relative paths of files under `.claude/` plus any
62
+ * tracked root files the manifest references at the top level. Used both for
63
+ * disk hashing and auto-register path-match. Only descends into `.claude/`;
64
+ * `src/` and everything else is invisible (design §7.2).
65
+ */
66
+ function collectClaudePaths(rootDir) {
67
+ const out = [];
68
+ const walk = (rel) => {
69
+ const abs = path.join(rootDir, rel);
70
+ if (!existsSync(abs))
71
+ return;
72
+ if (statSync(abs).isDirectory()) {
73
+ for (const entry of readdirSync(abs)) {
74
+ walk(path.posix.join(rel, entry));
75
+ }
76
+ }
77
+ else {
78
+ out.push(rel);
79
+ }
80
+ };
81
+ walk('.claude');
82
+ return out;
83
+ }
84
+ /**
85
+ * Hash the disk content (normalized) of every path in `paths` that exists.
86
+ * A missing file is simply absent from the returned map.
87
+ */
88
+ function hashDiskPaths(rootDir, paths) {
89
+ const map = new Map();
90
+ for (const rel of paths) {
91
+ const abs = path.join(rootDir, rel);
92
+ if (existsSync(abs) && statSync(abs).isFile()) {
93
+ map.set(rel, normalizeThenHash(readFileSync(abs, 'utf8')));
94
+ }
95
+ }
96
+ return map;
97
+ }
98
+ /** Resolve a single conflict via prompt — NO default option (design §7.3). */
99
+ async function resolveConflict(conflict, stagedDir, rootDir) {
100
+ for (;;) {
101
+ console.log(`\n⚠ ${conflict.path}: lo modificaste local y el Factory también cambió.\n` +
102
+ ' [1] Mantener el mío [2] Tomar el del Factory [3] Ver diff');
103
+ const { choice } = await prompts({
104
+ type: 'select',
105
+ name: 'choice',
106
+ message: 'Elige cómo resolver este conflicto',
107
+ // No `initial` → no option is highlighted by default (design §7.3).
108
+ choices: [
109
+ { title: '[1] Mantener el mío', value: 'mine' },
110
+ { title: '[2] Tomar el del Factory', value: 'theirs' },
111
+ { title: '[3] Ver diff', value: 'diff' },
112
+ ],
113
+ });
114
+ if (choice === undefined) {
115
+ throw new CLIError('Operación cancelada; no se modificó ningún archivo.', 130);
116
+ }
117
+ if (choice === 'diff') {
118
+ const local = readFileSync(path.join(rootDir, conflict.path), 'utf8');
119
+ const incoming = readFileSync(path.join(stagedDir, conflict.path), 'utf8');
120
+ console.log(`\n--- local (${conflict.path}) ---\n${local}\n--- Factory ---\n${incoming}\n`);
121
+ continue;
122
+ }
123
+ return choice;
124
+ }
125
+ }
126
+ /**
127
+ * Build the apply plan from a diff + the resolved conflicts. `writes` = adds +
128
+ * silent overwrites + conflicts resolved "theirs"; `deletes` = silent deletes.
129
+ * Conflicts resolved "mine" are simply omitted (the local file stays).
130
+ */
131
+ function buildPlan(diff, theirsConflicts) {
132
+ const writes = [
133
+ ...diff.add.map((e) => e.path),
134
+ ...diff.overwriteSilent.map((e) => e.path),
135
+ ...theirsConflicts,
136
+ ];
137
+ const deletes = diff.deleteSilent.map((e) => e.path);
138
+ return { writes, deletes };
139
+ }
140
+ /** `--verify`: report disk-vs-lockfile drift, modify nothing. */
141
+ function runVerify(rootDir) {
142
+ if (!hasLockfile(rootDir)) {
143
+ throw new CLIError('No hay lockfile que verificar. Corre `factory update` primero.');
144
+ }
145
+ const lock = readLockfile(rootDir);
146
+ const disk = hashDiskPaths(rootDir, lock.files.map((f) => f.path));
147
+ const missing = [];
148
+ const drifted = [];
149
+ for (const entry of lock.files) {
150
+ const localHash = disk.get(entry.path);
151
+ if (localHash === undefined) {
152
+ missing.push(entry.path);
153
+ }
154
+ else if (localHash !== entry.hash) {
155
+ drifted.push(entry.path);
156
+ }
157
+ }
158
+ if (missing.length === 0 && drifted.length === 0) {
159
+ console.log('✔ El estado del disco coincide con el lockfile. Sin discrepancias.');
160
+ return;
161
+ }
162
+ console.log('Discrepancias respecto al lockfile (no se modificó nada):');
163
+ for (const p of missing)
164
+ console.log(` • faltante en disco: ${p}`);
165
+ for (const p of drifted)
166
+ console.log(` • modificado localmente: ${p}`);
167
+ }
168
+ /**
169
+ * `--resume`: re-apply a persisted mid-flight state without re-downloading
170
+ * (design §7.5). Reads the staged dir + plan from `.timekast/.update-state.json`.
171
+ */
172
+ function runResume(rootDir) {
173
+ const state = readUpdateState(rootDir);
174
+ if (!existsSync(state.stagedDir)) {
175
+ throw new CLIError('El directorio temporal del update anterior ya no existe; vuelve a correr `factory update` sin `--resume`.');
176
+ }
177
+ // The lockfile on disk is still the PRE-update one (it is rewritten only after a
178
+ // clean apply), so it carries the birth stamp to preserve. A legacy resume (no
179
+ // prior lockfile) seals the birth at the persisted manifest's version.
180
+ const priorLock = hasLockfile(rootDir) ? readLockfile(rootDir) : undefined;
181
+ const newLock = priorLock
182
+ ? { ...state.newManifest, factoryVersion: priorLock.factoryVersion ?? priorLock.version }
183
+ : { ...state.newManifest, factoryVersion: state.newManifest.version };
184
+ applyPlan(rootDir, state.stagedDir, state.plan, state.backupDir);
185
+ writeLockfile(rootDir, newLock);
186
+ clearUpdateState(rootDir);
187
+ const pkgPath = path.join(rootDir, 'package.json');
188
+ if (existsSync(pkgPath))
189
+ warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
190
+ rmSync(state.stagedDir, { recursive: true, force: true });
191
+ console.log('✔ `update` retomado y completado.');
192
+ }
193
+ /**
194
+ * Stamp the birth seal onto the new manifest before it becomes the lockfile.
195
+ * `factoryVersion` is FROZEN at install: preserve the old lockfile's value, and
196
+ * fall back to its top-level `version` for repos that predate this field. The
197
+ * top-level `version` of the returned lockfile stays the new manifest's version
198
+ * (agentKitVersion advances on every update).
199
+ */
200
+ function stampBirth(oldLock, newManifest) {
201
+ return { ...newManifest, factoryVersion: oldLock.factoryVersion ?? oldLock.version };
202
+ }
203
+ /** Warn (never fail) when package.json holds a divergent factory:update value. */
204
+ function warnOnScriptConflict(action, _pkgPath) {
205
+ if (action === 'conflict') {
206
+ console.warn('Aviso: `factory:update` ya existe en package.json con otro valor; no se sobrescribió.');
207
+ }
208
+ }
209
+ /** Print the deletes (design §7.6 — visible, not silent) + a one-line summary. */
210
+ function reportSummary(diff) {
211
+ if (diff.deleteSilent.length > 0) {
212
+ console.log('\nArchivos retirados por el Factory en esta versión:');
213
+ for (const e of diff.deleteSilent)
214
+ console.log(` Archivos retirados: ${e.path}`);
215
+ }
216
+ console.log(`\n✔ update: ${diff.add.length} agregados, ${diff.overwriteSilent.length} actualizados, ` +
217
+ `${diff.deleteSilent.length} retirados, ${diff.conflicts.length} conflictos.`);
218
+ }
219
+ /**
220
+ * Run `factory update`. Orchestrates the full flow; `deps.acquire` is the only
221
+ * network seam (tests inject a local fixture).
222
+ */
223
+ export async function runUpdate(flags, deps = {}) {
224
+ const rootDir = process.cwd();
225
+ // --verify and --resume short-circuit (no download).
226
+ if (flags.verify) {
227
+ runVerify(rootDir);
228
+ return;
229
+ }
230
+ if (flags.resume) {
231
+ if (!hasUpdateState(rootDir)) {
232
+ throw new CLIError('No hay un `update` pendiente para retomar.');
233
+ }
234
+ runResume(rootDir);
235
+ return;
236
+ }
237
+ if (flags.theirsAll && flags.mineAll) {
238
+ throw new CLIError('No puedes combinar `--theirs-all` con `--mine-all`.');
239
+ }
240
+ // Preflight (gh) before any download — reused, not reimplemented.
241
+ await runPreflight();
242
+ const legacy = !hasLockfile(rootDir);
243
+ // Sticky profile: a lockfile pins the installed profile; a legacy repo with no
244
+ // lockfile defaults to `core` (the additive baseline a pre-CLI repo carries).
245
+ let oldLock;
246
+ let profile;
247
+ if (legacy) {
248
+ profile = 'core';
249
+ oldLock = { version: '0.0.0', profile, files: [] };
250
+ await confirmLegacy();
251
+ }
252
+ else {
253
+ oldLock = readLockfile(rootDir);
254
+ profile = oldLock.profile;
255
+ }
256
+ const acquire = deps.acquire ?? acquireFromRelease;
257
+ const { stagedDir, manifest, tmpRoot } = await acquire(profile);
258
+ try {
259
+ if (legacy) {
260
+ await applyLegacy(rootDir, stagedDir, manifest);
261
+ return;
262
+ }
263
+ // Hash only the union of tracked paths (old lock ∪ new manifest). Files
264
+ // outside both are never read (design §7.2 "ni lo mira").
265
+ const trackedPaths = new Set([
266
+ ...oldLock.files.map((f) => f.path),
267
+ ...manifest.files.map((f) => f.path),
268
+ ]);
269
+ const disk = hashDiskPaths(rootDir, trackedPaths);
270
+ const diff = diffLockfiles(oldLock, manifest, disk);
271
+ // Resolve conflicts.
272
+ const theirsConflicts = [];
273
+ for (const conflict of diff.conflicts) {
274
+ let decision;
275
+ if (flags.theirsAll)
276
+ decision = 'theirs';
277
+ else if (flags.mineAll)
278
+ decision = 'mine';
279
+ else
280
+ decision = await resolveConflict(conflict, stagedDir, rootDir);
281
+ if (decision === 'theirs')
282
+ theirsConflicts.push(conflict.path);
283
+ }
284
+ const plan = buildPlan(diff, theirsConflicts);
285
+ const backupDir = defaultBackupDir(tmpRoot);
286
+ // Persist resume state BEFORE applying, so a crash mid-apply is recoverable
287
+ // without re-downloading (design §7.5).
288
+ writeUpdateState(rootDir, { stagedDir, backupDir, plan, newManifest: manifest });
289
+ applyPlan(rootDir, stagedDir, plan, backupDir);
290
+ // Lockfile written ONLY after a clean apply. The birth stamp is preserved
291
+ // (agentKitVersion advances; factoryVersion stays frozen).
292
+ writeLockfile(rootDir, stampBirth(oldLock, manifest));
293
+ clearUpdateState(rootDir);
294
+ const pkgPath = path.join(rootDir, 'package.json');
295
+ if (existsSync(pkgPath))
296
+ warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
297
+ reportSummary(diff);
298
+ }
299
+ finally {
300
+ rmSync(tmpRoot, { recursive: true, force: true });
301
+ }
302
+ }
303
+ /** Warn the dev before a legacy auto-register sync (design §8 edge case). */
304
+ async function confirmLegacy() {
305
+ const { proceed } = await prompts({
306
+ type: 'confirm',
307
+ name: 'proceed',
308
+ message: 'Este repo no tiene lockfile. Se hará un auto-registro por paths. Los archivos del cerebro ' +
309
+ 'que coincidan se sobrescribirán con la última versión. ¿Continuar?',
310
+ initial: false,
311
+ });
312
+ if (!proceed) {
313
+ throw new CLIError('Operación cancelada.', 130);
314
+ }
315
+ }
316
+ /**
317
+ * Legacy auto-register (no lockfile): path-match against the fresh manifest.
318
+ * Kit-owned + new files are written; ambiguous `.claude/` paths are logged and
319
+ * NOT deleted on the first sync (design §6.3). Builds the lockfile from the
320
+ * normalized hashes of the installed files.
321
+ */
322
+ async function applyLegacy(rootDir, stagedDir, manifest) {
323
+ const repoPaths = new Set(collectClaudePaths(rootDir));
324
+ const plan = planAutoRegister(manifest, repoPaths);
325
+ // Everything in the manifest is written (kit-owned overwrite + adds). No
326
+ // deletes on the first sync.
327
+ const writes = manifest.files.map((f) => f.path);
328
+ const backupDir = defaultBackupDir(path.dirname(stagedDir));
329
+ applyPlan(rootDir, stagedDir, { writes, deletes: [] }, backupDir);
330
+ // The lockfile = the manifest (its hashes are already the normalized hashes of
331
+ // the just-installed files) PLUS the birth stamp. A legacy auto-register is the
332
+ // first sync this repo records, so the birth seal = the manifest's version.
333
+ writeLockfile(rootDir, { ...manifest, factoryVersion: manifest.version });
334
+ const pkgPath = path.join(rootDir, 'package.json');
335
+ if (existsSync(pkgPath))
336
+ warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
337
+ if (plan.ambiguous.length > 0) {
338
+ console.log('\nArchivos en `.claude/` que no están en el manifest (revisar manualmente):');
339
+ for (const p of plan.ambiguous)
340
+ console.log(` • ambiguo — revisar manualmente: ${p}`);
341
+ }
342
+ console.log(`\n✔ Auto-registro completo: ${plan.kitOwned.length} kit-owned, ${plan.toAdd.length} agregados, ` +
343
+ `${plan.ambiguous.length} ambiguos.`);
344
+ }
package/dist/index.js ADDED
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `@timekast/factory` — public, thin CLI entrypoint.
4
+ *
5
+ * Dispatches subcommands. All five commands are wired: `new` / `add` / `update`
6
+ * / `status` / `doctor` (DIST-003..006). The bin is deliberately thin — it
7
+ * imports nothing from the kit's `src/` or `.claude/`.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { CLIError } from './lib/cli-error.js';
13
+ import { runAdd } from './commands/add.js';
14
+ import { runDoctor } from './commands/doctor.js';
15
+ import { runNew } from './commands/new.js';
16
+ import { runStatus } from './commands/status.js';
17
+ import { parseUpdateFlags, runUpdate } from './commands/update.js';
18
+ const HELP = `
19
+ @timekast/factory — bootstrap y mantenimiento de proyectos derivados del Factory.
20
+
21
+ Uso:
22
+ factory <comando> [opciones]
23
+
24
+ Comandos:
25
+ new <Nombre> Crea un proyecto derivado nuevo (repo + perfil + git init)
26
+ add Instala el cerebro \`core\` (.claude/) en el repo actual
27
+ update Actualiza el cerebro al día sin pisar tu trabajo local
28
+ status Reporta la versión instalada vs. la última disponible
29
+ doctor Detecta huérfanos y conflictos pendientes + aviso de seguridad
30
+
31
+ Sobre \`add\`:
32
+ Requiere estar dentro de un repo git (al menos \`git init\`). Nunca toca tu
33
+ \`src/\`, tu \`package.json\` ni tus archivos propios (\`pj-*\`): solo escribe los
34
+ archivos del perfil \`core\`. Si un archivo del \`core\` ya existe, lo sobrescribe
35
+ sin preguntar (el manejo de conflictos es exclusivo de \`update\`).
36
+
37
+ Sobre \`update\`:
38
+ Lee el perfil instalado del lockfile y baja el tarball del MISMO perfil (nunca
39
+ cross-grada). Compara contra el lockfile y aplica solo cambios de \`.claude/\`:
40
+ agrega lo nuevo, sobrescribe lo del kit sin cambios locales, retira lo que el
41
+ Factory borró, y pregunta (sin opción default) ante un conflicto. Nunca toca
42
+ \`src/\` ni tus archivos propios. Flags:
43
+ --theirs-all Resuelve todos los conflictos con la versión del Factory
44
+ --mine-all Conserva tu versión en todos los conflictos
45
+ --resume Retoma un update interrumpido sin re-descargar
46
+ --verify Reporta diferencias disco-vs-lockfile sin modificar nada
47
+
48
+ Sobre \`status\` y \`doctor\`:
49
+ Solo diagnostican — nunca modifican archivos. \`status\` lee el lockfile y
50
+ consulta la última versión publicada (ignorando prereleases); sin lockfile o
51
+ sin conexión, avisa y termina con éxito. \`doctor\` lista huérfanos y conflictos
52
+ pendientes y siempre imprime el aviso de seguridad de \`src/\`.
53
+
54
+ Opciones:
55
+ -h, --help Muestra esta ayuda
56
+ -v, --version Muestra la versión del CLI
57
+ `.trim();
58
+ /** Read this package's version from its own package.json. */
59
+ function readVersion() {
60
+ try {
61
+ const here = dirname(fileURLToPath(import.meta.url));
62
+ // dist/index.js → ../package.json
63
+ const pkgPath = join(here, '..', 'package.json');
64
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
65
+ return pkg.version ?? '0.0.0';
66
+ }
67
+ catch {
68
+ return '0.0.0';
69
+ }
70
+ }
71
+ async function main(argv) {
72
+ const [command, ...rest] = argv;
73
+ if (command === undefined || command === '-h' || command === '--help' || command === 'help') {
74
+ console.log(HELP);
75
+ return;
76
+ }
77
+ if (command === '-v' || command === '--version' || command === 'version') {
78
+ console.log(readVersion());
79
+ return;
80
+ }
81
+ switch (command) {
82
+ case 'new': {
83
+ const name = rest.find((arg) => !arg.startsWith('-'));
84
+ if (!name) {
85
+ throw new CLIError('Falta el nombre del proyecto. Uso: `factory new <Nombre>`.');
86
+ }
87
+ await runNew(name);
88
+ return;
89
+ }
90
+ case 'add': {
91
+ await runAdd();
92
+ return;
93
+ }
94
+ case 'update': {
95
+ await runUpdate(parseUpdateFlags(rest));
96
+ return;
97
+ }
98
+ case 'status': {
99
+ await runStatus();
100
+ return;
101
+ }
102
+ case 'doctor': {
103
+ runDoctor();
104
+ return;
105
+ }
106
+ default:
107
+ throw new CLIError(`Comando desconocido: \`${command}\`.\nEjecuta \`factory --help\` para ver los comandos disponibles.`);
108
+ }
109
+ }
110
+ main(process.argv.slice(2)).then(() => {
111
+ process.exit(0);
112
+ }, (error) => {
113
+ if (error instanceof CLIError) {
114
+ console.error(`\n✖ ${error.message}`);
115
+ process.exit(error.exitCode);
116
+ }
117
+ // Unexpected error — surface it (this path is a bug, not a user error).
118
+ console.error('\n✖ Error inesperado:');
119
+ console.error(error);
120
+ process.exit(1);
121
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Atomic apply engine for `update` (design §7.5).
3
+ *
4
+ * `update` never writes the destination file-by-file from the network. Instead:
5
+ * 1. The tarball is extracted + validated in a temp dir (unpack.ts).
6
+ * 2. The diff is computed and conflicts resolved (lockfile.ts + update.ts).
7
+ * 3. THIS module applies the resolved plan atomically: it backs up every file
8
+ * it is about to overwrite or delete into a backup dir, then performs the
9
+ * writes/deletes. If anything throws mid-apply, `rollback()` restores the
10
+ * pre-update state from the backup so `.claude/` is never left partial.
11
+ * 4. The lockfile is written ONLY after the apply fully succeeds (the caller
12
+ * does this, so a crash before it leaves the OLD lockfile intact).
13
+ *
14
+ * State for `--resume`: a `.timekast/.update-state.json` records the staged dir,
15
+ * the resolved plan, and the backup dir. If the process dies between steps,
16
+ * `--resume` reads it and re-applies WITHOUT re-downloading. After a clean
17
+ * apply + lockfile write the state file is removed.
18
+ *
19
+ * The applied set is always a subset of the lockfile/manifest (kit-managed
20
+ * paths). `src/` and dev-owned files are never in the plan, so they are never
21
+ * read, written, or deleted here (design §7.2, §9 out-of-scope).
22
+ */
23
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24
+ import path from 'node:path';
25
+ import { CLIError } from './cli-error.js';
26
+ import { TIMEKAST_DIR } from './constants.js';
27
+ import { parseLockfile } from './lockfile.js';
28
+ /** Name of the resume-state sidecar inside `.timekast/`. */
29
+ export const UPDATE_STATE_FILE = '.update-state.json';
30
+ /** Absolute path to the resume-state sidecar inside a project root. */
31
+ export function updateStatePath(rootDir) {
32
+ return path.join(rootDir, TIMEKAST_DIR, UPDATE_STATE_FILE);
33
+ }
34
+ /** True when a resumable update state exists. */
35
+ export function hasUpdateState(rootDir) {
36
+ return existsSync(updateStatePath(rootDir));
37
+ }
38
+ /** Read + validate the resume state. */
39
+ export function readUpdateState(rootDir) {
40
+ const file = updateStatePath(rootDir);
41
+ if (!existsSync(file)) {
42
+ throw new CLIError('No hay un estado de `update` pendiente para retomar (`--resume`).');
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
47
+ }
48
+ catch {
49
+ throw new CLIError('El estado de `update` (`.timekast/.update-state.json`) está corrupto.');
50
+ }
51
+ const obj = parsed;
52
+ if (!obj ||
53
+ typeof obj.stagedDir !== 'string' ||
54
+ typeof obj.backupDir !== 'string' ||
55
+ !obj.plan ||
56
+ !Array.isArray(obj.plan.writes) ||
57
+ !Array.isArray(obj.plan.deletes) ||
58
+ !obj.newManifest) {
59
+ throw new CLIError('El estado de `update` está incompleto; no se puede retomar de forma segura.');
60
+ }
61
+ return obj;
62
+ }
63
+ /** Persist the resume state. */
64
+ export function writeUpdateState(rootDir, state) {
65
+ mkdirSync(path.join(rootDir, TIMEKAST_DIR), { recursive: true });
66
+ writeFileSync(updateStatePath(rootDir), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
67
+ }
68
+ /** Remove the resume state (after a clean apply + lockfile write). */
69
+ export function clearUpdateState(rootDir) {
70
+ rmSync(updateStatePath(rootDir), { force: true });
71
+ }
72
+ /**
73
+ * Validate the embedded manifest of a staged tarball (swap preflight, §7.5).
74
+ * Rejects a corrupt/empty manifest (missing `version`/`files`) before anything
75
+ * is touched, and verifies every file the manifest declares actually exists in
76
+ * the staged dir (an incomplete tarball must never reach the destination).
77
+ *
78
+ * @throws {CLIError} on any structural or completeness failure.
79
+ */
80
+ export function validateStagedManifest(stagedDir, manifestRaw) {
81
+ const manifest = parseLockfile(manifestRaw, 'manifest embebido');
82
+ for (const entry of manifest.files) {
83
+ if (!existsSync(path.join(stagedDir, entry.path))) {
84
+ throw new CLIError(`El tarball está incompleto: falta \`${entry.path}\` declarado en el manifest. ` +
85
+ 'No se tocó ningún archivo.');
86
+ }
87
+ }
88
+ return manifest;
89
+ }
90
+ /**
91
+ * Apply the plan atomically with a backup-and-rollback guard.
92
+ *
93
+ * For every write target and delete target that currently exists, a copy is
94
+ * saved into `backupDir` (preserving the relative path) BEFORE any mutation.
95
+ * Then writes (copy from staged) and deletes are performed. If any step throws,
96
+ * the partial changes are rolled back from the backup and the original error is
97
+ * rethrown — the destination ends in its exact pre-update state.
98
+ *
99
+ * Does NOT write the lockfile (the caller does that only after this returns
100
+ * cleanly) and does NOT remove the staged dir (kept for `--resume`).
101
+ *
102
+ * @param rootDir Destination project root.
103
+ * @param stagedDir Validated staged tarball contents.
104
+ * @param plan Resolved writes + deletes (tarball-relative paths).
105
+ * @param backupDir Directory to stash pre-update copies (created if missing).
106
+ * @param hooks Test seam: optional callbacks fired between phases to inject
107
+ * a mid-flight failure (rollback test).
108
+ */
109
+ export function applyPlan(rootDir, stagedDir, plan, backupDir, hooks) {
110
+ mkdirSync(backupDir, { recursive: true });
111
+ // 1. Back up everything we will touch (overwrite or delete), if it exists.
112
+ const touched = [...plan.writes, ...plan.deletes];
113
+ for (const rel of touched) {
114
+ const abs = path.join(rootDir, rel);
115
+ if (existsSync(abs)) {
116
+ const dest = path.join(backupDir, rel);
117
+ mkdirSync(path.dirname(dest), { recursive: true });
118
+ cpSync(abs, dest, { recursive: true });
119
+ }
120
+ }
121
+ // Record which touched paths existed pre-update (so rollback can re-delete
122
+ // files that were freshly added and must vanish on rollback).
123
+ const preExisting = new Set(touched.filter((rel) => existsSync(path.join(rootDir, rel))));
124
+ const rollback = () => {
125
+ // Restore backed-up files, and delete any file we created that did not
126
+ // exist before (a freshly-added file must not survive a rollback).
127
+ for (const rel of touched) {
128
+ const abs = path.join(rootDir, rel);
129
+ const backup = path.join(backupDir, rel);
130
+ if (existsSync(backup)) {
131
+ mkdirSync(path.dirname(abs), { recursive: true });
132
+ cpSync(backup, abs, { recursive: true });
133
+ }
134
+ else if (!preExisting.has(rel)) {
135
+ rmSync(abs, { force: true, recursive: true });
136
+ }
137
+ }
138
+ };
139
+ try {
140
+ hooks?.beforeWrites?.();
141
+ for (const rel of plan.writes) {
142
+ const src = path.join(stagedDir, rel);
143
+ const dst = path.join(rootDir, rel);
144
+ mkdirSync(path.dirname(dst), { recursive: true });
145
+ cpSync(src, dst, { recursive: true });
146
+ }
147
+ hooks?.afterWrites?.();
148
+ for (const rel of plan.deletes) {
149
+ rmSync(path.join(rootDir, rel), { force: true, recursive: true });
150
+ }
151
+ }
152
+ catch (error) {
153
+ rollback();
154
+ throw error;
155
+ }
156
+ }
157
+ /** Build the backup dir path under a staged temp root (kept off the dest tree). */
158
+ export function defaultBackupDir(stagedRoot) {
159
+ return path.join(stagedRoot, 'backup');
160
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Typed errors for the CLI. Expected, user-facing failures throw `CLIError`
3
+ * (or a subclass) so the top-level handler can print a clean, actionable
4
+ * message and exit with the right code — never a raw stack trace.
5
+ */
6
+ /** An expected, user-facing CLI failure. Carries an exit code. */
7
+ export class CLIError extends Error {
8
+ exitCode;
9
+ constructor(message, exitCode = 1) {
10
+ super(message);
11
+ this.name = 'CLIError';
12
+ this.exitCode = exitCode;
13
+ // Restore prototype chain for instanceof across transpilation targets.
14
+ Object.setPrototypeOf(this, new.target.prototype);
15
+ }
16
+ }
17
+ /** A preflight check failed (gh missing / not authed / not an org member). */
18
+ export class PreflightError extends CLIError {
19
+ constructor(message, exitCode = 1) {
20
+ super(message, exitCode);
21
+ this.name = 'PreflightError';
22
+ Object.setPrototypeOf(this, new.target.prototype);
23
+ }
24
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * CLI-wide constants. v1 has no `--org` flag — the destination org is always
3
+ * `TimeKast` (design §6.1). Centralized here so it is a named constant, never
4
+ * an inline literal.
5
+ */
6
+ /** Destination GitHub org for all derived projects (v1, no `--org` override). */
7
+ export const FACTORY_ORG = 'TimeKast';
8
+ /** The Factory monorepo, source of the distribution releases. */
9
+ export const FACTORY_REPO = 'TimeKast/TimeKast-Factory';
10
+ /** Distribution profiles selectable in `new`. */
11
+ export const PROFILES = {
12
+ full: 'full',
13
+ core: 'core',
14
+ };
15
+ /** Relative path (inside a derived project) to the embedded manifest / lockfile. */
16
+ export const TIMEKAST_DIR = '.timekast';
17
+ export const MANIFEST_FILE = 'manifest.json';
18
+ export const LOCKFILE_FILE = 'lockfile.json';
19
+ /** The script the CLI injects into a derived project's package.json. */
20
+ export const UPDATE_SCRIPT_NAME = 'factory:update';
21
+ // `npx` so the script resolves in a fresh derived repo where @timekast/factory
22
+ // is NOT a dependency (B3): a bare `@timekast/factory update` would be
23
+ // "command not found". npx resolves the published package on demand.
24
+ export const UPDATE_SCRIPT_CMD = 'npx @timekast/factory update';