@timekast/factory 1.3.0 → 1.4.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.
@@ -85,6 +85,8 @@ export async function runAdd(flags = { core: false, full: false }) {
85
85
  mineAll: false,
86
86
  resume: false,
87
87
  verify: false,
88
+ commit: false,
89
+ noCommit: false,
88
90
  });
89
91
  return;
90
92
  }
@@ -19,6 +19,7 @@ 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
21
  import { applyPackageJsonEdits } from '../lib/package-json.js';
22
+ import { renderDerivedReadme } from '../lib/readme.js';
22
23
  import { runPreflight } from '../lib/preflight.js';
23
24
  import { downloadProfileTarball, moveContentsInto, stageProfileTarball } from '../lib/unpack.js';
24
25
  import { validateProjectName } from '../lib/validate-name.js';
@@ -100,13 +101,19 @@ export async function runNew(name) {
100
101
  await execa('git', ['init'], { cwd: destDir });
101
102
  // Rename package.json.name + inject factory:update (surgical, §7.4).
102
103
  const pkgPath = path.join(destDir, 'package.json');
103
- if (existsSync(pkgPath)) {
104
+ const hasPackageJson = existsSync(pkgPath);
105
+ if (hasPackageJson) {
104
106
  const { content, scriptAlreadyPresent } = applyPackageJsonEdits(readFileSync(pkgPath, 'utf8'), validName);
105
107
  writeFileSync(pkgPath, content, 'utf8');
106
108
  if (scriptAlreadyPresent) {
107
109
  console.warn('Aviso: `factory:update` ya existía en package.json con otro valor; no se sobrescribió.');
108
110
  }
109
111
  }
112
+ // Replace the inherited README. The `full` tarball ships the Factory's own
113
+ // README (it documents the distribution CLI), which is wrong inside a derived
114
+ // app — overwrite it with one scoped to this project. Written either way so a
115
+ // brain-only `core` project also gets a README.
116
+ writeFileSync(path.join(destDir, 'README.md'), renderDerivedReadme(validName, profile, hasPackageJson), 'utf8');
110
117
  // Write the initial lockfile = the tarball's embedded manifest verbatim
111
118
  // (no hash recompute — that's DIST-005).
112
119
  writeInitialLockfile(destDir, manifestRaw);
@@ -36,11 +36,13 @@
36
36
  import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
37
37
  import { tmpdir } from 'node:os';
38
38
  import path from 'node:path';
39
+ import { execa } from 'execa';
39
40
  import prompts from 'prompts';
40
41
  import { applyPlan, clearUpdateState, defaultBackupDir, hasUpdateState, readUpdateState, validateStagedManifest, writeUpdateState, } from '../lib/atomic-swap.js';
41
42
  import { collectClaudePaths } from '../lib/claude-paths.js';
42
43
  import { CLIError } from '../lib/cli-error.js';
43
- import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, PROFILES, } from '../lib/constants.js';
44
+ import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, LOCKFILE_FILE, PROFILES, TIMEKAST_DIR, } from '../lib/constants.js';
45
+ import { decideCommit } from '../lib/commit-decision.js';
44
46
  import { extractManagedBlock, syncManagedBlock } from '../lib/gitattributes.js';
45
47
  import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
46
48
  import { detectProfile, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
@@ -56,6 +58,8 @@ export function parseUpdateFlags(argv) {
56
58
  verify: argv.includes('--verify'),
57
59
  core: argv.includes('--core'),
58
60
  full: argv.includes('--full'),
61
+ commit: argv.includes('--commit'),
62
+ noCommit: argv.includes('--no-commit'),
59
63
  };
60
64
  }
61
65
  /** Default acquire: download the sticky-profile tarball, stage + validate it. */
@@ -150,11 +154,66 @@ function runVerify(rootDir) {
150
154
  for (const p of drifted)
151
155
  console.log(` • modificado localmente: ${p}`);
152
156
  }
157
+ /**
158
+ * After a clean update, optionally commit the refreshed brain (local, NEVER push).
159
+ * Decision (`commit-decision.ts`): `--no-commit`→skip · `--commit`→commit · interactive
160
+ * → prompt y/n · headless → commit by default (the Agent Server needs it). Stages ONLY
161
+ * what the update touched (the applied manifest paths + lockfile + package.json +
162
+ * .gitattributes) — never `git add -A`. Best-effort: no identity / no repo → warn + skip.
163
+ */
164
+ async function maybeCommitBrain(rootDir, version, appliedPaths, flags) {
165
+ const decision = decideCommit({
166
+ commit: flags.commit,
167
+ noCommit: flags.noCommit,
168
+ isTTY: Boolean(process.stdout.isTTY),
169
+ });
170
+ if (decision === 'skip')
171
+ return;
172
+ let doCommit = decision === 'commit';
173
+ if (decision === 'prompt') {
174
+ const { yes } = await prompts({
175
+ type: 'confirm',
176
+ name: 'yes',
177
+ message: `¿Commitear el cerebro actualizado (v${version})? No se pushea.`,
178
+ initial: true,
179
+ });
180
+ doCommit = yes === true;
181
+ }
182
+ if (!doCommit)
183
+ return;
184
+ const candidates = [
185
+ ...appliedPaths,
186
+ `${TIMEKAST_DIR}/${LOCKFILE_FILE}`,
187
+ ...(existsSync(path.join(rootDir, 'package.json')) ? ['package.json'] : []),
188
+ ...(existsSync(path.join(rootDir, GITATTRIBUTES_FILE)) ? [GITATTRIBUTES_FILE] : []),
189
+ ];
190
+ try {
191
+ await execa('git', ['add', '--', ...candidates], { cwd: rootDir, reject: false });
192
+ // Nothing staged (e.g. a no-op update) → no empty commit.
193
+ const staged = await execa('git', ['diff', '--cached', '--quiet'], {
194
+ cwd: rootDir,
195
+ reject: false,
196
+ });
197
+ if (staged.exitCode === 0)
198
+ return;
199
+ const res = await execa('git', ['commit', '-m', `chore: factory update v${version}`], {
200
+ cwd: rootDir,
201
+ reject: false,
202
+ });
203
+ if (res.exitCode === 0)
204
+ console.log(`✔ Cerebro commiteado (v${version}).`);
205
+ else
206
+ console.warn('Aviso: no se pudo commitear (¿falta identidad git o no es un repo?); el cerebro se instaló igual.');
207
+ }
208
+ catch {
209
+ console.warn('Aviso: no se pudo commitear; el cerebro se instaló igual.');
210
+ }
211
+ }
153
212
  /**
154
213
  * `--resume`: re-apply a persisted mid-flight state without re-downloading
155
214
  * (design §7.5). Reads the staged dir + plan from `.timekast/.update-state.json`.
156
215
  */
157
- function runResume(rootDir) {
216
+ async function runResume(rootDir, flags) {
158
217
  const state = readUpdateState(rootDir);
159
218
  if (!existsSync(state.stagedDir)) {
160
219
  throw new CLIError('El directorio temporal del update anterior ya no existe; vuelve a correr `factory update` sin `--resume`.');
@@ -171,6 +230,7 @@ function runResume(rootDir) {
171
230
  clearUpdateState(rootDir);
172
231
  // Maintain dotfiles BEFORE removing the staged dir (the .gitattributes source).
173
232
  maintainDerivedDotfiles(rootDir, state.stagedDir, newLock.version);
233
+ await maybeCommitBrain(rootDir, newLock.version, [...state.plan.writes, ...state.plan.deletes], flags);
174
234
  rmSync(state.stagedDir, { recursive: true, force: true });
175
235
  console.log('✔ `update` retomado y completado.');
176
236
  }
@@ -302,7 +362,7 @@ export async function runUpdate(flags, deps = {}) {
302
362
  if (!hasUpdateState(rootDir)) {
303
363
  throw new CLIError('No hay un `update` pendiente para retomar.');
304
364
  }
305
- runResume(rootDir);
365
+ await runResume(rootDir, flags);
306
366
  return;
307
367
  }
308
368
  // Preflight (gh) before any download — reused, not reimplemented.
@@ -381,6 +441,7 @@ export async function runUpdate(flags, deps = {}) {
381
441
  writeLockfile(rootDir, stampBirth(oldLock, manifest));
382
442
  clearUpdateState(rootDir);
383
443
  maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
444
+ await maybeCommitBrain(rootDir, manifest.version, [...plan.writes, ...plan.deletes], flags);
384
445
  reportSummary(diff, manifest);
385
446
  }
386
447
  finally {
@@ -0,0 +1,7 @@
1
+ export function decideCommit({ commit, noCommit, isTTY }) {
2
+ if (noCommit)
3
+ return 'skip';
4
+ if (commit)
5
+ return 'commit';
6
+ return isTTY ? 'prompt' : 'commit';
7
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Render a README scoped to a freshly-bootstrapped derived project.
3
+ *
4
+ * Why: the `full` tarball ships the Factory's own README (it documents the
5
+ * distribution CLI — `factory new` etc.), which is wrong inside a derived app.
6
+ * `factory new` overwrites it with this one, scoped to the project: how to run
7
+ * it (full) and how to keep the `.claude/` brain current. Pure + deterministic
8
+ * so it unit-tests without mocks (mirrors `publish-core.ts`).
9
+ */
10
+ const FACTORY_URL = 'https://github.com/TimeKast/TimeKast-Factory';
11
+ /** The `factory *` invocation prefix: a Node project gets the `pnpm` scripts; otherwise `npx`. */
12
+ function factoryCmd(sub, hasPackageJson) {
13
+ return hasPackageJson ? `pnpm factory:${sub}` : `npx @timekast/factory ${sub}`;
14
+ }
15
+ /**
16
+ * Build the derived project's README markdown.
17
+ *
18
+ * @param name The project name (already validated/slugged by the caller).
19
+ * @param profile `full` ships the Next.js boilerplate; `core` is brain-only.
20
+ * @param hasPackageJson Whether the project carries a `package.json` (drives `pnpm` vs `npx`).
21
+ */
22
+ export function renderDerivedReadme(name, profile, hasPackageJson) {
23
+ const isFull = profile === 'full';
24
+ const dev = isFull
25
+ ? `## Desarrollo
26
+
27
+ \`\`\`bash
28
+ pnpm install
29
+ cp .env.example .env.local # edita DATABASE_URL + AUTH_SECRET
30
+ pnpm dev
31
+ \`\`\`
32
+
33
+ Abre [http://localhost:3000](http://localhost:3000).
34
+
35
+ `
36
+ : '';
37
+ const stack = isFull
38
+ ? `Boilerplate Next.js 16 + TypeScript + Drizzle ORM + Tailwind + NextAuth, más el cerebro AI-first \`.claude/\` (Claude Code).`
39
+ : `Este repo usa el cerebro AI-first \`.claude/\` de TimeKast Factory (reglas + skills + workflows para Claude Code), sin el boilerplate Next.js.`;
40
+ return `# ${name}
41
+
42
+ > Proyecto creado con [TimeKast Factory](${FACTORY_URL}).
43
+
44
+ ${stack}
45
+
46
+ ${dev}## Mantener el cerebro al día
47
+
48
+ El cerebro \`.claude/\` se actualiza sin pisar tu trabajo local:
49
+
50
+ \`\`\`bash
51
+ ${factoryCmd('update', hasPackageJson)} # actualizar a la última versión
52
+ ${factoryCmd('status', hasPackageJson)} # versión instalada vs. disponible
53
+ ${factoryCmd('doctor', hasPackageJson)} # diagnóstico (huérfanos, conflictos, seguridad)
54
+ \`\`\`
55
+
56
+ ---
57
+
58
+ _Bootstrapped from TimeKast Factory._
59
+ `;
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",