@timekast/factory 2.1.0 → 2.3.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/provision.js +109 -55
- package/dist/commands/ticket.js +17 -48
- package/dist/index.js +2 -2
- package/dist/lib/vault-api.js +4 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
|
76
|
-
* verified in Resend, NOT the apex
|
|
77
|
-
*
|
|
78
|
-
* `updates.<client>`
|
|
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
|
|
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'),
|
|
@@ -1923,12 +1931,15 @@ const RESEND_KEY_RESUME_HINT = 'repite la corrida con `--resume` y `dns` en `--s
|
|
|
1923
1931
|
'hasta publicarla';
|
|
1924
1932
|
/**
|
|
1925
1933
|
* NO-vault: the sending domain a fresh project key is limited to — the client's mail subdomain
|
|
1926
|
-
* once
|
|
1927
|
-
*
|
|
1928
|
-
* back to the
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1934
|
+
* once a `--client-mail` go-live is recorded (Resend already knows it: that go-live registers
|
|
1935
|
+
* it), else the org domain. A `--resume` that retries the key after such a go-live must not move
|
|
1936
|
+
* the mail back to the org domain. `sharedMail` marks a go-live that kept the org sender; a
|
|
1937
|
+
* `live` state without it predates the flag, when every go-live moved the mail.
|
|
1938
|
+
*/
|
|
1939
|
+
export function noVaultMintDomain(state) {
|
|
1940
|
+
const client = state.dns?.clientDomainStatus === 'live' && state.dns.sharedMail !== true
|
|
1941
|
+
? state.dns.clientDomain
|
|
1942
|
+
: undefined;
|
|
1932
1943
|
return client ? mailDomainFor(client) : sendingDomainOf(PROVISION_EMAIL_FROM);
|
|
1933
1944
|
}
|
|
1934
1945
|
/**
|
|
@@ -4711,8 +4722,9 @@ async function resolveDnsToken(route, rail, headless, rootDir) {
|
|
|
4711
4722
|
/**
|
|
4712
4723
|
* PROV-006 — attach the client's DEFINITIVE domain to the Vercel project, create
|
|
4713
4724
|
* (or print, for `registrar`) its DNS record, poll Vercel until verified, and on
|
|
4714
|
-
* success: retire the interim prod domain (with explicit confirmation)
|
|
4715
|
-
* `
|
|
4725
|
+
* success: retire the interim prod domain (with explicit confirmation) and update
|
|
4726
|
+
* `NEXT_PUBLIC_APP_URL`. The mail stays on the org sender unless `deps.clientMail`
|
|
4727
|
+
* (`--client-mail`): then `EMAIL_FROM` moves to `updates.<domain>`, registered in Resend.
|
|
4716
4728
|
*
|
|
4717
4729
|
* Standalone-safe: runnable as a PATCH over an existing provision (the `dns` step
|
|
4718
4730
|
* of PROV-005 already attached the interim domains) or inside the initial run. It
|
|
@@ -4745,6 +4757,7 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
|
|
|
4745
4757
|
'Corre el paso de Vercel/GitHub primero (`--services=github,...`) y reintenta con `--resume`.');
|
|
4746
4758
|
}
|
|
4747
4759
|
const route = deps.dnsRoute ?? 'org';
|
|
4760
|
+
const clientMail = deps.clientMail === true;
|
|
4748
4761
|
const rail = deps.rail ?? defaultRail();
|
|
4749
4762
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
4750
4763
|
const vercelToken = await readVercelToken(rail);
|
|
@@ -4823,27 +4836,32 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
|
|
|
4823
4836
|
// 5. Update EMAIL_FROM + NEXT_PUBLIC_APP_URL + the WebAuthn rpID/related origins in Vercel env
|
|
4824
4837
|
// (idempotent — §8), and in the vault's `main:/` when the repo has one (VAULT-002).
|
|
4825
4838
|
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
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4839
|
+
await updateDomainEnv(projectId, vercelToken, teamId, domain, vercelDeps, { dev: hosts.dev, interim: hosts.prod, interimRetired }, deps.vault ?? null, clientMail);
|
|
4840
|
+
if (clientMail) {
|
|
4841
|
+
// 6. Register the client's MAIL SUBDOMAIN (`updates.<domain>`) in Resend — NOT the
|
|
4842
|
+
// apex, which holds the client's corporate MX/SPF (Google Workspace, M365, …).
|
|
4843
|
+
// Verifying Resend on a dedicated subdomain keeps the client's own mail intact.
|
|
4844
|
+
// BOOT-003: also CREATES the DKIM/SPF/MX records Resend returns (in the same
|
|
4845
|
+
// client zone as the CNAME) and polls until Resend reports `verified`.
|
|
4846
|
+
await registerResendDomain(mailDomainFor(domain), rail, deps, fetchImpl, resendDns);
|
|
4847
|
+
// 6b. VAULT-002 — the project's Resend key is limited to its sending domain, which just
|
|
4848
|
+
// changed: re-scope it to `updates.<domain>` (vault, when there is one, + deploys) and
|
|
4849
|
+
// revoke the old one once the new one reached them. Without a vault too: the deploys
|
|
4850
|
+
// carry the project's key there as well (never the rail's team key).
|
|
4851
|
+
await rescopeProjectResendKey(rootDir, state, deps.vault ?? null, mailDomainFor(domain), { projectId, token: vercelToken, teamId, deps: vercelDeps }, rail, fetchImpl, { sleep: deps.sleep, now: deps.now });
|
|
4852
|
+
}
|
|
4853
|
+
else {
|
|
4854
|
+
logSharedMail();
|
|
4855
|
+
}
|
|
4838
4856
|
// 7. Record the client domain as LIVE and persist.
|
|
4839
|
-
state.dns = {
|
|
4857
|
+
state.dns = withMailChoice({
|
|
4840
4858
|
...(state.dns ?? {
|
|
4841
4859
|
domain: provisionDomains(state.projectName).prod,
|
|
4842
4860
|
devDomain: provisionDomains(state.projectName).dev,
|
|
4843
4861
|
}),
|
|
4844
4862
|
clientDomain: domain,
|
|
4845
4863
|
clientDomainStatus: 'live',
|
|
4846
|
-
};
|
|
4864
|
+
}, clientMail);
|
|
4847
4865
|
writeProvisionState(rootDir, state);
|
|
4848
4866
|
}
|
|
4849
4867
|
/** RWAY-005 — the two records the owner creates by hand for `--dns=registrar` on Railway. */
|
|
@@ -4890,8 +4908,9 @@ async function pollRailwayDomainVerification(client, scope, dns, deps) {
|
|
|
4890
4908
|
* only PRINTS both.
|
|
4891
4909
|
* 3. Poll the domain's state in Railway (`--verify-timeout`); timeout → `pending-verification`.
|
|
4892
4910
|
* 4. Retire the interim `{slug}` from Railway with the same confirmation (`--confirm` headless).
|
|
4893
|
-
* 5. `
|
|
4894
|
-
* vault ONLY (its sync is Railway's only writer); Resend
|
|
4911
|
+
* 5. `NEXT_PUBLIC_APP_URL`, `WEBAUTHN_RP_ID`/`_RELATED_ORIGINS` (+ `EMAIL_FROM` with
|
|
4912
|
+
* `--client-mail`) → `main:/` of the vault ONLY (its sync is Railway's only writer); Resend
|
|
4913
|
+
* as on Vercel (only with `--client-mail`).
|
|
4895
4914
|
* 🔴 No request to Vercel. Requires the vault and the `railway` block (none → `--adopt`).
|
|
4896
4915
|
*/
|
|
4897
4916
|
async function provisionRailwayClientDomain(rootDir, state, domain, deps) {
|
|
@@ -4901,6 +4920,7 @@ async function provisionRailwayClientDomain(rootDir, state, domain, deps) {
|
|
|
4901
4920
|
assertRailwayDomainIds(state);
|
|
4902
4921
|
const rw = state.railway;
|
|
4903
4922
|
const route = deps.dnsRoute ?? 'org';
|
|
4923
|
+
const clientMail = deps.clientMail === true;
|
|
4904
4924
|
const rail = deps.rail ?? defaultRail();
|
|
4905
4925
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
4906
4926
|
const client = railwayClientFor(rail, deps.railway);
|
|
@@ -4987,15 +5007,21 @@ async function provisionRailwayClientDomain(rootDir, state, domain, deps) {
|
|
|
4987
5007
|
dev: hosts.dev,
|
|
4988
5008
|
interim: hosts.prod,
|
|
4989
5009
|
interimRetired,
|
|
4990
|
-
});
|
|
4991
|
-
// 6. Resend — unchanged by the destination (mail subdomain + the project's key re-scoped)
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
5010
|
+
}, clientMail);
|
|
5011
|
+
// 6. Resend — unchanged by the destination (mail subdomain + the project's key re-scoped),
|
|
5012
|
+
// and only with `--client-mail`.
|
|
5013
|
+
if (clientMail) {
|
|
5014
|
+
await registerResendDomain(mailDomainFor(domain), rail, paced, fetchImpl, resendDns);
|
|
5015
|
+
await rescopeProjectResendKey(rootDir, state, vault, mailDomainFor(domain), null, rail, fetchImpl, {
|
|
5016
|
+
sleep: paced.sleep,
|
|
5017
|
+
now: paced.now,
|
|
5018
|
+
});
|
|
5019
|
+
}
|
|
5020
|
+
else {
|
|
5021
|
+
logSharedMail();
|
|
5022
|
+
}
|
|
4997
5023
|
// 7. Live.
|
|
4998
|
-
state.dns = { ...dnsBlock(), clientDomainStatus: 'live' };
|
|
5024
|
+
state.dns = withMailChoice({ ...dnsBlock(), clientDomainStatus: 'live' }, clientMail);
|
|
4999
5025
|
writeProvisionState(rootDir, state);
|
|
5000
5026
|
}
|
|
5001
5027
|
/**
|
|
@@ -5125,12 +5151,15 @@ async function deleteInterimCname(interim, rail, fetchImpl) {
|
|
|
5125
5151
|
*
|
|
5126
5152
|
* No-echo: only KEY NAMES + targets print (the values here are public, but the discipline holds).
|
|
5127
5153
|
*/
|
|
5128
|
-
export async function updateDomainEnv(projectId, token, teamId, domain, deps, hosts, vault = null) {
|
|
5154
|
+
export async function updateDomainEnv(projectId, token, teamId, domain, deps, hosts, vault = null, clientMail = false) {
|
|
5129
5155
|
const { appOrigin, emailFrom, relatedOrigins } = domainEnvValues(domain, hosts);
|
|
5130
5156
|
const plan = [
|
|
5131
|
-
// EMAIL_FROM
|
|
5132
|
-
// — the apex carries the client's corporate mail.
|
|
5133
|
-
|
|
5157
|
+
// EMAIL_FROM only with `--client-mail`: the dedicated mail subdomain (`updates.<domain>`),
|
|
5158
|
+
// NOT the apex — the apex carries the client's corporate mail. Without it the sender
|
|
5159
|
+
// already in place (the org's, or one a team set by hand) is left alone.
|
|
5160
|
+
...(clientMail
|
|
5161
|
+
? [{ key: 'EMAIL_FROM', value: emailFrom, target: ['production', 'preview'] }]
|
|
5162
|
+
: []),
|
|
5134
5163
|
// 🔴 AUTH_URL / NEXTAUTH_URL are DELIBERATELY ABSENT — do not re-add them.
|
|
5135
5164
|
// `next-auth/lib/env.js` rewrites the origin of every auth request to their value,
|
|
5136
5165
|
// so scoping either to `preview` pins all branch deployments' callbacks to this one
|
|
@@ -5169,7 +5198,7 @@ export async function updateDomainEnv(projectId, token, teamId, domain, deps, ho
|
|
|
5169
5198
|
}
|
|
5170
5199
|
}
|
|
5171
5200
|
if (vault) {
|
|
5172
|
-
await writeDomainEnvToVault(vault, domain, hosts);
|
|
5201
|
+
await writeDomainEnvToVault(vault, domain, hosts, clientMail);
|
|
5173
5202
|
}
|
|
5174
5203
|
else {
|
|
5175
5204
|
warnRpIdMoved(domain);
|
|
@@ -5191,10 +5220,10 @@ function domainEnvValues(domain, hosts) {
|
|
|
5191
5220
|
* reference for the shared ones; the syncs deliver them to production (Vercel or Railway, SYNC-001).
|
|
5192
5221
|
* `NEXT_PUBLIC_APP_URL` goes to `main:/` only: `develop:/` keeps develop's origin.
|
|
5193
5222
|
*/
|
|
5194
|
-
async function writeDomainEnvToVault(vault, domain, hosts) {
|
|
5223
|
+
async function writeDomainEnvToVault(vault, domain, hosts, clientMail) {
|
|
5195
5224
|
const { appOrigin, emailFrom, relatedOrigins } = domainEnvValues(domain, hosts);
|
|
5196
5225
|
await vaultWrite(vault, VAULT_MAIN_ROOT, {
|
|
5197
|
-
EMAIL_FROM: emailFrom,
|
|
5226
|
+
...(clientMail ? { EMAIL_FROM: emailFrom } : {}),
|
|
5198
5227
|
NEXT_PUBLIC_APP_URL: appOrigin,
|
|
5199
5228
|
WEBAUTHN_RP_ID: domain,
|
|
5200
5229
|
WEBAUTHN_RELATED_ORIGINS: relatedOrigins,
|
|
@@ -5211,9 +5240,22 @@ function warnRpIdMoved(domain) {
|
|
|
5211
5240
|
'bajo el rpID interino queda invalidada — esos usuarios deben registrar una nueva ' +
|
|
5212
5241
|
'passkey en el dominio definitivo.');
|
|
5213
5242
|
}
|
|
5243
|
+
/** The go-live without `--client-mail`: say where the mail stays and how to move it. */
|
|
5244
|
+
function logSharedMail() {
|
|
5245
|
+
console.log(` ⏭ correo: se queda en ${sendingDomainOf(PROVISION_EMAIL_FROM)} ` +
|
|
5246
|
+
'(usa `--client-mail` para enviar desde el dominio del cliente)');
|
|
5247
|
+
}
|
|
5248
|
+
/**
|
|
5249
|
+
* The go-live's mail choice in the state: `sharedMail: true` when the mail stayed on the org
|
|
5250
|
+
* sender, absent when it moved to the client ({@link noVaultMintDomain} reads it).
|
|
5251
|
+
*/
|
|
5252
|
+
function withMailChoice(dns, clientMail) {
|
|
5253
|
+
const { sharedMail: _previous, ...rest } = dns;
|
|
5254
|
+
return clientMail ? rest : { ...rest, sharedMail: true };
|
|
5255
|
+
}
|
|
5214
5256
|
/**
|
|
5215
|
-
* VAULT-002 — re-scope the project's Resend key after
|
|
5216
|
-
* sending domain, and `EMAIL_FROM` just moved to `updates.<client>`. Mints a new send-only key
|
|
5257
|
+
* VAULT-002 — re-scope the project's Resend key after a `--client-mail` go-live: the key is
|
|
5258
|
+
* limited to its sending domain, and `EMAIL_FROM` just moved to `updates.<client>`. Mints a new send-only key
|
|
5217
5259
|
* for the new domain, upserts it into `main:/` (the one copy the other environments reference;
|
|
5218
5260
|
* skipped without a vault) and into the deploys, records its id, and revokes the previous key.
|
|
5219
5261
|
*
|
|
@@ -5399,7 +5441,7 @@ function goLiveRetryHint(mailDomain) {
|
|
|
5399
5441
|
* (PROV-006 §8): the exact domain, the DNS route, whether the CNAME is created or
|
|
5400
5442
|
* printed, the interim-retire intent, and the env updates — mutating NOTHING.
|
|
5401
5443
|
*/
|
|
5402
|
-
function printClientDomainPlan(domain, interim, route, confirm, target) {
|
|
5444
|
+
function printClientDomainPlan(domain, interim, route, confirm, target, clientMail = false) {
|
|
5403
5445
|
console.log(`\nPlan de \`--domain=${domain}\` (ruta DNS: ${route}):`);
|
|
5404
5446
|
if (target === 'railway') {
|
|
5405
5447
|
// RWAY-005: the CNAME's destination and the TXT are what Railway returns for the domain.
|
|
@@ -5426,15 +5468,22 @@ function printClientDomainPlan(domain, interim, route, confirm, target) {
|
|
|
5426
5468
|
}
|
|
5427
5469
|
console.log(` ${confirm ? '-' : '? (requiere --confirm o prompt)'} retira el interino ${interim} ` +
|
|
5428
5470
|
`(el dev ${domain}-dev se conserva)`);
|
|
5429
|
-
console.log(
|
|
5471
|
+
console.log(clientMail
|
|
5472
|
+
? ` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · NEXT_PUBLIC_APP_URL=https://${domain}`
|
|
5473
|
+
: ` ↻ NEXT_PUBLIC_APP_URL=https://${domain}`);
|
|
5430
5474
|
console.log(` ↻ WEBAUTHN_RP_ID=${domain} (production y preview — las passkeys del rpID interino quedan invalidadas) · ` +
|
|
5431
5475
|
`WEBAUTHN_RELATED_ORIGINS = origen de develop${confirm ? '' : ' (+ el interino si se conserva)'}`);
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5476
|
+
if (clientMail) {
|
|
5477
|
+
console.log(` + Resend: POST /domains { name: ${mailDomainFor(domain)} } (subdominio mail, no el apex)`);
|
|
5478
|
+
if (route === 'registrar') {
|
|
5479
|
+
console.log(' · (registrar) los records DNS de Resend (DKIM/SPF/MX) se crean a mano en el dashboard de Resend');
|
|
5480
|
+
}
|
|
5481
|
+
else {
|
|
5482
|
+
console.log(` + records DNS de Resend (DKIM/SPF/MX, los que devuelva) en la zona ${apexZoneOf(domain)} + sondeo hasta verified`);
|
|
5483
|
+
}
|
|
5435
5484
|
}
|
|
5436
5485
|
else {
|
|
5437
|
-
|
|
5486
|
+
logSharedMail();
|
|
5438
5487
|
}
|
|
5439
5488
|
console.log('\n(`--dry-run`) No se ejecutó ninguna llamada a API ni se escribió estado.');
|
|
5440
5489
|
}
|
|
@@ -8867,7 +8916,11 @@ export async function runProvision(flags, deps = {}) {
|
|
|
8867
8916
|
result.initialMigration = true;
|
|
8868
8917
|
}
|
|
8869
8918
|
if (hasDomain) {
|
|
8870
|
-
result.clientDomain = {
|
|
8919
|
+
result.clientDomain = {
|
|
8920
|
+
domain: flags.domain,
|
|
8921
|
+
route: flags.dns,
|
|
8922
|
+
clientMail: flags.clientMail === true,
|
|
8923
|
+
};
|
|
8871
8924
|
}
|
|
8872
8925
|
if (flags.services.includes('vault')) {
|
|
8873
8926
|
result.vault = buildVaultPlan(projectName, flags.services);
|
|
@@ -8904,7 +8957,7 @@ export async function runProvision(flags, deps = {}) {
|
|
|
8904
8957
|
// Same rule as the real retire (DOM-001): the plan must print the interim the
|
|
8905
8958
|
// project ACTUALLY has, not one re-derived in the current org zone.
|
|
8906
8959
|
const interim = existingProvisionDomains(projectName, state?.dns).prod;
|
|
8907
|
-
printClientDomainPlan(flags.domain, interim, flags.dns, flags.confirm, target);
|
|
8960
|
+
printClientDomainPlan(flags.domain, interim, flags.dns, flags.confirm, target, flags.clientMail === true);
|
|
8908
8961
|
}
|
|
8909
8962
|
}
|
|
8910
8963
|
return result;
|
|
@@ -9110,6 +9163,7 @@ export async function runProvision(flags, deps = {}) {
|
|
|
9110
9163
|
const headless = !process.stdin.isTTY;
|
|
9111
9164
|
await provisionClientDomain(rootDir, runState, flags.team, flags.domain, {
|
|
9112
9165
|
dnsRoute: flags.dns,
|
|
9166
|
+
clientMail: flags.clientMail === true,
|
|
9113
9167
|
verifyTimeoutMin: flags.verifyTimeout,
|
|
9114
9168
|
preConfirmed: flags.confirm,
|
|
9115
9169
|
headless,
|
package/dist/commands/ticket.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* A derivative documents a kit defect as a markdown file under `project/factory/`
|
|
5
5
|
* (the `fx-factory-tickets` convention). Until now that file was a dead end: it sat
|
|
6
6
|
* in the repo until somebody opened the Factory by hand. This command delivers it as
|
|
7
|
-
* a GitHub issue in the Factory repo and
|
|
8
|
-
*
|
|
7
|
+
* a GitHub issue in the Factory repo and then DELETES the local file: it was only a
|
|
8
|
+
* draft, and from here on the issue is the one source of state (open / closed,
|
|
9
|
+
* labels). A local copy with its own status only drifted from GitHub, and a second
|
|
10
|
+
* `push` of it opened a duplicate issue (`fx-factory-tickets §6`).
|
|
9
11
|
*
|
|
10
12
|
* THREE THINGS THIS COMMAND DELIBERATELY DOES NOT DO:
|
|
11
13
|
*
|
|
@@ -34,7 +36,7 @@
|
|
|
34
36
|
* preflight degrades to a note + `exit 0` — the same `not-configured` contract
|
|
35
37
|
* `factory backlog push` has without `BACKLOG_API_KEY`, never a stack trace.
|
|
36
38
|
*/
|
|
37
|
-
import { existsSync, readFileSync, realpathSync, statSync,
|
|
39
|
+
import { existsSync, readFileSync, realpathSync, statSync, unlinkSync } from 'node:fs';
|
|
38
40
|
import path from 'node:path';
|
|
39
41
|
import { execa } from 'execa';
|
|
40
42
|
import prompts from 'prompts';
|
|
@@ -163,36 +165,6 @@ export function resolveTicketPath(rootDir, filePath) {
|
|
|
163
165
|
return realTarget;
|
|
164
166
|
}
|
|
165
167
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
166
|
-
// Stamping (`fx-factory-tickets §6.2`)
|
|
167
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
168
|
-
/** Canonical header field the delivered URL is stamped into. It is the ONLY field a tool writes. */
|
|
169
|
-
const GITHUB_ISSUE_FIELD = '**GitHub issue:**';
|
|
170
|
-
const ESTADO_FIELD = '**Estado:**';
|
|
171
|
-
/**
|
|
172
|
-
* Write `url` into the ticket's `**GitHub issue:**` slot, which is born holding an
|
|
173
|
-
* em-dash and sits immediately after `**Estado:**`. Stamping the URL does NOT touch
|
|
174
|
-
* `Estado` — delivering is not triaging (`fx-factory-tickets §6.2`).
|
|
175
|
-
*
|
|
176
|
-
* A ticket written before the convention has no such field; it is inserted right
|
|
177
|
-
* after `Estado` rather than dropped, so the ticket stops being a dead end either
|
|
178
|
-
* way. With no header block at all, nothing is changed and the caller warns.
|
|
179
|
-
*/
|
|
180
|
-
export function stampGitHubIssueUrl(content, url) {
|
|
181
|
-
const stamped = `${GITHUB_ISSUE_FIELD} ${url}`;
|
|
182
|
-
const lines = content.split('\n');
|
|
183
|
-
const fieldIdx = lines.findIndex((line) => line.startsWith(GITHUB_ISSUE_FIELD));
|
|
184
|
-
if (fieldIdx >= 0) {
|
|
185
|
-
lines[fieldIdx] = stamped;
|
|
186
|
-
return { content: lines.join('\n'), changed: true };
|
|
187
|
-
}
|
|
188
|
-
const estadoIdx = lines.findIndex((line) => line.startsWith(ESTADO_FIELD));
|
|
189
|
-
if (estadoIdx >= 0) {
|
|
190
|
-
lines.splice(estadoIdx + 1, 0, stamped);
|
|
191
|
-
return { content: lines.join('\n'), changed: true };
|
|
192
|
-
}
|
|
193
|
-
return { content, changed: false };
|
|
194
|
-
}
|
|
195
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
196
168
|
// Terminal safety
|
|
197
169
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
198
170
|
/**
|
|
@@ -271,7 +243,7 @@ async function defaultConfirm(message) {
|
|
|
271
243
|
*
|
|
272
244
|
* Order matters and is asserted by the unit test: containment → read → size →
|
|
273
245
|
* (`--dry-run` stops here) → gh session → publish gate → create → verify
|
|
274
|
-
* assignee/label →
|
|
246
|
+
* assignee/label → delete the draft. Every guard that can reject runs BEFORE `gh` is ever
|
|
275
247
|
* spawned.
|
|
276
248
|
*/
|
|
277
249
|
export async function runTicketPush(filePath, flags = parseTicketPushFlags([]), rootDir = detectRepo(process.cwd()).repoRoot ?? process.cwd(), deps = {}) {
|
|
@@ -387,8 +359,8 @@ export async function runTicketPush(filePath, flags = parseTicketPushFlags([]),
|
|
|
387
359
|
if (!url) {
|
|
388
360
|
console.log(`✅ Issue creado en ${FACTORY_REPO}.`);
|
|
389
361
|
console.log(stdout.trim());
|
|
390
|
-
console.warn('⚠️ No se pudo leer la URL de la salida de `gh`, así que el
|
|
391
|
-
'
|
|
362
|
+
console.warn('⚠️ No se pudo leer la URL de la salida de `gh`, así que el borrador local NO se borró. ' +
|
|
363
|
+
'Revisa el issue de arriba y borra el archivo a mano (no lo vuelvas a entregar: abriría otro issue).');
|
|
392
364
|
return;
|
|
393
365
|
}
|
|
394
366
|
console.log(`✅ Issue creado: ${url}`);
|
|
@@ -407,22 +379,19 @@ export async function runTicketPush(filePath, flags = parseTicketPushFlags([]),
|
|
|
407
379
|
// permission is `read` — so the two notification mechanisms of this channel can
|
|
408
380
|
// fail at once, without a word, unless we look.
|
|
409
381
|
await verifyIssueFields(url, flags, labelDropped, assigneeDropped);
|
|
410
|
-
// 6.
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
console.warn(`⚠️ \`${shown}\` no tiene el campo \`${GITHUB_ISSUE_FIELD}\` ni \`${ESTADO_FIELD}\`, así que no se estampó. ` +
|
|
415
|
-
'Agrega la URL a mano (`fx-factory-tickets §6.2`).');
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
382
|
+
// 6. The draft is delivered: delete it. From here the issue is the only source of
|
|
383
|
+
// state. If this fails, the URL is already on screen (step above) — never leave
|
|
384
|
+
// the dev without the URL of an issue that already exists, and never fail a
|
|
385
|
+
// delivery that succeeded.
|
|
418
386
|
try {
|
|
419
|
-
(deps.
|
|
420
|
-
console.log(`
|
|
387
|
+
(deps.removeFile ?? unlinkSync)(target);
|
|
388
|
+
console.log(` Borré el borrador \`${shown}\`: el estado vive en el issue. ` +
|
|
389
|
+
'Commitea el borrado (`docs(factory): …`).');
|
|
421
390
|
}
|
|
422
391
|
catch (error) {
|
|
423
|
-
console.warn(`⚠️ El issue existe (${url}) pero \`${shown}\` no se pudo
|
|
392
|
+
console.warn(`⚠️ El issue existe (${url}) pero \`${shown}\` no se pudo borrar: ` +
|
|
424
393
|
`${error instanceof Error ? error.message : String(error)}\n` +
|
|
425
|
-
|
|
394
|
+
' Bórralo a mano; entregarlo otra vez abriría un issue duplicado.');
|
|
426
395
|
}
|
|
427
396
|
}
|
|
428
397
|
/**
|
package/dist/index.js
CHANGED
|
@@ -191,8 +191,8 @@ Sobre \`backlog\`:
|
|
|
191
191
|
|
|
192
192
|
Sobre \`ticket\`:
|
|
193
193
|
\`ticket push <archivo>\` entrega un factory-ticket de \`project/factory/\` al repo
|
|
194
|
-
del Factory como un issue de GitHub
|
|
195
|
-
|
|
194
|
+
del Factory como un issue de GitHub y, entregado, BORRA el archivo local: era un
|
|
195
|
+
borrador, y el estado vive en el issue (commitea el borrado con \`docs(factory): …\`).
|
|
196
196
|
Cero credenciales nuevas: usa tu propia sesión de \`gh\`; sin sesión avisa y sale 0.
|
|
197
197
|
El cuerpo se publica ÍNTEGRO en un repo compartido, así que hay compuerta:
|
|
198
198
|
--dry-run Imprime destino, asignado, etiqueta y título; no crea nada
|
package/dist/lib/vault-api.js
CHANGED
|
@@ -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
|
-
|
|
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);
|