@timekast/factory 2.2.0 → 2.3.1

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.
@@ -63,7 +63,8 @@ export const E2E_DATABASE_URL_SECRET = 'DATABASE_URL';
63
63
  * `${MAIL_SUBDOMAIN}.<base>` — NEVER the apex `<base>`, which holds the org's (or a
64
64
  * client's) corporate MX/SPF (Google Workspace, M365, …). Verifying Resend on the
65
65
  * apex would collide with that mail; an isolated subdomain keeps both working. Same
66
- * convention for the org (`updates.timekast.mx`) and every client (`updates.<client>`).
66
+ * convention for the org (`updates.timekast.com`) and, with `--client-mail`, a client
67
+ * (`updates.<client>`).
67
68
  */
68
69
  export const MAIL_SUBDOMAIN = 'updates';
69
70
  /** Build the transactional-mail domain for a base domain: `updates.<base>`. */
@@ -72,10 +73,11 @@ export function mailDomainFor(base) {
72
73
  }
73
74
  /**
74
75
  * The canonical, ORG-LEVEL sender for transactional mail on provisioned stacks
75
- * (issue §4 + §5). Sends from `noreply@updates.timekast.mx` — the dedicated subdomain
76
- * verified in Resend, NOT the apex `timekast.mx` (which is on Google Workspace). The
77
- * client's own sender is set in the `--domain` flow (PROV-006), using the same
78
- * `updates.<client>` convention. The unit test pins this value.
76
+ * (issue §4 + §5). Sends from `noreply@updates.timekast.com` — the dedicated subdomain
77
+ * verified in Resend, NOT the apex (which is on Google Workspace). It stays the sender
78
+ * after the `--domain` go-live (PROV-006): the Resend account has no room for a domain
79
+ * per client, so the client's own `updates.<client>` is opt-in (`--client-mail`). The
80
+ * unit test pins this value.
79
81
  */
80
82
  export const PROVISION_EMAIL_FROM = `noreply@${mailDomainFor(TIMEKAST_ZONE_NAME)}`;
81
83
  // `provisionDomains` moved to `cloudflare-api.ts` (next to TIMEKAST_ZONE_NAME) so
@@ -250,6 +252,11 @@ export function parseProvisionFlags(argv) {
250
252
  throw new CLIError('`--no-vault` y `--services=…vault…` se contradicen: uno pide omitir la bóveda y el otro ' +
251
253
  'crearla. Quita uno de los dos.');
252
254
  }
255
+ const clientMail = argv.includes('--client-mail');
256
+ const domain = readValueFlag(argv, 'domain');
257
+ if (clientMail && !domain) {
258
+ throw new CLIError('`--client-mail` solo aplica al go-live: pásalo junto con `--domain=<dominio del cliente>`.');
259
+ }
253
260
  return {
254
261
  services,
255
262
  noVault,
@@ -259,7 +266,8 @@ export function parseProvisionFlags(argv) {
259
266
  remint: argv.includes('--remint'),
260
267
  slug: readValueFlag(argv, 'slug'),
261
268
  repo: parseRepoFlag(readValueFlag(argv, 'repo')),
262
- domain: readValueFlag(argv, 'domain'),
269
+ domain,
270
+ clientMail,
263
271
  dns: parseDnsRoute(readValueFlag(argv, 'dns')),
264
272
  verifyTimeout: parseVerifyTimeout(readValueFlag(argv, 'verify-timeout')),
265
273
  confirm: argv.includes('--confirm'),
@@ -1368,7 +1376,7 @@ const VAULT_PER_ENV_MINTED = ['AUTH_SECRET'];
1368
1376
  * Which keys each destination gets depends on the services the run includes: `neon` writes the
1369
1377
  * connection strings and the CI keys, `vercel` the runtime values, `backlog` its local creds.
1370
1378
  */
1371
- export function buildVaultPlan(projectName, services) {
1379
+ export function buildVaultPlan(projectName, services, target) {
1372
1380
  const keys = {};
1373
1381
  const references = {};
1374
1382
  const add = (loc, names, asRef = false) => {
@@ -1408,7 +1416,9 @@ export function buildVaultPlan(projectName, services) {
1408
1416
  if (services.includes('backlog')) {
1409
1417
  add(VAULT_LOCAL_ROOT, ['BACKLOG_API_KEY', 'BACKLOG_PROJECT_ID']);
1410
1418
  }
1411
- const railway = services.includes('railway');
1419
+ // The syncs follow the DESTINATION, not this run's steps: a run with `vault` but without the
1420
+ // `railway` step still deploys to Railway, and never gets the Vercel syncs.
1421
+ const railway = services.includes('railway') || target === 'railway';
1412
1422
  return {
1413
1423
  project: projectName,
1414
1424
  environments: VAULT_LAYOUT_ENVIRONMENTS.map((env) => env.slug),
@@ -1923,12 +1933,15 @@ const RESEND_KEY_RESUME_HINT = 'repite la corrida con `--resume` y `dns` en `--s
1923
1933
  'hasta publicarla';
1924
1934
  /**
1925
1935
  * NO-vault: the sending domain a fresh project key is limited to — the client's mail subdomain
1926
- * once its go-live is recorded (Resend already knows it: the go-live registers it), else the
1927
- * interim org domain. A `--resume` that retries the key after a go-live must not move the mail
1928
- * back to the interim domain.
1929
- */
1930
- function noVaultMintDomain(state) {
1931
- const client = state.dns?.clientDomainStatus === 'live' ? state.dns.clientDomain : undefined;
1936
+ * once a `--client-mail` go-live is recorded (Resend already knows it: that go-live registers
1937
+ * it), else the org domain. A `--resume` that retries the key after such a go-live must not move
1938
+ * the mail back to the org domain. `sharedMail` marks a go-live that kept the org sender; a
1939
+ * `live` state without it predates the flag, when every go-live moved the mail.
1940
+ */
1941
+ export function noVaultMintDomain(state) {
1942
+ const client = state.dns?.clientDomainStatus === 'live' && state.dns.sharedMail !== true
1943
+ ? state.dns.clientDomain
1944
+ : undefined;
1932
1945
  return client ? mailDomainFor(client) : sendingDomainOf(PROVISION_EMAIL_FROM);
1933
1946
  }
1934
1947
  /**
@@ -4711,8 +4724,9 @@ async function resolveDnsToken(route, rail, headless, rootDir) {
4711
4724
  /**
4712
4725
  * PROV-006 — attach the client's DEFINITIVE domain to the Vercel project, create
4713
4726
  * (or print, for `registrar`) its DNS record, poll Vercel until verified, and on
4714
- * success: retire the interim prod domain (with explicit confirmation), update
4715
- * `EMAIL_FROM` + `NEXT_PUBLIC_APP_URL`, and register the domain in Resend.
4727
+ * success: retire the interim prod domain (with explicit confirmation) and update
4728
+ * `NEXT_PUBLIC_APP_URL`. The mail stays on the org sender unless `deps.clientMail`
4729
+ * (`--client-mail`): then `EMAIL_FROM` moves to `updates.<domain>`, registered in Resend.
4716
4730
  *
4717
4731
  * Standalone-safe: runnable as a PATCH over an existing provision (the `dns` step
4718
4732
  * of PROV-005 already attached the interim domains) or inside the initial run. It
@@ -4745,6 +4759,7 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
4745
4759
  'Corre el paso de Vercel/GitHub primero (`--services=github,...`) y reintenta con `--resume`.');
4746
4760
  }
4747
4761
  const route = deps.dnsRoute ?? 'org';
4762
+ const clientMail = deps.clientMail === true;
4748
4763
  const rail = deps.rail ?? defaultRail();
4749
4764
  const fetchImpl = deps.fetchImpl ?? fetch;
4750
4765
  const vercelToken = await readVercelToken(rail);
@@ -4823,27 +4838,32 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
4823
4838
  // 5. Update EMAIL_FROM + NEXT_PUBLIC_APP_URL + the WebAuthn rpID/related origins in Vercel env
4824
4839
  // (idempotent — §8), and in the vault's `main:/` when the repo has one (VAULT-002).
4825
4840
  const hosts = existingProvisionDomains(state.projectName, state.dns);
4826
- await updateDomainEnv(projectId, vercelToken, teamId, domain, vercelDeps, { dev: hosts.dev, interim: hosts.prod, interimRetired }, deps.vault ?? null);
4827
- // 6. Register the client's MAIL SUBDOMAIN (`updates.<domain>`) in Resend — NOT the
4828
- // apex, which holds the client's corporate MX/SPF (Google Workspace, M365, …).
4829
- // Verifying Resend on a dedicated subdomain keeps the client's own mail intact.
4830
- // BOOT-003: also CREATES the DKIM/SPF/MX records Resend returns (in the same
4831
- // client zone as the CNAME) and polls until Resend reports `verified`.
4832
- await registerResendDomain(mailDomainFor(domain), rail, deps, fetchImpl, resendDns);
4833
- // 6b. VAULT-002 — the project's Resend key is limited to its sending domain, which just
4834
- // changed: re-scope it to `updates.<domain>` (vault, when there is one, + deploys) and
4835
- // revoke the old one once the new one reached them. Without a vault too: the deploys carry
4836
- // the project's key there as well (never the rail's team key).
4837
- await rescopeProjectResendKey(rootDir, state, deps.vault ?? null, mailDomainFor(domain), { projectId, token: vercelToken, teamId, deps: vercelDeps }, rail, fetchImpl, { sleep: deps.sleep, now: deps.now });
4841
+ await updateDomainEnv(projectId, vercelToken, teamId, domain, vercelDeps, { dev: hosts.dev, interim: hosts.prod, interimRetired }, deps.vault ?? null, clientMail);
4842
+ if (clientMail) {
4843
+ // 6. Register the client's MAIL SUBDOMAIN (`updates.<domain>`) in Resend — NOT the
4844
+ // apex, which holds the client's corporate MX/SPF (Google Workspace, M365, …).
4845
+ // Verifying Resend on a dedicated subdomain keeps the client's own mail intact.
4846
+ // BOOT-003: also CREATES the DKIM/SPF/MX records Resend returns (in the same
4847
+ // client zone as the CNAME) and polls until Resend reports `verified`.
4848
+ await registerResendDomain(mailDomainFor(domain), rail, deps, fetchImpl, resendDns);
4849
+ // 6b. VAULT-002 — the project's Resend key is limited to its sending domain, which just
4850
+ // changed: re-scope it to `updates.<domain>` (vault, when there is one, + deploys) and
4851
+ // revoke the old one once the new one reached them. Without a vault too: the deploys
4852
+ // carry the project's key there as well (never the rail's team key).
4853
+ await rescopeProjectResendKey(rootDir, state, deps.vault ?? null, mailDomainFor(domain), { projectId, token: vercelToken, teamId, deps: vercelDeps }, rail, fetchImpl, { sleep: deps.sleep, now: deps.now });
4854
+ }
4855
+ else {
4856
+ logSharedMail();
4857
+ }
4838
4858
  // 7. Record the client domain as LIVE and persist.
4839
- state.dns = {
4859
+ state.dns = withMailChoice({
4840
4860
  ...(state.dns ?? {
4841
4861
  domain: provisionDomains(state.projectName).prod,
4842
4862
  devDomain: provisionDomains(state.projectName).dev,
4843
4863
  }),
4844
4864
  clientDomain: domain,
4845
4865
  clientDomainStatus: 'live',
4846
- };
4866
+ }, clientMail);
4847
4867
  writeProvisionState(rootDir, state);
4848
4868
  }
4849
4869
  /** RWAY-005 — the two records the owner creates by hand for `--dns=registrar` on Railway. */
@@ -4890,8 +4910,9 @@ async function pollRailwayDomainVerification(client, scope, dns, deps) {
4890
4910
  * only PRINTS both.
4891
4911
  * 3. Poll the domain's state in Railway (`--verify-timeout`); timeout → `pending-verification`.
4892
4912
  * 4. Retire the interim `{slug}` from Railway with the same confirmation (`--confirm` headless).
4893
- * 5. `EMAIL_FROM`, `NEXT_PUBLIC_APP_URL`, `WEBAUTHN_RP_ID`/`_RELATED_ORIGINS` → `main:/` of the
4894
- * vault ONLY (its sync is Railway's only writer); Resend as on Vercel.
4913
+ * 5. `NEXT_PUBLIC_APP_URL`, `WEBAUTHN_RP_ID`/`_RELATED_ORIGINS` (+ `EMAIL_FROM` with
4914
+ * `--client-mail`) → `main:/` of the vault ONLY (its sync is Railway's only writer); Resend
4915
+ * as on Vercel (only with `--client-mail`).
4895
4916
  * 🔴 No request to Vercel. Requires the vault and the `railway` block (none → `--adopt`).
4896
4917
  */
4897
4918
  async function provisionRailwayClientDomain(rootDir, state, domain, deps) {
@@ -4901,6 +4922,7 @@ async function provisionRailwayClientDomain(rootDir, state, domain, deps) {
4901
4922
  assertRailwayDomainIds(state);
4902
4923
  const rw = state.railway;
4903
4924
  const route = deps.dnsRoute ?? 'org';
4925
+ const clientMail = deps.clientMail === true;
4904
4926
  const rail = deps.rail ?? defaultRail();
4905
4927
  const fetchImpl = deps.fetchImpl ?? fetch;
4906
4928
  const client = railwayClientFor(rail, deps.railway);
@@ -4987,15 +5009,21 @@ async function provisionRailwayClientDomain(rootDir, state, domain, deps) {
4987
5009
  dev: hosts.dev,
4988
5010
  interim: hosts.prod,
4989
5011
  interimRetired,
4990
- });
4991
- // 6. Resend — unchanged by the destination (mail subdomain + the project's key re-scoped).
4992
- await registerResendDomain(mailDomainFor(domain), rail, paced, fetchImpl, resendDns);
4993
- await rescopeProjectResendKey(rootDir, state, vault, mailDomainFor(domain), null, rail, fetchImpl, {
4994
- sleep: paced.sleep,
4995
- now: paced.now,
4996
- });
5012
+ }, clientMail);
5013
+ // 6. Resend — unchanged by the destination (mail subdomain + the project's key re-scoped),
5014
+ // and only with `--client-mail`.
5015
+ if (clientMail) {
5016
+ await registerResendDomain(mailDomainFor(domain), rail, paced, fetchImpl, resendDns);
5017
+ await rescopeProjectResendKey(rootDir, state, vault, mailDomainFor(domain), null, rail, fetchImpl, {
5018
+ sleep: paced.sleep,
5019
+ now: paced.now,
5020
+ });
5021
+ }
5022
+ else {
5023
+ logSharedMail();
5024
+ }
4997
5025
  // 7. Live.
4998
- state.dns = { ...dnsBlock(), clientDomainStatus: 'live' };
5026
+ state.dns = withMailChoice({ ...dnsBlock(), clientDomainStatus: 'live' }, clientMail);
4999
5027
  writeProvisionState(rootDir, state);
5000
5028
  }
5001
5029
  /**
@@ -5125,12 +5153,15 @@ async function deleteInterimCname(interim, rail, fetchImpl) {
5125
5153
  *
5126
5154
  * No-echo: only KEY NAMES + targets print (the values here are public, but the discipline holds).
5127
5155
  */
5128
- export async function updateDomainEnv(projectId, token, teamId, domain, deps, hosts, vault = null) {
5156
+ export async function updateDomainEnv(projectId, token, teamId, domain, deps, hosts, vault = null, clientMail = false) {
5129
5157
  const { appOrigin, emailFrom, relatedOrigins } = domainEnvValues(domain, hosts);
5130
5158
  const plan = [
5131
- // EMAIL_FROM uses the dedicated mail subdomain (`updates.<domain>`), NOT the apex
5132
- // — the apex carries the client's corporate mail.
5133
- { key: 'EMAIL_FROM', value: emailFrom, target: ['production', 'preview'] },
5159
+ // EMAIL_FROM only with `--client-mail`: the dedicated mail subdomain (`updates.<domain>`),
5160
+ // NOT the apex — the apex carries the client's corporate mail. Without it the sender
5161
+ // already in place (the org's, or one a team set by hand) is left alone.
5162
+ ...(clientMail
5163
+ ? [{ key: 'EMAIL_FROM', value: emailFrom, target: ['production', 'preview'] }]
5164
+ : []),
5134
5165
  // 🔴 AUTH_URL / NEXTAUTH_URL are DELIBERATELY ABSENT — do not re-add them.
5135
5166
  // `next-auth/lib/env.js` rewrites the origin of every auth request to their value,
5136
5167
  // so scoping either to `preview` pins all branch deployments' callbacks to this one
@@ -5169,7 +5200,7 @@ export async function updateDomainEnv(projectId, token, teamId, domain, deps, ho
5169
5200
  }
5170
5201
  }
5171
5202
  if (vault) {
5172
- await writeDomainEnvToVault(vault, domain, hosts);
5203
+ await writeDomainEnvToVault(vault, domain, hosts, clientMail);
5173
5204
  }
5174
5205
  else {
5175
5206
  warnRpIdMoved(domain);
@@ -5191,10 +5222,10 @@ function domainEnvValues(domain, hosts) {
5191
5222
  * reference for the shared ones; the syncs deliver them to production (Vercel or Railway, SYNC-001).
5192
5223
  * `NEXT_PUBLIC_APP_URL` goes to `main:/` only: `develop:/` keeps develop's origin.
5193
5224
  */
5194
- async function writeDomainEnvToVault(vault, domain, hosts) {
5225
+ async function writeDomainEnvToVault(vault, domain, hosts, clientMail) {
5195
5226
  const { appOrigin, emailFrom, relatedOrigins } = domainEnvValues(domain, hosts);
5196
5227
  await vaultWrite(vault, VAULT_MAIN_ROOT, {
5197
- EMAIL_FROM: emailFrom,
5228
+ ...(clientMail ? { EMAIL_FROM: emailFrom } : {}),
5198
5229
  NEXT_PUBLIC_APP_URL: appOrigin,
5199
5230
  WEBAUTHN_RP_ID: domain,
5200
5231
  WEBAUTHN_RELATED_ORIGINS: relatedOrigins,
@@ -5211,9 +5242,22 @@ function warnRpIdMoved(domain) {
5211
5242
  'bajo el rpID interino queda invalidada — esos usuarios deben registrar una nueva ' +
5212
5243
  'passkey en el dominio definitivo.');
5213
5244
  }
5245
+ /** The go-live without `--client-mail`: say where the mail stays and how to move it. */
5246
+ function logSharedMail() {
5247
+ console.log(` ⏭ correo: se queda en ${sendingDomainOf(PROVISION_EMAIL_FROM)} ` +
5248
+ '(usa `--client-mail` para enviar desde el dominio del cliente)');
5249
+ }
5250
+ /**
5251
+ * The go-live's mail choice in the state: `sharedMail: true` when the mail stayed on the org
5252
+ * sender, absent when it moved to the client ({@link noVaultMintDomain} reads it).
5253
+ */
5254
+ function withMailChoice(dns, clientMail) {
5255
+ const { sharedMail: _previous, ...rest } = dns;
5256
+ return clientMail ? rest : { ...rest, sharedMail: true };
5257
+ }
5214
5258
  /**
5215
- * VAULT-002 — re-scope the project's Resend key after the go-live: the key is limited to its
5216
- * sending domain, and `EMAIL_FROM` just moved to `updates.<client>`. Mints a new send-only key
5259
+ * VAULT-002 — re-scope the project's Resend key after a `--client-mail` go-live: the key is
5260
+ * limited to its sending domain, and `EMAIL_FROM` just moved to `updates.<client>`. Mints a new send-only key
5217
5261
  * for the new domain, upserts it into `main:/` (the one copy the other environments reference;
5218
5262
  * skipped without a vault) and into the deploys, records its id, and revokes the previous key.
5219
5263
  *
@@ -5394,12 +5438,17 @@ function goLiveRetryHint(mailDomain) {
5394
5438
  const client = mailDomain.startsWith(prefix) ? mailDomain.slice(prefix.length) : '<tu dominio>';
5395
5439
  return `repite el go-live (\`factory provision --domain=${client}\`)`;
5396
5440
  }
5441
+ /** Whether the state records a kit domain in that Railway environment (created, or found by adopt). */
5442
+ function hasRailwayKitDomain(state, role) {
5443
+ const rw = state?.railway;
5444
+ return Boolean(rw?.customDomains?.[role] || rw?.adoptedDomains?.some((d) => d.role === role));
5445
+ }
5397
5446
  /**
5398
5447
  * Print the resolved client-domain operation tree for `--domain --dry-run`
5399
5448
  * (PROV-006 §8): the exact domain, the DNS route, whether the CNAME is created or
5400
5449
  * printed, the interim-retire intent, and the env updates — mutating NOTHING.
5401
5450
  */
5402
- function printClientDomainPlan(domain, interim, route, confirm, target) {
5451
+ function printClientDomainPlan(domain, hosts, route, confirm, target, clientMail = false) {
5403
5452
  console.log(`\nPlan de \`--domain=${domain}\` (ruta DNS: ${route}):`);
5404
5453
  if (target === 'railway') {
5405
5454
  // RWAY-005: the CNAME's destination and the TXT are what Railway returns for the domain.
@@ -5424,17 +5473,27 @@ function printClientDomainPlan(domain, interim, route, confirm, target) {
5424
5473
  }
5425
5474
  console.log(` … sondea GET /v9/projects/.../domains/${domain} hasta verified:true (o timeout)`);
5426
5475
  }
5427
- console.log(` ${confirm ? '-' : '? (requiere --confirm o prompt)'} retira el interino ${interim} ` +
5428
- `(el dev ${domain}-dev se conserva)`);
5429
- console.log(` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · NEXT_PUBLIC_APP_URL=https://${domain}`);
5476
+ const { interim, dev } = hosts;
5477
+ console.log(interim
5478
+ ? ` ${confirm ? '-' : '? (requiere --confirm o prompt)'} retira el interino ${interim}` +
5479
+ (dev ? ` (el dev ${dev} se conserva)` : '')
5480
+ : ' · sin dominio interino registrado: no hay nada que retirar');
5481
+ console.log(clientMail
5482
+ ? ` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · NEXT_PUBLIC_APP_URL=https://${domain}`
5483
+ : ` ↻ NEXT_PUBLIC_APP_URL=https://${domain}`);
5430
5484
  console.log(` ↻ WEBAUTHN_RP_ID=${domain} (production y preview — las passkeys del rpID interino quedan invalidadas) · ` +
5431
5485
  `WEBAUTHN_RELATED_ORIGINS = origen de develop${confirm ? '' : ' (+ el interino si se conserva)'}`);
5432
- console.log(` + Resend: POST /domains { name: ${mailDomainFor(domain)} } (subdominio mail, no el apex)`);
5433
- if (route === 'registrar') {
5434
- console.log(' · (registrar) los records DNS de Resend (DKIM/SPF/MX) se crean a mano en el dashboard de Resend');
5486
+ if (clientMail) {
5487
+ console.log(` + Resend: POST /domains { name: ${mailDomainFor(domain)} } (subdominio mail, no el apex)`);
5488
+ if (route === 'registrar') {
5489
+ console.log(' · (registrar) los records DNS de Resend (DKIM/SPF/MX) se crean a mano en el dashboard de Resend');
5490
+ }
5491
+ else {
5492
+ console.log(` + records DNS de Resend (DKIM/SPF/MX, los que devuelva) en la zona ${apexZoneOf(domain)} + sondeo hasta verified`);
5493
+ }
5435
5494
  }
5436
5495
  else {
5437
- console.log(` + records DNS de Resend (DKIM/SPF/MX, los que devuelva) en la zona ${apexZoneOf(domain)} + sondeo hasta verified`);
5496
+ logSharedMail();
5438
5497
  }
5439
5498
  console.log('\n(`--dry-run`) No se ejecutó ninguna llamada a API ni se escribió estado.');
5440
5499
  }
@@ -8867,10 +8926,14 @@ export async function runProvision(flags, deps = {}) {
8867
8926
  result.initialMigration = true;
8868
8927
  }
8869
8928
  if (hasDomain) {
8870
- result.clientDomain = { domain: flags.domain, route: flags.dns };
8929
+ result.clientDomain = {
8930
+ domain: flags.domain,
8931
+ route: flags.dns,
8932
+ clientMail: flags.clientMail === true,
8933
+ };
8871
8934
  }
8872
8935
  if (flags.services.includes('vault')) {
8873
- result.vault = buildVaultPlan(projectName, flags.services);
8936
+ result.vault = buildVaultPlan(projectName, flags.services, target);
8874
8937
  }
8875
8938
  if (vaultDecision.skipped) {
8876
8939
  result.vaultSkipped = vaultDecision.skipped;
@@ -8903,8 +8966,12 @@ export async function runProvision(flags, deps = {}) {
8903
8966
  if (hasDomain) {
8904
8967
  // Same rule as the real retire (DOM-001): the plan must print the interim the
8905
8968
  // project ACTUALLY has, not one re-derived in the current org zone.
8906
- const interim = existingProvisionDomains(projectName, state?.dns).prod;
8907
- printClientDomainPlan(flags.domain, interim, flags.dns, flags.confirm, target);
8969
+ const hosts = existingProvisionDomains(projectName, state?.dns);
8970
+ // Railway records its kit domains by environment: without one there is nothing to retire.
8971
+ const onRailway = target === 'railway';
8972
+ const interim = onRailway && !hasRailwayKitDomain(state, 'main') ? undefined : hosts.prod;
8973
+ const dev = onRailway && !hasRailwayKitDomain(state, 'develop') ? undefined : hosts.dev;
8974
+ printClientDomainPlan(flags.domain, { interim, dev }, flags.dns, flags.confirm, target, flags.clientMail === true);
8908
8975
  }
8909
8976
  }
8910
8977
  return result;
@@ -8915,7 +8982,9 @@ export async function runProvision(flags, deps = {}) {
8915
8982
  const preflight = await runProvisionPreflight({
8916
8983
  services: preflightServices,
8917
8984
  teamId: flags.team,
8918
- repoSlug: `${FACTORY_ORG}/${projectName}`,
8985
+ // The repo the steps target (state → `--repo` → `origin` → default), never one re-derived
8986
+ // from the slug: a repo named apart from its slug would fail a check the run would pass.
8987
+ ...(repoSlug ? { repoSlug } : {}),
8919
8988
  rootDir,
8920
8989
  ...(target ? { target } : {}),
8921
8990
  }, { rail });
@@ -9110,6 +9179,7 @@ export async function runProvision(flags, deps = {}) {
9110
9179
  const headless = !process.stdin.isTTY;
9111
9180
  await provisionClientDomain(rootDir, runState, flags.team, flags.domain, {
9112
9181
  dnsRoute: flags.dns,
9182
+ clientMail: flags.clientMail === true,
9113
9183
  verifyTimeoutMin: flags.verifyTimeout,
9114
9184
  preConfirmed: flags.confirm,
9115
9185
  headless,
@@ -946,7 +946,10 @@ export function createVaultClient(deps = {}) {
946
946
  if (app !== 'railway')
947
947
  assertDestination(app);
948
948
  const operation = `consultar la conexión de la organización con ${app}`;
949
- const body = asObject(ok(await call(operation, 'GET', `/api/v1/app-connections/${app}`), operation));
949
+ // `/available`, not the bare list: the list needs the org `Read` permission on app
950
+ // connections, which only admins hold; `/available` needs `Connect` — the same one a sync
951
+ // needs — and the built-in `member` role has it. With the list, a member got a 403.
952
+ const body = asObject(ok(await call(operation, 'GET', `/api/v1/app-connections/${app}/available`), operation));
950
953
  const connections = asArray(body?.appConnections).flatMap((entry) => {
951
954
  const connection = asObject(entry);
952
955
  const id = asString(connection?.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",