@timekast/factory 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `.claude/settings.json` is shared: the kit owns the top-level keys ITS file declares (today
3
+ * `$schema`, `hooks`, `permissions`); every other top-level key belongs to the project
4
+ * (`enabledPlugins`, `extraKnownMarketplaces`, `env`, `statusLine`…). `update` compares only
5
+ * the kit's keys, by CONTENT — a project that only added its own keys, or whose tools
6
+ * reformatted the file (prettier, `/plugin install --scope project`), is not "edited" — and
7
+ * after writing the kit's file it puts the project's keys back.
8
+ *
9
+ * The manifest entry of this path carries `keys` (the kit's keys in that version) and a
10
+ * {@link settingsContentHash} instead of the raw-bytes hash. Lockfiles written before that
11
+ * carry the raw hash; {@link KIT_SETTINGS_HISTORY} maps it to the content hash of the same file.
12
+ * Contract: `fx-extension-points` §3.
13
+ */
14
+ import { createHash } from 'node:crypto';
15
+ import KIT_SETTINGS_HISTORY_JSON from './kit-settings-history.json' with { type: 'json' };
16
+ import { normalizeThenHash } from './lockfile.js';
17
+ export const KIT_SETTINGS_PATH = '.claude/settings.json';
18
+ /** The kit's top-level keys in every `settings.json` published before entries carried `keys`. */
19
+ export const LEGACY_KIT_SETTINGS_KEYS = ['$schema', 'hooks', 'permissions'];
20
+ /**
21
+ * Raw-bytes hash (`normalizeThenHash`) → content hash over {@link LEGACY_KIT_SETTINGS_KEYS}, for
22
+ * every distinct `settings.json` the kit published before content hashes. Frozen: generated once
23
+ * from the `v*` tags (every later release records a content hash with its `keys`).
24
+ */
25
+ export const KIT_SETTINGS_HISTORY = KIT_SETTINGS_HISTORY_JSON;
26
+ /** JSON with object keys sorted at every level and no whitespace — arrays keep their order. */
27
+ export function canonicalJson(value) {
28
+ if (Array.isArray(value))
29
+ return `[${value.map(canonicalJson).join(',')}]`;
30
+ if (value !== null && typeof value === 'object') {
31
+ const obj = value;
32
+ return `{${Object.keys(obj)
33
+ .sort()
34
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`)
35
+ .join(',')}}`;
36
+ }
37
+ return JSON.stringify(value);
38
+ }
39
+ function parseObject(raw) {
40
+ try {
41
+ const parsed = JSON.parse(raw);
42
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
43
+ ? parsed
44
+ : null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * SHA-256 of the canonical JSON of the `keys` subset of `raw` (absent keys are left out).
52
+ * `null` when `raw` is not a JSON object. Parity-locked with `distribution/build-dist.ts`.
53
+ */
54
+ export function settingsContentHash(raw, keys) {
55
+ const obj = parseObject(raw);
56
+ if (!obj)
57
+ return null;
58
+ const subset = {};
59
+ for (const key of keys)
60
+ if (key in obj)
61
+ subset[key] = obj[key];
62
+ return createHash('sha256').update(canonicalJson(subset), 'utf8').digest('hex');
63
+ }
64
+ /** Whether `raw` holds the kit's keys of `entry` unchanged (the project's own keys aside). */
65
+ function matchesEntry(raw, entry) {
66
+ if (normalizeThenHash(raw) === entry.hash)
67
+ return true;
68
+ const keys = entry.keys ?? (KIT_SETTINGS_HISTORY[entry.hash] ? LEGACY_KIT_SETTINGS_KEYS : null);
69
+ const expected = entry.keys ? entry.hash : KIT_SETTINGS_HISTORY[entry.hash];
70
+ return keys !== null && expected !== undefined && settingsContentHash(raw, keys) === expected;
71
+ }
72
+ /**
73
+ * The hash `diffLockfiles` sees for `settings.json` on disk: the old or the new entry's hash when
74
+ * the kit's keys match it, the raw hash otherwise (= edited, as for any other file).
75
+ */
76
+ export function effectiveSettingsHash(raw, entries) {
77
+ for (const entry of entries)
78
+ if (entry && matchesEntry(raw, entry))
79
+ return entry.hash;
80
+ return normalizeThenHash(raw);
81
+ }
82
+ /**
83
+ * The kit's file plus the project's keys of `previousRaw` (every top-level key the kit does not
84
+ * declare). `content` is `null` when there is nothing to carry over — the kit's file stays byte
85
+ * for byte. `replaced` names the project keys the kit now declares (the kit wins).
86
+ *
87
+ * @param previousKitKeys the kit's keys BEFORE this update (old entry's `keys`, or the legacy
88
+ * ones): a previous key outside them was the project's.
89
+ */
90
+ export function mergeProjectSettingsKeys(kitRaw, previousRaw, previousKitKeys) {
91
+ const kit = parseObject(kitRaw);
92
+ const previous = parseObject(previousRaw);
93
+ if (!kit || !previous)
94
+ return { content: null, kept: [], replaced: [] };
95
+ const projectKeys = Object.keys(previous).filter((k) => !previousKitKeys.includes(k));
96
+ const kept = projectKeys.filter((k) => !(k in kit));
97
+ const replaced = projectKeys.filter((k) => k in kit);
98
+ if (kept.length === 0)
99
+ return { content: null, kept, replaced };
100
+ const merged = { ...kit };
101
+ for (const key of kept)
102
+ merged[key] = previous[key];
103
+ return { content: `${JSON.stringify(merged, null, 2)}\n`, kept, replaced };
104
+ }
@@ -65,6 +65,12 @@ export const PROFILES = {
65
65
  export const TIMEKAST_DIR = '.timekast';
66
66
  export const MANIFEST_FILE = 'manifest.json';
67
67
  export const LOCKFILE_FILE = 'lockfile.json';
68
+ /**
69
+ * The npm packages the kit's tracked files need (Factory#78), embedded by the builder next to
70
+ * `manifest.json`: `{ version, profile, packages: { name: range } }`. Read from the STAGED
71
+ * tarball by `add`/`update` (`kit-deps.ts`); never left in a derived repo.
72
+ */
73
+ export const KIT_DEPS_FILE = 'kit-deps.json';
68
74
  /**
69
75
  * Repo-root path of the runtime entrypoint markdown. It is a tracked manifest
70
76
  * file (via `track` + the `core` rename `CLAUDE.core.md → CLAUDE.md`), so it
@@ -86,6 +92,21 @@ export const CLAUDE_MD_FILE = 'CLAUDE.md';
86
92
  * split as `.husky/pre-commit` → `.husky/pre-commit.project`.
87
93
  */
88
94
  export const VITEST_SETUP_FILE = 'vitest.setup.ts';
95
+ /**
96
+ * Path prefixes of the brain proper: the two trees the kit's `.prettierignore` block has always
97
+ * covered whole. Every other path the manifest tracks (`CLAUDE.md`, `.husky/*`,
98
+ * `.github/workflows/*`, `vitest.setup.ts`) lives among the repo's own files, so the block lists
99
+ * those one by one, generated from the manifest at sync time (`renderPrettierignoreBlock`).
100
+ * Trailing slash so a prefix match cannot catch a sibling (`scripts/tools-extra/`).
101
+ */
102
+ export const BRAIN_PREFIXES = ['.claude/', 'scripts/tools/'];
103
+ /**
104
+ * Suffix of the kit's copy of a file the repo already had with its own content (`add` keeps the
105
+ * repo's and writes the kit's next to it). APPENDED to the whole name, never inserted before the
106
+ * extension: GitHub Actions runs every `*.yml` under `.github/workflows/`, so `ci.kit-example.yml`
107
+ * would run as a second CI, while `ci.yml.kit-example` is inert.
108
+ */
109
+ export const KIT_EXAMPLE_SUFFIX = '.kit-example';
89
110
  /**
90
111
  * Repo-root `.gitattributes`. NOT a tracked manifest file (absent from `track`),
91
112
  * so it never flows through the diff/lockfile engine — a derived repo's own rules
@@ -104,6 +125,19 @@ export const GITATTRIBUTES_FILE = '.gitattributes';
104
125
  * turns every later `factory:update` into false conflicts (A2).
105
126
  */
106
127
  export const PRETTIERIGNORE_FILE = '.prettierignore';
128
+ /**
129
+ * Repo-root `.gitignore`. Same managed-block treatment, but ONLY in a pruned repo
130
+ * (`factory prune`): the block lists the kit paths kept local to the members, generated
131
+ * from `.claude/policy/prune-tiers.json`. A repo without the switch below never gets one.
132
+ */
133
+ export const GITIGNORE_FILE = '.gitignore';
134
+ /**
135
+ * File name, under `.timekast/`, of the per-repo switch written by `factory prune prepare`
136
+ * (`{ tier, at }`). Its OWN file, not a lockfile field: `update` rewrites the lockfile whole.
137
+ * Without it every prune behavior — the `.gitignore` block, the untracking in `update`, the
138
+ * pre-commit guard — stays off.
139
+ */
140
+ export const PRUNE_FILE = 'prune.json';
107
141
  /**
108
142
  * Managed-block sentinels. These are PREFIXES (the live source lines carry extra
109
143
  * descriptive text + trailing `>>>`/`<<<`), matched with `startsWith` so detection
@@ -131,6 +165,10 @@ export const UNPUBLISH_SCRIPT_CMD = 'npx @timekast/factory unpublish';
131
165
  // through `npx` so it resolves without a global install.
132
166
  export const ENV_PUSH_SCRIPT_NAME = 'env:push';
133
167
  export const ENV_PUSH_SCRIPT_CMD = 'npx @timekast/factory env push';
168
+ // Agentic Dashboard: the team's local usage dashboard. It reads this machine's Claude Code
169
+ // transcripts, not the repo, so it needs nothing the brain ships and goes to every derivative.
170
+ export const DASHBOARD_SCRIPT_NAME = 'dashboard';
171
+ export const DASHBOARD_SCRIPT_CMD = 'npx -y @timekast/agentic-dashboard --open';
134
172
  /**
135
173
  * All convenience scripts the installer ensures in a Node derivative's
136
174
  * package.json (each: add if missing, never overwrite a divergent value). Keyed
@@ -144,6 +182,7 @@ export const FACTORY_SCRIPTS = {
144
182
  [PUBLISH_SCRIPT_NAME]: PUBLISH_SCRIPT_CMD,
145
183
  [UNPUBLISH_SCRIPT_NAME]: UNPUBLISH_SCRIPT_CMD,
146
184
  [ENV_PUSH_SCRIPT_NAME]: ENV_PUSH_SCRIPT_CMD,
185
+ [DASHBOARD_SCRIPT_NAME]: DASHBOARD_SCRIPT_CMD,
147
186
  };
148
187
  /**
149
188
  * The local wrapper that injects the vault's environment into the kit's scripts (VAULT-003).
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The derived project's dual-owned dotfiles — `.gitattributes` and `.prettierignore` — synced
3
+ * from the staged tarball after the brain lands. Shared by `add` and the three `update` paths
4
+ * (main, legacy auto-register, resume): `add` used to skip them, so the first commit of a fresh
5
+ * install ran the repo's `prettier --write` over every kit file and drifted them all from the
6
+ * lockfile hashes (Factory#83/#78).
7
+ *
8
+ * The generic block engine lives in `gitattributes.ts` (`extractManagedBlock`,
9
+ * `syncManagedBlock`); this module only decides WHICH block goes into WHICH file and reports it.
10
+ * Both syncs are best-effort: a tarball without the file (pre-block releases) is a silent no-op,
11
+ * and a write failure never fails an install that already happened.
12
+ */
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { BRAIN_PREFIXES, CLAUDE_MD_FILE, GITATTRIBUTES_FILE, KIT_EXAMPLE_SUFFIX, MANAGED_BLOCK_END, PRETTIERIGNORE_FILE, } from './constants.js';
16
+ import { extractManagedBlock, syncManagedBlock } from './gitattributes.js';
17
+ import { MIGRATIONS_DIR } from './initial-migration.js';
18
+ /** Config files drizzle-kit reads, in the order it would pick them. */
19
+ const DRIZZLE_CONFIG_FILES = [
20
+ 'drizzle.config.ts',
21
+ 'drizzle.config.mts',
22
+ 'drizzle.config.js',
23
+ 'drizzle.config.mjs',
24
+ 'drizzle.config.cjs',
25
+ ];
26
+ /** The `out` key of a drizzle config, as a string literal. */
27
+ const DRIZZLE_OUT_RE = /\bout\s*:\s*['"`]([^'"`]+)['"`]/;
28
+ /** The kit's migration line inside the canonical block (`src/lib/db/migrations/`). */
29
+ const KIT_MIGRATIONS_LINE = `${MIGRATIONS_DIR}/`;
30
+ /**
31
+ * Where THIS repo keeps its drizzle migrations, as an ignore line (`lib/db/migrations/`), or
32
+ * `null` to keep the kit's. The `out` of the repo's drizzle config wins; without one, a repo with
33
+ * no `src/` gets the kit path without its `src/` prefix — the adjustment a derivative born before
34
+ * `src/` makes by hand, which the sync used to revert on every update (Factory#83).
35
+ */
36
+ export function resolveMigrationsLine(root) {
37
+ for (const name of DRIZZLE_CONFIG_FILES) {
38
+ const file = path.join(root, name);
39
+ if (!existsSync(file))
40
+ continue;
41
+ const match = DRIZZLE_OUT_RE.exec(readFileSync(file, 'utf8'));
42
+ if (!match)
43
+ break;
44
+ const out = match[1].trim().replace(/^\.\//, '').replace(/\/+$/, '');
45
+ // An absolute path or one that climbs out of the repo is nothing prettier could match.
46
+ if (out === '' || out === '.' || path.isAbsolute(out) || out.split('/').includes('..'))
47
+ break;
48
+ return `${out}/`;
49
+ }
50
+ if (!existsSync(path.join(root, 'src')))
51
+ return KIT_MIGRATIONS_LINE.replace(/^src\//, '');
52
+ return null;
53
+ }
54
+ /**
55
+ * The kit files this repo's formatter must leave alone that live OUTSIDE the brain prefixes:
56
+ * every manifest path not under `BRAIN_PREFIXES`, minus `CLAUDE.md` (dev-owned content) and
57
+ * minus any path with a `<path>.kit-example` beside it — there the file on disk is the repo's
58
+ * own, and formatting it is the repo's business. Sorted, so the block is stable across runs.
59
+ */
60
+ export function kitFilesOutsideBrain(manifestPaths, root) {
61
+ return [...new Set(manifestPaths)]
62
+ .filter((p) => !BRAIN_PREFIXES.some((prefix) => p.startsWith(prefix)))
63
+ .filter((p) => p !== CLAUDE_MD_FILE)
64
+ .filter((p) => !existsSync(path.join(root, `${p}${KIT_EXAMPLE_SUFFIX}`)))
65
+ .sort();
66
+ }
67
+ /**
68
+ * Render the `.prettierignore` block for THIS repo from the canonical one the tarball ships.
69
+ * The canonical block only knows the Factory's own layout and covers `.claude/` and
70
+ * `scripts/tools/`; two things are generated at sync time, the same precedent as
71
+ * `renderGitignoreBlock` in `prune`:
72
+ *
73
+ * - the migrations line follows the repo's real drizzle `out` (`resolveMigrationsLine`);
74
+ * - a section lists the kit files tracked outside the brain prefixes (`ci.yml`,
75
+ * `vitest.setup.ts`, `.husky/*`). Prettier reformatted them in every derivative whose style
76
+ * differs from the kit's, and each one became a false conflict on the next update.
77
+ *
78
+ * Reads the repo (drizzle config, `src/`, `.kit-example` files) but writes nothing. The same
79
+ * inputs always give the same block, so a second sync is a no-op.
80
+ */
81
+ export function renderPrettierignoreBlock(canonical, ctx) {
82
+ const migrations = resolveMigrationsLine(ctx.root);
83
+ const lines = canonical
84
+ .replace(/\r\n/g, '\n')
85
+ .split('\n')
86
+ .map((line) => (migrations && line.trim() === KIT_MIGRATIONS_LINE ? migrations : line));
87
+ const present = new Set(lines.map((l) => l.trim()));
88
+ const extra = kitFilesOutsideBrain(ctx.manifestPaths, ctx.root).filter((p) => !present.has(p));
89
+ if (extra.length === 0)
90
+ return lines.join('\n');
91
+ const section = [
92
+ '# Kit files tracked outside .claude/ and scripts/tools/, generated from the manifest by',
93
+ '# factory add/update. A reformat drifts them from the lockfile just like the brain.',
94
+ ...extra,
95
+ ];
96
+ const end = lines.findIndex((l) => l.trimStart().startsWith(MANAGED_BLOCK_END));
97
+ if (end === -1)
98
+ return [...lines, ...section].join('\n');
99
+ return [...lines.slice(0, end), ...section, ...lines.slice(end)].join('\n');
100
+ }
101
+ /**
102
+ * Sync the Factory's managed `.gitattributes` block (the EOL/binary rules that
103
+ * keep the kit's committed binaries from being corrupted by CRLF→LF normalization)
104
+ * into the derived repo, preserving the dev's own rules. The canonical block is
105
+ * READ from the staged tarball (single SSOT), so the rules are never duplicated in
106
+ * the CLI. Runs even without package.json (a non-Node derivative needs the rules
107
+ * too).
108
+ */
109
+ export function syncDerivedGitattributes(rootDir, stagedDir) {
110
+ try {
111
+ const srcPath = path.join(stagedDir, GITATTRIBUTES_FILE);
112
+ if (!existsSync(srcPath))
113
+ return;
114
+ const block = extractManagedBlock(readFileSync(srcPath, 'utf8'));
115
+ if (!block)
116
+ return;
117
+ const { action } = syncManagedBlock(rootDir, block);
118
+ if (action === 'unchanged')
119
+ return;
120
+ console.log(action === 'created'
121
+ ? '✔ `.gitattributes` creado con las reglas de normalización del kit.'
122
+ : '✔ `.gitattributes`: bloque de reglas del kit sincronizado.');
123
+ }
124
+ catch {
125
+ console.warn('Aviso: no se pudo sincronizar `.gitattributes`; el cerebro se instaló igual.');
126
+ }
127
+ }
128
+ /**
129
+ * Sync the Factory's managed `.prettierignore` block (keeps the derived repo's
130
+ * formatter away from `.claude/**` — a pre-commit `prettier --write` over the kit
131
+ * files drifts disk from the lockfile hashes and turns every later update into
132
+ * false conflicts) into the derived repo, preserving the dev's own ignore rules.
133
+ * The block is rendered for this repo (`renderPrettierignoreBlock`); otherwise the
134
+ * same contract as `syncDerivedGitattributes`.
135
+ */
136
+ export function syncDerivedPrettierignore(rootDir, stagedDir, manifestPaths) {
137
+ try {
138
+ const srcPath = path.join(stagedDir, PRETTIERIGNORE_FILE);
139
+ if (!existsSync(srcPath))
140
+ return;
141
+ const canonical = extractManagedBlock(readFileSync(srcPath, 'utf8'));
142
+ if (!canonical)
143
+ return;
144
+ const block = renderPrettierignoreBlock(canonical, { manifestPaths, root: rootDir });
145
+ const { action } = syncManagedBlock(rootDir, block, PRETTIERIGNORE_FILE);
146
+ if (action === 'unchanged')
147
+ return;
148
+ console.log(action === 'created'
149
+ ? '✔ `.prettierignore` creado con el ignore del cerebro (`.claude/`) — evita falsos conflictos por formato en futuros updates.'
150
+ : '✔ `.prettierignore`: bloque del kit sincronizado (`.claude/` fuera del formatter).');
151
+ }
152
+ catch {
153
+ console.warn('Aviso: no se pudo sincronizar `.prettierignore`; el cerebro se instaló igual.');
154
+ }
155
+ }
156
+ /**
157
+ * Sync both managed blocks. `manifest` is the profile's manifest just installed (the
158
+ * `.prettierignore` block lists its files outside the brain prefixes). Must run while
159
+ * `stagedDir` still exists (it is the source of the blocks) and BEFORE any commit of the brain,
160
+ * so the ignore lands before a pre-commit hook could reformat the freshly-installed kit files.
161
+ */
162
+ export function syncDerivedDotfiles(rootDir, stagedDir, manifest) {
163
+ syncDerivedGitattributes(rootDir, stagedDir);
164
+ syncDerivedPrettierignore(rootDir, stagedDir, manifest.files.map((f) => f.path));
165
+ }
@@ -16,10 +16,7 @@
16
16
  * 🔒 TOTAL (never throws): it runs on values read from a deploy, which the CLI does not control.
17
17
  */
18
18
  export function sendingDomainOf(emailFrom) {
19
- const value = emailFrom.trim();
20
- const open = value.lastIndexOf('<');
21
- const close = value.lastIndexOf('>');
22
- const address = open !== -1 && close > open ? value.slice(open + 1, close) : value;
19
+ const address = senderAddressOf(emailFrom);
23
20
  const at = address.lastIndexOf('@');
24
21
  return at === -1
25
22
  ? ''
@@ -28,3 +25,14 @@ export function sendingDomainOf(emailFrom) {
28
25
  .trim()
29
26
  .toLowerCase();
30
27
  }
28
+ /**
29
+ * The bare address of an `EMAIL_FROM` value: the part inside the LAST `<…>` when there is one,
30
+ * else the whole value, trimmed (`Agent Fleet <noreply@x.com>` → `noreply@x.com`). Same rule as
31
+ * {@link sendingDomainOf}. 🔒 TOTAL (never throws).
32
+ */
33
+ export function senderAddressOf(emailFrom) {
34
+ const value = emailFrom.trim();
35
+ const open = value.lastIndexOf('<');
36
+ const close = value.lastIndexOf('>');
37
+ return (open !== -1 && close > open ? value.slice(open + 1, close) : value).trim();
38
+ }
@@ -0,0 +1,14 @@
1
+ /** Read the value of a `--key=value` or `--key value` flag from an argv slice. */
2
+ export function readValueFlag(argv, name) {
3
+ const prefix = `--${name}=`;
4
+ const inline = argv.find((arg) => arg.startsWith(prefix));
5
+ if (inline)
6
+ return inline.slice(prefix.length);
7
+ const idx = argv.indexOf(`--${name}`);
8
+ if (idx !== -1) {
9
+ const next = argv[idx + 1];
10
+ if (next && !next.startsWith('-'))
11
+ return next;
12
+ }
13
+ return undefined;
14
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * What a FIRST install (`factory add`, and the legacy auto-register of `factory update`) may
3
+ * write over a repo that already has files of its own (Factory#76).
4
+ *
5
+ * Both paths used to write every manifest file without looking, which is right for the brain
6
+ * (`.claude/`, `scripts/tools/`) and wrong for the handful of kit files that live among the
7
+ * repo's own: an `add --full` replaced a repo's `.github/workflows/ci.yml` with the kit
8
+ * template and dropped its build, E2E and gitleaks steps. Three classes, decided by path:
9
+ *
10
+ * - brain (`BRAIN_PREFIXES`) → always written, as before;
11
+ * - `CLAUDE.md` → written only when absent (dev-owned content, unchanged rule);
12
+ * - every other kit file → written when absent; left alone when it already holds the kit's
13
+ * content; KEPT when it holds something else, with the kit's copy written next to it as
14
+ * `<path>.kit-example` for the team to compare.
15
+ *
16
+ * The lockfile still records the KIT hash for a kept file (it is the embedded manifest), the
17
+ * same as a preserved `CLAUDE.md`. On the next `update` the diff engine then reads it as a local
18
+ * edit: kept silently while the kit does not change it (`ignoreLocal`), a mine/theirs prompt
19
+ * when it does.
20
+ */
21
+ import { copyFileSync, existsSync, readFileSync, statSync } from 'node:fs';
22
+ import path from 'node:path';
23
+ import { BRAIN_PREFIXES, CLAUDE_MD_FILE, KIT_EXAMPLE_SUFFIX } from './constants.js';
24
+ import { normalizeThenHash } from './lockfile.js';
25
+ /** Classify a manifest path (see the module header for what each class does). */
26
+ export function classifyInitialWrite(relPath) {
27
+ if (BRAIN_PREFIXES.some((prefix) => relPath.startsWith(prefix)))
28
+ return 'brain';
29
+ if (relPath === CLAUDE_MD_FILE)
30
+ return 'claude-md';
31
+ return 'repo';
32
+ }
33
+ /** The kit's copy of a kept repo file: the suffix goes after the WHOLE name. */
34
+ export function kitExamplePath(relPath) {
35
+ return `${relPath}${KIT_EXAMPLE_SUFFIX}`;
36
+ }
37
+ /**
38
+ * Split the manifest into what a first install writes and what it keeps. Reads the repo, writes
39
+ * nothing. A `repo`-class file whose normalized hash equals the kit's is neither written nor
40
+ * kept: it already is the kit's file.
41
+ */
42
+ export function partitionInitialWrites(root, manifest) {
43
+ const writes = [];
44
+ const kept = [];
45
+ for (const entry of manifest.files) {
46
+ const abs = path.join(root, entry.path);
47
+ const onDisk = existsSync(abs) && statSync(abs).isFile();
48
+ switch (classifyInitialWrite(entry.path)) {
49
+ case 'brain':
50
+ writes.push(entry.path);
51
+ break;
52
+ case 'claude-md':
53
+ if (!onDisk)
54
+ writes.push(entry.path);
55
+ break;
56
+ case 'repo':
57
+ if (!onDisk)
58
+ writes.push(entry.path);
59
+ else if (normalizeThenHash(readFileSync(abs, 'utf8')) !== entry.hash)
60
+ kept.push(entry.path);
61
+ break;
62
+ }
63
+ }
64
+ return { writes, kept };
65
+ }
66
+ /**
67
+ * Copy the kit's version of each kept file from the staged tarball to `<path>.kit-example`.
68
+ * Returns the example paths actually written (a path missing from the staged dir is skipped).
69
+ */
70
+ export function writeKitExamples(root, stagedDir, kept) {
71
+ const written = [];
72
+ for (const rel of kept) {
73
+ const src = path.join(stagedDir, rel);
74
+ if (!existsSync(src))
75
+ continue;
76
+ copyFileSync(src, path.join(root, kitExamplePath(rel)));
77
+ written.push(kitExamplePath(rel));
78
+ }
79
+ return written;
80
+ }
81
+ /**
82
+ * `update`: refresh every `<path>.kit-example` already on disk with the incoming kit version,
83
+ * so the copy the team compares against never goes stale. Only paths of the manifest, only
84
+ * examples that already exist (an update never creates one), only when the bytes change.
85
+ * Returns the example paths rewritten.
86
+ */
87
+ export function refreshKitExamples(root, stagedDir, manifestPaths) {
88
+ const refreshed = [];
89
+ for (const rel of manifestPaths) {
90
+ if (classifyInitialWrite(rel) !== 'repo')
91
+ continue;
92
+ const example = path.join(root, kitExamplePath(rel));
93
+ const src = path.join(stagedDir, rel);
94
+ if (!existsSync(example) || !existsSync(src))
95
+ continue;
96
+ const incoming = readFileSync(src);
97
+ if (readFileSync(example).equals(incoming))
98
+ continue;
99
+ copyFileSync(src, example);
100
+ refreshed.push(kitExamplePath(rel));
101
+ }
102
+ return refreshed;
103
+ }
104
+ /** Tell the dev which files were kept and where the kit's version went. */
105
+ export function reportKeptRepoFiles(kept) {
106
+ if (kept.length === 0)
107
+ return;
108
+ console.log('\nConservé estos archivos tuyos; la versión del kit quedó al lado para comparar:');
109
+ for (const rel of kept)
110
+ console.log(` • ${rel} → ${kitExamplePath(rel)}`);
111
+ console.log(' Si quieres algo de la versión del kit, cópialo a mano. Mientras el kit no cambie esos ' +
112
+ 'archivos, `factory update` los deja como están; si los cambia, te pregunta cuál conservar.');
113
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The npm packages the kit's files need, against the ones the repo declares (Factory#78).
3
+ *
4
+ * A derivative of an older kit (or an app that never was one) gets kit files that import
5
+ * packages it does not have — `sharp`, `@testing-library/*`, `knip`… — and its typecheck,
6
+ * `next build` and CI go red with nothing telling it why. The builder embeds the list in
7
+ * every tarball (`.timekast/kit-deps.json`, `distribution/build-dist.ts#buildKitDeps`):
8
+ * each package with the range the Factory declares. `add` and `update` compare it with the
9
+ * repo's `package.json` and OFFER to install what is missing:
10
+ *
11
+ * · interactive terminal → asks, and on yes runs `<pm> add -D …` in the repo;
12
+ * · no terminal, or `--yes` → only prints the exact command (never installs blindly —
13
+ * installing a dependency is the dev's call, `CODING.md §7`);
14
+ * · `--dry-run` → only lists them.
15
+ *
16
+ * A repo without `package.json` is not a Node project: nothing is offered, and none is
17
+ * ever created. A tarball from before this list existed has no file: nothing is offered.
18
+ */
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import { execa } from 'execa';
22
+ import prompts from 'prompts';
23
+ import { KIT_DEPS_FILE, TIMEKAST_DIR } from './constants.js';
24
+ import { PACKAGE_JSON_FILE, PNPM_LOCKFILE } from './initial-migration.js';
25
+ /** Lockfile → package manager, in detection order. No lockfile → `pnpm` (the kit's). */
26
+ const PM_LOCKFILES = [
27
+ [PNPM_LOCKFILE, 'pnpm'],
28
+ ['yarn.lock', 'yarn'],
29
+ ['package-lock.json', 'npm'],
30
+ ['bun.lockb', 'bun'],
31
+ ['bun.lock', 'bun'],
32
+ ];
33
+ /** The "add as devDependency" verb of each package manager. */
34
+ const PM_ADD_DEV = {
35
+ pnpm: ['add', '-D'],
36
+ yarn: ['add', '-D'],
37
+ npm: ['install', '-D'],
38
+ bun: ['add', '-d'],
39
+ };
40
+ /**
41
+ * Read the list from a STAGED tarball. Absent (a release from before the list) → `null`,
42
+ * silently. Unreadable or with the wrong shape → `null` with a warning: the brain still
43
+ * installs, the offer is just skipped.
44
+ */
45
+ export function readKitDeps(stagedDir) {
46
+ const file = path.join(stagedDir, TIMEKAST_DIR, KIT_DEPS_FILE);
47
+ if (!existsSync(file))
48
+ return null;
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
51
+ const packages = parsed.packages;
52
+ if (typeof packages !== 'object' ||
53
+ packages === null ||
54
+ Array.isArray(packages) ||
55
+ !Object.values(packages).every((range) => typeof range === 'string')) {
56
+ throw new Error('shape');
57
+ }
58
+ return parsed;
59
+ }
60
+ catch {
61
+ console.warn(`Aviso: el \`${TIMEKAST_DIR}/${KIT_DEPS_FILE}\` del tarball no se pudo leer; omito la revisión de dependencias.`);
62
+ return null;
63
+ }
64
+ }
65
+ /**
66
+ * The kit packages the repo's `package.json` does not declare (in `dependencies`,
67
+ * `devDependencies`, `optionalDependencies` or `peerDependencies`), as `name@range`
68
+ * specs sorted by name — pure.
69
+ */
70
+ export function missingKitDeps(pkg, kitDeps) {
71
+ const declared = new Set();
72
+ for (const field of [
73
+ 'dependencies',
74
+ 'devDependencies',
75
+ 'optionalDependencies',
76
+ 'peerDependencies',
77
+ ]) {
78
+ const deps = pkg[field];
79
+ if (deps && typeof deps === 'object')
80
+ for (const name of Object.keys(deps))
81
+ declared.add(name);
82
+ }
83
+ return Object.keys(kitDeps.packages)
84
+ .filter((name) => !declared.has(name))
85
+ .sort()
86
+ .map((name) => `${name}@${kitDeps.packages[name]}`);
87
+ }
88
+ /** The repo's package manager, by its lockfile — pure over `exists`. */
89
+ export function detectPackageManager(rootDir, exists = existsSync) {
90
+ return PM_LOCKFILES.find(([file]) => exists(path.join(rootDir, file)))?.[1] ?? 'pnpm';
91
+ }
92
+ /** `<pm>` + its args to add `specs` as devDependencies. */
93
+ export function installArgs(pm, specs) {
94
+ return [pm, [...PM_ADD_DEV[pm], ...specs]];
95
+ }
96
+ /** The command as a dev types it: each spec quoted when the shell could read it (`^` in zsh). */
97
+ export function installCommand(pm, specs) {
98
+ const [bin, args] = installArgs(pm, specs);
99
+ const quote = (arg) => (/^[A-Za-z0-9@/._=-]+$/.test(arg) ? arg : `'${arg}'`);
100
+ return [bin, ...args.map(quote)].join(' ');
101
+ }
102
+ async function defaultConfirm(question) {
103
+ const answer = (await prompts({
104
+ type: 'confirm',
105
+ name: 'ok',
106
+ message: question,
107
+ initial: true,
108
+ }));
109
+ return answer?.ok === true;
110
+ }
111
+ async function defaultRun(bin, args, cwd) {
112
+ const result = await execa(bin, args, { cwd, stdio: 'inherit', reject: false });
113
+ return result.exitCode ?? 1;
114
+ }
115
+ /**
116
+ * Compare the kit's list with the repo's `package.json` and offer to install what is
117
+ * missing (see the module doc for who gets asked). Never throws for a missing or invalid
118
+ * `package.json` — the brain is installed either way.
119
+ */
120
+ export async function offerKitDeps(rootDir, kitDeps, opts = {}) {
121
+ const pkgPath = path.join(rootDir, PACKAGE_JSON_FILE);
122
+ if (kitDeps === null || !existsSync(pkgPath))
123
+ return { action: 'none', missing: [] };
124
+ let pkg;
125
+ try {
126
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
127
+ }
128
+ catch {
129
+ return { action: 'none', missing: [] };
130
+ }
131
+ const missing = missingKitDeps(pkg, kitDeps);
132
+ if (missing.length === 0)
133
+ return { action: 'none', missing };
134
+ const pm = detectPackageManager(rootDir);
135
+ const command = installCommand(pm, missing);
136
+ console.log(`\nLos archivos del kit usan ${missing.length} paquete(s) que tu \`package.json\` no declara ` +
137
+ '(sin ellos el typecheck, el build o el CI pueden fallar):');
138
+ for (const spec of missing)
139
+ console.log(` • ${spec}`);
140
+ if (opts.dryRun) {
141
+ console.log(`Para instalarlos: \`${command}\``);
142
+ return { action: 'listed', missing };
143
+ }
144
+ const isTTY = opts.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
145
+ if (opts.assumeYes || !isTTY) {
146
+ console.log(`Instálalos con: \`${command}\``);
147
+ return { action: 'printed', missing };
148
+ }
149
+ const confirm = opts.confirm ?? defaultConfirm;
150
+ if (!(await confirm(`¿Los instalo ahora como devDependencies? (\`${command}\`)`))) {
151
+ console.log(`No instalé nada. Cuando quieras: \`${command}\``);
152
+ return { action: 'declined', missing };
153
+ }
154
+ const [bin, args] = installArgs(pm, missing);
155
+ const exitCode = await (opts.run ?? defaultRun)(bin, args, rootDir);
156
+ if (exitCode !== 0) {
157
+ console.warn(`Aviso: \`${command}\` falló (código ${exitCode}). Revísalo y repítelo a mano.`);
158
+ return { action: 'failed', missing };
159
+ }
160
+ console.log(`✔ Paquetes instalados. \`package.json\` y el lockfile de \`${pm}\` quedaron modificados ` +
161
+ 'y sin commitear: revísalos y commitéalos tú.');
162
+ return { action: 'installed', missing };
163
+ }