@timekast/factory 2.4.0 → 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.
- package/dist/commands/add.js +29 -8
- package/dist/commands/doctor.js +32 -3
- package/dist/commands/env.js +16 -4
- package/dist/commands/provision.js +85 -27
- package/dist/commands/prune.js +60 -10
- package/dist/commands/update.js +104 -85
- package/dist/commands/usage.js +303 -0
- package/dist/commands/vault.js +81 -18
- package/dist/index.js +26 -0
- package/dist/lib/backlog-mapping.js +1 -1
- package/dist/lib/claude-paths.js +8 -2
- package/dist/lib/claude-settings.js +104 -0
- package/dist/lib/constants.js +26 -0
- package/dist/lib/derived-dotfiles.js +165 -0
- package/dist/lib/email-from.js +12 -4
- package/dist/lib/initial-writes.js +113 -0
- package/dist/lib/kit-deps.js +163 -0
- package/dist/lib/kit-settings-history.json +13 -0
- package/dist/lib/lockfile.js +10 -5
- package/dist/lib/prune-remote.js +8 -6
- package/dist/lib/prune-vercel.js +137 -0
- package/dist/lib/rail.js +5 -3
- package/dist/lib/railway-api.js +3 -0
- package/dist/lib/usage-scan.js +516 -0
- package/dist/lib/vault-adopt.js +103 -5
- package/dist/lib/vault-api.js +2 -2
- package/dist/lib/vercel-env.js +21 -0
- package/package.json +1 -1
package/dist/commands/vault.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* Contract: `fx-secrets-vault §8`. The decisions live in `lib/vault-adopt.ts`; this module runs
|
|
4
4
|
* them against the real services, in the order the contract fixes:
|
|
5
5
|
*
|
|
6
|
-
* Preconditions (both modes) —
|
|
7
|
-
* (the comparison of `factory status`); no fork in the
|
|
8
|
-
* `factory update --verify`).
|
|
6
|
+
* Preconditions (both modes) — the wrapper on disk (`full` and `core` both ship it; missing →
|
|
7
|
+
* `factory update`); the brain up to date (the comparison of `factory status`); no fork in the
|
|
8
|
+
* files the wrapper runs (the census of `factory update --verify`). A `core` repo is accepted:
|
|
9
|
+
* its own `dev`/`build` scripts read as customized and run without the vault until the team
|
|
10
|
+
* prefixes them with the wrapper (Factory#76).
|
|
9
11
|
*
|
|
10
12
|
* Plan (no `--apply`) — inventory by NAME and display hash of `.env.local`, Vercel production +
|
|
11
13
|
* preview and Railway; the state of each key, where the apply would write it, the confirmations
|
|
@@ -39,11 +41,10 @@ import { WITH_VAULT_SCRIPT_FILE } from '../lib/constants.js';
|
|
|
39
41
|
import { parseEnvBody } from '../lib/env-wizard.js';
|
|
40
42
|
import { hasLockfile, readLockfile } from '../lib/lockfile.js';
|
|
41
43
|
import { getConnectionUri, getNeonProjectOrg, isNeonKeyFamily, listNeonApiKeys, listNeonBranches, NEON_DEVELOP_BRANCH, NeonApiKeyListError, } from '../lib/neon-api.js';
|
|
42
|
-
import { detectCoreProfile } from '../lib/provision-preflight.js';
|
|
43
44
|
import { readDestroyTarget, readNeonRevokePending, readVaultDeclined, readVaultLink, addNeonRevokePending, } from '../lib/provision-state.js';
|
|
44
45
|
import { createRailResolver, RAIL_TIMEKAST } from '../lib/rail.js';
|
|
45
46
|
import { createRailwayClient, RAILWAY_TOKEN_KEY, railwayFetchTransport, } from '../lib/railway-api.js';
|
|
46
|
-
import { buildPlanEntries, classifySensitive, createDisplayHasher, describeWrite, ENV_LOCAL_BACKUP_FILE, ENV_LOCAL_FILE, FACTORY_REPO, FACTORY_SLUG, folderOfTarget, isConnectedEmptyDeploy, isFactoryIdentity, manualVaultProjectSteps, isFactoryMode, isProjectScopedNeonKey, isRailKey, isRailwayProvided, isRailwaySyncTo, listRailwayProjects, MFA_KEY, NEON_API_KEY, planAdoptionLayout, planRailwayImport, proposeRailwaySyncs, RAILWAY_VAULT_ENVS, railwaySyncDifferences, railwaySyncLabel, railwaySyncName, readRailwayInventory, readRailwayVariables, recordedAdoptionVercelId, RESEND_KEY, retiredKeys, retireEnvLocal, sealedStopExplanation, sharedCandidatesOf, SHORTIO_KEY, wrapperDrift, writeAdoptionLink, writeDeclineMark, } from '../lib/vault-adopt.js';
|
|
47
|
+
import { buildPlanEntries, classifySensitive, adoptSender, createDisplayHasher, dangerousSharedTargets, describeWrite, ENV_LOCAL_BACKUP_FILE, ENV_LOCAL_FILE, FACTORY_REPO, FACTORY_SLUG, folderOfTarget, isConnectedEmptyDeploy, isFactoryIdentity, manualVaultProjectSteps, isFactoryMode, isProjectScopedNeonKey, isRailKey, isRailwayProvided, isRailwaySyncTo, listRailwayProjects, MFA_KEY, NEON_API_KEY, planAdoptionLayout, planRailwayImport, proposeRailwaySyncs, RAILWAY_VAULT_ENVS, railwaySyncDifferences, railwaySyncLabel, railwaySyncName, readRailwayInventory, readRailwayVariables, recordedAdoptionVercelId, RESEND_KEY, retiredKeys, retireEnvLocal, sealedStopExplanation, SENDER_KEYS, sharedCandidatesOf, sharedDatabaseKeys, SHORTIO_KEY, vaultWriteConflicts, wrapperDrift, writeAdoptionLink, writeDeclineMark, } from '../lib/vault-adopt.js';
|
|
47
48
|
import { createVaultClient, formatLocation, VAULT_CI_PATH, VAULT_ENV_DEVELOP, VAULT_ENV_LOCAL, VAULT_ENV_MAIN, VAULT_ROOT_PATH, VAULT_TEST_ISOLATION_KEY, VaultSeamMissingError, } from '../lib/vault-api.js';
|
|
48
49
|
import { LOCAL_ONLY_KEYS } from '../lib/env-file.js';
|
|
49
50
|
import { buildEnvPlan, deployHostCandidates, generateAuthSecret, listEnvVarNames, listProjectDomains, locateVercelProject, readEnvVarsWithValues, relatedOriginsFor, } from '../lib/vercel-env.js';
|
|
@@ -321,6 +322,12 @@ const UNKNOWN = 'unknown';
|
|
|
321
322
|
* reports (`resolveSuccessExitCode`), distinct from 0 so no one — person or agent — reads it as done.
|
|
322
323
|
*/
|
|
323
324
|
export const VAULT_ADOPT_SEALED_EXIT_CODE = 3;
|
|
325
|
+
/** Factory#73 — why a shared Vercel record blocks (plan and apply say it the same way). */
|
|
326
|
+
const SHARED_RECORDS_REASON = 'Variables de Vercel que son UN solo registro para production y preview, y develop tendría su ' +
|
|
327
|
+
'propio valor: el sync de preview reescribiría el registro y producción terminaría con el de develop';
|
|
328
|
+
/** Factory#75 — why a shared database key needs a yes. */
|
|
329
|
+
const SHARED_DATABASES_REASON = 'Preview tiene la MISMA base que producción: los previews y el entorno local escribirían en la ' +
|
|
330
|
+
'base de producción';
|
|
324
331
|
function realField(value) {
|
|
325
332
|
return value && value !== UNKNOWN ? value : null;
|
|
326
333
|
}
|
|
@@ -331,6 +338,7 @@ function vercelMaps(vars) {
|
|
|
331
338
|
const branchScoped = [];
|
|
332
339
|
const sensitive = [];
|
|
333
340
|
const unreadableOther = new Set();
|
|
341
|
+
const sharedTargets = new Set();
|
|
334
342
|
const note = (key, type, value, target) => {
|
|
335
343
|
if (value !== null)
|
|
336
344
|
return;
|
|
@@ -340,6 +348,9 @@ function vercelMaps(vars) {
|
|
|
340
348
|
unreadableOther.add(key);
|
|
341
349
|
};
|
|
342
350
|
for (const env of vars) {
|
|
351
|
+
if (env.target.includes('production') && env.target.includes('preview') && !env.gitBranch) {
|
|
352
|
+
sharedTargets.add(env.key);
|
|
353
|
+
}
|
|
343
354
|
if (env.target.includes('production')) {
|
|
344
355
|
production.set(env.key, env.value);
|
|
345
356
|
note(env.key, env.type, env.value, 'production');
|
|
@@ -359,6 +370,7 @@ function vercelMaps(vars) {
|
|
|
359
370
|
branchScoped,
|
|
360
371
|
sensitive,
|
|
361
372
|
unreadableOther: [...unreadableOther].sort(),
|
|
373
|
+
sharedTargets: [...sharedTargets].sort(),
|
|
362
374
|
};
|
|
363
375
|
}
|
|
364
376
|
/** Why a derivation needs a host, and which one. */
|
|
@@ -1011,10 +1023,14 @@ async function prepareAdoption(rootDir, flags, deps) {
|
|
|
1011
1023
|
const resendTeamKey = hasResend ? await rail.getOptional(RESEND_KEY) : null;
|
|
1012
1024
|
const replaceResend = resendTeamKey !== null &&
|
|
1013
1025
|
[values.production, values.preview, values.local].some((m) => m.get(RESEND_KEY) === resendTeamKey);
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1026
|
+
// Factory#74 — the repo's own sender, under any name it uses; the kit's default only when none.
|
|
1027
|
+
const found = adoptSender(values);
|
|
1028
|
+
if (found.sender === null && replaceResend && found.unknown.length > 0) {
|
|
1029
|
+
throw new CLIError(`No encuentro el remitente del repo en ${SENDER_KEYS.join(', ')}, pero ${found.unknown.join(', ')} ` +
|
|
1030
|
+
'tiene una dirección de correo. No acuño la key de Resend para el dominio del kit a ciegas: ' +
|
|
1031
|
+
'agrega `EMAIL_FROM` con el remitente real al deploy y repite. No se escribió nada.');
|
|
1032
|
+
}
|
|
1033
|
+
const sender = found.sender ?? PROVISION_EMAIL_FROM;
|
|
1018
1034
|
const resendDomain = sendingDomainOf(sender);
|
|
1019
1035
|
const client = deps.vaultClient ?? createVaultClient();
|
|
1020
1036
|
const githubSecretNames = repo
|
|
@@ -1207,6 +1223,8 @@ async function prepareAdoption(rootDir, flags, deps) {
|
|
|
1207
1223
|
sealed: [],
|
|
1208
1224
|
sealedExplanation: null,
|
|
1209
1225
|
branchScoped: vercel.branchScoped,
|
|
1226
|
+
sharedRecords: dangerousSharedTargets(vercel.sharedTargets, writes),
|
|
1227
|
+
sharedDatabases: sharedDatabaseKeys(writes, neonProjectId !== null),
|
|
1210
1228
|
confirmations: confirmations.map((c) => c.name),
|
|
1211
1229
|
github,
|
|
1212
1230
|
wrapper: {
|
|
@@ -1235,6 +1253,7 @@ async function prepareAdoption(rootDir, flags, deps) {
|
|
|
1235
1253
|
hosts,
|
|
1236
1254
|
hostCandidates,
|
|
1237
1255
|
vaultProjectExists,
|
|
1256
|
+
sharedTargets: vercel.sharedTargets,
|
|
1238
1257
|
vercelToken,
|
|
1239
1258
|
teamId,
|
|
1240
1259
|
resendTeamKey: replaceResend ? resendTeamKey : null,
|
|
@@ -1354,6 +1373,14 @@ function printPlan(plan, apply) {
|
|
|
1354
1373
|
`${plan.branchScoped.map((v) => `${v.name} (rama ${v.branch})`).join(', ')}. ` +
|
|
1355
1374
|
'Decide en Vercel (quítales la rama o bórralas) y repite. Bloquean el --apply.');
|
|
1356
1375
|
}
|
|
1376
|
+
if (plan.sharedRecords.length > 0) {
|
|
1377
|
+
out(`\n🔴 ${SHARED_RECORDS_REASON}: ${plan.sharedRecords.join(', ')}. ` +
|
|
1378
|
+
'Sepáralas en Vercel (un registro por entorno) y repite. Bloquean el --apply.');
|
|
1379
|
+
}
|
|
1380
|
+
if (plan.sharedDatabases.length > 0) {
|
|
1381
|
+
out(`\n⚠ ${SHARED_DATABASES_REASON}: ${plan.sharedDatabases.join(', ')}. ` +
|
|
1382
|
+
'El --apply te pide confirmarlo; si no es lo que quieres, dale a preview su propia base en el deploy y repite.');
|
|
1383
|
+
}
|
|
1357
1384
|
if (plan.railwaySyncs.length > 0) {
|
|
1358
1385
|
out(`\nRailway${plan.railway ? ` (proyecto \`${plan.railway.project}\`, id ${plan.railway.projectId})` : ''} — ` +
|
|
1359
1386
|
'syncs propuestos (confirmas cada uno; el que no confirmes no se importa ni se crea): ' +
|
|
@@ -1483,6 +1510,32 @@ async function writeLayout(prepared, projectId, deps) {
|
|
|
1483
1510
|
}
|
|
1484
1511
|
return resend;
|
|
1485
1512
|
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Factory#72 — the writes are `create-if-missing`: a key the vault already holds keeps its value,
|
|
1515
|
+
* and the first sync carries THAT value to the deploy. A vault value that differs from what the
|
|
1516
|
+
* deploy runs (a stale copy, a Vercel ciphertext imported by hand) stops the apply BEFORE writing.
|
|
1517
|
+
*/
|
|
1518
|
+
async function assertVaultMatchesPlan(prepared, projectId) {
|
|
1519
|
+
const locations = new Map();
|
|
1520
|
+
for (const write of prepared.writes) {
|
|
1521
|
+
if (write.location.env !== VAULT_ENV_LOCAL) {
|
|
1522
|
+
locations.set(formatLocation(write.location), write.location);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
const existing = new Map();
|
|
1526
|
+
for (const [where, location] of locations) {
|
|
1527
|
+
existing.set(where, await prepared.client.readSecrets(projectId, location, { expandReferences: true }));
|
|
1528
|
+
}
|
|
1529
|
+
const conflicts = vaultWriteConflicts(prepared.writes, existing, createDisplayHasher());
|
|
1530
|
+
if (conflicts.length === 0)
|
|
1531
|
+
return;
|
|
1532
|
+
throw new CLIError('La bóveda ya tiene OTRO valor en claves que el deploy usa, y el primer sync lo llevaría al deploy: ' +
|
|
1533
|
+
conflicts
|
|
1534
|
+
.map((c) => `${c.location} ${c.key} (bóveda ${c.vault} ≠ deploy ${c.plan})`)
|
|
1535
|
+
.join(', ') +
|
|
1536
|
+
'. Lo que corre hoy es el deploy: corrige o borra esas claves en la bóveda y repite. ' +
|
|
1537
|
+
'No escribí ningún secreto ni creé ningún sync.');
|
|
1538
|
+
}
|
|
1486
1539
|
/**
|
|
1487
1540
|
* Right before creating the syncs: the vault folder must hold every key its Vercel target holds
|
|
1488
1541
|
* (import first — the first sync deletes nothing), except the retirements already confirmed; and
|
|
@@ -1753,6 +1806,23 @@ async function applyAdoption(prepared, deps) {
|
|
|
1753
1806
|
`${plan.railwayConflicts.join(', ')}. Una carpeta tiene un solo valor por clave y no elijo uno a ` +
|
|
1754
1807
|
'ciegas: iguálalos en Railway, o no confirmes uno de esos syncs. No se escribió nada.');
|
|
1755
1808
|
}
|
|
1809
|
+
// Factory#73 — recomputed on the final writes: a materialized value can give develop its own.
|
|
1810
|
+
const sharedRecords = dangerousSharedTargets(prepared.sharedTargets, prepared.writes);
|
|
1811
|
+
if (sharedRecords.length > 0) {
|
|
1812
|
+
throw new CLIError(`${SHARED_RECORDS_REASON}: ${sharedRecords.join(', ')}. Sepáralas en Vercel (un registro por ` +
|
|
1813
|
+
'entorno) y repite. No se escribió nada.');
|
|
1814
|
+
}
|
|
1815
|
+
// Factory#75 — any provider, not only Neon: a yes before previews write to production's base.
|
|
1816
|
+
const sharedDatabases = sharedDatabaseKeys(prepared.writes, prepared.neonProjectId !== null);
|
|
1817
|
+
if (sharedDatabases.length > 0) {
|
|
1818
|
+
prepared.questions = [
|
|
1819
|
+
...prepared.questions,
|
|
1820
|
+
{
|
|
1821
|
+
name: `misma base: ${sharedDatabases.join(', ')}`,
|
|
1822
|
+
question: `${SHARED_DATABASES_REASON} (${sharedDatabases.join(', ')}). ¿Confirmas?`,
|
|
1823
|
+
},
|
|
1824
|
+
];
|
|
1825
|
+
}
|
|
1756
1826
|
await confirmAll(prepared, deps);
|
|
1757
1827
|
// Resolved BEFORE writing anything: without it no Railway sync can exist, so nothing is imported.
|
|
1758
1828
|
const railwayConnection = railwaySelected.length > 0 ? await requireRailwayConnection(client) : null;
|
|
@@ -1768,6 +1838,8 @@ async function applyAdoption(prepared, deps) {
|
|
|
1768
1838
|
reportDeleteProtection(layout.project.slug, layout.deleteProtection);
|
|
1769
1839
|
for (const member of layout.members.added)
|
|
1770
1840
|
console.log(` + bóveda: miembro ${member}`);
|
|
1841
|
+
// Factory#72 — what the vault already holds must be what the deploy runs, before writing.
|
|
1842
|
+
await assertVaultMatchesPlan(prepared, projectId);
|
|
1771
1843
|
// (2) the writes — import first.
|
|
1772
1844
|
const resend = await writeLayout(prepared, projectId, deps);
|
|
1773
1845
|
// (2b) ADOPT-009 — the project's Neon key, minted key-only BEFORE any sync exists (so the GitHub
|
|
@@ -2129,15 +2201,6 @@ function declineVault(rootDir, deps) {
|
|
|
2129
2201
|
export async function runVaultAdopt(flags, deps = {}) {
|
|
2130
2202
|
assertAdoptSeams(deps);
|
|
2131
2203
|
const rootDir = deps.rootDir ?? process.cwd();
|
|
2132
|
-
const core = detectCoreProfile(rootDir);
|
|
2133
|
-
if (core !== null) {
|
|
2134
|
-
const why = core === 'lockfile'
|
|
2135
|
-
? 'el lockfile declara el perfil `core`'
|
|
2136
|
-
: `no hay lockfile y falta \`${WITH_VAULT_SCRIPT_FILE}\``;
|
|
2137
|
-
console.log(`ℹ️ La bóveda no se ofrece en perfil \`core\` (${why}): ese perfil no trae el wrapper que la inyecta ` +
|
|
2138
|
-
'a los scripts. No hice nada.');
|
|
2139
|
-
return { status: 'core' };
|
|
2140
|
-
}
|
|
2141
2204
|
if (flags.decline)
|
|
2142
2205
|
return declineVault(rootDir, deps);
|
|
2143
2206
|
const prepared = await prepareAdoption(rootDir, flags, deps);
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { parseProvisionFlags, runProvision } from './commands/provision.js';
|
|
|
22
22
|
import { runPruneCommand } from './commands/prune.js';
|
|
23
23
|
import { parsePublishArgs, runPublish } from './commands/publish.js';
|
|
24
24
|
import { parseStatusFlags, runStatus } from './commands/status.js';
|
|
25
|
+
import { parseUsageFlags, runUsage } from './commands/usage.js';
|
|
25
26
|
import { runTicketCommand } from './commands/ticket.js';
|
|
26
27
|
import { parseUnpublishTargets, runUnpublish } from './commands/unpublish.js';
|
|
27
28
|
import { maybeOfferAdopt, parseUpdateFlags, runUpdate } from './commands/update.js';
|
|
@@ -44,6 +45,8 @@ Comandos:
|
|
|
44
45
|
status Reporta la versión instalada vs. la última disponible
|
|
45
46
|
doctor Detecta huérfanos, conflictos y rules sin importar + aviso de seguridad
|
|
46
47
|
(--launcher: verifica el entorno — Node ≥ 22, gh, gh auth, membresía org)
|
|
48
|
+
usage Mide el consumo real de Claude Code de TU máquina (~/.claude/projects/)
|
|
49
|
+
— corre desde cualquier carpeta, no requiere repo
|
|
47
50
|
publish <qué> Publica el entregable (proposal|mockup|both) a proposals.timekast.mx
|
|
48
51
|
unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
|
|
49
52
|
provision Aprovisiona la infra de un derivado (Neon, GitHub, DNS, backlog y su destino de
|
|
@@ -165,6 +168,22 @@ Sobre \`status\` y \`doctor\`:
|
|
|
165
168
|
gh auth, membresía del org) — termina con código 1 si falla algún check, para
|
|
166
169
|
que un lanzador GUI / CI / el TUI puedan ramificar sobre el resultado.
|
|
167
170
|
|
|
171
|
+
Sobre \`usage\`:
|
|
172
|
+
Mide el consumo REAL por transcripts (\`usage\` de cada request — sin estimadores),
|
|
173
|
+
barriendo TODOS los directorios de \`~/.claude/projects/\`. Local-only: nada sale
|
|
174
|
+
de tu máquina. Cortes: día × superficie (main loop vs subagentes), por modelo,
|
|
175
|
+
por repo, por agente, tokens por unidad entregada y rondas de revisión. Flags:
|
|
176
|
+
--month=<mes|YYYY-MM> Atajo mensual ("julio" resuelve el más reciente; alias --mes=;
|
|
177
|
+
default: mes en curso)
|
|
178
|
+
--since=YYYY-MM-DD Inicio de ventana (excluyente con --month)
|
|
179
|
+
--until=YYYY-MM-DD Fin de ventana inclusivo (default: hoy)
|
|
180
|
+
--repo=<subcadena> Filtra por repo (match sobre nombre o slug)
|
|
181
|
+
--agent=<tipo> Filtra por tipo de agente (p.ej. imp-issue-executor)
|
|
182
|
+
--anon Anonimiza los repos (repo-01, repo-02…) para reportes compartibles
|
|
183
|
+
--json Salida machine-readable estable (diffeable entre corridas)
|
|
184
|
+
Privacidad: solo agregados — fechas, conteos, tokens, nombres de modelo/agente.
|
|
185
|
+
Nunca contenido de mensajes, paths de archivos del cliente ni código.
|
|
186
|
+
|
|
168
187
|
Sobre \`env\`:
|
|
169
188
|
\`env push\` sube las product vars de \`.env.local\` a Vercel (production+preview),
|
|
170
189
|
sin tocar las provision-owned. En un repo con bóveda (bloque \`vault\`) no escribe:
|
|
@@ -336,6 +355,13 @@ async function main(argv) {
|
|
|
336
355
|
}
|
|
337
356
|
return;
|
|
338
357
|
}
|
|
358
|
+
case 'usage': {
|
|
359
|
+
// 🔴 No repo preflight — ever. The data source is `~/.claude/projects/`
|
|
360
|
+
// (the developer's home), so this command runs from ANY folder: copying
|
|
361
|
+
// the update/status/doctor preflight here breaks the whole use case.
|
|
362
|
+
runUsage(parseUsageFlags(rest));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
339
365
|
case 'provision': {
|
|
340
366
|
await runProvision(parseProvisionFlags(rest));
|
|
341
367
|
return;
|
|
@@ -183,7 +183,7 @@ function vaultFailureReason(error, link) {
|
|
|
183
183
|
}
|
|
184
184
|
if (error instanceof RailNotInstalledError) {
|
|
185
185
|
return ('El CLI de la bóveda (`infisical`) no está instalado; instálalo con ' +
|
|
186
|
-
|
|
186
|
+
`${INFISICAL_INSTALL_HINT} y entra con \`${loginCommand()}\`.`);
|
|
187
187
|
}
|
|
188
188
|
if (error instanceof VaultNoAccessError) {
|
|
189
189
|
return (`Tu sesión de la bóveda no tiene acceso al proyecto \`${link.projectId}\` de este repo ` +
|
package/dist/lib/claude-paths.js
CHANGED
|
@@ -61,8 +61,14 @@ function dropGitignored(rootDir, paths) {
|
|
|
61
61
|
return paths.filter((p) => !ignored.has(p));
|
|
62
62
|
}
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
64
|
+
* The project's own Claude Code hooks (`fx-extension-points`): dev-owned, never shipped, so
|
|
65
|
+
* never an orphan nor an ambiguous kit file.
|
|
66
|
+
*/
|
|
67
|
+
export const PROJECT_HOOKS_DIR = '.claude/hooks/project/';
|
|
68
|
+
/**
|
|
69
|
+
* Repo-relative POSIX paths of NON-gitignored files under `.claude/`, minus the project's own
|
|
70
|
+
* hooks ({@link PROJECT_HOOKS_DIR}).
|
|
65
71
|
*/
|
|
66
72
|
export function collectClaudePaths(rootDir) {
|
|
67
|
-
return dropGitignored(rootDir, walkClaude(rootDir));
|
|
73
|
+
return dropGitignored(rootDir, walkClaude(rootDir)).filter((p) => !p.startsWith(PROJECT_HOOKS_DIR));
|
|
68
74
|
}
|
|
@@ -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
|
+
}
|
package/dist/lib/constants.js
CHANGED
|
@@ -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
|
|
@@ -144,6 +165,10 @@ export const UNPUBLISH_SCRIPT_CMD = 'npx @timekast/factory unpublish';
|
|
|
144
165
|
// through `npx` so it resolves without a global install.
|
|
145
166
|
export const ENV_PUSH_SCRIPT_NAME = 'env:push';
|
|
146
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';
|
|
147
172
|
/**
|
|
148
173
|
* All convenience scripts the installer ensures in a Node derivative's
|
|
149
174
|
* package.json (each: add if missing, never overwrite a divergent value). Keyed
|
|
@@ -157,6 +182,7 @@ export const FACTORY_SCRIPTS = {
|
|
|
157
182
|
[PUBLISH_SCRIPT_NAME]: PUBLISH_SCRIPT_CMD,
|
|
158
183
|
[UNPUBLISH_SCRIPT_NAME]: UNPUBLISH_SCRIPT_CMD,
|
|
159
184
|
[ENV_PUSH_SCRIPT_NAME]: ENV_PUSH_SCRIPT_CMD,
|
|
185
|
+
[DASHBOARD_SCRIPT_NAME]: DASHBOARD_SCRIPT_CMD,
|
|
160
186
|
};
|
|
161
187
|
/**
|
|
162
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
|
+
}
|
package/dist/lib/email-from.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|