@timekast/factory 1.2.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.
- package/dist/commands/add.js +2 -0
- package/dist/commands/new.js +8 -1
- package/dist/commands/update.js +121 -19
- package/dist/lib/commit-decision.js +7 -0
- package/dist/lib/constants.js +17 -0
- package/dist/lib/gitattributes.js +101 -0
- package/dist/lib/readme.js +60 -0
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
package/dist/commands/new.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/commands/update.js
CHANGED
|
@@ -36,11 +36,14 @@
|
|
|
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, 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';
|
|
46
|
+
import { extractManagedBlock, syncManagedBlock } from '../lib/gitattributes.js';
|
|
44
47
|
import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
|
|
45
48
|
import { detectProfile, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
|
|
46
49
|
import { runPreflight } from '../lib/preflight.js';
|
|
@@ -55,6 +58,8 @@ export function parseUpdateFlags(argv) {
|
|
|
55
58
|
verify: argv.includes('--verify'),
|
|
56
59
|
core: argv.includes('--core'),
|
|
57
60
|
full: argv.includes('--full'),
|
|
61
|
+
commit: argv.includes('--commit'),
|
|
62
|
+
noCommit: argv.includes('--no-commit'),
|
|
58
63
|
};
|
|
59
64
|
}
|
|
60
65
|
/** Default acquire: download the sticky-profile tarball, stage + validate it. */
|
|
@@ -149,11 +154,66 @@ function runVerify(rootDir) {
|
|
|
149
154
|
for (const p of drifted)
|
|
150
155
|
console.log(` • modificado localmente: ${p}`);
|
|
151
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
|
+
}
|
|
152
212
|
/**
|
|
153
213
|
* `--resume`: re-apply a persisted mid-flight state without re-downloading
|
|
154
214
|
* (design §7.5). Reads the staged dir + plan from `.timekast/.update-state.json`.
|
|
155
215
|
*/
|
|
156
|
-
function runResume(rootDir) {
|
|
216
|
+
async function runResume(rootDir, flags) {
|
|
157
217
|
const state = readUpdateState(rootDir);
|
|
158
218
|
if (!existsSync(state.stagedDir)) {
|
|
159
219
|
throw new CLIError('El directorio temporal del update anterior ya no existe; vuelve a correr `factory update` sin `--resume`.');
|
|
@@ -168,9 +228,9 @@ function runResume(rootDir) {
|
|
|
168
228
|
applyPlan(rootDir, state.stagedDir, state.plan, state.backupDir);
|
|
169
229
|
writeLockfile(rootDir, newLock);
|
|
170
230
|
clearUpdateState(rootDir);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
231
|
+
// Maintain dotfiles BEFORE removing the staged dir (the .gitattributes source).
|
|
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
|
}
|
|
@@ -191,15 +251,31 @@ function warnOnScriptConflict(action, _pkgPath) {
|
|
|
191
251
|
}
|
|
192
252
|
}
|
|
193
253
|
/**
|
|
194
|
-
* After a sync, maintain the derived project's package.json
|
|
195
|
-
* `
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
254
|
+
* After a sync, maintain the derived project's co-owned dotfiles — `package.json`
|
|
255
|
+
* AND `.gitattributes` — in one place. Centralized (not scattered per command) so
|
|
256
|
+
* all THREE update paths (main, legacy auto-register, resume) are covered by
|
|
257
|
+
* construction: a per-command call already regressed once (legacy + resume were
|
|
258
|
+
* missed). `stagedDir` is the freshly-unpacked tarball, the source of the
|
|
259
|
+
* `.gitattributes` managed block.
|
|
260
|
+
*/
|
|
261
|
+
function maintainDerivedDotfiles(rootDir, stagedDir, agentKitVersion) {
|
|
262
|
+
maintainDerivedPkg(rootDir, agentKitVersion);
|
|
263
|
+
syncDerivedGitattributes(rootDir, stagedDir);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Maintain the derived project's package.json: ensure the `factory:*` scripts
|
|
267
|
+
* (warn on a divergent `factory:update`) and set `agentKitVersion` to the
|
|
268
|
+
* just-installed brain version, mirroring the lockfile's `version`. Run on every
|
|
269
|
+
* update, so the field tracks each `factory:update` (and a derivative whose field
|
|
270
|
+
* was missing or stale gets reconciled — same shape as the Factory's own
|
|
199
271
|
* package.json). `factoryVersion` (the frozen birth stamp) and `version` (the app
|
|
200
|
-
* semver) are untouched.
|
|
272
|
+
* semver) are untouched. No-op (not an error) when the repo has no package.json
|
|
273
|
+
* (a non-Node derivative).
|
|
201
274
|
*/
|
|
202
|
-
function maintainDerivedPkg(
|
|
275
|
+
function maintainDerivedPkg(rootDir, agentKitVersion) {
|
|
276
|
+
const pkgPath = path.join(rootDir, 'package.json');
|
|
277
|
+
if (!existsSync(pkgPath))
|
|
278
|
+
return;
|
|
203
279
|
// Tolerant (mirrors `add`): a malformed package.json must NOT throw AFTER the
|
|
204
280
|
// brain was already applied + the lockfile written — the install succeeded; the
|
|
205
281
|
// script/version mirror is best-effort. Warn and move on.
|
|
@@ -212,6 +288,35 @@ function maintainDerivedPkg(pkgPath, agentKitVersion) {
|
|
|
212
288
|
'Corrige package.json y vuelve a correr `factory update` para sincronizar agentKitVersion.');
|
|
213
289
|
}
|
|
214
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Sync the Factory's managed `.gitattributes` block (the EOL/binary rules that
|
|
293
|
+
* keep the kit's committed binaries from being corrupted by CRLF→LF normalization)
|
|
294
|
+
* into the derived repo, preserving the dev's own rules. The canonical block is
|
|
295
|
+
* READ from the staged tarball (single SSOT), so the rules are never duplicated in
|
|
296
|
+
* the CLI. Runs even without package.json (a non-Node derivative needs the rules
|
|
297
|
+
* too). Best-effort like the package.json mirror: a tarball that predates the
|
|
298
|
+
* managed block (no markers → no block) is a silent no-op, and a write failure
|
|
299
|
+
* never fails an already-applied update.
|
|
300
|
+
*/
|
|
301
|
+
function syncDerivedGitattributes(rootDir, stagedDir) {
|
|
302
|
+
try {
|
|
303
|
+
const srcPath = path.join(stagedDir, GITATTRIBUTES_FILE);
|
|
304
|
+
if (!existsSync(srcPath))
|
|
305
|
+
return;
|
|
306
|
+
const block = extractManagedBlock(readFileSync(srcPath, 'utf8'));
|
|
307
|
+
if (!block)
|
|
308
|
+
return;
|
|
309
|
+
const { action } = syncManagedBlock(rootDir, block);
|
|
310
|
+
if (action === 'unchanged')
|
|
311
|
+
return;
|
|
312
|
+
console.log(action === 'created'
|
|
313
|
+
? '✔ `.gitattributes` creado con las reglas de normalización del kit.'
|
|
314
|
+
: '✔ `.gitattributes`: bloque de reglas del kit sincronizado.');
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
console.warn('Aviso: no se pudo sincronizar `.gitattributes`; el cerebro se instaló igual.');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
215
320
|
/** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
|
|
216
321
|
function reportSummary(diff, manifest) {
|
|
217
322
|
if (diff.deleteSilent.length > 0) {
|
|
@@ -257,7 +362,7 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
257
362
|
if (!hasUpdateState(rootDir)) {
|
|
258
363
|
throw new CLIError('No hay un `update` pendiente para retomar.');
|
|
259
364
|
}
|
|
260
|
-
runResume(rootDir);
|
|
365
|
+
await runResume(rootDir, flags);
|
|
261
366
|
return;
|
|
262
367
|
}
|
|
263
368
|
// Preflight (gh) before any download — reused, not reimplemented.
|
|
@@ -335,9 +440,8 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
335
440
|
// (agentKitVersion advances; factoryVersion stays frozen).
|
|
336
441
|
writeLockfile(rootDir, stampBirth(oldLock, manifest));
|
|
337
442
|
clearUpdateState(rootDir);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
maintainDerivedPkg(pkgPath, manifest.version);
|
|
443
|
+
maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
|
|
444
|
+
await maybeCommitBrain(rootDir, manifest.version, [...plan.writes, ...plan.deletes], flags);
|
|
341
445
|
reportSummary(diff, manifest);
|
|
342
446
|
}
|
|
343
447
|
finally {
|
|
@@ -394,9 +498,7 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
|
|
|
394
498
|
// the just-installed files) PLUS the birth stamp. A legacy auto-register is the
|
|
395
499
|
// first sync this repo records, so the birth seal = the manifest's version.
|
|
396
500
|
writeLockfile(rootDir, { ...manifest, factoryVersion: manifest.version });
|
|
397
|
-
|
|
398
|
-
if (existsSync(pkgPath))
|
|
399
|
-
maintainDerivedPkg(pkgPath, manifest.version);
|
|
501
|
+
maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
|
|
400
502
|
if (claudeMdExists) {
|
|
401
503
|
console.log('\nConservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
|
|
402
504
|
'(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
|
package/dist/lib/constants.js
CHANGED
|
@@ -30,6 +30,23 @@ export const LOCKFILE_FILE = 'lockfile.json';
|
|
|
30
30
|
* exists on disk (path-match path). See `diffLockfiles` + the install commands.
|
|
31
31
|
*/
|
|
32
32
|
export const CLAUDE_MD_FILE = 'CLAUDE.md';
|
|
33
|
+
/**
|
|
34
|
+
* Repo-root `.gitattributes`. NOT a tracked manifest file (absent from `track`),
|
|
35
|
+
* so it never flows through the diff/lockfile engine — a derived repo's own rules
|
|
36
|
+
* would otherwise read as "locally edited" and conflict on every update. Instead a
|
|
37
|
+
* delimited managed block (markers below) is synced surgically post-apply, like
|
|
38
|
+
* `package.json` scripts: the block is replaced verbatim, everything outside it is
|
|
39
|
+
* preserved byte-for-byte. See `syncManagedBlock` in `lib/gitattributes.ts`.
|
|
40
|
+
*/
|
|
41
|
+
export const GITATTRIBUTES_FILE = '.gitattributes';
|
|
42
|
+
/**
|
|
43
|
+
* Managed-block sentinels. These are PREFIXES (the live source lines carry extra
|
|
44
|
+
* descriptive text + trailing `>>>`/`<<<`), matched with `startsWith` so detection
|
|
45
|
+
* survives edits to the comment copy. Do not change without a migration: a derived
|
|
46
|
+
* repo's existing block is located by these prefixes.
|
|
47
|
+
*/
|
|
48
|
+
export const MANAGED_BLOCK_START = '# >>> timekast-factory managed';
|
|
49
|
+
export const MANAGED_BLOCK_END = '# <<< timekast-factory managed';
|
|
33
50
|
/** The scripts the CLI injects into a derived project's package.json. */
|
|
34
51
|
export const UPDATE_SCRIPT_NAME = 'factory:update';
|
|
35
52
|
// `npx` so the script resolves in a fresh derived repo where @timekast/factory
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surgical sync of the Factory's managed `.gitattributes` block into a derived
|
|
3
|
+
* project, mirroring the `package.json` script-insertion pattern (`package-json.ts`):
|
|
4
|
+
* the delimited block is replaced verbatim, everything outside it is preserved.
|
|
5
|
+
*
|
|
6
|
+
* Why not the lockfile/`track` engine: `.gitattributes` is dual-owned at the
|
|
7
|
+
* intra-file level (the Factory block + the dev's own rules share one file). The
|
|
8
|
+
* lockfile hashes whole files, so tracking it would read the dev's rules as a
|
|
9
|
+
* local edit and conflict on every update. Instead the block is synced as an
|
|
10
|
+
* idempotent post-apply step, outside `diffLockfiles`/`applyPlan`.
|
|
11
|
+
*
|
|
12
|
+
* The canonical block is the single SSOT: it is READ from the `.gitattributes`
|
|
13
|
+
* that ships in the staged tarball (see `extractManagedBlock`), never duplicated
|
|
14
|
+
* as a CLI constant. A derived repo's existing block is located by the
|
|
15
|
+
* `MANAGED_BLOCK_START` / `_END` sentinels (prefix match), so the descriptive
|
|
16
|
+
* comment copy can evolve without breaking detection.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { GITATTRIBUTES_FILE, MANAGED_BLOCK_END, MANAGED_BLOCK_START } from './constants.js';
|
|
21
|
+
const isStart = (line) => line.trimStart().startsWith(MANAGED_BLOCK_START);
|
|
22
|
+
const isEnd = (line) => line.trimStart().startsWith(MANAGED_BLOCK_END);
|
|
23
|
+
/** Drop trailing all-whitespace lines (so appends don't pile up blank lines). */
|
|
24
|
+
function stripTrailingEmpty(lines) {
|
|
25
|
+
const out = [...lines];
|
|
26
|
+
while (out.length > 0 && out[out.length - 1].trim() === '')
|
|
27
|
+
out.pop();
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Extract the managed block (markers inclusive) from a `.gitattributes` content,
|
|
32
|
+
* or `null` if no start marker is present. Used on the well-formed staged source
|
|
33
|
+
* to obtain the canonical block to sync. If a start marker is found with no end
|
|
34
|
+
* marker, returns from start to EOF (defensive — the source is well-formed).
|
|
35
|
+
*/
|
|
36
|
+
export function extractManagedBlock(content) {
|
|
37
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
38
|
+
const start = lines.findIndex(isStart);
|
|
39
|
+
if (start === -1)
|
|
40
|
+
return null;
|
|
41
|
+
let end = -1;
|
|
42
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
43
|
+
if (isEnd(lines[i])) {
|
|
44
|
+
end = i;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const slice = end === -1 ? lines.slice(start) : lines.slice(start, end + 1);
|
|
49
|
+
return slice.join('\n');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Sync `sourceBlock` (markers inclusive) into `<rootDir>/.gitattributes`,
|
|
53
|
+
* preserving every rule outside the managed block. The whole file is normalized
|
|
54
|
+
* to LF (it lives at repo root, outside `.claude/** text eol=lf`, so a source
|
|
55
|
+
* packed with CRLF on an autocrlf machine is normalized here). Idempotent:
|
|
56
|
+
* writes only when the bytes actually change.
|
|
57
|
+
*
|
|
58
|
+
* - no file / empty file → `created` (block only)
|
|
59
|
+
* - file without a start marker → `inserted` (block appended after a blank line)
|
|
60
|
+
* - start + end markers → `updated` (content between markers replaced)
|
|
61
|
+
* - start without end (dev clobbered half a line) → `updated`, regenerated
|
|
62
|
+
* cleanly from start to EOF (NOT appended — avoids a dangling start marker)
|
|
63
|
+
*/
|
|
64
|
+
export function syncManagedBlock(rootDir, sourceBlock) {
|
|
65
|
+
const filePath = path.join(rootDir, GITATTRIBUTES_FILE);
|
|
66
|
+
const raw = existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
|
|
67
|
+
const block = sourceBlock.replace(/\r\n/g, '\n').replace(/\n+$/, '');
|
|
68
|
+
let result;
|
|
69
|
+
let action;
|
|
70
|
+
if (raw.trim() === '') {
|
|
71
|
+
result = `${block}\n`;
|
|
72
|
+
action = 'created';
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const lines = raw.replace(/\r\n/g, '\n').split('\n');
|
|
76
|
+
const start = lines.findIndex(isStart);
|
|
77
|
+
if (start === -1) {
|
|
78
|
+
const before = stripTrailingEmpty(lines);
|
|
79
|
+
result = `${before.join('\n')}\n\n${block}\n`;
|
|
80
|
+
action = 'inserted';
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Replace from start to the end marker, or to EOF when the end marker is
|
|
84
|
+
// missing (malformed block → regenerate clean rather than append).
|
|
85
|
+
let end = lines.length - 1;
|
|
86
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
87
|
+
if (isEnd(lines[i])) {
|
|
88
|
+
end = i;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const merged = [...lines.slice(0, start), ...block.split('\n'), ...lines.slice(end + 1)];
|
|
93
|
+
result = `${stripTrailingEmpty(merged).join('\n')}\n`;
|
|
94
|
+
action = 'updated';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (result === raw)
|
|
98
|
+
return { action: 'unchanged' };
|
|
99
|
+
writeFileSync(filePath, result, 'utf8');
|
|
100
|
+
return { action };
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|