@timekast/factory 0.1.9 → 1.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.
- package/dist/commands/add.js +103 -38
- package/dist/commands/doctor.js +113 -23
- package/dist/commands/new.js +5 -2
- package/dist/commands/update.js +102 -55
- package/dist/index.js +17 -13
- package/dist/lib/atomic-swap.js +22 -1
- package/dist/lib/claude-paths.js +68 -0
- package/dist/lib/constants.js +26 -1
- package/dist/lib/lockfile.js +17 -4
- package/dist/lib/package-json.js +68 -27
- 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
|
@@ -16,9 +16,20 @@
|
|
|
16
16
|
* No lockfile (pre-CLI repo) → delegate to the "run factory:update" advice, exit
|
|
17
17
|
* 0, never abort (same pattern as `status` / the §8 edge case).
|
|
18
18
|
*/
|
|
19
|
-
import { existsSync, readFileSync,
|
|
19
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
|
+
import { collectClaudePaths } from '../lib/claude-paths.js';
|
|
22
|
+
import { CLAUDE_MD_FILE } from '../lib/constants.js';
|
|
21
23
|
import { hasLockfile, normalizeThenHash, readLockfile } from '../lib/lockfile.js';
|
|
24
|
+
import { detectRepo } from '../lib/repo-detection.js';
|
|
25
|
+
/** Matches a flat always-on rule file shipped by the kit (`.claude/rules/<name>.md`). */
|
|
26
|
+
const RULE_PATH_RE = /^\.claude\/rules\/[^/]+\.md$/;
|
|
27
|
+
/**
|
|
28
|
+
* Matches ONLY the reserved on-demand rules heading (`Rules (on-demand reference)`
|
|
29
|
+
* and close variants), not any heading that merely contains "on-demand" (e.g.
|
|
30
|
+
* `## On-demand migration notes`).
|
|
31
|
+
*/
|
|
32
|
+
const ON_DEMAND_HEADING_RE = /rules?\s*\(\s*on-demand/i;
|
|
22
33
|
/**
|
|
23
34
|
* The A1 security notice (design §1 + §11, decision A1). Literal text emitted at
|
|
24
35
|
* the end of every `doctor` run so the dev always sees the `src/` responsibility
|
|
@@ -33,27 +44,53 @@ export const A1_WARNING = 'Aviso de seguridad (A1): el boilerplate `src/` y sus
|
|
|
33
44
|
'(update agentico con merge inteligente), aún no disponible. Mantén `src/` y tus ' +
|
|
34
45
|
'dependencias al día por tu cuenta mientras tanto.';
|
|
35
46
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
47
|
+
* Concatenate the body of the reserved "on-demand reference" section of CLAUDE.md
|
|
48
|
+
* — the lines under a heading matching `ON_DEMAND_HEADING_RE`, until the next
|
|
49
|
+
* heading of the SAME or HIGHER level (a deeper nested heading stays inside the
|
|
50
|
+
* section). Rules listed there are loaded by skills on demand (NOT via `@import`),
|
|
51
|
+
* so they must not be flagged as unimported. Empty string when no such section.
|
|
52
|
+
*
|
|
53
|
+
* Level-aware (a nested `###` under a `##` on-demand heading does not end it) and
|
|
54
|
+
* specific (only the reserved heading starts it, not any "on-demand" mention).
|
|
39
55
|
*/
|
|
40
|
-
function
|
|
56
|
+
function onDemandSectionText(claudeMd) {
|
|
41
57
|
const out = [];
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
let sectionLevel = 0; // 0 = not currently inside an on-demand section
|
|
59
|
+
for (const line of claudeMd.split('\n')) {
|
|
60
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
61
|
+
if (heading) {
|
|
62
|
+
const level = heading[1].length;
|
|
63
|
+
const isOnDemand = ON_DEMAND_HEADING_RE.test(heading[2]);
|
|
64
|
+
if (sectionLevel === 0) {
|
|
65
|
+
if (isOnDemand)
|
|
66
|
+
sectionLevel = level; // enter the section
|
|
49
67
|
}
|
|
68
|
+
else if (level <= sectionLevel) {
|
|
69
|
+
// sibling/higher heading ends the section (unless it is on-demand too)
|
|
70
|
+
sectionLevel = isOnDemand ? level : 0;
|
|
71
|
+
}
|
|
72
|
+
// a deeper heading (level > sectionLevel) stays inside → no change
|
|
73
|
+
continue;
|
|
50
74
|
}
|
|
51
|
-
|
|
52
|
-
out.push(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
if (sectionLevel > 0)
|
|
76
|
+
out.push(line);
|
|
77
|
+
}
|
|
78
|
+
return out.join('\n');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Collect the rule paths a CLAUDE.md actually `@import`s — a line whose trimmed
|
|
82
|
+
* text is `@<path>` (the real import directive). A prose mention or a fenced
|
|
83
|
+
* code block ("no agregues `@.claude/rules/SK.md`") is NOT a bare directive line,
|
|
84
|
+
* so it does not count as imported.
|
|
85
|
+
*/
|
|
86
|
+
function importedRulePaths(claudeMd) {
|
|
87
|
+
const paths = new Set();
|
|
88
|
+
for (const raw of claudeMd.split('\n')) {
|
|
89
|
+
const line = raw.trim();
|
|
90
|
+
if (line.startsWith('@'))
|
|
91
|
+
paths.add(line.slice(1).trim());
|
|
92
|
+
}
|
|
93
|
+
return paths;
|
|
57
94
|
}
|
|
58
95
|
/**
|
|
59
96
|
* Compute the doctor diagnosis. No lockfile → `noLockfile: true` (the caller
|
|
@@ -63,9 +100,19 @@ function collectClaudePaths(rootDir) {
|
|
|
63
100
|
* - conflict = tracked path whose normalized disk hash != recorded hash.
|
|
64
101
|
*/
|
|
65
102
|
export function computeDoctor(deps = {}) {
|
|
66
|
-
|
|
103
|
+
// Diagnose at the REPO ROOT (where the brain + lockfile live), not cwd — so
|
|
104
|
+
// `doctor` from a subdir of a managed repo reports the real state instead of
|
|
105
|
+
// "no lockfile" (consistency with `add`/`update`). Tests inject `rootDir`.
|
|
106
|
+
const rootDir = deps.rootDir ?? detectRepo(process.cwd()).repoRoot ?? process.cwd();
|
|
67
107
|
if (!hasLockfile(rootDir)) {
|
|
68
|
-
return {
|
|
108
|
+
return {
|
|
109
|
+
orphans: [],
|
|
110
|
+
conflicts: [],
|
|
111
|
+
unimportedRules: [],
|
|
112
|
+
claudeMdMissing: false,
|
|
113
|
+
a1Warning: A1_WARNING,
|
|
114
|
+
noLockfile: true,
|
|
115
|
+
};
|
|
69
116
|
}
|
|
70
117
|
const lock = readLockfile(rootDir);
|
|
71
118
|
const manifestPaths = new Set(lock.files.map((f) => f.path));
|
|
@@ -85,7 +132,37 @@ export function computeDoctor(deps = {}) {
|
|
|
85
132
|
}
|
|
86
133
|
}
|
|
87
134
|
conflicts.sort();
|
|
88
|
-
|
|
135
|
+
// Unimported kit rules: a kit-shipped always-on rule (`.claude/rules/*.md`)
|
|
136
|
+
// that is NOT `@import`ed by CLAUDE.md → it does NOT load at runtime. The check
|
|
137
|
+
// is strict on the `@import` directive (a prose mention does NOT count — a
|
|
138
|
+
// mentioned-but-not-imported always-on rule is dead). Exception: a rule listed
|
|
139
|
+
// under an "on-demand" heading is loaded by skills on demand, not via @import,
|
|
140
|
+
// so it is excluded. No CLAUDE.md on disk in a managed repo → can't check, and
|
|
141
|
+
// every rule is dead → flagged via `claudeMdMissing` instead.
|
|
142
|
+
const unimportedRules = [];
|
|
143
|
+
const claudeMdPath = path.join(rootDir, CLAUDE_MD_FILE);
|
|
144
|
+
const claudeMdMissing = !existsSync(claudeMdPath);
|
|
145
|
+
if (!claudeMdMissing) {
|
|
146
|
+
const claudeMd = readFileSync(claudeMdPath, 'utf8');
|
|
147
|
+
const imported = importedRulePaths(claudeMd); // bare `@<path>` directive lines
|
|
148
|
+
const onDemand = onDemandSectionText(claudeMd);
|
|
149
|
+
for (const entry of lock.files) {
|
|
150
|
+
if (!RULE_PATH_RE.test(entry.path))
|
|
151
|
+
continue;
|
|
152
|
+
const isOnDemand = onDemand.includes(entry.path);
|
|
153
|
+
if (!imported.has(entry.path) && !isOnDemand)
|
|
154
|
+
unimportedRules.push(entry.path);
|
|
155
|
+
}
|
|
156
|
+
unimportedRules.sort();
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
orphans,
|
|
160
|
+
conflicts,
|
|
161
|
+
unimportedRules,
|
|
162
|
+
claudeMdMissing,
|
|
163
|
+
a1Warning: A1_WARNING,
|
|
164
|
+
noLockfile: false,
|
|
165
|
+
};
|
|
89
166
|
}
|
|
90
167
|
/** Render the doctor result to the console. The A1 notice is ALWAYS last. */
|
|
91
168
|
function renderDoctor(result) {
|
|
@@ -96,10 +173,17 @@ function renderDoctor(result) {
|
|
|
96
173
|
console.log(`\n${result.a1Warning}`);
|
|
97
174
|
return;
|
|
98
175
|
}
|
|
99
|
-
if (result.orphans.length === 0 &&
|
|
100
|
-
|
|
176
|
+
if (result.orphans.length === 0 &&
|
|
177
|
+
result.conflicts.length === 0 &&
|
|
178
|
+
result.unimportedRules.length === 0 &&
|
|
179
|
+
!result.claudeMdMissing) {
|
|
180
|
+
console.log('✔ Sin huérfanos, conflictos ni rules sin importar.');
|
|
101
181
|
}
|
|
102
182
|
else {
|
|
183
|
+
if (result.claudeMdMissing) {
|
|
184
|
+
console.log('🔴 Falta `CLAUDE.md` en la raíz: las rules del kit NO se cargan (el auto-load es por ' +
|
|
185
|
+
'`@import` desde ese archivo). Recupéralo (`git checkout -- CLAUDE.md`) o corre `factory update`.');
|
|
186
|
+
}
|
|
103
187
|
if (result.conflicts.length > 0) {
|
|
104
188
|
console.log('Modificados localmente respecto al lockfile (conflicto si el Factory los actualiza):');
|
|
105
189
|
for (const p of result.conflicts) {
|
|
@@ -107,6 +191,12 @@ function renderDoctor(result) {
|
|
|
107
191
|
}
|
|
108
192
|
console.log(' Sugerencia: corre `factory:update --verify` para re-chequear, o `factory:update` para reconciliar.');
|
|
109
193
|
}
|
|
194
|
+
if (result.unimportedRules.length > 0) {
|
|
195
|
+
console.log('\nRules del kit que tu `CLAUDE.md` no importa (no se cargan en runtime):');
|
|
196
|
+
for (const p of result.unimportedRules) {
|
|
197
|
+
console.log(` • ${p} — agrega \`@${p}\` a tu CLAUDE.md o no se aplicará`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
110
200
|
if (result.orphans.length > 0) {
|
|
111
201
|
console.log('\nArchivos huérfanos (en `.claude/` fuera del manifest instalado):');
|
|
112
202
|
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
|
|
@@ -30,15 +33,18 @@
|
|
|
30
33
|
* `update` NEVER touches `src/` — files outside the lockfile/manifest are
|
|
31
34
|
* invisible (design §9 out-of-scope).
|
|
32
35
|
*/
|
|
33
|
-
import { existsSync, mkdtempSync, readFileSync,
|
|
36
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
34
37
|
import { tmpdir } from 'node:os';
|
|
35
38
|
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';
|
|
41
|
+
import { collectClaudePaths } from '../lib/claude-paths.js';
|
|
38
42
|
import { CLIError } from '../lib/cli-error.js';
|
|
43
|
+
import { CLAUDE_MD_FILE, PROFILES } from '../lib/constants.js';
|
|
39
44
|
import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
|
|
40
|
-
import { insertFactoryUpdateScript, setAgentKitVersion } from '../lib/package-json.js';
|
|
45
|
+
import { detectProfile, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
|
|
41
46
|
import { runPreflight } from '../lib/preflight.js';
|
|
47
|
+
import { detectRepo } from '../lib/repo-detection.js';
|
|
42
48
|
import { downloadProfileTarball, stageProfileTarball } from '../lib/unpack.js';
|
|
43
49
|
/** Parse `update`'s argv slice into flags. Unknown flags are ignored here. */
|
|
44
50
|
export function parseUpdateFlags(argv) {
|
|
@@ -47,6 +53,8 @@ export function parseUpdateFlags(argv) {
|
|
|
47
53
|
mineAll: argv.includes('--mine-all'),
|
|
48
54
|
resume: argv.includes('--resume'),
|
|
49
55
|
verify: argv.includes('--verify'),
|
|
56
|
+
core: argv.includes('--core'),
|
|
57
|
+
full: argv.includes('--full'),
|
|
50
58
|
};
|
|
51
59
|
}
|
|
52
60
|
/** Default acquire: download the sticky-profile tarball, stage + validate it. */
|
|
@@ -57,30 +65,6 @@ async function acquireFromRelease(profile) {
|
|
|
57
65
|
const manifest = validateStagedManifest(stagedDir, manifestRaw);
|
|
58
66
|
return { stagedDir, manifest, tmpRoot };
|
|
59
67
|
}
|
|
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
68
|
/**
|
|
85
69
|
* Hash the disk content (normalized) of every path in `paths` that exists.
|
|
86
70
|
* A missing file is simply absent from the returned map.
|
|
@@ -216,11 +200,20 @@ function warnOnScriptConflict(action, _pkgPath) {
|
|
|
216
200
|
* semver) are untouched.
|
|
217
201
|
*/
|
|
218
202
|
function maintainDerivedPkg(pkgPath, agentKitVersion) {
|
|
219
|
-
|
|
220
|
-
|
|
203
|
+
// Tolerant (mirrors `add`): a malformed package.json must NOT throw AFTER the
|
|
204
|
+
// brain was already applied + the lockfile written — the install succeeded; the
|
|
205
|
+
// script/version mirror is best-effort. Warn and move on.
|
|
206
|
+
try {
|
|
207
|
+
warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
|
|
208
|
+
setAgentKitVersion(pkgPath, agentKitVersion);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
console.warn('Aviso: no se pudo actualizar package.json (¿JSON inválido?); el cerebro se instaló igual. ' +
|
|
212
|
+
'Corrige package.json y vuelve a correr `factory update` para sincronizar agentKitVersion.');
|
|
213
|
+
}
|
|
221
214
|
}
|
|
222
215
|
/** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
|
|
223
|
-
function reportSummary(diff) {
|
|
216
|
+
function reportSummary(diff, manifest) {
|
|
224
217
|
if (diff.deleteSilent.length > 0) {
|
|
225
218
|
console.log('\nArchivos retirados por el Factory en esta versión:');
|
|
226
219
|
for (const e of diff.deleteSilent)
|
|
@@ -233,7 +226,8 @@ function reportSummary(diff) {
|
|
|
233
226
|
}
|
|
234
227
|
const unchanged = diff.unchanged.length > 0 ? ` (${diff.unchanged.length} sin cambios)` : '';
|
|
235
228
|
const kept = diff.keptRetiredLocal.length > 0 ? `, ${diff.keptRetiredLocal.length} conservados` : '';
|
|
236
|
-
console.log(`\n✔ update
|
|
229
|
+
console.log(`\n✔ update a v${manifest.version} (perfil ${manifest.profile}): ${diff.add.length} agregados, ` +
|
|
230
|
+
`${diff.overwriteSilent.length} actualizados${unchanged}, ` +
|
|
237
231
|
`${diff.deleteSilent.length} retirados${kept}, ${diff.conflicts.length} conflictos.`);
|
|
238
232
|
}
|
|
239
233
|
/**
|
|
@@ -241,7 +235,19 @@ function reportSummary(diff) {
|
|
|
241
235
|
* network seam (tests inject a local fixture).
|
|
242
236
|
*/
|
|
243
237
|
export async function runUpdate(flags, deps = {}) {
|
|
244
|
-
|
|
238
|
+
// Operate on the REPO ROOT (the brain + lockfile live there), not cwd — running
|
|
239
|
+
// `update` from a subdir of a managed repo must not miss the lockfile and
|
|
240
|
+
// legacy-register a second nested brain (consistency with `add`).
|
|
241
|
+
const rootDir = detectRepo(process.cwd()).repoRoot ?? process.cwd();
|
|
242
|
+
// Mutually-exclusive flag validation FIRST — before --verify/--resume
|
|
243
|
+
// short-circuit, so an invalid combination is always rejected (not silently
|
|
244
|
+
// honored by verify/resume).
|
|
245
|
+
if (flags.theirsAll && flags.mineAll) {
|
|
246
|
+
throw new CLIError('No puedes combinar `--theirs-all` con `--mine-all`.');
|
|
247
|
+
}
|
|
248
|
+
if (flags.core && flags.full) {
|
|
249
|
+
throw new CLIError('No puedes combinar `--core` con `--full`.');
|
|
250
|
+
}
|
|
245
251
|
// --verify and --resume short-circuit (no download).
|
|
246
252
|
if (flags.verify) {
|
|
247
253
|
runVerify(rootDir);
|
|
@@ -254,28 +260,46 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
254
260
|
runResume(rootDir);
|
|
255
261
|
return;
|
|
256
262
|
}
|
|
257
|
-
if (flags.theirsAll && flags.mineAll) {
|
|
258
|
-
throw new CLIError('No puedes combinar `--theirs-all` con `--mine-all`.');
|
|
259
|
-
}
|
|
260
263
|
// Preflight (gh) before any download — reused, not reimplemented.
|
|
261
264
|
await runPreflight();
|
|
262
265
|
const legacy = !hasLockfile(rootDir);
|
|
263
|
-
//
|
|
264
|
-
//
|
|
266
|
+
// Profile resolution:
|
|
267
|
+
// - legacy (no lockfile): auto-detect from package.json (`factoryVersion` →
|
|
268
|
+
// full, else core), unless a flag forces it. This is the adoption path for
|
|
269
|
+
// pre-CLI derivatives, so full-brain is the default for Factory repos.
|
|
270
|
+
// - lockfile'd: sticky to the recorded profile, EXCEPT `--full` cross-grades
|
|
271
|
+
// a `core` repo → `full` (additive: adds sk-* + SK.md, swaps CLAUDE.md to the
|
|
272
|
+
// full one). `--core` on a `full` repo is a downgrade → rejected.
|
|
265
273
|
let oldLock;
|
|
266
274
|
let profile;
|
|
267
275
|
if (legacy) {
|
|
268
|
-
profile =
|
|
276
|
+
profile = flags.full ? PROFILES.full : flags.core ? PROFILES.core : detectProfile(rootDir);
|
|
269
277
|
oldLock = { version: '0.0.0', profile, files: [] };
|
|
270
|
-
await confirmLegacy();
|
|
278
|
+
await confirmLegacy(profile);
|
|
271
279
|
}
|
|
272
280
|
else {
|
|
273
281
|
oldLock = readLockfile(rootDir);
|
|
274
|
-
|
|
282
|
+
if (flags.full && oldLock.profile === PROFILES.core) {
|
|
283
|
+
profile = PROFILES.full; // cross-grade core → full (additive)
|
|
284
|
+
}
|
|
285
|
+
else if (flags.core && oldLock.profile === PROFILES.full) {
|
|
286
|
+
throw new CLIError('Este repo está registrado como `full`; el downgrade a `core` no está soportado ' +
|
|
287
|
+
'(implicaría borrar sk-*/SK.md). Si de verdad lo quieres, hazlo a mano.');
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
profile = oldLock.profile; // sticky (--full on full / --core on core = refresh)
|
|
291
|
+
}
|
|
275
292
|
}
|
|
276
293
|
const acquire = deps.acquire ?? acquireFromRelease;
|
|
277
294
|
const { stagedDir, manifest, tmpRoot } = await acquire(profile);
|
|
278
295
|
try {
|
|
296
|
+
// Guard: the downloaded tarball must match the resolved profile. A mispublished
|
|
297
|
+
// asset (e.g. a `full` tag carrying a `core` manifest) would otherwise write a
|
|
298
|
+
// lockfile for the wrong profile or silently no-op a cross-grade. Refuse loudly.
|
|
299
|
+
if (manifest.profile !== profile) {
|
|
300
|
+
throw new CLIError(`El tarball descargado declara perfil \`${manifest.profile}\` pero se resolvió \`${profile}\`. ` +
|
|
301
|
+
'El release está mal publicado; no se modificó nada.');
|
|
302
|
+
}
|
|
279
303
|
if (legacy) {
|
|
280
304
|
await applyLegacy(rootDir, stagedDir, manifest);
|
|
281
305
|
return;
|
|
@@ -314,19 +338,31 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
314
338
|
const pkgPath = path.join(rootDir, 'package.json');
|
|
315
339
|
if (existsSync(pkgPath))
|
|
316
340
|
maintainDerivedPkg(pkgPath, manifest.version);
|
|
317
|
-
reportSummary(diff);
|
|
341
|
+
reportSummary(diff, manifest);
|
|
318
342
|
}
|
|
319
343
|
finally {
|
|
320
|
-
|
|
344
|
+
// Keep the staged tarball if an update-state survives (a post-apply failure —
|
|
345
|
+
// e.g. writeLockfile threw after applyPlan): the persisted state points into
|
|
346
|
+
// tmpRoot, so deleting it would strand `--resume` with a vanished stagedDir
|
|
347
|
+
// and no recovery. A clean run clears the state first → tmpRoot is removed.
|
|
348
|
+
if (!hasUpdateState(rootDir)) {
|
|
349
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
350
|
+
}
|
|
321
351
|
}
|
|
322
352
|
}
|
|
323
353
|
/** Warn the dev before a legacy auto-register sync (design §8 edge case). */
|
|
324
|
-
async function confirmLegacy() {
|
|
354
|
+
async function confirmLegacy(profile) {
|
|
355
|
+
const detected = profile === PROFILES.full
|
|
356
|
+
? 'Detectado como derivado del Factory (`factoryVersion` en package.json) → perfil `full` ' +
|
|
357
|
+
'(incluye sk-* + SK.md). Usa `--core` si quieres solo el cerebro base.'
|
|
358
|
+
: 'Detectado como repo de otro stack (sin `factoryVersion`) → perfil `core` (sin sk-*). ' +
|
|
359
|
+
'Usa `--full` si es un derivado Next del Factory.';
|
|
325
360
|
const { proceed } = await prompts({
|
|
326
361
|
type: 'confirm',
|
|
327
362
|
name: 'proceed',
|
|
328
|
-
message:
|
|
329
|
-
|
|
363
|
+
message: `Este repo no tiene lockfile. Se hará un auto-registro por paths (perfil \`${profile}\`).\n` +
|
|
364
|
+
`${detected}\nLos archivos del cerebro que coincidan se sobrescribirán con la última ` +
|
|
365
|
+
'versión; tu `CLAUDE.md` se conserva si ya existe. ¿Continuar?',
|
|
330
366
|
initial: false,
|
|
331
367
|
});
|
|
332
368
|
if (!proceed) {
|
|
@@ -343,8 +379,15 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
|
|
|
343
379
|
const repoPaths = new Set(collectClaudePaths(rootDir));
|
|
344
380
|
const plan = planAutoRegister(manifest, repoPaths);
|
|
345
381
|
// Everything in the manifest is written (kit-owned overwrite + adds). No
|
|
346
|
-
// deletes on the first sync.
|
|
347
|
-
|
|
382
|
+
// deletes on the first sync. CLAUDE.md is dev-owned (rama B / write-if-absent):
|
|
383
|
+
// if it already exists on disk it is preserved (filtered out of writes); only
|
|
384
|
+
// written when absent. The lockfile still records the manifest's CLAUDE.md hash,
|
|
385
|
+
// so a preserved dev CLAUDE.md reads as "locally edited" on future updates →
|
|
386
|
+
// always ignoreLocal (rama A), never silently clobbered.
|
|
387
|
+
const claudeMdExists = existsSync(path.join(rootDir, CLAUDE_MD_FILE));
|
|
388
|
+
const writes = manifest.files
|
|
389
|
+
.map((f) => f.path)
|
|
390
|
+
.filter((p) => !(p === CLAUDE_MD_FILE && claudeMdExists));
|
|
348
391
|
const backupDir = defaultBackupDir(path.dirname(stagedDir));
|
|
349
392
|
applyPlan(rootDir, stagedDir, { writes, deletes: [] }, backupDir);
|
|
350
393
|
// The lockfile = the manifest (its hashes are already the normalized hashes of
|
|
@@ -354,11 +397,15 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
|
|
|
354
397
|
const pkgPath = path.join(rootDir, 'package.json');
|
|
355
398
|
if (existsSync(pkgPath))
|
|
356
399
|
maintainDerivedPkg(pkgPath, manifest.version);
|
|
400
|
+
if (claudeMdExists) {
|
|
401
|
+
console.log('\nConservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
|
|
402
|
+
'(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
|
|
403
|
+
}
|
|
357
404
|
if (plan.ambiguous.length > 0) {
|
|
358
405
|
console.log('\nArchivos en `.claude/` que no están en el manifest (revisar manualmente):');
|
|
359
406
|
for (const p of plan.ambiguous)
|
|
360
407
|
console.log(` • ambiguo — revisar manualmente: ${p}`);
|
|
361
408
|
}
|
|
362
|
-
console.log(`\n✔ Auto-registro completo
|
|
363
|
-
`${plan.ambiguous.length} ambiguos.`);
|
|
409
|
+
console.log(`\n✔ Auto-registro completo (v${manifest.version}, perfil ${manifest.profile}): ` +
|
|
410
|
+
`${plan.kitOwned.length} kit-owned, ${plan.toAdd.length} agregados, ${plan.ambiguous.length} ambiguos.`);
|
|
364
411
|
}
|
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.');
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect the repo-relative POSIX paths of files under `.claude/`, EXCLUDING
|
|
3
|
+
* gitignored ones. Shared by `update` (legacy auto-register ambiguous report)
|
|
4
|
+
* and `doctor` (orphan report) so neither flags a dev-local, gitignored file
|
|
5
|
+
* (`settings.local.json`, `transitions/`, `.DS_Store`) as "ambiguous"/"orphan" —
|
|
6
|
+
* those are never kit-managed, so surfacing them is pure noise.
|
|
7
|
+
*
|
|
8
|
+
* Only descends into `.claude/`; `src/` and everything else is invisible
|
|
9
|
+
* (design §7.2).
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
/**
|
|
15
|
+
* Fallback ignore set, used only when `git check-ignore` is unavailable (no git
|
|
16
|
+
* binary, or the dir is not a git repo). Mirrors the kit's own `.gitignore`
|
|
17
|
+
* entries for `.claude/`. The git path is authoritative when present.
|
|
18
|
+
*/
|
|
19
|
+
const IGNORE_FALLBACK_RE = /(^|\/)(\.DS_Store|settings\.local\.json)$|(^|\/)transitions\//;
|
|
20
|
+
/** Recursively gather repo-relative POSIX paths of files under `.claude/`. */
|
|
21
|
+
function walkClaude(rootDir) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const walk = (rel) => {
|
|
24
|
+
const abs = path.join(rootDir, rel);
|
|
25
|
+
if (!existsSync(abs))
|
|
26
|
+
return;
|
|
27
|
+
if (statSync(abs).isDirectory()) {
|
|
28
|
+
for (const entry of readdirSync(abs))
|
|
29
|
+
walk(path.posix.join(rel, entry));
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
out.push(rel);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
walk('.claude');
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Drop the gitignored paths from `paths`. Uses `git check-ignore --stdin` (the
|
|
40
|
+
* authoritative source — respects nested `.gitignore`, negations, etc.); falls
|
|
41
|
+
* back to `IGNORE_FALLBACK_RE` when git is missing or the dir is not a repo.
|
|
42
|
+
*
|
|
43
|
+
* `git check-ignore` exit codes: 0 = some paths matched (printed), 1 = none
|
|
44
|
+
* matched (empty output), 128 = error / not a repo. `spawnSync` does not throw,
|
|
45
|
+
* so 0 and 1 both yield usable stdout; >1 or a spawn error → fallback.
|
|
46
|
+
*/
|
|
47
|
+
function dropGitignored(rootDir, paths) {
|
|
48
|
+
if (paths.length === 0)
|
|
49
|
+
return paths;
|
|
50
|
+
const res = spawnSync('git', ['-C', rootDir, 'check-ignore', '--stdin'], {
|
|
51
|
+
input: paths.join('\n'),
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
});
|
|
54
|
+
if (res.error || res.status === null || res.status > 1) {
|
|
55
|
+
return paths.filter((p) => !IGNORE_FALLBACK_RE.test(p));
|
|
56
|
+
}
|
|
57
|
+
const ignored = new Set((res.stdout || '')
|
|
58
|
+
.split('\n')
|
|
59
|
+
.map((l) => l.trim())
|
|
60
|
+
.filter(Boolean));
|
|
61
|
+
return paths.filter((p) => !ignored.has(p));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Repo-relative POSIX paths of NON-gitignored files under `.claude/`.
|
|
65
|
+
*/
|
|
66
|
+
export function collectClaudePaths(rootDir) {
|
|
67
|
+
return dropGitignored(rootDir, walkClaude(rootDir));
|
|
68
|
+
}
|
package/dist/lib/constants.js
CHANGED
|
@@ -16,9 +16,34 @@ 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
|
-
/**
|
|
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';
|
|
29
|
+
/** The scripts 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
|
|
22
32
|
// is NOT a dependency (B3): a bare `@timekast/factory update` would be
|
|
23
33
|
// "command not found". npx resolves the published package on demand.
|
|
24
34
|
export const UPDATE_SCRIPT_CMD = 'npx @timekast/factory update';
|
|
35
|
+
export const DOCTOR_SCRIPT_NAME = 'factory:doctor';
|
|
36
|
+
export const DOCTOR_SCRIPT_CMD = 'npx @timekast/factory doctor';
|
|
37
|
+
export const STATUS_SCRIPT_NAME = 'factory:status';
|
|
38
|
+
export const STATUS_SCRIPT_CMD = 'npx @timekast/factory status';
|
|
39
|
+
/**
|
|
40
|
+
* All convenience scripts the installer ensures in a Node derivative's
|
|
41
|
+
* package.json (each: add if missing, never overwrite a divergent value). Keyed
|
|
42
|
+
* by script name → command. `factory:update` is the primary (drives the install
|
|
43
|
+
* messaging); `doctor`/`status` are read-only diagnostics with no other alias.
|
|
44
|
+
*/
|
|
45
|
+
export const FACTORY_SCRIPTS = {
|
|
46
|
+
[UPDATE_SCRIPT_NAME]: UPDATE_SCRIPT_CMD,
|
|
47
|
+
[DOCTOR_SCRIPT_NAME]: DOCTOR_SCRIPT_CMD,
|
|
48
|
+
[STATUS_SCRIPT_NAME]: STATUS_SCRIPT_CMD,
|
|
49
|
+
};
|
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 {
|
|
11
|
+
import { FACTORY_SCRIPTS, PROFILES, 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);
|
|
@@ -32,26 +58,40 @@ export function parsePackageJson(raw) {
|
|
|
32
58
|
}
|
|
33
59
|
}
|
|
34
60
|
/**
|
|
35
|
-
* Pure core of the script insertion: mutate `pkg.scripts` in place to ensure
|
|
36
|
-
* `factory
|
|
61
|
+
* Pure core of the script insertion: mutate `pkg.scripts` in place to ensure ALL
|
|
62
|
+
* `factory:*` convenience scripts exist (`update` / `doctor` / `status`), adding
|
|
63
|
+
* each if missing and NEVER overwriting a divergent value (§7.4). Shared by
|
|
37
64
|
* `applyPackageJsonEdits` (the `new` flow) and `insertFactoryUpdateScript`
|
|
38
|
-
* (the `update` flow)
|
|
65
|
+
* (the `update` flow). Returns whether anything changed (so the caller writes
|
|
66
|
+
* only on a real edit) + the result for the primary `factory:update` script
|
|
67
|
+
* (which drives the install messaging).
|
|
39
68
|
*/
|
|
40
|
-
function
|
|
69
|
+
function ensureFactoryScripts(pkg) {
|
|
41
70
|
const scripts = pkg.scripts ?? {};
|
|
42
|
-
const existing = scripts[UPDATE_SCRIPT_NAME];
|
|
43
71
|
pkg.scripts = scripts;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
72
|
+
let changed = false;
|
|
73
|
+
let primary = { action: 'already-correct' };
|
|
74
|
+
for (const [name, cmd] of Object.entries(FACTORY_SCRIPTS)) {
|
|
75
|
+
const existing = scripts[name];
|
|
76
|
+
let result;
|
|
77
|
+
if (existing === undefined) {
|
|
78
|
+
scripts[name] = cmd;
|
|
79
|
+
changed = true;
|
|
80
|
+
result = { action: 'added' };
|
|
81
|
+
}
|
|
82
|
+
else if (existing === cmd) {
|
|
83
|
+
result = { action: 'already-correct' };
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
result = { action: 'conflict', existingValue: existing };
|
|
87
|
+
}
|
|
88
|
+
if (name === UPDATE_SCRIPT_NAME)
|
|
89
|
+
primary = result;
|
|
50
90
|
}
|
|
51
|
-
return {
|
|
91
|
+
return { changed, primary };
|
|
52
92
|
}
|
|
53
93
|
/**
|
|
54
|
-
* Rename `package.json.name` and ensure the `factory
|
|
94
|
+
* Rename `package.json.name` and ensure the `factory:*` scripts exist,
|
|
55
95
|
* preserving everything else. Returns the serialized document (with the
|
|
56
96
|
* original indentation + trailing newline).
|
|
57
97
|
*
|
|
@@ -62,31 +102,32 @@ export function applyPackageJsonEdits(raw, name) {
|
|
|
62
102
|
const indent = detectIndent(raw);
|
|
63
103
|
const pkg = parsePackageJson(raw);
|
|
64
104
|
pkg.name = name;
|
|
65
|
-
const
|
|
105
|
+
const { primary } = ensureFactoryScripts(pkg);
|
|
66
106
|
const content = `${JSON.stringify(pkg, null, indent)}\n`;
|
|
67
|
-
return { content, scriptAlreadyPresent:
|
|
107
|
+
return { content, scriptAlreadyPresent: primary.action === 'conflict' };
|
|
68
108
|
}
|
|
69
109
|
/**
|
|
70
|
-
* Surgically ensure the `factory
|
|
71
|
-
* `pkgPath` (design §7.4). Reads, mutates only
|
|
72
|
-
* with the original indentation + trailing
|
|
73
|
-
* value and NEVER touches `name`, deps,
|
|
110
|
+
* Surgically ensure the `factory:*` scripts (`update` / `doctor` / `status`)
|
|
111
|
+
* exist in the `package.json` at `pkgPath` (design §7.4). Reads, mutates only
|
|
112
|
+
* those keys, and re-serializes with the original indentation + trailing
|
|
113
|
+
* newline. NEVER overwrites a divergent value and NEVER touches `name`, deps,
|
|
114
|
+
* or any other script.
|
|
74
115
|
*
|
|
75
|
-
* Writes the file only when
|
|
76
|
-
*
|
|
116
|
+
* Writes the file only when at least one script was added; if all are already
|
|
117
|
+
* present (correct or divergent) the file is left byte-identical.
|
|
77
118
|
*
|
|
78
119
|
* @param pkgPath Absolute path to the derived project's `package.json`.
|
|
79
|
-
* @returns
|
|
120
|
+
* @returns The result for the primary `factory:update` script (+ existing value on conflict).
|
|
80
121
|
*/
|
|
81
122
|
export function insertFactoryUpdateScript(pkgPath) {
|
|
82
123
|
const raw = readFileSync(pkgPath, 'utf8');
|
|
83
124
|
const indent = detectIndent(raw);
|
|
84
125
|
const pkg = parsePackageJson(raw);
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
126
|
+
const { changed, primary } = ensureFactoryScripts(pkg);
|
|
127
|
+
if (changed) {
|
|
87
128
|
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
|
|
88
129
|
}
|
|
89
|
-
return
|
|
130
|
+
return primary;
|
|
90
131
|
}
|
|
91
132
|
/**
|
|
92
133
|
* Surgically set the derived project's `package.json.agentKitVersion` to the
|