@timekast/factory 0.1.9 → 1.0.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 +103 -38
- package/dist/commands/doctor.js +117 -5
- package/dist/commands/new.js +5 -2
- package/dist/commands/update.js +100 -30
- package/dist/index.js +17 -13
- package/dist/lib/atomic-swap.js +22 -1
- package/dist/lib/constants.js +10 -0
- package/dist/lib/lockfile.js +17 -4
- package/dist/lib/package-json.js +28 -2
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -1,53 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `factory add` — install the
|
|
2
|
+
* `factory add` — install the brain (`.claude/` + tracked scripts) into an
|
|
3
|
+
* existing repo (design §6.2).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Profile: full-brain is the DEFAULT. `add` auto-detects from the target's
|
|
6
|
+
* `package.json` — a `factoryVersion` field means the repo was born from the
|
|
7
|
+
* Factory boilerplate (sk-* + SK.md apply) → `full`; its absence (non-Node repo,
|
|
8
|
+
* or a Node repo not derived from the kit) → `core`. `--full`/`--core` override.
|
|
9
|
+
*
|
|
10
|
+
* Flow: require a git repo → GATE on a pre-existing brain (a lockfile → reject:
|
|
11
|
+
* use `update`; a `.claude/` without lockfile → redirect to `update`, where the
|
|
12
|
+
* legacy auto-register lives with its safeguards) → preflight (gh) → resolve
|
|
13
|
+
* profile → download + stage + validate the manifest → install ONLY the manifest
|
|
14
|
+
* files via `applyPlan` (so a `full` tarball never lays down `src/`; `track`
|
|
15
|
+
* excludes it) → write `.timekast/lockfile.json` → ensure `factory:update` (H5).
|
|
8
16
|
*
|
|
9
17
|
* Invariants:
|
|
10
|
-
* - `add`
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `pnpm factory:update`, matching the `full` profile. A non-Node repo
|
|
19
|
-
* (Python/Flutter/Go) gets NO `package.json` created — it updates via
|
|
20
|
-
* `npx @timekast/factory update` (or a global install of the CLI).
|
|
18
|
+
* - `add` never touches `src/`, `pj-*`, or any file outside the manifest. It
|
|
19
|
+
* installs the manifest's files (`.claude/` + tracked scripts + CLAUDE.md),
|
|
20
|
+
* never the raw tarball payload — so reusing the `full` tarball cannot reach
|
|
21
|
+
* `src/`. cwd files not in the manifest stay byte-identical.
|
|
22
|
+
* - CLAUDE.md is dev-owned (write-if-absent): if it already exists on disk it
|
|
23
|
+
* is preserved (filtered out of writes) + a warning; only written when absent.
|
|
24
|
+
* - `add` is for a repo WITHOUT the brain. A repo that already has `.claude/`
|
|
25
|
+
* or a lockfile is routed to `update` (never a raw re-install).
|
|
21
26
|
* - `add` overwrites manifest files without prompting — the conflict prompt is
|
|
22
|
-
* exclusive to `update` (DIST-005).
|
|
23
|
-
* - Atomicity (§7.5): extract + validate in a temp dir before touching the cwd
|
|
24
|
-
* a failure mid-flight never leaves a partial `.claude/`.
|
|
27
|
+
* exclusive to `update` (DIST-005).
|
|
28
|
+
* - Atomicity (§7.5): extract + validate in a temp dir before touching the cwd.
|
|
25
29
|
*
|
|
26
30
|
* Expected failures throw `CLIError`; the top-level handler prints + exits.
|
|
27
31
|
*/
|
|
28
32
|
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
29
33
|
import { tmpdir } from 'node:os';
|
|
30
34
|
import path from 'node:path';
|
|
35
|
+
import { applyPlan, defaultBackupDir, validateStagedManifest } from '../lib/atomic-swap.js';
|
|
31
36
|
import { CLIError } from '../lib/cli-error.js';
|
|
32
|
-
import { PROFILES } from '../lib/constants.js';
|
|
33
|
-
import { writeInitialLockfile } from '../lib/lockfile.js';
|
|
34
|
-
import { insertFactoryUpdateScript } from '../lib/package-json.js';
|
|
37
|
+
import { CLAUDE_MD_FILE, PROFILES } from '../lib/constants.js';
|
|
38
|
+
import { hasLockfile, writeInitialLockfile } from '../lib/lockfile.js';
|
|
39
|
+
import { detectProfile, insertFactoryUpdateScript, } from '../lib/package-json.js';
|
|
35
40
|
import { runPreflight } from '../lib/preflight.js';
|
|
36
41
|
import { detectRepo } from '../lib/repo-detection.js';
|
|
37
|
-
import { downloadProfileTarball,
|
|
42
|
+
import { downloadProfileTarball, stageProfileTarball } from '../lib/unpack.js';
|
|
43
|
+
import { runUpdate } from './update.js';
|
|
38
44
|
/** Message shown when `add` runs outside any git repo context. */
|
|
39
45
|
const NO_REPO_MESSAGE = 'No hay repositorio git aquí. Ejecuta `git init` primero, o usa `factory new` para crear un proyecto desde cero.';
|
|
40
|
-
|
|
46
|
+
/** Parse `add`'s argv slice into flags. Unknown flags are ignored here. */
|
|
47
|
+
export function parseAddFlags(argv) {
|
|
48
|
+
return { core: argv.includes('--core'), full: argv.includes('--full') };
|
|
49
|
+
}
|
|
50
|
+
export async function runAdd(flags = { core: false, full: false }) {
|
|
41
51
|
const cwd = process.cwd();
|
|
52
|
+
if (flags.core && flags.full) {
|
|
53
|
+
throw new CLIError('No puedes combinar `--core` con `--full`.');
|
|
54
|
+
}
|
|
42
55
|
// 1. Require a git repo context (cwd or any ancestor). Refuse before any
|
|
43
|
-
// network call or write — nothing is touched when there is no repo.
|
|
44
|
-
|
|
56
|
+
// network call or write — nothing is touched when there is no repo. The
|
|
57
|
+
// brain lives at the REPO ROOT, so all gating + install target `root`, not
|
|
58
|
+
// `cwd` — running `add` from a subdir must not plant a nested second brain.
|
|
59
|
+
const { hasRepo, repoRoot } = detectRepo(cwd);
|
|
45
60
|
if (!hasRepo) {
|
|
46
61
|
throw new CLIError(NO_REPO_MESSAGE);
|
|
47
62
|
}
|
|
48
|
-
|
|
63
|
+
const root = repoRoot ?? cwd;
|
|
64
|
+
// 2. Gate on a pre-existing brain (checked at the repo root, not cwd). `add` is
|
|
65
|
+
// for a repo WITHOUT one; anything else routes to `update` so the raw
|
|
66
|
+
// `applyPlan` install never clobbers an old `.claude/` (leaving orphans, no
|
|
67
|
+
// confirm) — `update` has the legacy auto-register with its safeguards.
|
|
68
|
+
if (hasLockfile(root)) {
|
|
69
|
+
throw new CLIError('Este repo ya está gestionado por el Factory (tiene `.timekast/lockfile.json`). ' +
|
|
70
|
+
'Para actualizar el cerebro usa `factory update`.');
|
|
71
|
+
}
|
|
72
|
+
if (existsSync(path.join(root, '.claude'))) {
|
|
73
|
+
// `update` opera sobre process.cwd(); si la raíz es un ancestro, no podemos
|
|
74
|
+
// redirigir limpio desde un subdir → pedir correr desde la raíz.
|
|
75
|
+
if (path.resolve(root) !== path.resolve(cwd)) {
|
|
76
|
+
throw new CLIError(`Este repo ya tiene un cerebro \`.claude/\` en su raíz (${root}). ` +
|
|
77
|
+
'Corre `factory update` desde la raíz del repo.');
|
|
78
|
+
}
|
|
79
|
+
console.log('Este repo ya tiene `.claude/` (sin lockfile). Te redirijo a `factory update`, ' +
|
|
80
|
+
'que lo registra de forma segura (preserva tus archivos propios y tu CLAUDE.md).\n');
|
|
81
|
+
await runUpdate({
|
|
82
|
+
core: flags.core,
|
|
83
|
+
full: flags.full,
|
|
84
|
+
theirsAll: false,
|
|
85
|
+
mineAll: false,
|
|
86
|
+
resume: false,
|
|
87
|
+
verify: false,
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// 3. Preflight: gh installed + authed + org member. Runs before any download.
|
|
49
92
|
await runPreflight();
|
|
50
|
-
//
|
|
93
|
+
// 4. Resolve profile: flag override, else auto-detect from package.json (at root).
|
|
94
|
+
const profile = flags.full
|
|
95
|
+
? PROFILES.full
|
|
96
|
+
: flags.core
|
|
97
|
+
? PROFILES.core
|
|
98
|
+
: detectProfile(root);
|
|
99
|
+
// 5. Download + unpack into a temp dir, validate, then install into place (§7.5).
|
|
51
100
|
const tmpDir = mkdtempSync(path.join(tmpdir(), 'tk-add-'));
|
|
52
101
|
const cleanup = () => {
|
|
53
102
|
try {
|
|
@@ -63,17 +112,33 @@ export async function runAdd() {
|
|
|
63
112
|
};
|
|
64
113
|
process.once('SIGINT', onSignal);
|
|
65
114
|
process.once('SIGTERM', onSignal);
|
|
115
|
+
let version;
|
|
66
116
|
try {
|
|
67
|
-
|
|
68
|
-
const tarball = await downloadProfileTarball(PROFILES.core, tmpDir);
|
|
69
|
-
// Extract + validate the embedded manifest before touching the destination.
|
|
117
|
+
const tarball = await downloadProfileTarball(profile, tmpDir);
|
|
70
118
|
const { stagedDir, manifestRaw } = await stageProfileTarball(tarball, tmpDir);
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
119
|
+
const manifest = validateStagedManifest(stagedDir, manifestRaw);
|
|
120
|
+
// Guard: the downloaded tarball must actually be the profile we asked for. A
|
|
121
|
+
// mispublished asset (e.g. a `full` tag carrying a `core` manifest) would
|
|
122
|
+
// otherwise write a lockfile for the wrong profile. Refuse loudly.
|
|
123
|
+
if (manifest.profile !== profile) {
|
|
124
|
+
throw new CLIError(`El tarball descargado declara perfil \`${manifest.profile}\` pero se pidió \`${profile}\`. ` +
|
|
125
|
+
'El release está mal publicado; no se modificó nada.');
|
|
126
|
+
}
|
|
127
|
+
version = manifest.version;
|
|
128
|
+
// Install ONLY the manifest's files (never the raw tarball payload — so a
|
|
129
|
+
// `full` tarball cannot lay down `src/`). CLAUDE.md is dev-owned: filtered out
|
|
130
|
+
// if it already exists on disk (write-if-absent + warning).
|
|
131
|
+
const claudeMdExists = existsSync(path.join(root, CLAUDE_MD_FILE));
|
|
132
|
+
const writes = manifest.files
|
|
133
|
+
.map((f) => f.path)
|
|
134
|
+
.filter((p) => !(p === CLAUDE_MD_FILE && claudeMdExists));
|
|
135
|
+
applyPlan(root, stagedDir, { writes, deletes: [] }, defaultBackupDir(tmpDir));
|
|
75
136
|
// Write the lockfile = the tarball's embedded manifest (no hash recompute).
|
|
76
|
-
writeInitialLockfile(
|
|
137
|
+
writeInitialLockfile(root, manifestRaw);
|
|
138
|
+
if (claudeMdExists) {
|
|
139
|
+
console.log('Conservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
|
|
140
|
+
'(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
|
|
141
|
+
}
|
|
77
142
|
}
|
|
78
143
|
finally {
|
|
79
144
|
process.removeListener('SIGINT', onSignal);
|
|
@@ -84,7 +149,7 @@ export async function runAdd() {
|
|
|
84
149
|
// surgically inserting the script into the EXISTING package.json. Non-fatal — a
|
|
85
150
|
// missing/invalid package.json just means the dev uses `npx` (the brain is already
|
|
86
151
|
// installed). We NEVER create a package.json in a non-Node repo.
|
|
87
|
-
const pkgPath = path.join(
|
|
152
|
+
const pkgPath = path.join(root, 'package.json');
|
|
88
153
|
let scriptAction = 'none';
|
|
89
154
|
if (existsSync(pkgPath)) {
|
|
90
155
|
try {
|
|
@@ -94,7 +159,7 @@ export async function runAdd() {
|
|
|
94
159
|
scriptAction = 'none'; // invalid package.json — skip the alias, brain still installed
|
|
95
160
|
}
|
|
96
161
|
}
|
|
97
|
-
console.log(
|
|
162
|
+
console.log(`\n✔ Cerebro \`${profile}\` v${version} instalado. Lockfile escrito en \`.timekast/lockfile.json\`.`);
|
|
98
163
|
if (scriptAction === 'added' || scriptAction === 'already-correct') {
|
|
99
164
|
console.log('Para actualizar: `pnpm factory:update` (alias de `npx @timekast/factory update`).');
|
|
100
165
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -18,7 +18,17 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
|
+
import { CLAUDE_MD_FILE } from '../lib/constants.js';
|
|
21
22
|
import { hasLockfile, normalizeThenHash, readLockfile } from '../lib/lockfile.js';
|
|
23
|
+
import { detectRepo } from '../lib/repo-detection.js';
|
|
24
|
+
/** Matches a flat always-on rule file shipped by the kit (`.claude/rules/<name>.md`). */
|
|
25
|
+
const RULE_PATH_RE = /^\.claude\/rules\/[^/]+\.md$/;
|
|
26
|
+
/**
|
|
27
|
+
* Matches ONLY the reserved on-demand rules heading (`Rules (on-demand reference)`
|
|
28
|
+
* and close variants), not any heading that merely contains "on-demand" (e.g.
|
|
29
|
+
* `## On-demand migration notes`).
|
|
30
|
+
*/
|
|
31
|
+
const ON_DEMAND_HEADING_RE = /rules?\s*\(\s*on-demand/i;
|
|
22
32
|
/**
|
|
23
33
|
* The A1 security notice (design §1 + §11, decision A1). Literal text emitted at
|
|
24
34
|
* the end of every `doctor` run so the dev always sees the `src/` responsibility
|
|
@@ -55,6 +65,55 @@ function collectClaudePaths(rootDir) {
|
|
|
55
65
|
walk('.claude');
|
|
56
66
|
return out;
|
|
57
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Concatenate the body of the reserved "on-demand reference" section of CLAUDE.md
|
|
70
|
+
* — the lines under a heading matching `ON_DEMAND_HEADING_RE`, until the next
|
|
71
|
+
* heading of the SAME or HIGHER level (a deeper nested heading stays inside the
|
|
72
|
+
* section). Rules listed there are loaded by skills on demand (NOT via `@import`),
|
|
73
|
+
* so they must not be flagged as unimported. Empty string when no such section.
|
|
74
|
+
*
|
|
75
|
+
* Level-aware (a nested `###` under a `##` on-demand heading does not end it) and
|
|
76
|
+
* specific (only the reserved heading starts it, not any "on-demand" mention).
|
|
77
|
+
*/
|
|
78
|
+
function onDemandSectionText(claudeMd) {
|
|
79
|
+
const out = [];
|
|
80
|
+
let sectionLevel = 0; // 0 = not currently inside an on-demand section
|
|
81
|
+
for (const line of claudeMd.split('\n')) {
|
|
82
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
83
|
+
if (heading) {
|
|
84
|
+
const level = heading[1].length;
|
|
85
|
+
const isOnDemand = ON_DEMAND_HEADING_RE.test(heading[2]);
|
|
86
|
+
if (sectionLevel === 0) {
|
|
87
|
+
if (isOnDemand)
|
|
88
|
+
sectionLevel = level; // enter the section
|
|
89
|
+
}
|
|
90
|
+
else if (level <= sectionLevel) {
|
|
91
|
+
// sibling/higher heading ends the section (unless it is on-demand too)
|
|
92
|
+
sectionLevel = isOnDemand ? level : 0;
|
|
93
|
+
}
|
|
94
|
+
// a deeper heading (level > sectionLevel) stays inside → no change
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (sectionLevel > 0)
|
|
98
|
+
out.push(line);
|
|
99
|
+
}
|
|
100
|
+
return out.join('\n');
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Collect the rule paths a CLAUDE.md actually `@import`s — a line whose trimmed
|
|
104
|
+
* text is `@<path>` (the real import directive). A prose mention or a fenced
|
|
105
|
+
* code block ("no agregues `@.claude/rules/SK.md`") is NOT a bare directive line,
|
|
106
|
+
* so it does not count as imported.
|
|
107
|
+
*/
|
|
108
|
+
function importedRulePaths(claudeMd) {
|
|
109
|
+
const paths = new Set();
|
|
110
|
+
for (const raw of claudeMd.split('\n')) {
|
|
111
|
+
const line = raw.trim();
|
|
112
|
+
if (line.startsWith('@'))
|
|
113
|
+
paths.add(line.slice(1).trim());
|
|
114
|
+
}
|
|
115
|
+
return paths;
|
|
116
|
+
}
|
|
58
117
|
/**
|
|
59
118
|
* Compute the doctor diagnosis. No lockfile → `noLockfile: true` (the caller
|
|
60
119
|
* renders the "run factory:update" advice). Otherwise diffs the on-disk
|
|
@@ -63,9 +122,19 @@ function collectClaudePaths(rootDir) {
|
|
|
63
122
|
* - conflict = tracked path whose normalized disk hash != recorded hash.
|
|
64
123
|
*/
|
|
65
124
|
export function computeDoctor(deps = {}) {
|
|
66
|
-
|
|
125
|
+
// Diagnose at the REPO ROOT (where the brain + lockfile live), not cwd — so
|
|
126
|
+
// `doctor` from a subdir of a managed repo reports the real state instead of
|
|
127
|
+
// "no lockfile" (consistency with `add`/`update`). Tests inject `rootDir`.
|
|
128
|
+
const rootDir = deps.rootDir ?? detectRepo(process.cwd()).repoRoot ?? process.cwd();
|
|
67
129
|
if (!hasLockfile(rootDir)) {
|
|
68
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
orphans: [],
|
|
132
|
+
conflicts: [],
|
|
133
|
+
unimportedRules: [],
|
|
134
|
+
claudeMdMissing: false,
|
|
135
|
+
a1Warning: A1_WARNING,
|
|
136
|
+
noLockfile: true,
|
|
137
|
+
};
|
|
69
138
|
}
|
|
70
139
|
const lock = readLockfile(rootDir);
|
|
71
140
|
const manifestPaths = new Set(lock.files.map((f) => f.path));
|
|
@@ -85,7 +154,37 @@ export function computeDoctor(deps = {}) {
|
|
|
85
154
|
}
|
|
86
155
|
}
|
|
87
156
|
conflicts.sort();
|
|
88
|
-
|
|
157
|
+
// Unimported kit rules: a kit-shipped always-on rule (`.claude/rules/*.md`)
|
|
158
|
+
// that is NOT `@import`ed by CLAUDE.md → it does NOT load at runtime. The check
|
|
159
|
+
// is strict on the `@import` directive (a prose mention does NOT count — a
|
|
160
|
+
// mentioned-but-not-imported always-on rule is dead). Exception: a rule listed
|
|
161
|
+
// under an "on-demand" heading is loaded by skills on demand, not via @import,
|
|
162
|
+
// so it is excluded. No CLAUDE.md on disk in a managed repo → can't check, and
|
|
163
|
+
// every rule is dead → flagged via `claudeMdMissing` instead.
|
|
164
|
+
const unimportedRules = [];
|
|
165
|
+
const claudeMdPath = path.join(rootDir, CLAUDE_MD_FILE);
|
|
166
|
+
const claudeMdMissing = !existsSync(claudeMdPath);
|
|
167
|
+
if (!claudeMdMissing) {
|
|
168
|
+
const claudeMd = readFileSync(claudeMdPath, 'utf8');
|
|
169
|
+
const imported = importedRulePaths(claudeMd); // bare `@<path>` directive lines
|
|
170
|
+
const onDemand = onDemandSectionText(claudeMd);
|
|
171
|
+
for (const entry of lock.files) {
|
|
172
|
+
if (!RULE_PATH_RE.test(entry.path))
|
|
173
|
+
continue;
|
|
174
|
+
const isOnDemand = onDemand.includes(entry.path);
|
|
175
|
+
if (!imported.has(entry.path) && !isOnDemand)
|
|
176
|
+
unimportedRules.push(entry.path);
|
|
177
|
+
}
|
|
178
|
+
unimportedRules.sort();
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
orphans,
|
|
182
|
+
conflicts,
|
|
183
|
+
unimportedRules,
|
|
184
|
+
claudeMdMissing,
|
|
185
|
+
a1Warning: A1_WARNING,
|
|
186
|
+
noLockfile: false,
|
|
187
|
+
};
|
|
89
188
|
}
|
|
90
189
|
/** Render the doctor result to the console. The A1 notice is ALWAYS last. */
|
|
91
190
|
function renderDoctor(result) {
|
|
@@ -96,10 +195,17 @@ function renderDoctor(result) {
|
|
|
96
195
|
console.log(`\n${result.a1Warning}`);
|
|
97
196
|
return;
|
|
98
197
|
}
|
|
99
|
-
if (result.orphans.length === 0 &&
|
|
100
|
-
|
|
198
|
+
if (result.orphans.length === 0 &&
|
|
199
|
+
result.conflicts.length === 0 &&
|
|
200
|
+
result.unimportedRules.length === 0 &&
|
|
201
|
+
!result.claudeMdMissing) {
|
|
202
|
+
console.log('✔ Sin huérfanos, conflictos ni rules sin importar.');
|
|
101
203
|
}
|
|
102
204
|
else {
|
|
205
|
+
if (result.claudeMdMissing) {
|
|
206
|
+
console.log('🔴 Falta `CLAUDE.md` en la raíz: las rules del kit NO se cargan (el auto-load es por ' +
|
|
207
|
+
'`@import` desde ese archivo). Recupéralo (`git checkout -- CLAUDE.md`) o corre `factory update`.');
|
|
208
|
+
}
|
|
103
209
|
if (result.conflicts.length > 0) {
|
|
104
210
|
console.log('Modificados localmente respecto al lockfile (conflicto si el Factory los actualiza):');
|
|
105
211
|
for (const p of result.conflicts) {
|
|
@@ -107,6 +213,12 @@ function renderDoctor(result) {
|
|
|
107
213
|
}
|
|
108
214
|
console.log(' Sugerencia: corre `factory:update --verify` para re-chequear, o `factory:update` para reconciliar.');
|
|
109
215
|
}
|
|
216
|
+
if (result.unimportedRules.length > 0) {
|
|
217
|
+
console.log('\nRules del kit que tu `CLAUDE.md` no importa (no se cargan en runtime):');
|
|
218
|
+
for (const p of result.unimportedRules) {
|
|
219
|
+
console.log(` • ${p} — agrega \`@${p}\` a tu CLAUDE.md o no se aplicará`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
110
222
|
if (result.orphans.length > 0) {
|
|
111
223
|
console.log('\nArchivos huérfanos (en `.claude/` fuera del manifest instalado):');
|
|
112
224
|
for (const p of result.orphans) {
|
package/dist/commands/new.js
CHANGED
|
@@ -17,7 +17,7 @@ import { execa } from 'execa';
|
|
|
17
17
|
import prompts from 'prompts';
|
|
18
18
|
import { CLIError } from '../lib/cli-error.js';
|
|
19
19
|
import { FACTORY_ORG, PROFILES } from '../lib/constants.js';
|
|
20
|
-
import { writeInitialLockfile } from '../lib/lockfile.js';
|
|
20
|
+
import { parseLockfile, writeInitialLockfile } from '../lib/lockfile.js';
|
|
21
21
|
import { applyPackageJsonEdits } from '../lib/package-json.js';
|
|
22
22
|
import { runPreflight } from '../lib/preflight.js';
|
|
23
23
|
import { downloadProfileTarball, moveContentsInto, stageProfileTarball } from '../lib/unpack.js';
|
|
@@ -87,10 +87,12 @@ export async function runNew(name) {
|
|
|
87
87
|
};
|
|
88
88
|
process.once('SIGINT', onSignal);
|
|
89
89
|
process.once('SIGTERM', onSignal);
|
|
90
|
+
let version = '';
|
|
90
91
|
try {
|
|
91
92
|
const tarball = await downloadProfileTarball(profile, tmpDir);
|
|
92
93
|
// Extract + validate the embedded manifest before touching the destination.
|
|
93
94
|
const { stagedDir, manifestRaw } = await stageProfileTarball(tarball, tmpDir);
|
|
95
|
+
version = parseLockfile(manifestRaw, 'manifest').version;
|
|
94
96
|
// Move contents into ./<name>/ (without the Factory's git lineage).
|
|
95
97
|
mkdirSync(destDir, { recursive: true });
|
|
96
98
|
moveContentsInto(stagedDir, destDir);
|
|
@@ -126,6 +128,7 @@ export async function runNew(name) {
|
|
|
126
128
|
process.removeListener('SIGTERM', onSignal);
|
|
127
129
|
cleanup();
|
|
128
130
|
}
|
|
129
|
-
console.log(`\n✔ Proyecto \`${validName}\` listo
|
|
131
|
+
console.log(`\n✔ Proyecto \`${validName}\` listo (perfil \`${profile}\`, cerebro v${version}). ` +
|
|
132
|
+
`Repo: ${FACTORY_ORG}/${validName}`);
|
|
130
133
|
console.log(` cd ${validName}`);
|
|
131
134
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -4,24 +4,27 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Flow:
|
|
6
6
|
* 1. Preflight (gh) — reused from DIST-003, never reimplemented.
|
|
7
|
-
* 2.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* 2. Resolve the profile. With a lockfile it is STICKY (`core` | `full`),
|
|
8
|
+
* EXCEPT `--full` cross-grades a `core` repo → `full` (additive: adds
|
|
9
|
+
* sk-* + SK.md, swaps CLAUDE.md to the full one); `--core` on a `full` repo
|
|
10
|
+
* is a rejected downgrade. With no lockfile (legacy) the profile is
|
|
11
|
+
* auto-detected from package.json (`factoryVersion` → full, else core),
|
|
12
|
+
* overridable with `--full`/`--core`. Download the tarball of that profile.
|
|
11
13
|
* 3. Stage + validate the embedded manifest (the swap preflight rejects a
|
|
12
14
|
* corrupt/empty manifest before touching the FS).
|
|
13
15
|
* 4. Diff the new manifest against the lockfile → the 4 buckets + conflicts.
|
|
14
16
|
* 5. Resolve conflicts: `--theirs-all` / `--mine-all`, else prompt PER FILE
|
|
15
17
|
* with NO default option (design §7.3) — the file is not modified until the
|
|
16
|
-
* dev chooses.
|
|
18
|
+
* dev chooses. CLAUDE.md never conflicts (dev-owned → ignoreLocal).
|
|
17
19
|
* 6. Apply atomically (backup + rollback guard, design §7.5).
|
|
18
20
|
* 7. Write the lockfile (= the new manifest) ONLY after a clean apply.
|
|
19
21
|
* 8. Surgically ensure `factory:update` is in package.json (design §7.4).
|
|
20
22
|
*
|
|
21
23
|
* Auto-register (no lockfile, pre-CLI repo, design §6.3): path-match — repo
|
|
22
24
|
* 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
|
|
25
|
+
* from the manifest are logged "ambiguous" and NOT deleted on the first sync;
|
|
26
|
+
* an existing `CLAUDE.md` is preserved (write-if-absent). The dev is warned + sees
|
|
27
|
+
* the auto-detected profile before proceeding.
|
|
25
28
|
*
|
|
26
29
|
* `--verify`: compares disk vs lockfile and reports drift WITHOUT modifying
|
|
27
30
|
* anything. `--resume`: re-applies a persisted mid-flight state without
|
|
@@ -36,9 +39,11 @@ import path from 'node:path';
|
|
|
36
39
|
import prompts from 'prompts';
|
|
37
40
|
import { applyPlan, clearUpdateState, defaultBackupDir, hasUpdateState, readUpdateState, validateStagedManifest, writeUpdateState, } from '../lib/atomic-swap.js';
|
|
38
41
|
import { CLIError } from '../lib/cli-error.js';
|
|
42
|
+
import { CLAUDE_MD_FILE, PROFILES } from '../lib/constants.js';
|
|
39
43
|
import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
|
|
40
|
-
import { insertFactoryUpdateScript, setAgentKitVersion } from '../lib/package-json.js';
|
|
44
|
+
import { detectProfile, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
|
|
41
45
|
import { runPreflight } from '../lib/preflight.js';
|
|
46
|
+
import { detectRepo } from '../lib/repo-detection.js';
|
|
42
47
|
import { downloadProfileTarball, stageProfileTarball } from '../lib/unpack.js';
|
|
43
48
|
/** Parse `update`'s argv slice into flags. Unknown flags are ignored here. */
|
|
44
49
|
export function parseUpdateFlags(argv) {
|
|
@@ -47,6 +52,8 @@ export function parseUpdateFlags(argv) {
|
|
|
47
52
|
mineAll: argv.includes('--mine-all'),
|
|
48
53
|
resume: argv.includes('--resume'),
|
|
49
54
|
verify: argv.includes('--verify'),
|
|
55
|
+
core: argv.includes('--core'),
|
|
56
|
+
full: argv.includes('--full'),
|
|
50
57
|
};
|
|
51
58
|
}
|
|
52
59
|
/** Default acquire: download the sticky-profile tarball, stage + validate it. */
|
|
@@ -216,11 +223,20 @@ function warnOnScriptConflict(action, _pkgPath) {
|
|
|
216
223
|
* semver) are untouched.
|
|
217
224
|
*/
|
|
218
225
|
function maintainDerivedPkg(pkgPath, agentKitVersion) {
|
|
219
|
-
|
|
220
|
-
|
|
226
|
+
// Tolerant (mirrors `add`): a malformed package.json must NOT throw AFTER the
|
|
227
|
+
// brain was already applied + the lockfile written — the install succeeded; the
|
|
228
|
+
// script/version mirror is best-effort. Warn and move on.
|
|
229
|
+
try {
|
|
230
|
+
warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
|
|
231
|
+
setAgentKitVersion(pkgPath, agentKitVersion);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
console.warn('Aviso: no se pudo actualizar package.json (¿JSON inválido?); el cerebro se instaló igual. ' +
|
|
235
|
+
'Corrige package.json y vuelve a correr `factory update` para sincronizar agentKitVersion.');
|
|
236
|
+
}
|
|
221
237
|
}
|
|
222
238
|
/** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
|
|
223
|
-
function reportSummary(diff) {
|
|
239
|
+
function reportSummary(diff, manifest) {
|
|
224
240
|
if (diff.deleteSilent.length > 0) {
|
|
225
241
|
console.log('\nArchivos retirados por el Factory en esta versión:');
|
|
226
242
|
for (const e of diff.deleteSilent)
|
|
@@ -233,7 +249,8 @@ function reportSummary(diff) {
|
|
|
233
249
|
}
|
|
234
250
|
const unchanged = diff.unchanged.length > 0 ? ` (${diff.unchanged.length} sin cambios)` : '';
|
|
235
251
|
const kept = diff.keptRetiredLocal.length > 0 ? `, ${diff.keptRetiredLocal.length} conservados` : '';
|
|
236
|
-
console.log(`\n✔ update
|
|
252
|
+
console.log(`\n✔ update a v${manifest.version} (perfil ${manifest.profile}): ${diff.add.length} agregados, ` +
|
|
253
|
+
`${diff.overwriteSilent.length} actualizados${unchanged}, ` +
|
|
237
254
|
`${diff.deleteSilent.length} retirados${kept}, ${diff.conflicts.length} conflictos.`);
|
|
238
255
|
}
|
|
239
256
|
/**
|
|
@@ -241,7 +258,19 @@ function reportSummary(diff) {
|
|
|
241
258
|
* network seam (tests inject a local fixture).
|
|
242
259
|
*/
|
|
243
260
|
export async function runUpdate(flags, deps = {}) {
|
|
244
|
-
|
|
261
|
+
// Operate on the REPO ROOT (the brain + lockfile live there), not cwd — running
|
|
262
|
+
// `update` from a subdir of a managed repo must not miss the lockfile and
|
|
263
|
+
// legacy-register a second nested brain (consistency with `add`).
|
|
264
|
+
const rootDir = detectRepo(process.cwd()).repoRoot ?? process.cwd();
|
|
265
|
+
// Mutually-exclusive flag validation FIRST — before --verify/--resume
|
|
266
|
+
// short-circuit, so an invalid combination is always rejected (not silently
|
|
267
|
+
// honored by verify/resume).
|
|
268
|
+
if (flags.theirsAll && flags.mineAll) {
|
|
269
|
+
throw new CLIError('No puedes combinar `--theirs-all` con `--mine-all`.');
|
|
270
|
+
}
|
|
271
|
+
if (flags.core && flags.full) {
|
|
272
|
+
throw new CLIError('No puedes combinar `--core` con `--full`.');
|
|
273
|
+
}
|
|
245
274
|
// --verify and --resume short-circuit (no download).
|
|
246
275
|
if (flags.verify) {
|
|
247
276
|
runVerify(rootDir);
|
|
@@ -254,28 +283,46 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
254
283
|
runResume(rootDir);
|
|
255
284
|
return;
|
|
256
285
|
}
|
|
257
|
-
if (flags.theirsAll && flags.mineAll) {
|
|
258
|
-
throw new CLIError('No puedes combinar `--theirs-all` con `--mine-all`.');
|
|
259
|
-
}
|
|
260
286
|
// Preflight (gh) before any download — reused, not reimplemented.
|
|
261
287
|
await runPreflight();
|
|
262
288
|
const legacy = !hasLockfile(rootDir);
|
|
263
|
-
//
|
|
264
|
-
//
|
|
289
|
+
// Profile resolution:
|
|
290
|
+
// - legacy (no lockfile): auto-detect from package.json (`factoryVersion` →
|
|
291
|
+
// full, else core), unless a flag forces it. This is the adoption path for
|
|
292
|
+
// pre-CLI derivatives, so full-brain is the default for Factory repos.
|
|
293
|
+
// - lockfile'd: sticky to the recorded profile, EXCEPT `--full` cross-grades
|
|
294
|
+
// a `core` repo → `full` (additive: adds sk-* + SK.md, swaps CLAUDE.md to the
|
|
295
|
+
// full one). `--core` on a `full` repo is a downgrade → rejected.
|
|
265
296
|
let oldLock;
|
|
266
297
|
let profile;
|
|
267
298
|
if (legacy) {
|
|
268
|
-
profile =
|
|
299
|
+
profile = flags.full ? PROFILES.full : flags.core ? PROFILES.core : detectProfile(rootDir);
|
|
269
300
|
oldLock = { version: '0.0.0', profile, files: [] };
|
|
270
|
-
await confirmLegacy();
|
|
301
|
+
await confirmLegacy(profile);
|
|
271
302
|
}
|
|
272
303
|
else {
|
|
273
304
|
oldLock = readLockfile(rootDir);
|
|
274
|
-
|
|
305
|
+
if (flags.full && oldLock.profile === PROFILES.core) {
|
|
306
|
+
profile = PROFILES.full; // cross-grade core → full (additive)
|
|
307
|
+
}
|
|
308
|
+
else if (flags.core && oldLock.profile === PROFILES.full) {
|
|
309
|
+
throw new CLIError('Este repo está registrado como `full`; el downgrade a `core` no está soportado ' +
|
|
310
|
+
'(implicaría borrar sk-*/SK.md). Si de verdad lo quieres, hazlo a mano.');
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
profile = oldLock.profile; // sticky (--full on full / --core on core = refresh)
|
|
314
|
+
}
|
|
275
315
|
}
|
|
276
316
|
const acquire = deps.acquire ?? acquireFromRelease;
|
|
277
317
|
const { stagedDir, manifest, tmpRoot } = await acquire(profile);
|
|
278
318
|
try {
|
|
319
|
+
// Guard: the downloaded tarball must match the resolved profile. A mispublished
|
|
320
|
+
// asset (e.g. a `full` tag carrying a `core` manifest) would otherwise write a
|
|
321
|
+
// lockfile for the wrong profile or silently no-op a cross-grade. Refuse loudly.
|
|
322
|
+
if (manifest.profile !== profile) {
|
|
323
|
+
throw new CLIError(`El tarball descargado declara perfil \`${manifest.profile}\` pero se resolvió \`${profile}\`. ` +
|
|
324
|
+
'El release está mal publicado; no se modificó nada.');
|
|
325
|
+
}
|
|
279
326
|
if (legacy) {
|
|
280
327
|
await applyLegacy(rootDir, stagedDir, manifest);
|
|
281
328
|
return;
|
|
@@ -314,19 +361,31 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
314
361
|
const pkgPath = path.join(rootDir, 'package.json');
|
|
315
362
|
if (existsSync(pkgPath))
|
|
316
363
|
maintainDerivedPkg(pkgPath, manifest.version);
|
|
317
|
-
reportSummary(diff);
|
|
364
|
+
reportSummary(diff, manifest);
|
|
318
365
|
}
|
|
319
366
|
finally {
|
|
320
|
-
|
|
367
|
+
// Keep the staged tarball if an update-state survives (a post-apply failure —
|
|
368
|
+
// e.g. writeLockfile threw after applyPlan): the persisted state points into
|
|
369
|
+
// tmpRoot, so deleting it would strand `--resume` with a vanished stagedDir
|
|
370
|
+
// and no recovery. A clean run clears the state first → tmpRoot is removed.
|
|
371
|
+
if (!hasUpdateState(rootDir)) {
|
|
372
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
373
|
+
}
|
|
321
374
|
}
|
|
322
375
|
}
|
|
323
376
|
/** Warn the dev before a legacy auto-register sync (design §8 edge case). */
|
|
324
|
-
async function confirmLegacy() {
|
|
377
|
+
async function confirmLegacy(profile) {
|
|
378
|
+
const detected = profile === PROFILES.full
|
|
379
|
+
? 'Detectado como derivado del Factory (`factoryVersion` en package.json) → perfil `full` ' +
|
|
380
|
+
'(incluye sk-* + SK.md). Usa `--core` si quieres solo el cerebro base.'
|
|
381
|
+
: 'Detectado como repo de otro stack (sin `factoryVersion`) → perfil `core` (sin sk-*). ' +
|
|
382
|
+
'Usa `--full` si es un derivado Next del Factory.';
|
|
325
383
|
const { proceed } = await prompts({
|
|
326
384
|
type: 'confirm',
|
|
327
385
|
name: 'proceed',
|
|
328
|
-
message:
|
|
329
|
-
|
|
386
|
+
message: `Este repo no tiene lockfile. Se hará un auto-registro por paths (perfil \`${profile}\`).\n` +
|
|
387
|
+
`${detected}\nLos archivos del cerebro que coincidan se sobrescribirán con la última ` +
|
|
388
|
+
'versión; tu `CLAUDE.md` se conserva si ya existe. ¿Continuar?',
|
|
330
389
|
initial: false,
|
|
331
390
|
});
|
|
332
391
|
if (!proceed) {
|
|
@@ -343,8 +402,15 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
|
|
|
343
402
|
const repoPaths = new Set(collectClaudePaths(rootDir));
|
|
344
403
|
const plan = planAutoRegister(manifest, repoPaths);
|
|
345
404
|
// Everything in the manifest is written (kit-owned overwrite + adds). No
|
|
346
|
-
// deletes on the first sync.
|
|
347
|
-
|
|
405
|
+
// deletes on the first sync. CLAUDE.md is dev-owned (rama B / write-if-absent):
|
|
406
|
+
// if it already exists on disk it is preserved (filtered out of writes); only
|
|
407
|
+
// written when absent. The lockfile still records the manifest's CLAUDE.md hash,
|
|
408
|
+
// so a preserved dev CLAUDE.md reads as "locally edited" on future updates →
|
|
409
|
+
// always ignoreLocal (rama A), never silently clobbered.
|
|
410
|
+
const claudeMdExists = existsSync(path.join(rootDir, CLAUDE_MD_FILE));
|
|
411
|
+
const writes = manifest.files
|
|
412
|
+
.map((f) => f.path)
|
|
413
|
+
.filter((p) => !(p === CLAUDE_MD_FILE && claudeMdExists));
|
|
348
414
|
const backupDir = defaultBackupDir(path.dirname(stagedDir));
|
|
349
415
|
applyPlan(rootDir, stagedDir, { writes, deletes: [] }, backupDir);
|
|
350
416
|
// The lockfile = the manifest (its hashes are already the normalized hashes of
|
|
@@ -354,11 +420,15 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
|
|
|
354
420
|
const pkgPath = path.join(rootDir, 'package.json');
|
|
355
421
|
if (existsSync(pkgPath))
|
|
356
422
|
maintainDerivedPkg(pkgPath, manifest.version);
|
|
423
|
+
if (claudeMdExists) {
|
|
424
|
+
console.log('\nConservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
|
|
425
|
+
'(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
|
|
426
|
+
}
|
|
357
427
|
if (plan.ambiguous.length > 0) {
|
|
358
428
|
console.log('\nArchivos en `.claude/` que no están en el manifest (revisar manualmente):');
|
|
359
429
|
for (const p of plan.ambiguous)
|
|
360
430
|
console.log(` • ambiguo — revisar manualmente: ${p}`);
|
|
361
431
|
}
|
|
362
|
-
console.log(`\n✔ Auto-registro completo
|
|
363
|
-
`${plan.ambiguous.length} ambiguos.`);
|
|
432
|
+
console.log(`\n✔ Auto-registro completo (v${manifest.version}, perfil ${manifest.profile}): ` +
|
|
433
|
+
`${plan.kitOwned.length} kit-owned, ${plan.toAdd.length} agregados, ${plan.ambiguous.length} ambiguos.`);
|
|
364
434
|
}
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs';
|
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { CLIError } from './lib/cli-error.js';
|
|
13
|
-
import { runAdd } from './commands/add.js';
|
|
13
|
+
import { parseAddFlags, runAdd } from './commands/add.js';
|
|
14
14
|
import { runDoctor } from './commands/doctor.js';
|
|
15
15
|
import { runNew } from './commands/new.js';
|
|
16
16
|
import { runStatus } from './commands/status.js';
|
|
@@ -23,23 +23,27 @@ Uso:
|
|
|
23
23
|
|
|
24
24
|
Comandos:
|
|
25
25
|
new <Nombre> Crea un proyecto derivado nuevo (repo + perfil + git init)
|
|
26
|
-
add Instala el cerebro
|
|
26
|
+
add Instala el cerebro (.claude/) en el repo actual
|
|
27
27
|
update Actualiza el cerebro al día sin pisar tu trabajo local
|
|
28
28
|
status Reporta la versión instalada vs. la última disponible
|
|
29
|
-
doctor Detecta huérfanos y
|
|
29
|
+
doctor Detecta huérfanos, conflictos y rules sin importar + aviso de seguridad
|
|
30
30
|
|
|
31
31
|
Sobre \`add\`:
|
|
32
|
-
Requiere estar dentro de un repo git (al menos \`git init\`).
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
Requiere estar dentro de un repo git (al menos \`git init\`). Por default instala
|
|
33
|
+
el cerebro \`full\` (incluye sk-* + SK.md) si el repo es un derivado del Factory
|
|
34
|
+
(tiene \`factoryVersion\` en package.json); si no, instala \`core\` (sin sk-*).
|
|
35
|
+
Flags: --full / --core fuerzan el perfil. Nunca toca tu \`src/\` ni tus archivos
|
|
36
|
+
propios; instala solo los archivos del manifest. Tu \`CLAUDE.md\` se conserva si
|
|
37
|
+
ya existe. Si el repo ya tiene \`.claude/\` o lockfile, te redirige a \`update\`.
|
|
36
38
|
|
|
37
39
|
Sobre \`update\`:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
agrega lo nuevo, sobrescribe lo del kit sin cambios locales, retira
|
|
41
|
-
Factory borró, y pregunta (sin opción default) ante un conflicto.
|
|
42
|
-
\`
|
|
40
|
+
Con lockfile, es sticky al perfil instalado; sin lockfile (legacy), auto-detecta
|
|
41
|
+
el perfil (igual que \`add\`). Compara contra el lockfile y aplica solo cambios de
|
|
42
|
+
\`.claude/\`: agrega lo nuevo, sobrescribe lo del kit sin cambios locales, retira
|
|
43
|
+
lo que el Factory borró, y pregunta (sin opción default) ante un conflicto. Tu
|
|
44
|
+
\`CLAUDE.md\` nunca se sobrescribe en silencio. Nunca toca \`src/\`. Flags:
|
|
45
|
+
--full Sube un repo \`core\` a \`full\` (cross-grade aditivo: + sk-*/SK.md)
|
|
46
|
+
--core Fuerza \`core\` en un install legacy (downgrade de full→core no soportado)
|
|
43
47
|
--theirs-all Resuelve todos los conflictos con la versión del Factory
|
|
44
48
|
--mine-all Conserva tu versión en todos los conflictos
|
|
45
49
|
--resume Retoma un update interrumpido sin re-descargar
|
|
@@ -88,7 +92,7 @@ async function main(argv) {
|
|
|
88
92
|
return;
|
|
89
93
|
}
|
|
90
94
|
case 'add': {
|
|
91
|
-
await runAdd();
|
|
95
|
+
await runAdd(parseAddFlags(rest));
|
|
92
96
|
return;
|
|
93
97
|
}
|
|
94
98
|
case 'update': {
|
package/dist/lib/atomic-swap.js
CHANGED
|
@@ -23,8 +23,27 @@
|
|
|
23
23
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
import { CLIError } from './cli-error.js';
|
|
26
|
-
import { TIMEKAST_DIR } from './constants.js';
|
|
26
|
+
import { CLAUDE_MD_FILE, TIMEKAST_DIR } from './constants.js';
|
|
27
27
|
import { parseLockfile } from './lockfile.js';
|
|
28
|
+
/** Roots a manifest path may live under (mirrors `profiles.json#track`). */
|
|
29
|
+
const ALLOWED_ROOTS = ['.claude/', 'scripts/'];
|
|
30
|
+
/**
|
|
31
|
+
* Reject a manifest path that is unsafe to write: absolute, escaping via `..`, or
|
|
32
|
+
* outside the tracked scope (`.claude/**`, `scripts/**`, `CLAUDE.md`). A correct
|
|
33
|
+
* release never produces these — this guards a mispublished/tampered tarball from
|
|
34
|
+
* writing `src/` or escaping the repo root. Separator-agnostic (Windows-safe).
|
|
35
|
+
*/
|
|
36
|
+
function assertSafeTrackedPath(rel) {
|
|
37
|
+
const segments = rel.split(/[\\/]/);
|
|
38
|
+
if (rel.length === 0 || path.isAbsolute(rel) || segments.includes('..')) {
|
|
39
|
+
throw new CLIError(`El manifest declara un path inseguro \`${rel}\` (absoluto o con \`..\`); no se tocó nada.`);
|
|
40
|
+
}
|
|
41
|
+
const tracked = rel === CLAUDE_MD_FILE || ALLOWED_ROOTS.some((root) => rel.startsWith(root));
|
|
42
|
+
if (!tracked) {
|
|
43
|
+
throw new CLIError(`El manifest declara \`${rel}\`, fuera del scope rastreado (\`.claude/\`, \`scripts/\`, \`CLAUDE.md\`). ` +
|
|
44
|
+
'El release está mal construido; no se tocó nada.');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
28
47
|
/** Name of the resume-state sidecar inside `.timekast/`. */
|
|
29
48
|
export const UPDATE_STATE_FILE = '.update-state.json';
|
|
30
49
|
/** Absolute path to the resume-state sidecar inside a project root. */
|
|
@@ -80,6 +99,8 @@ export function clearUpdateState(rootDir) {
|
|
|
80
99
|
export function validateStagedManifest(stagedDir, manifestRaw) {
|
|
81
100
|
const manifest = parseLockfile(manifestRaw, 'manifest embebido');
|
|
82
101
|
for (const entry of manifest.files) {
|
|
102
|
+
// Path safety BEFORE any existence check: never resolve an unsafe path.
|
|
103
|
+
assertSafeTrackedPath(entry.path);
|
|
83
104
|
if (!existsSync(path.join(stagedDir, entry.path))) {
|
|
84
105
|
throw new CLIError(`El tarball está incompleto: falta \`${entry.path}\` declarado en el manifest. ` +
|
|
85
106
|
'No se tocó ningún archivo.');
|
package/dist/lib/constants.js
CHANGED
|
@@ -16,6 +16,16 @@ export const PROFILES = {
|
|
|
16
16
|
export const TIMEKAST_DIR = '.timekast';
|
|
17
17
|
export const MANIFEST_FILE = 'manifest.json';
|
|
18
18
|
export const LOCKFILE_FILE = 'lockfile.json';
|
|
19
|
+
/**
|
|
20
|
+
* Repo-root path of the runtime entrypoint markdown. It is a tracked manifest
|
|
21
|
+
* file (via `track` + the `core` rename `CLAUDE.core.md → CLAUDE.md`), so it
|
|
22
|
+
* flows through the diff/install engine. It gets special handling because it is
|
|
23
|
+
* dev-owned after bootstrap (it carries project-specific sections + `@import`s):
|
|
24
|
+
* a derived repo's edited CLAUDE.md is never silently overwritten or prompted on
|
|
25
|
+
* `update` (lockfile path), and never clobbered by `add`/legacy if it already
|
|
26
|
+
* exists on disk (path-match path). See `diffLockfiles` + the install commands.
|
|
27
|
+
*/
|
|
28
|
+
export const CLAUDE_MD_FILE = 'CLAUDE.md';
|
|
19
29
|
/** The script the CLI injects into a derived project's package.json. */
|
|
20
30
|
export const UPDATE_SCRIPT_NAME = 'factory:update';
|
|
21
31
|
// `npx` so the script resolves in a fresh derived repo where @timekast/factory
|
package/dist/lib/lockfile.js
CHANGED
|
@@ -32,7 +32,7 @@ import { createHash } from 'node:crypto';
|
|
|
32
32
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
33
33
|
import path from 'node:path';
|
|
34
34
|
import { CLIError } from './cli-error.js';
|
|
35
|
-
import { LOCKFILE_FILE, MANIFEST_FILE, TIMEKAST_DIR } from './constants.js';
|
|
35
|
+
import { CLAUDE_MD_FILE, LOCKFILE_FILE, MANIFEST_FILE, TIMEKAST_DIR, } from './constants.js';
|
|
36
36
|
/**
|
|
37
37
|
* Write the initial lockfile in `destDir/.timekast/lockfile.json` (used by
|
|
38
38
|
* `new` / `add`). On the initial install the lockfile carries the tarball's
|
|
@@ -185,6 +185,18 @@ export function diffLockfiles(oldLock, newManifest, diskHashes) {
|
|
|
185
185
|
ignoreLocal: [],
|
|
186
186
|
conflicts: [],
|
|
187
187
|
};
|
|
188
|
+
// CLAUDE.md special-case (rama A): it is dev-owned after bootstrap, so it must
|
|
189
|
+
// NEVER prompt. Any path that would classify it as a `conflict` instead routes
|
|
190
|
+
// it to `ignoreLocal` (kept as-is, silently). The clean kit-owned branches
|
|
191
|
+
// (`add` / `overwriteSilent` / `unchanged`) stay normal, so a clean CLAUDE.md
|
|
192
|
+
// still propagates — e.g. a core→full cross-grade swaps in the full CLAUDE.md
|
|
193
|
+
// that imports `@.claude/rules/SK.md`, and new always-on rules reach it.
|
|
194
|
+
const recordConflict = (entry) => {
|
|
195
|
+
if (entry.path === CLAUDE_MD_FILE)
|
|
196
|
+
diff.ignoreLocal.push(entry.path);
|
|
197
|
+
else
|
|
198
|
+
diff.conflicts.push(entry);
|
|
199
|
+
};
|
|
188
200
|
// Walk the new manifest: add / overwriteSilent / conflicts.
|
|
189
201
|
for (const newEntry of newManifest.files) {
|
|
190
202
|
const localHash = diskHashes.get(newEntry.path);
|
|
@@ -203,7 +215,7 @@ export function diffLockfiles(oldLock, newManifest, diskHashes) {
|
|
|
203
215
|
diff.unchanged.push(newEntry);
|
|
204
216
|
}
|
|
205
217
|
else {
|
|
206
|
-
|
|
218
|
+
recordConflict({
|
|
207
219
|
path: newEntry.path,
|
|
208
220
|
localHash,
|
|
209
221
|
registeredHash: localHash, // no prior record; treat current as baseline
|
|
@@ -233,8 +245,9 @@ export function diffLockfiles(oldLock, newManifest, diskHashes) {
|
|
|
233
245
|
diff.unchanged.push(newEntry);
|
|
234
246
|
}
|
|
235
247
|
else {
|
|
236
|
-
// Edited locally AND changed by the Factory, diverging → conflict
|
|
237
|
-
|
|
248
|
+
// Edited locally AND changed by the Factory, diverging → conflict
|
|
249
|
+
// (CLAUDE.md routes to ignoreLocal instead — see recordConflict).
|
|
250
|
+
recordConflict({
|
|
238
251
|
path: newEntry.path,
|
|
239
252
|
localHash,
|
|
240
253
|
registeredHash,
|
package/dist/lib/package-json.js
CHANGED
|
@@ -5,9 +5,35 @@
|
|
|
5
5
|
* indentation, never rewriting the whole document. The dev's deps, scripts,
|
|
6
6
|
* field order and formatting are preserved.
|
|
7
7
|
*/
|
|
8
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
9
10
|
import { CLIError } from './cli-error.js';
|
|
10
|
-
import { UPDATE_SCRIPT_CMD, UPDATE_SCRIPT_NAME } from './constants.js';
|
|
11
|
+
import { PROFILES, UPDATE_SCRIPT_CMD, UPDATE_SCRIPT_NAME } from './constants.js';
|
|
12
|
+
/**
|
|
13
|
+
* Auto-detect the profile to install into an existing repo from its
|
|
14
|
+
* `package.json`: a `factoryVersion` field means the repo was born from the
|
|
15
|
+
* Factory boilerplate (the `sk-*` skills + `SK.md` rule apply) → `full`. Its
|
|
16
|
+
* absence — including a non-Node repo with no `package.json`, or an invalid one —
|
|
17
|
+
* means the repo is not a Factory derivative → `core`.
|
|
18
|
+
*
|
|
19
|
+
* Tolerant by design: any read/parse failure degrades to `core`, never throws.
|
|
20
|
+
* A `--full` / `--core` flag overrides this (the caller decides). The signal is
|
|
21
|
+
* one-time: once a lockfile records the profile, `update` is sticky to it.
|
|
22
|
+
*/
|
|
23
|
+
export function detectProfile(rootDir) {
|
|
24
|
+
const pkgPath = path.join(rootDir, 'package.json');
|
|
25
|
+
try {
|
|
26
|
+
if (!existsSync(pkgPath))
|
|
27
|
+
return PROFILES.core;
|
|
28
|
+
const pkg = parsePackageJson(readFileSync(pkgPath, 'utf8'));
|
|
29
|
+
return typeof pkg.factoryVersion === 'string' && pkg.factoryVersion.length > 0
|
|
30
|
+
? PROFILES.full
|
|
31
|
+
: PROFILES.core;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return PROFILES.core;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
11
37
|
/** Detect the indentation used by an existing JSON document (defaults to 2 spaces). */
|
|
12
38
|
function detectIndent(raw) {
|
|
13
39
|
const match = raw.match(/^(\s+)"/m);
|