@timekast/factory 2.0.0 → 2.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.
@@ -38,7 +38,7 @@ import { CLAUDE_MD_FILE, PROFILES } from '../lib/constants.js';
38
38
  import { hasLockfile, writeInitialLockfile } from '../lib/lockfile.js';
39
39
  import { detectProfile, maintainKitScripts } from '../lib/package-json.js';
40
40
  import { runPreflight } from '../lib/preflight.js';
41
- import { detectRepo } from '../lib/repo-detection.js';
41
+ import { detectRepo, resolveBrainRoot } from '../lib/repo-detection.js';
42
42
  import { downloadProfileTarball, stageProfileTarball } from '../lib/unpack.js';
43
43
  import { reportPkgMutations, runUpdate } from './update.js';
44
44
  /** Message shown when `add` runs outside any git repo context. */
@@ -56,11 +56,12 @@ export async function runAdd(flags = { core: false, full: false }) {
56
56
  // network call or write — nothing is touched when there is no repo. The
57
57
  // brain lives at the REPO ROOT, so all gating + install target `root`, not
58
58
  // `cwd` — running `add` from a subdir must not plant a nested second brain.
59
- const { hasRepo, repoRoot } = detectRepo(cwd);
59
+ const { hasRepo } = detectRepo(cwd);
60
60
  if (!hasRepo) {
61
61
  throw new CLIError(NO_REPO_MESSAGE);
62
62
  }
63
- const root = repoRoot ?? cwd;
63
+ // Same target guard as `update`: never a root that is `~` or contains it.
64
+ const root = resolveBrainRoot(cwd);
64
65
  // 2. Gate on a pre-existing brain (checked at the repo root, not cwd). `add` is
65
66
  // for a repo WITHOUT one; anything else routes to `update` so the raw
66
67
  // `applyPlan` install never clobbers an old `.claude/` (leaving orphans, no
@@ -127,6 +127,8 @@ export function readVaultVercelSyncStatus(rootDir) {
127
127
  }
128
128
  if (!state)
129
129
  return 'unknown';
130
+ if (state.target === 'railway')
131
+ return 'railway';
130
132
  const syncs = state.syncs;
131
133
  const recorded = VERCEL_SYNC_ROLES.every((role) => Boolean(syncs?.ids?.[role]));
132
134
  const pending = VERCEL_SYNC_ROLES.some((role) => syncs?.pending?.includes(role));
@@ -141,6 +143,12 @@ export function readVaultVercelSyncStatus(rootDir) {
141
143
  * says they are not confirmed and points at `factory provision --resume`, which creates them.
142
144
  */
143
145
  export function vaultPushCessionMessage(link, syncs = 'active') {
146
+ if (syncs === 'railway') {
147
+ return ('Este repo usa la bóveda y despliega en Railway: sus variables llegan ahí por los syncs de la ' +
148
+ 'bóveda y env push no escribe en ningún destino. Cambia el valor en la bóveda ' +
149
+ `(proyecto ${link.projectId}, entorno ${link.envs.main} → Railway \`main\`, ` +
150
+ `${link.envs.develop} → Railway \`develop\`); el sync lo lleva a Railway. No se envió nada.`);
151
+ }
144
152
  const where = `(proyecto ${link.projectId}, entorno ${link.envs.main} → production, ` +
145
153
  `${link.envs.develop} → preview)`;
146
154
  const delivery = syncs === 'active'
@@ -30,6 +30,7 @@ import { execa } from 'execa';
30
30
  import prompts from 'prompts';
31
31
  import { FACTORY_ORG } from '../lib/constants.js';
32
32
  import { CLIError } from '../lib/cli-error.js';
33
+ import { sendingDomainOf } from '../lib/email-from.js';
33
34
  import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, absoluteRecordName, deleteDnsRecord, getDnsRecord, ensureCnameRecord, ensureDnsRecord, ensureUnproxiedRecord, resolveZoneForDomain, findCnameRecord, resolveZoneId, resolveZoneIdForName, resolveZoneIdForDomain, CNAME_VERCEL_TARGET, TIMEKAST_ZONE_NAME, provisionDomains, existingProvisionDomains, ORG_ZONE_NAMES, } from '../lib/cloudflare-api.js';
34
35
  import { createDevelopBranch, createNeonProject, createProjectApiKey, deleteNeonApiKeysByName, getNeonProjectOrg, listNeonOrgs, resolveNeonOrg, deleteNeonProject, getConnectionUri, isNeonKeyFamily, listNeonApiKeys, newestNeonFamilyKey, listNeonBranches, listNeonProjects, NeonApiError, NeonApiKeyListError, revokeNeonApiKey, rotatedNeonKeyName, NEON_API_BASE, NEON_DEVELOP_BRANCH, } from '../lib/neon-api.js';
35
36
  import { createBacklogProject, deleteBacklogProject, mintBacklogKey, BacklogApiError, } from '../lib/backlog-api.js';
@@ -301,8 +302,15 @@ function buildPlan(services, state, opts = {}) {
301
302
  }));
302
303
  }
303
304
  /** Print the plain-text plan (the `--dry-run` and pre-run narration). */
304
- function printPlan(result) {
305
- console.log(`Plan de provision para \`${result.projectName}\` (lifecycle: ${result.lifecycle}):`);
305
+ /**
306
+ * `plan` — before anything runs (`--dry-run`): what WOULD be created. `summary` — the closing
307
+ * report of a real run, after every step already narrated itself: what this run DID, worded so it
308
+ * never reads as pending work.
309
+ */
310
+ function printPlan(result, mode = 'plan') {
311
+ console.log(mode === 'plan'
312
+ ? `Plan de provision para \`${result.projectName}\` (lifecycle: ${result.lifecycle}):`
313
+ : `\nResumen de esta corrida para \`${result.projectName}\` (lifecycle: ${result.lifecycle}):`);
306
314
  // BOOT-001: print the RESOLVED repo (--repo → origin → FACTORY_ORG/name), so a
307
315
  // `--dry-run` shows the exact repo the github step / E2E secrets will target.
308
316
  if (result.repo) {
@@ -315,7 +323,13 @@ function printPlan(result) {
315
323
  console.log(` ${TARGET_UNRESOLVED_DRY_RUN}`);
316
324
  }
317
325
  for (const step of result.steps) {
318
- const mark = step.action === 'skip' ? '⏭ (ya completado)' : '+ crear';
326
+ const mark = step.action === 'skip'
327
+ ? mode === 'plan'
328
+ ? '⏭ (ya completado)'
329
+ : '⏭ ya estaba'
330
+ : mode === 'plan'
331
+ ? '+ crear'
332
+ : '✔ hecho';
319
333
  const label = step.service === 'github' && result.target === 'railway'
320
334
  ? 'GitHub (sustrato git main + develop + E2E — nada en Vercel)'
321
335
  : SERVICE_LABEL[step.service];
@@ -1915,9 +1929,7 @@ const RESEND_KEY_RESUME_HINT = 'repite la corrida con `--resume` y `dns` en `--s
1915
1929
  */
1916
1930
  function noVaultMintDomain(state) {
1917
1931
  const client = state.dns?.clientDomainStatus === 'live' ? state.dns.clientDomain : undefined;
1918
- return client
1919
- ? mailDomainFor(client)
1920
- : PROVISION_EMAIL_FROM.slice(PROVISION_EMAIL_FROM.indexOf('@') + 1);
1932
+ return client ? mailDomainFor(client) : sendingDomainOf(PROVISION_EMAIL_FROM);
1921
1933
  }
1922
1934
  /**
1923
1935
  * NO-vault path (F9 — the rail's team key never reaches a deploy): the project's OWN send-only
@@ -2016,7 +2028,7 @@ async function ensureProjectResendKey(rootDir, state, ctx, rail, fetchImpl, pers
2016
2028
  return null;
2017
2029
  }
2018
2030
  const sender = vaultSender(main);
2019
- const domain = sender.slice(sender.indexOf('@') + 1);
2031
+ const domain = sendingDomainOf(sender);
2020
2032
  // SYNC-001: a key this one REPLACES is revoked only once the syncs delivered the new one — the
2021
2033
  // marker is each deploy sync's last job (Vercel or Railway) before the write (taken only when there is a key to
2022
2034
  // revoke later). Taken BEFORE the mint: a vault that cannot list its syncs stops the run here,
@@ -7532,8 +7544,19 @@ export const RAILWAY_ENVIRONMENTS = [
7532
7544
  ];
7533
7545
  /** The port the app listens on in Railway: the vault writes it as `PORT` (Decision 10). */
7534
7546
  export const RAILWAY_APP_PORT = 3000;
7535
- /** The public health route the probe asks, and the service's `healthcheckPath` once it answers. */
7536
- export const RAILWAY_HEALTH_PATH = '/api/health';
7547
+ /**
7548
+ * The public liveness route the probe asks, and the service's `healthcheckPath` once it answers.
7549
+ * It touches no database: Railway only asks whether the process started, and a public route that
7550
+ * queries Neon lets any bot keep it from autosuspending.
7551
+ */
7552
+ export const RAILWAY_HEALTH_PATH = '/api/health/live';
7553
+ /**
7554
+ * The fallback for a derivative whose `src/` predates the liveness route but applied the earlier
7555
+ * retrofit, which made the DB-backed `/api/health` public. Asked only when `/api/health/live`
7556
+ * answers 404, so the CLI tolerates that frozen `src/` (BR-FACTORY-006) instead of leaving its
7557
+ * healthcheck off.
7558
+ */
7559
+ export const RAILWAY_LEGACY_HEALTH_PATH = '/api/health';
7537
7560
  /**
7538
7561
  * The service's commands, with the vault wrapper OFF explicitly (Decision 11): in Railway the env
7539
7562
  * already holds what the sync delivered. Railway runs build → pre-deploy → start, so the build still
@@ -7817,14 +7840,14 @@ async function waitForRailwayDeployment(client, role, deploymentId, seams) {
7817
7840
  }
7818
7841
  }
7819
7842
  /**
7820
- * One ANONYMOUS request to `/api/health` through Railway's own domain of an environment: no
7821
- * cookie, no token, redirects not followed (a redirect to the login is "not public"). 200 → `ok`;
7822
- * 401/403/3xx → `not-public`; anything else, or no answer in time → `down`.
7843
+ * One ANONYMOUS request to `path` (default `/api/health/live`) through Railway's own domain of an
7844
+ * environment: no cookie, no token, redirects not followed (a redirect to the login is "not
7845
+ * public"). 200 → `ok`; 401/403/3xx → `not-public`; anything else, or no answer in time → `down`.
7823
7846
  */
7824
- export async function probeRailwayHealth(domain, fetchImpl) {
7847
+ export async function probeRailwayHealth(domain, fetchImpl, path = RAILWAY_HEALTH_PATH) {
7825
7848
  let status;
7826
7849
  try {
7827
- const response = await fetchImpl(`https://${domain}${RAILWAY_HEALTH_PATH}`, {
7850
+ const response = await fetchImpl(`https://${domain}${path}`, {
7828
7851
  method: 'GET',
7829
7852
  redirect: 'manual',
7830
7853
  signal: AbortSignal.timeout(RAILWAY_PROBE_TIMEOUT_MS),
@@ -7854,6 +7877,8 @@ function railwayProbeFetch(seams) {
7854
7877
  * Probe each environment whose healthcheck is not `on` yet; with 200, set `healthcheckPath` there
7855
7878
  * and mark it `on`, otherwise mark it `pending` and print the retrofit instruction. Never fails
7856
7879
  * the alta: a derivative whose `src/` lacks the public route keeps deploying without healthcheck.
7880
+ * A 404 on the liveness route falls back to the legacy public `/api/health` (see
7881
+ * `RAILWAY_LEGACY_HEALTH_PATH`); with 200 there it is used, and the guide to move off it is named.
7857
7882
  */
7858
7883
  async function settleRailwayHealthchecks(client, rw, seams, persist) {
7859
7884
  const probeFetch = railwayProbeFetch(seams);
@@ -7864,15 +7889,25 @@ async function settleRailwayHealthchecks(client, rw, seams, persist) {
7864
7889
  if (!domain)
7865
7890
  continue;
7866
7891
  const result = await probeRailwayHealth(domain, probeFetch);
7867
- if (result.kind === 'ok') {
7892
+ let path = RAILWAY_HEALTH_PATH;
7893
+ if (result.kind === 'down' && result.status === 404) {
7894
+ const legacy = await probeRailwayHealth(domain, probeFetch, RAILWAY_LEGACY_HEALTH_PATH);
7895
+ if (legacy.kind === 'ok')
7896
+ path = RAILWAY_LEGACY_HEALTH_PATH;
7897
+ }
7898
+ if (result.kind === 'ok' || path === RAILWAY_LEGACY_HEALTH_PATH) {
7868
7899
  await client.updateServiceInstance({
7869
7900
  serviceId: rw.serviceId,
7870
7901
  environmentId: rw.environments?.[role],
7871
- settings: { healthcheckPath: RAILWAY_HEALTH_PATH },
7902
+ settings: { healthcheckPath: path },
7872
7903
  });
7873
7904
  rw.healthcheck = { ...rw.healthcheck, [role]: 'on' };
7874
7905
  persist();
7875
- console.log(` ✔ healthcheck de \`${role}\` activado (\`${RAILWAY_HEALTH_PATH}\` respondió 200)`);
7906
+ console.log(` ✔ healthcheck de \`${role}\` activado (\`${path}\` respondió 200)`);
7907
+ if (path === RAILWAY_LEGACY_HEALTH_PATH) {
7908
+ console.warn(` ℹ Este \`src/\` no tiene \`${RAILWAY_HEALTH_PATH}\`; uso \`${path}\`, que es pública y consulta la base ` +
7909
+ `(cualquier sonda anónima la despierta). Para pasarte a la ruta sin base: §5 de \`${RAILWAY_HEALTH_RETROFIT_GUIDE}\`.`);
7910
+ }
7876
7911
  continue;
7877
7912
  }
7878
7913
  rw.healthcheck = { ...rw.healthcheck, [role]: 'pending' };
@@ -9097,13 +9132,15 @@ export async function runProvision(flags, deps = {}) {
9097
9132
  else {
9098
9133
  warnNeonctl(preflight.neonctl);
9099
9134
  if (steps.length > 0)
9100
- printPlan(result);
9135
+ printPlan(result, 'summary');
9101
9136
  printCustomizedScripts(result.wrapperWiring);
9102
9137
  if (hasDomain) {
9103
9138
  console.log(`\n✔ Dominio del cliente listo: ${flags.domain} (ruta ${flags.dns}).`);
9104
9139
  }
9105
9140
  else {
9106
- console.log(`\n✔ Scaffold de provision listo (lifecycle: ${lifecycle}). ` +
9141
+ console.log(
9142
+ // The EFFECTIVE lifecycle: a `--resume` does not repeat `--ephemeral`, and the state keeps it.
9143
+ `\n✔ Scaffold de provision listo (lifecycle: ${result.lifecycle}). ` +
9107
9144
  'Estado en `.timekast/provision.json`.');
9108
9145
  }
9109
9146
  }
@@ -52,7 +52,7 @@ import { printProvisionPointer } from '../lib/provision-pointer.js';
52
52
  import { printRetrofitReminder } from '../lib/retrofit-reminder.js';
53
53
  import { hasProvisionState } from '../lib/provision-state.js';
54
54
  import { loginCommand } from '../lib/rail.js';
55
- import { detectRepo } from '../lib/repo-detection.js';
55
+ import { resolveBrainRoot } from '../lib/repo-detection.js';
56
56
  import { downloadBetaProfileTarball, downloadProfileTarball, stageProfileTarball, } from '../lib/unpack.js';
57
57
  /** Parse `update`'s argv slice into flags. Unknown flags are ignored here. */
58
58
  export function parseUpdateFlags(argv) {
@@ -795,8 +795,9 @@ export async function maybeOfferEmptyE2EBranch(rootDir, flags, opts = {}) {
795
795
  export async function runUpdate(flags, deps = {}) {
796
796
  // Operate on the REPO ROOT (the brain + lockfile live there), not cwd — running
797
797
  // `update` from a subdir of a managed repo must not miss the lockfile and
798
- // legacy-register a second nested brain (consistency with `add`).
799
- const rootDir = detectRepo(process.cwd()).repoRoot ?? process.cwd();
798
+ // legacy-register a second nested brain (consistency with `add`). Outside a repo,
799
+ // or with a root that is `~` or an ancestor of it, it stops before anything else.
800
+ const rootDir = resolveBrainRoot(process.cwd());
800
801
  // Mutually-exclusive flag validation FIRST — before --verify/--resume
801
802
  // short-circuit, so an invalid combination is always rejected (not silently
802
803
  // honored by verify/resume).
@@ -34,6 +34,7 @@ import path from 'node:path';
34
34
  import { execa } from 'execa';
35
35
  import prompts from 'prompts';
36
36
  import { CLIError } from '../lib/cli-error.js';
37
+ import { sendingDomainOf } from '../lib/email-from.js';
37
38
  import { WITH_VAULT_SCRIPT_FILE } from '../lib/constants.js';
38
39
  import { parseEnvBody } from '../lib/env-wizard.js';
39
40
  import { hasLockfile, readLockfile } from '../lib/lockfile.js';
@@ -53,12 +54,14 @@ import { computeStatus } from './status.js';
53
54
  // ---------------------------------------------------------------------------
54
55
  // Help + flags
55
56
  // ---------------------------------------------------------------------------
57
+ /** The vault (Infisical) refuses a project slug shorter than this. */
58
+ export const VAULT_SLUG_MIN_LENGTH = 5;
56
59
  /** What `factory vault` prints without a subcommand. */
57
60
  export const VAULT_HELP = `
58
61
  factory vault — la bóveda de secretos de la organización (fx-secrets-vault).
59
62
 
60
63
  Uso:
61
- factory vault adopt [--apply] [--json] [--team=<id>] [--vercel-project <id>]
64
+ factory vault adopt [--apply] [--json] [--team=<id>] [--vercel-project <id>] [--slug <slug>]
62
65
  factory vault adopt --decline
63
66
  factory vault sync railway --project <id> --service <id> --env <main|develop> [--railway-env <nombre>]
64
67
  factory vault deploys [--deploy <slug>] [--apply] [--yes]
@@ -83,6 +86,10 @@ Subcomandos:
83
86
  Con varios proyectos de Vercel conectados al repo, elige el que queda como
84
87
  proyecto principal (tiene que ser uno de ellos). Los demás despliegues se
85
88
  operan después con \`factory vault deploys\`.
89
+ --slug <slug>
90
+ Slug del proyecto en la bóveda (mínimo ${VAULT_SLUG_MIN_LENGTH} caracteres). Default: el
91
+ que registra .timekast/provision.json, si no el proyecto de Vercel, si no
92
+ el repo de origin, y sólo al final el nombre del directorio.
86
93
  --decline Registra en .timekast/provision.json que este repo NO entra a la bóveda:
87
94
  sigue con .env.local y env:push, y doctor deja de proponerlo. No toca nada
88
95
  más ni consulta ningún servicio. Adoptar después sigue siendo posible.
@@ -140,15 +147,32 @@ export function parseVaultAdoptFlags(argv) {
140
147
  }
141
148
  flags.vercelProject = value.trim();
142
149
  }
150
+ else if (arg === '--slug' || arg.startsWith('--slug=')) {
151
+ let value;
152
+ if (arg.startsWith('--slug='))
153
+ value = arg.slice('--slug='.length);
154
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) {
155
+ value = argv[i + 1];
156
+ i += 1;
157
+ }
158
+ if (value === undefined || value.trim() === '') {
159
+ throw new CLIError('`--slug` necesita el slug del proyecto en la bóveda.');
160
+ }
161
+ flags.slug = value.trim().toLowerCase();
162
+ }
143
163
  else {
144
- throw new CLIError(`Opción desconocida para \`factory vault adopt\`: \`${arg}\`. Uso: \`factory vault adopt [--apply] [--json] [--team=<id>] [--vercel-project <id>]\` o \`factory vault adopt --decline\`.`);
164
+ throw new CLIError(`Opción desconocida para \`factory vault adopt\`: \`${arg}\`. Uso: \`factory vault adopt [--apply] [--json] [--team=<id>] [--vercel-project <id>] [--slug <slug>]\` o \`factory vault adopt --decline\`.`);
145
165
  }
146
166
  }
147
167
  if (flags.apply && flags.json) {
148
168
  throw new CLIError('`--json` es sólo para el plan (sin `--apply`).');
149
169
  }
150
170
  if (flags.decline &&
151
- (flags.apply || flags.json || flags.team !== undefined || flags.vercelProject !== undefined)) {
171
+ (flags.apply ||
172
+ flags.json ||
173
+ flags.team !== undefined ||
174
+ flags.vercelProject !== undefined ||
175
+ flags.slug !== undefined)) {
152
176
  throw new CLIError('`--decline` va solo: registra que el repo no entra a la bóveda, sin plan ni `--apply`.');
153
177
  }
154
178
  return flags;
@@ -937,9 +961,19 @@ async function prepareAdoption(rootDir, flags, deps) {
937
961
  slug = fromConfig;
938
962
  }
939
963
  else {
940
- slug = (realField(target?.slug) ?? vercelProject?.name ?? path.basename(rootDir)).toLowerCase();
964
+ // The origin repo's name comes before the directory's: a worktree or a renamed checkout
965
+ // would otherwise name the vault project after the folder.
966
+ slug = (flags.slug ??
967
+ realField(target?.slug) ??
968
+ vercelProject?.name ??
969
+ repo?.split('/')[1] ??
970
+ path.basename(rootDir)).toLowerCase();
941
971
  }
942
972
  assertValidSubdomainSlug(slug);
973
+ if (slug.length < VAULT_SLUG_MIN_LENGTH) {
974
+ throw new CLIError(`El slug \`${slug}\` tiene menos de ${VAULT_SLUG_MIN_LENGTH} caracteres y la bóveda no acepta ` +
975
+ `crear un proyecto así. Elige uno con \`--slug <slug>\` (p. ej. \`${slug}-web\`). No escribí nada.`);
976
+ }
943
977
  const railwayToken = await rail.getOptional(RAILWAY_TOKEN_KEY);
944
978
  if (!railwayToken) {
945
979
  console.warn('⚠ No hay `RAILWAY_TOKEN` en `rail-timekast`: no inventarié Railway. Si este repo despliega ahí, ' +
@@ -981,7 +1015,7 @@ async function prepareAdoption(rootDir, flags, deps) {
981
1015
  values.preview.get('EMAIL_FROM') ??
982
1016
  values.local.get('EMAIL_FROM') ??
983
1017
  PROVISION_EMAIL_FROM;
984
- const resendDomain = sender.slice(sender.indexOf('@') + 1);
1018
+ const resendDomain = sendingDomainOf(sender);
985
1019
  const client = deps.vaultClient ?? createVaultClient();
986
1020
  const githubSecretNames = repo
987
1021
  ? [...(await (deps.listGithubSecretNames ?? ghSecretNames)(repo))].sort()
@@ -224,8 +224,10 @@ export function classifyClaudeMd(opts) {
224
224
  return { case: 'A', action: 'none', uncovered: [] };
225
225
  }
226
226
  const uncovered = uncoveredKitRules(content, kitRules);
227
- if (uncovered.length > 0)
228
- return { case: 'C', action: 'none', uncovered };
227
+ // `skip-core` on case C only shapes the advice: `core` has no INDEX to point at.
228
+ if (uncovered.length > 0) {
229
+ return { case: 'C', action: profile === 'core' ? 'skip-core' : 'none', uncovered };
230
+ }
229
231
  if (hasIndexImport(content))
230
232
  return { case: 'B', action: 'already-present', uncovered: [] };
231
233
  // The `core` profile does not ship `.claude/rules/INDEX.md` at all (it resolves its
@@ -296,7 +298,10 @@ export function reportClaudeMd(assessment, mode, inserted = true) {
296
298
  if (kind === 'C') {
297
299
  console.log(`\n\`${CLAUDE_MD_FILE}\` (caso C — necesita revisión manual): lo editaste y le faltan rules del kit ` +
298
300
  `en los imports: ${uncovered.join(', ')}.`);
299
- console.log(` No se tocó nada. Agrega \`${RULES_INDEX_IMPORT}\` a mano (importa todas) o repón los imports que falten.`);
301
+ console.log(action === 'skip-core'
302
+ ? ` No se tocó nada. Repón a mano los imports que falten (\`@<ruta>\` por cada una); ` +
303
+ `el perfil \`core\` no incluye \`${RULES_INDEX_PATH}\`.`
304
+ : ` No se tocó nada. Agrega \`${RULES_INDEX_IMPORT}\` a mano (importa todas) o repón los imports que falten.`);
300
305
  return;
301
306
  }
302
307
  // Case B — edited outside the kit's zone.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The sending domain of an `EMAIL_FROM` value — the domain a project's Resend key is limited to.
3
+ *
4
+ * `EMAIL_FROM` comes in two shapes, and a live deploy carries either: the bare address the kit
5
+ * writes (`noreply@updates.timekast.com`) and the RFC 5322 display-name form a team sets so the
6
+ * inbox shows a name (`TimeKast Calendar <noreply@tk-cal.com>`). Taking everything after the `@`
7
+ * of the raw value turns the second one into `tk-cal.com>` — a domain Resend never lists, so
8
+ * `vault adopt --apply` aborted on a perfectly valid sender.
9
+ *
10
+ * Rule: the address is the part inside the LAST `<…>` when there is one, else the whole value;
11
+ * the domain is what follows its LAST `@`, trimmed and lower-cased (DNS names are
12
+ * case-insensitive and Resend lists them lower-case). No `@` at all → `''`, which matches no
13
+ * Resend domain, so the caller's "domain not registered" path stops the run instead of minting
14
+ * a key for a guessed domain.
15
+ *
16
+ * 🔒 TOTAL (never throws): it runs on values read from a deploy, which the CLI does not control.
17
+ */
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;
23
+ const at = address.lastIndexOf('@');
24
+ return at === -1
25
+ ? ''
26
+ : address
27
+ .slice(at + 1)
28
+ .trim()
29
+ .toLowerCase();
30
+ }
@@ -9,8 +9,10 @@
9
9
  * is a valid context (DIST-004 §8). `add` installs in the CWD, not the repo
10
10
  * root — `repoRoot` is informational (e.g. monorepo with `.git/` in an ancestor).
11
11
  */
12
- import { existsSync } from 'node:fs';
12
+ import { existsSync, realpathSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
13
14
  import path from 'node:path';
15
+ import { CLIError } from './cli-error.js';
14
16
  /**
15
17
  * Detect whether `cwd` (or any ancestor) is inside a git repository.
16
18
  *
@@ -30,3 +32,42 @@ export function detectRepo(cwd) {
30
32
  }
31
33
  return { hasRepo: false, repoRoot: null };
32
34
  }
35
+ /**
36
+ * The repo root a brain-writing command (`update`, `add`) may install into, or a
37
+ * `CLIError` before anything is read, downloaded or written.
38
+ *
39
+ * Refuses outside a git repo (no silent fallback to `cwd`) and refuses a root that
40
+ * is the home directory or one of its ancestors: run from `~`, or from a `~` that
41
+ * versions dotfiles in a `~/.git`, the manifest would land on `~/.claude`. No flag
42
+ * skips it — `--yes` only answers prompts, it never widens the target.
43
+ *
44
+ * @param cwd Directory the command was run from.
45
+ * @param home The user's home directory (injectable for tests).
46
+ */
47
+ export function resolveBrainRoot(cwd, home = homedir()) {
48
+ const { repoRoot } = detectRepo(cwd);
49
+ if (!repoRoot) {
50
+ throw new CLIError('No hay repositorio git aquí. Corre el comando desde la raíz de tu proyecto ' +
51
+ '(o `git init` primero, o `factory new` para crear uno desde cero). No toqué nada.');
52
+ }
53
+ // Compared through symlinks: `cwd` arrives resolved (`/private/var/...` on macOS)
54
+ // while `HOME` may name the same folder by its link.
55
+ const root = path.resolve(repoRoot);
56
+ // Home is at or under the root unless the way from root to home climbs out first.
57
+ const relative = path.relative(realOrResolved(root), realOrResolved(home));
58
+ const homeOutsideRoot = relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
59
+ if (!homeOutsideRoot) {
60
+ throw new CLIError(`La raíz del repo es \`${root}\`, que es tu carpeta personal o la contiene. ` +
61
+ 'El kit solo se instala dentro de un proyecto: corre el comando desde la carpeta del proyecto. No toqué nada.');
62
+ }
63
+ return root;
64
+ }
65
+ /** `realpath` of `p`, or its plain resolution when it does not exist. */
66
+ function realOrResolved(p) {
67
+ try {
68
+ return realpathSync(p);
69
+ }
70
+ catch {
71
+ return path.resolve(p);
72
+ }
73
+ }
@@ -953,6 +953,11 @@ export function createVaultClient(deps = {}) {
953
953
  const connectionApp = asString(connection?.app);
954
954
  if (!connection || !id || connectionApp !== app)
955
955
  return [];
956
+ // A connection scoped to ONE vault project (a client with infrastructure of its own)
957
+ // is not the organization's: the instance lets only that project use it. It never
958
+ // counts toward — or breaks — the choice of the org-wide connection.
959
+ if (asString(connection.projectId))
960
+ return [];
956
961
  return [
957
962
  {
958
963
  id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",