@timekast/factory 1.28.1 → 2.0.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.
Files changed (38) hide show
  1. package/dist/commands/backlog.js +52 -15
  2. package/dist/commands/doctor.js +210 -29
  3. package/dist/commands/env.js +154 -18
  4. package/dist/commands/invite-admin.js +62 -15
  5. package/dist/commands/provision.js +5815 -763
  6. package/dist/commands/status.js +12 -2
  7. package/dist/commands/ticket.js +4 -4
  8. package/dist/commands/update.js +97 -12
  9. package/dist/commands/vault.js +2627 -0
  10. package/dist/index.js +97 -33
  11. package/dist/lib/backlog-mapping.js +111 -18
  12. package/dist/lib/backlog-parse.js +1 -1
  13. package/dist/lib/claude-md.js +320 -0
  14. package/dist/lib/cloudflare-api.js +213 -37
  15. package/dist/lib/constants.js +70 -6
  16. package/dist/lib/env-file.js +45 -7
  17. package/dist/lib/env-wizard.js +66 -2
  18. package/dist/lib/initial-migration.js +3 -2
  19. package/dist/lib/neon-api.js +145 -10
  20. package/dist/lib/package-json.js +37 -16
  21. package/dist/lib/preflight.js +92 -38
  22. package/dist/lib/provision-preflight.js +158 -100
  23. package/dist/lib/provision-state.js +652 -20
  24. package/dist/lib/publish-engine.js +1 -1
  25. package/dist/lib/rail.js +400 -0
  26. package/dist/lib/railway-api.js +851 -0
  27. package/dist/lib/vault-adopt.js +1495 -0
  28. package/dist/lib/vault-api.js +1106 -0
  29. package/dist/lib/vault-config.js +21 -0
  30. package/dist/lib/vault-deploys.js +898 -0
  31. package/dist/lib/vercel-env.js +251 -31
  32. package/dist/lib/wrapper-wiring.js +160 -0
  33. package/dist/tui/env-wizard-flow.js +17 -3
  34. package/dist/tui/menu.js +4 -22
  35. package/dist/tui/provision-flow.js +68 -13
  36. package/package.json +1 -1
  37. package/dist/commands/secrets.js +0 -71
  38. package/dist/lib/env-merge.js +0 -238
@@ -15,6 +15,10 @@
15
15
  * - Run `vercel link` / `vercel pull` — NEVER. It writes `.vercel/project.json`
16
16
  * itself (`{ projectId, orgId, teamId }`) so downstream steps never depend on
17
17
  * the Vercel CLI rewriting `.env.local` (SK.md §7.1 — overwrite locks devs out).
18
+ * - Write the env of a project that lives in the vault (SYNC-001). There the vault's secret
19
+ * syncs are the single writer of each target; provision gates every direct writer on the
20
+ * `vault` block, and `VercelApiDeps.vaultOwned` refuses any env write that slips past it.
21
+ * `listEnvVarNames` (names + targets, never values) is what such a repo may read.
18
22
  *
19
23
  * Per-target push (issue §4): production-only secrets push to `["production"]`,
20
24
  * preview/develop ones to `["preview"]`, and shared values to both. `AUTH_SECRET`
@@ -22,7 +26,7 @@
22
26
  * prod and preview must never share a secret. The push is idempotent: a var that
23
27
  * already exists for a target is PATCHed (not re-POSTed → no `ENV_ALREADY_EXISTS`).
24
28
  *
25
- * No-echo discipline (issue §4 + `factory secrets` pattern): a secret VALUE never
29
+ * No-echo discipline (issue §4 + `fx-secrets-vault`): a secret VALUE never
26
30
  * reaches stdout. The public surface returns only KEY NAMES + targets; values
27
31
  * live solely inside the request bodies handed to `fetchImpl`.
28
32
  *
@@ -42,6 +46,17 @@ const VERCEL_API_TIMEOUT_MS = 30_000;
42
46
  export const VERCEL_DIR = '.vercel';
43
47
  /** Filename of the Vercel project-link document inside {@link VERCEL_DIR}. */
44
48
  export const VERCEL_PROJECT_FILE = 'project.json';
49
+ /** An env-var path of the Vercel API (`/v9|v10/projects/{id}/env[/{envId}]`). */
50
+ const VERCEL_ENV_PATH_RE = /^\/v\d+\/projects\/[^/]+\/env(\/|$)/;
51
+ /** Raised when a direct env write is attempted on a project whose env the vault owns (SYNC-001). */
52
+ export class VercelEnvOwnedByVaultError extends CLIError {
53
+ constructor(method) {
54
+ super(`No escribo variables de entorno directo en Vercel (${method}): este repo vive en la bóveda y ` +
55
+ 'su sync es el único escritor del proyecto (fx-secrets-vault §7). Cambia el valor en la bóveda.');
56
+ this.name = 'VercelEnvOwnedByVaultError';
57
+ Object.setPrototypeOf(this, new.target.prototype);
58
+ }
59
+ }
45
60
  /** A non-2xx response from the Vercel REST API. Carries the HTTP status for mapping. */
46
61
  export class VercelApiError extends Error {
47
62
  status;
@@ -100,6 +115,9 @@ export function vercelUrl(pathname, teamId, extraQuery = {}) {
100
115
  * 403 on a team-owned project. Surfaces a typed {@link VercelApiError} on non-2xx.
101
116
  */
102
117
  async function vercelApi(method, pathname, token, teamId, deps, body, extraQuery) {
118
+ if (deps.vaultOwned && method !== 'GET' && VERCEL_ENV_PATH_RE.test(pathname)) {
119
+ throw new VercelEnvOwnedByVaultError(method);
120
+ }
103
121
  const fetchImpl = deps.fetchImpl ?? fetch;
104
122
  const url = vercelUrl(pathname, teamId, extraQuery);
105
123
  const init = {
@@ -202,6 +220,22 @@ export async function getLinkedRepoId(projectId, token, teamId, deps = {}) {
202
220
  const repoId = data.link?.repoId;
203
221
  return typeof repoId === 'number' ? repoId : null;
204
222
  }
223
+ /**
224
+ * Whether the project already has at least one deployment for `target` (`GET /v6/deployments`
225
+ * filtered by `projectId` + `target`, `limit=1` — never an unfiltered listing). The first-
226
+ * deployment trigger asks this per target before firing, so a re-entry never fires production
227
+ * again on a project that already deploys (a push, a previous run, a manual redeploy).
228
+ *
229
+ * @throws {VercelApiError} on any non-2xx (the caller treats it as NON-FATAL, like the trigger).
230
+ */
231
+ export async function hasDeployment(projectId, target, token, teamId, deps = {}) {
232
+ const data = (await vercelApi('GET', '/v6/deployments', token, teamId, deps, undefined, {
233
+ projectId,
234
+ target,
235
+ limit: '1',
236
+ }));
237
+ return Array.isArray(data.deployments) && data.deployments.length > 0;
238
+ }
205
239
  /**
206
240
  * Trigger the FIRST git deployment for ONE branch (`POST /v13/deployments`).
207
241
  * provision links the repo to the Vercel project AFTER `factory new`/provision
@@ -382,8 +416,13 @@ export async function listVercelProjects(token, teamId, deps = {}) {
382
416
  query.until = String(until);
383
417
  const data = (await vercelApi('GET', '/v9/projects', token, teamId, deps, undefined, query));
384
418
  for (const p of data.projects ?? []) {
385
- if (p.id && p.name)
386
- collected.push({ id: p.id, name: p.name });
419
+ if (!p.id || !p.name)
420
+ continue;
421
+ const link = p.link;
422
+ const repo = link?.type === 'github' && typeof link.org === 'string' && typeof link.repo === 'string'
423
+ ? `${link.org}/${link.repo}`
424
+ : undefined;
425
+ collected.push({ id: p.id, name: p.name, ...(repo ? { repo } : {}) });
387
426
  }
388
427
  const next = data.pagination?.next;
389
428
  if (next === undefined || next === null || next === until)
@@ -392,6 +431,23 @@ export async function listVercelProjects(token, teamId, deps = {}) {
392
431
  }
393
432
  return collected;
394
433
  }
434
+ /**
435
+ * The team's projects CONNECTED to `repo` (`owner/name`): the WHOLE paginated listing
436
+ * ({@link listVercelProjects}), matched on the Git link's `owner/repo`, case-insensitive (GitHub
437
+ * slugs are). A project that merely has the repo's NAME but is linked to another repo (or to
438
+ * none) does not count. STRICTLY read-only.
439
+ *
440
+ * The one "projects by connected repo" reading of the kit: `locateVercelProject` (ADOPT-001) and
441
+ * EPIC-06's destination discovery (RWAY-003) both go through it. What zero or several mean is
442
+ * the caller's question, not this one's.
443
+ *
444
+ * @throws {VercelApiError} on any non-2xx.
445
+ */
446
+ export async function listVercelProjectsConnectedTo(repo, token, teamId, deps = {}) {
447
+ const wanted = repo.trim().toLowerCase();
448
+ const projects = await listVercelProjects(token, teamId, deps);
449
+ return projects.filter((p) => p.repo?.toLowerCase() === wanted);
450
+ }
395
451
  /**
396
452
  * Apex of the hosts Vercel mints on its own (`{project}.vercel.app`). Nobody
397
453
  * created these and nobody can delete them, so they are the LAST choice when
@@ -412,6 +468,20 @@ export async function listProjectDomains(projectId, token, teamId, deps = {}) {
412
468
  .filter((d) => Boolean(d.name))
413
469
  .map((d) => ({ name: d.name, apexName: d.apexName ?? '', gitBranch: d.gitBranch ?? null }));
414
470
  }
471
+ /** The git branch whose domain is the preview host of a deploy. */
472
+ export const PREVIEW_HOST_BRANCH = 'develop';
473
+ /**
474
+ * ADOPT-009 — the candidate hosts of a project from {@link listProjectDomains}: production = every
475
+ * domain without `gitBranch`; preview = every domain pinned to `develop`. Pure: it never picks one
476
+ * among several, and never invents one — the caller asks whoever runs when a list is not exactly one.
477
+ */
478
+ export function deployHostCandidates(domains) {
479
+ const unique = (names) => [...new Set(names)];
480
+ return {
481
+ production: unique(domains.filter((d) => !d.gitBranch).map((d) => d.name)),
482
+ preview: unique(domains.filter((d) => d.gitBranch === PREVIEW_HOST_BRANCH).map((d) => d.name)),
483
+ };
484
+ }
415
485
  /**
416
486
  * List the project's existing env vars (issue §8 idempotency: we PATCH a var that
417
487
  * already covers a target instead of POSTing a duplicate). `decrypt=false` keeps
@@ -422,6 +492,138 @@ export async function listEnvVars(projectId, token, teamId, deps = {}) {
422
492
  const data = (await vercelApi('GET', `/v9/projects/${encodeURIComponent(projectId)}/env`, token, teamId, deps));
423
493
  return data.envs ?? [];
424
494
  }
495
+ /**
496
+ * The project's env vars as NAMES + targets only (SYNC-001). Vercel returns the value of a
497
+ * non-sensitive var in the listing, so {@link listEnvVars}' objects carry it at runtime even
498
+ * though the type hides it; this strips it at the boundary, so the destination checks of
499
+ * provision (empty destination, first-sync delivery, `/ci` leak) can never print one.
500
+ */
501
+ export async function listEnvVarNames(projectId, token, teamId, deps = {}) {
502
+ return (await listEnvVars(projectId, token, teamId, deps)).flatMap((env) => typeof env.key === 'string'
503
+ ? [{ key: env.key, target: Array.isArray(env.target) ? [...env.target] : [] }]
504
+ : []);
505
+ }
506
+ /** Vercel types whose value the API never returns. */
507
+ const UNREADABLE_ENV_TYPES = new Set(['sensitive', 'secret']);
508
+ /**
509
+ * Rebuild a Vercel failure WITHOUT its response body: a body could quote what was asked for,
510
+ * and this path handles values. Only the status and the operation name survive.
511
+ */
512
+ function valueSafeError(error, operation) {
513
+ const status = error instanceof VercelApiError ? ` (status ${error.status})` : '';
514
+ return new CLIError(`Vercel no respondió bien al ${operation}${status}.`);
515
+ }
516
+ /**
517
+ * The project's env vars WITH their values (`GET /v9/projects/{id}/env?decrypt=true`), for the
518
+ * adoption's inventory. An `encrypted` var the listing returns still encrypted is re-read by id
519
+ * (`GET /v1/projects/{id}/env/{envId}`); whatever stays unreadable comes back with
520
+ * `value: null`. {@link listEnvVars} is untouched — its consumers keep a value-free listing.
521
+ *
522
+ * A response WITHOUT the `envs` list reads as `[]` here; {@link readEnvVarsWithValues} tells the
523
+ * two apart.
524
+ *
525
+ * @throws {CLIError} on any Vercel failure, rebuilt without the response body.
526
+ */
527
+ export async function listEnvVarsWithValues(projectId, token, teamId, deps = {}) {
528
+ return (await readEnvVarsWithValues(projectId, token, teamId, deps)) ?? [];
529
+ }
530
+ /**
531
+ * ADOPT-009 — {@link listEnvVarsWithValues}, but a response that does NOT carry the `envs` list
532
+ * comes back as `null` instead of `[]`. `vault adopt` decides "the deploy is connected and EMPTY"
533
+ * only on an explicit empty list: a response without it is never proof of emptiness.
534
+ *
535
+ * @throws {CLIError} on any Vercel failure, rebuilt without the response body.
536
+ */
537
+ export async function readEnvVarsWithValues(projectId, token, teamId, deps = {}) {
538
+ const operation = 'leer las variables del proyecto';
539
+ let data;
540
+ try {
541
+ data = (await vercelApi('GET', `/v9/projects/${encodeURIComponent(projectId)}/env`, token, teamId, deps, undefined, { decrypt: 'true' }));
542
+ }
543
+ catch (error) {
544
+ throw valueSafeError(error, operation);
545
+ }
546
+ if (!Array.isArray(data.envs))
547
+ return null;
548
+ const out = [];
549
+ for (const env of data.envs) {
550
+ if (typeof env.key !== 'string')
551
+ continue;
552
+ const type = typeof env.type === 'string' ? env.type : null;
553
+ const target = Array.isArray(env.target) ? [...env.target] : env.target ? [env.target] : [];
554
+ let value = type !== null && UNREADABLE_ENV_TYPES.has(type)
555
+ ? null
556
+ : typeof env.value === 'string'
557
+ ? env.value
558
+ : null;
559
+ if (value !== null && type === 'encrypted' && env.decrypted === false)
560
+ value = null;
561
+ if (value === null && type === 'encrypted' && typeof env.id === 'string') {
562
+ try {
563
+ const one = (await vercelApi('GET', `/v1/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(env.id)}`, token, teamId, deps));
564
+ value = typeof one.value === 'string' && one.decrypted !== false ? one.value : null;
565
+ }
566
+ catch (error) {
567
+ throw valueSafeError(error, `leer la variable \`${env.key}\``);
568
+ }
569
+ }
570
+ out.push({
571
+ key: env.key,
572
+ target,
573
+ gitBranch: typeof env.gitBranch === 'string' && env.gitBranch !== '' ? env.gitBranch : null,
574
+ type,
575
+ value,
576
+ });
577
+ }
578
+ return out;
579
+ }
580
+ /**
581
+ * ADOPT-001 — locate the Vercel project of a repo, in this order:
582
+ *
583
+ * 1. the id the repo already RECORDED (`.timekast/provision.json` — the `vault` link, the
584
+ * step state or the adopted shape; the caller reads it). A recorded id Vercel does not know
585
+ * stops: a stale record is not silently replaced by a search.
586
+ * 2. the project CONNECTED to the repo: the WHOLE paginated listing, matched on the Git link's
587
+ * `owner/repo`, case-insensitive (GitHub slugs are).
588
+ *
589
+ * Never `.vercel/project.json`: it is gitignored, so its absence says nothing about a deploy.
590
+ * The caller decides what zero or several mean (`vault adopt`: zero with another deploy is fine,
591
+ * several stop). Reused by `doctor`/`status` (ADOPT-004) and EPIC-06's destination choice.
592
+ *
593
+ * @throws {CLIError} when the recorded id does not exist, or Vercel fails.
594
+ */
595
+ export async function locateVercelProject(input) {
596
+ const deps = input.deps ?? {};
597
+ if (input.recordedId) {
598
+ let project;
599
+ try {
600
+ project = await getVercelProjectByName(input.recordedId, input.token, input.teamId, deps);
601
+ }
602
+ catch (error) {
603
+ throw valueSafeError(error, 'leer el proyecto registrado');
604
+ }
605
+ if (!project) {
606
+ throw new CLIError(`El proyecto de Vercel que registra \`.timekast/provision.json\` (\`${input.recordedId}\`) no ` +
607
+ 'existe en el team. No busco otro por mi cuenta: corrige el registro y vuelve a correr.');
608
+ }
609
+ return { kind: 'found', project, source: 'recorded' };
610
+ }
611
+ if (!input.repo)
612
+ return { kind: 'none' };
613
+ let candidates;
614
+ try {
615
+ candidates = await listVercelProjectsConnectedTo(input.repo, input.token, input.teamId, deps);
616
+ }
617
+ catch (error) {
618
+ throw valueSafeError(error, 'listar los proyectos del team');
619
+ }
620
+ if (candidates.length === 0)
621
+ return { kind: 'none' };
622
+ if (candidates.length === 1) {
623
+ return { kind: 'found', project: candidates[0], source: 'connected' };
624
+ }
625
+ return { kind: 'ambiguous', candidates };
626
+ }
425
627
  /**
426
628
  * Idempotently set ONE env var for its targets (issue §4 + §8). If existing vars
427
629
  * already cover any of the requested targets, they are RECONCILED into a single
@@ -560,53 +762,71 @@ export function writeVercelProjectJson(rootDir, link, options = {}) {
560
762
  `${error?.message ?? 'error desconocido'}.`);
561
763
  }
562
764
  }
765
+ /**
766
+ * The `WEBAUTHN_RELATED_ORIGINS` value for a set of hosts: comma-separated ORIGINS with scheme
767
+ * (the format `src/lib/auth/webauthn.ts#getRelatedOrigins` parses — a bare host would be dropped
768
+ * there with a warning). Empty/duplicate hosts are skipped; order is kept.
769
+ */
770
+ export function relatedOriginsFor(hosts) {
771
+ const origins = [];
772
+ for (const host of hosts) {
773
+ const trimmed = host.trim();
774
+ if (trimmed === '')
775
+ continue;
776
+ const origin = `https://${trimmed}`;
777
+ if (!origins.includes(origin))
778
+ origins.push(origin);
779
+ }
780
+ return origins.join(',');
781
+ }
563
782
  /**
564
783
  * Build the per-target env-var plan for a provisioned stack (issue §4).
565
784
  *
566
785
  * Production-bound values (the `main` DATABASE_URL, the prod `AUTH_SECRET`,
567
786
  * `NEXT_PUBLIC_APP_ENV=production`) → `["production"]`. Preview/develop values
568
787
  * (the `develop` DATABASE_URL, the preview `AUTH_SECRET`, `NEXT_PUBLIC_APP_ENV=
569
- * preview`) → `["preview"]`. Shared, non-secret values (e.g. `EMAIL_FROM`,
570
- * `RESEND_API_KEY`) apply to both. Each `AUTH_SECRET` is minted independently so
571
- * the two environments never share a secret.
572
- *
573
- * `shared` values arrive already split on the first `=` (the caller reuses the
574
- * `env-merge.ts` first-`=` rule); this builder does no parsing of its own.
788
+ * preview`) → `["preview"]`. Shared, non-environment-specific values (e.g.
789
+ * `RESEND_API_KEY`) apply to both. Each `AUTH_SECRET` is DISTINCT per environment, with or
790
+ * without a vault (in a vault repo they are `main:/AUTH_SECRET` and `develop:/AUTH_SECRET`).
791
+ *
792
+ * WebAuthn (VAULT-002, ADR-005): ONE rpID for the whole project — the PRODUCTION host — in
793
+ * both targets, and the develop origin as a Related Origin (`WEBAUTHN_RELATED_ORIGINS`), so a
794
+ * preview deployment runs its ceremonies for the production rpID and the browser authorizes it
795
+ * through `https://<prod>/.well-known/webauthn`. A per-target rpID made a passkey enrolled on
796
+ * one host die on the other and forced the rpID to move at go-live.
797
+ *
798
+ * `shared` arrives as a ready key→value map (provision's `readSharedVercelEnv`, which
799
+ * never includes a rail admin token such as `CLOUDFLARE_API_TOKEN` — a rail token
800
+ * never leaves toward a deploy); this builder does no parsing of its own.
575
801
  */
576
802
  export function buildEnvPlan(input) {
577
803
  const plan = [
578
804
  // Database — distinct connection string per environment.
579
805
  { key: 'DATABASE_URL', value: input.mainDatabaseUrl, target: ['production'] },
580
806
  { key: 'DATABASE_URL', value: input.developDatabaseUrl, target: ['preview'] },
581
- // Auth secret — distinct value per environment (never shared).
807
+ // Auth secret — per environment record, distinct values (a preview never signs prod sessions).
582
808
  { key: 'AUTH_SECRET', value: input.authSecretProd, target: ['production'] },
583
809
  { key: 'AUTH_SECRET', value: input.authSecretPreview, target: ['preview'] },
584
- // MFA encryption key — distinct per env (base64url 32 bytes). Without it a
585
- // derivative with MFA enabled boots broken; minted independently so prod and
586
- // preview never share it (P-1).
810
+ // MFA encryption key (base64url 32 bytes). Without it a derivative with MFA enabled
811
+ // boots broken (P-1).
587
812
  { key: 'MFA_ENCRYPTION_KEY', value: input.mfaEncryptionKeyProd, target: ['production'] },
588
813
  { key: 'MFA_ENCRYPTION_KEY', value: input.mfaEncryptionKeyPreview, target: ['preview'] },
589
- // WebAuthn relying-party id — the EXACT host per env (a passkey dies if the
590
- // rpID changes), so passkeys work without manual Vercel setup (P-1 / PASSKEY-002 §8).
591
- { key: 'WEBAUTHN_RP_ID', value: input.webauthnRpIdProd, target: ['production'] },
592
- { key: 'WEBAUTHN_RP_ID', value: input.webauthnRpIdPreview, target: ['preview'] },
814
+ // WebAuthn — ONE rpID (the production host) for both targets + the develop origin as a
815
+ // related origin, so passkeys work without manual Vercel setup (P-1 / PASSKEY-002 §8).
816
+ { key: 'WEBAUTHN_RP_ID', value: input.prodHost, target: ['production', 'preview'] },
817
+ {
818
+ key: 'WEBAUTHN_RELATED_ORIGINS',
819
+ value: relatedOriginsFor([input.previewHost]),
820
+ target: ['production', 'preview'],
821
+ },
593
822
  // Deployment identity — drives the env-aware PWA manifest (see .env.example).
594
823
  { key: 'NEXT_PUBLIC_APP_ENV', value: 'production', target: ['production'] },
595
824
  { key: 'NEXT_PUBLIC_APP_ENV', value: 'preview', target: ['preview'] },
596
- // App origin — the EXACT per-env URL (same host as WEBAUTHN_RP_ID), so links,
597
- // OAuth callbacks and the PWA manifest resolve to the real domain instead of the
598
- // `localhost:3000` default of `.env.local`. Per-env (PROVISION_OWNED) → `env:push`
599
- // never overwrites it with a single shared value (P-1 parity with the other hosts).
600
- {
601
- key: 'NEXT_PUBLIC_APP_URL',
602
- value: `https://${input.webauthnRpIdProd}`,
603
- target: ['production'],
604
- },
605
- {
606
- key: 'NEXT_PUBLIC_APP_URL',
607
- value: `https://${input.webauthnRpIdPreview}`,
608
- target: ['preview'],
609
- },
825
+ // App origin — the EXACT per-env URL, so links, OAuth callbacks and the PWA manifest
826
+ // resolve to the real domain instead of the `localhost:3000` default of `.env.local`.
827
+ // Per-env (PROVISION_OWNED) → `env:push` never overwrites it with a single shared value.
828
+ { key: 'NEXT_PUBLIC_APP_URL', value: `https://${input.prodHost}`, target: ['production'] },
829
+ { key: 'NEXT_PUBLIC_APP_URL', value: `https://${input.previewHost}`, target: ['preview'] },
610
830
  ];
611
831
  for (const [key, value] of Object.entries(input.shared ?? {})) {
612
832
  if (!value)
@@ -0,0 +1,160 @@
1
+ /**
2
+ * VAULT-004 — wire the vault wrapper into a derivative's `package.json` scripts.
3
+ *
4
+ * WHY THIS EXISTS. A derivative's `package.json` is born frozen (BR-FACTORY-006) and
5
+ * `factory update` does not rewrite the boilerplate scripts (`dev`, `build`, `db:*`, …). A repo
6
+ * born before the wrapper that activates the vault with `provision` would have the vault project
7
+ * and scripts that never read it: `pnpm dev` would start with no secrets. So `provision`, when it
8
+ * activates the vault, rewrites THOSE scripts — the same precedent as `healVercelBuildOrder`:
9
+ *
10
+ * · a value the kit shipped without the wrapper (the current inner command, or a registered
11
+ * historic one) → rewritten to the wrapped value;
12
+ * · already wrapped → nothing;
13
+ * · anything else → the team's own script (`fx-extension-points`: "wrap from outside" is a
14
+ * legitimate extension point) → left intact and NAMED, because it runs without secrets until
15
+ * the team adapts it;
16
+ * · absent → nothing is inserted: a repo without `db:seed` is a repo that should not get one.
17
+ *
18
+ * The comparison is `classifyKitScript` — the one every heal of a kit-owned script uses — and the
19
+ * values come from `VAULT_WRAPPED_SCRIPTS` (`constants.ts`), the single source.
20
+ *
21
+ * The change is SHOWN before it is written (`fx-secrets-vault §8` step 5): the writer returns the
22
+ * line diff of the file, and the caller prints it before (or instead of, under `--dry-run`)
23
+ * writing. It is the REAL diff: computed between the bytes on disk and the bytes that would be
24
+ * written.
25
+ *
26
+ * Also consumed by the adoption of an existing repo (ADOPT-001).
27
+ */
28
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
29
+ import path from 'node:path';
30
+ import { VAULT_WRAPPED_SCRIPTS, WITH_VAULT_SCRIPT_CMD } from './constants.js';
31
+ import { classifyKitScript, detectIndent, parsePackageJson } from './package-json.js';
32
+ /**
33
+ * Decide, per wrapped kit script, what the wiring does (see the module header). Pure: the input
34
+ * is not mutated and nothing is read or written.
35
+ */
36
+ export function planWrapperWiring(scripts) {
37
+ const next = { ...(scripts ?? {}) };
38
+ const plan = {
39
+ rewritten: [],
40
+ alreadyWrapped: [],
41
+ customized: [],
42
+ absent: [],
43
+ scripts: next,
44
+ };
45
+ for (const [name, spec] of Object.entries(VAULT_WRAPPED_SCRIPTS)) {
46
+ const existing = next[name];
47
+ // A value the dev ALREADY put behind the wrapper (with a step of their own after it) goes
48
+ // through the vault: it is wrapped, not customized — naming it as "runs without secrets"
49
+ // would be false.
50
+ if (existing !== undefined && existing.startsWith(`${WITH_VAULT_SCRIPT_CMD} `)) {
51
+ plan.alreadyWrapped.push(name);
52
+ continue;
53
+ }
54
+ const kind = classifyKitScript(existing, spec.cmd, spec.unwrapped);
55
+ if (kind === 'absent')
56
+ plan.absent.push(name);
57
+ else if (kind === 'current')
58
+ plan.alreadyWrapped.push(name);
59
+ else if (kind === 'shipped') {
60
+ next[name] = spec.cmd;
61
+ plan.rewritten.push(name);
62
+ }
63
+ else
64
+ plan.customized.push({ name, value: existing });
65
+ }
66
+ return plan;
67
+ }
68
+ /**
69
+ * Line diff between two texts, as `-`/`+` lines (unchanged lines omitted). An LCS over lines —
70
+ * a `package.json` is a few hundred lines at most — so the output is the real difference even
71
+ * when the re-serialization moved something the wiring did not intend to.
72
+ */
73
+ export function diffLines(before, after) {
74
+ if (before === after)
75
+ return [];
76
+ const a = before.split('\n');
77
+ const b = after.split('\n');
78
+ const n = a.length;
79
+ const m = b.length;
80
+ // lcs[i][j] = length of the LCS of a[i..] and b[j..].
81
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
82
+ for (let i = n - 1; i >= 0; i -= 1) {
83
+ for (let j = m - 1; j >= 0; j -= 1) {
84
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
85
+ }
86
+ }
87
+ const out = [];
88
+ let i = 0;
89
+ let j = 0;
90
+ while (i < n && j < m) {
91
+ if (a[i] === b[j]) {
92
+ i += 1;
93
+ j += 1;
94
+ }
95
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
96
+ out.push(`- ${a[i]}`);
97
+ i += 1;
98
+ }
99
+ else {
100
+ out.push(`+ ${b[j]}`);
101
+ j += 1;
102
+ }
103
+ }
104
+ while (i < n)
105
+ out.push(`- ${a[i++]}`);
106
+ while (j < m)
107
+ out.push(`+ ${b[j++]}`);
108
+ return out;
109
+ }
110
+ /**
111
+ * Write `content` to `file` atomically: a sibling temp file, then `rename(2)` — a process killed
112
+ * mid-write leaves the old file or the new one, never half of each.
113
+ */
114
+ function writeAtomic(file, content) {
115
+ const tmp = `${file}.tk-wiring-${process.pid}.tmp`;
116
+ try {
117
+ writeFileSync(tmp, content, 'utf8');
118
+ renameSync(tmp, file);
119
+ }
120
+ catch (error) {
121
+ rmSync(tmp, { force: true });
122
+ throw error;
123
+ }
124
+ }
125
+ /**
126
+ * Wire the wrapper into `<rootDir>/package.json` (see the module header). Reads, plans, builds the
127
+ * new document with the file's own indentation (only the values of the affected scripts change),
128
+ * and returns the diff. Writes ONLY when the diff is not empty and `dryRun` is off — so a second
129
+ * run on a wired repo writes nothing (idempotent).
130
+ *
131
+ * @throws {CLIError} when `package.json` is not valid JSON (`parsePackageJson`).
132
+ */
133
+ export function wireVaultWrapper(rootDir, opts = {}) {
134
+ const pkgPath = path.join(rootDir, 'package.json');
135
+ if (!existsSync(pkgPath)) {
136
+ return {
137
+ rewritten: [],
138
+ alreadyWrapped: [],
139
+ customized: [],
140
+ absent: Object.keys(VAULT_WRAPPED_SCRIPTS),
141
+ diff: [],
142
+ written: false,
143
+ hasPackageJson: false,
144
+ };
145
+ }
146
+ const raw = readFileSync(pkgPath, 'utf8');
147
+ const pkg = parsePackageJson(raw);
148
+ const plan = planWrapperWiring(pkg.scripts);
149
+ const { scripts, ...summary } = plan;
150
+ if (plan.rewritten.length === 0) {
151
+ return { ...summary, diff: [], written: false, hasPackageJson: true };
152
+ }
153
+ pkg.scripts = scripts;
154
+ const next = `${JSON.stringify(pkg, null, detectIndent(raw))}\n`;
155
+ const diff = diffLines(raw, next);
156
+ const write = diff.length > 0 && !opts.dryRun;
157
+ if (write)
158
+ writeAtomic(pkgPath, next);
159
+ return { ...summary, diff, written: write, hasPackageJson: true };
160
+ }
@@ -6,13 +6,16 @@
6
6
  * Shares the SAME pure primitives as the agent path (`lib/env-wizard.ts` +
7
7
  * `commands/env.ts`): this file only adds the terminal interaction. No TTY →
8
8
  * degrade with a hint to the headless `--plan`/`--apply` primitives.
9
+ *
10
+ * In a repo linked to the vault (VAULT-002) the answers go to the vault — one copy in `main:/`,
11
+ * referenced from `develop:/` and `local` — and there is no `.env.local` nor `env:push` offer.
9
12
  */
10
13
  import { existsSync, readFileSync } from 'node:fs';
11
14
  import path from 'node:path';
12
15
  import prompts from 'prompts';
13
- import { runEnvPush } from '../commands/env.js';
16
+ import { runEnvPush, VAULT_DEPLOYS_NOTICE, wizardVaultTarget } from '../commands/env.js';
14
17
  import { CLIError } from '../lib/cli-error.js';
15
- import { applyWizardAnswers, buildQuestionPlan, isQuestionActive, parseEnvExample, } from '../lib/env-wizard.js';
18
+ import { applyWizardAnswers, applyWizardAnswersToVault, buildQuestionPlan, isQuestionActive, parseEnvExample, } from '../lib/env-wizard.js';
16
19
  /** Ask one question by kind. Returns `undefined` when the user cancels (Ctrl+C). */
17
20
  async function askOne(q) {
18
21
  const message = q.help ? `${q.key} — ${q.help}` : q.key;
@@ -56,6 +59,9 @@ export async function envWizardFlow(rootDir = process.cwd(), deps = {}) {
56
59
  throw new CLIError('No encontré `.env.example` en este proyecto. El wizard deriva las preguntas de ahí.');
57
60
  }
58
61
  const plan = buildQuestionPlan(parseEnvExample(readFileSync(examplePath, 'utf8')));
62
+ // Decided up front (fail-closed on an unreadable state), so the cancel message is right too.
63
+ const vault = wizardVaultTarget(rootDir, { vaultClient: deps.vaultClient });
64
+ const destination = vault ? 'la bóveda' : '`.env.local`';
59
65
  console.log('\nConfiguremos las variables de entorno del proyecto.\n' +
60
66
  '(Enter conserva el valor por defecto · Ctrl+C cancela sin escribir nada.)\n');
61
67
  const answers = {};
@@ -69,11 +75,19 @@ export async function envWizardFlow(rootDir = process.cwd(), deps = {}) {
69
75
  }
70
76
  const value = await askOne(q);
71
77
  if (value === undefined) {
72
- console.log('\nCancelado. No se escribió nada en `.env.local`.');
78
+ console.log(`\nCancelado. No se escribió nada en ${destination}.`);
73
79
  return;
74
80
  }
75
81
  answers[q.key] = value;
76
82
  }
83
+ if (vault) {
84
+ const { written, empty } = await applyWizardAnswersToVault(vault, answers);
85
+ console.log(`\n✔ Escribí ${written.length} variable(s) en la bóveda (main:/, referenciadas desde develop:/ y local).`);
86
+ if (empty.length > 0)
87
+ console.log(` (sin valor, no se guardan: ${empty.join(', ')})`);
88
+ console.log(VAULT_DEPLOYS_NOTICE);
89
+ return;
90
+ }
77
91
  const written = applyWizardAnswers(rootDir, answers);
78
92
  console.log(`\n✔ Escribí ${written.length} variable(s) en \`.env.local\`.`);
79
93
  const { doPush } = await prompts({
package/dist/tui/menu.js CHANGED
@@ -2,9 +2,10 @@
2
2
  * `factory` (no args) — interactive TUI menu (LTUI-001).
3
3
  *
4
4
  * Replaces the old static HELP print on the no-args path with a cross-platform
5
- * (pure TypeScript) menu: Nuevo / Actualizar / Aprovisionar / Secrets. The menu
6
- * is a thin dispatcher — each option delegates to the existing command function
7
- * (`runNew` / `runUpdate` / `secretsCommand`), which keep their own contracts.
5
+ * (pure TypeScript) menu: Nuevo / Actualizar / Aprovisionar / Variables de entorno.
6
+ * The menu is a thin dispatcher — each option delegates to the existing command
7
+ * function (`runNew` / `runUpdate` / `provisionFlow` / `envWizardFlow`), which keep
8
+ * their own contracts.
8
9
  * No OS branching: one binary serves Mac, Windows and Linux.
9
10
  *
10
11
  * Edge cases this module owns:
@@ -21,7 +22,6 @@
21
22
  */
22
23
  import prompts from 'prompts';
23
24
  import { parseNewFlags, runNew } from '../commands/new.js';
24
- import { secretsCommand } from '../commands/secrets.js';
25
25
  import { parseUpdateFlags, runUpdate } from '../commands/update.js';
26
26
  import { envWizardFlow } from './env-wizard-flow.js';
27
27
  import { provisionFlow } from './provision-flow.js';
@@ -36,7 +36,6 @@ async function promptAction() {
36
36
  { title: 'Actualizar proyecto', value: 'update' },
37
37
  { title: 'Aprovisionar (ephemeral)', value: 'provision' },
38
38
  { title: 'Configurar variables de entorno', value: 'env' },
39
- { title: 'Secrets', value: 'secrets' },
40
39
  ],
41
40
  initial: 0,
42
41
  }, { onCancel: () => true } // let the value come back undefined; we exit cleanly below
@@ -82,19 +81,6 @@ async function dispatchUpdate(deps) {
82
81
  async function dispatchProvision(deps) {
83
82
  await deps.provision();
84
83
  }
85
- /** Sub-flow: pick a secrets master file, then delegate to `secretsCommand`. */
86
- async function dispatchSecrets(deps) {
87
- const { file } = await prompts({
88
- type: 'text',
89
- name: 'file',
90
- message: 'Ruta del archivo master de secrets (KEY=value):',
91
- }, { onCancel: () => true });
92
- if (!file || typeof file !== 'string' || file.trim() === '') {
93
- // Cancel or empty — back out cleanly.
94
- return;
95
- }
96
- await deps.secrets(file.trim());
97
- }
98
84
  /**
99
85
  * Run the interactive top-level menu. Called from `index.ts` when `factory` is
100
86
  * invoked with no command (and not `-h`/`--help`).
@@ -107,7 +93,6 @@ export async function runTuiMenu(deps = {}) {
107
93
  const printHelp = deps.printHelp ?? (() => { });
108
94
  const newProject = deps.newProject ?? runNew;
109
95
  const updateProject = deps.updateProject ?? runUpdate;
110
- const secrets = deps.secrets ?? secretsCommand;
111
96
  const provision = deps.provision ?? provisionFlow;
112
97
  const envWizard = deps.envWizard ?? envWizardFlow;
113
98
  // No TTY (CI / pipe): can't read interactive input. Degrade to HELP and return
@@ -134,8 +119,5 @@ export async function runTuiMenu(deps = {}) {
134
119
  case 'env':
135
120
  await envWizard();
136
121
  return;
137
- case 'secrets':
138
- await dispatchSecrets({ secrets });
139
- return;
140
122
  }
141
123
  }