@timekast/factory 2.4.0 → 2.6.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.
@@ -32,7 +32,7 @@ import { createHash } from 'node:crypto';
32
32
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
33
  import path from 'node:path';
34
34
  import { CLIError } from './cli-error.js';
35
- import { CLAUDE_MD_FILE, LOCKFILE_FILE, MANIFEST_FILE, TIMEKAST_DIR, } from './constants.js';
35
+ import { CLAUDE_MD_FILE, KIT_DEPS_FILE, LOCKFILE_FILE, MANIFEST_FILE, TIMEKAST_DIR, } from './constants.js';
36
36
  /**
37
37
  * Write the initial lockfile in `destDir/.timekast/lockfile.json` (used by
38
38
  * `new` / `add`). On the initial install the lockfile carries the tarball's
@@ -54,9 +54,11 @@ export function writeInitialLockfile(destDir, manifestRaw) {
54
54
  const stamped = { ...manifest, factoryVersion: manifest.version };
55
55
  // If the tarball already unpacked manifest.json into .timekast/, drop it: the
56
56
  // stamped lockfile is the SSOT and we do not keep a duplicate manifest around.
57
- const unpackedManifest = path.join(timekastDir, MANIFEST_FILE);
58
- if (existsSync(unpackedManifest)) {
59
- rmSync(unpackedManifest, { force: true });
57
+ // Same for the dependency list (Factory#78): it is read from the staged tarball, never kept.
58
+ for (const unpacked of [MANIFEST_FILE, KIT_DEPS_FILE]) {
59
+ const abs = path.join(timekastDir, unpacked);
60
+ if (existsSync(abs))
61
+ rmSync(abs, { force: true });
60
62
  }
61
63
  writeLockfile(destDir, stamped);
62
64
  }
@@ -124,7 +126,10 @@ export function parseLockfile(raw, source = 'lockfile') {
124
126
  if (typeof e.path !== 'string' || typeof e.hash !== 'string') {
125
127
  throw new CLIError(`El ${source} tiene una entrada sin \`path\`/\`hash\` en files[${i}].`);
126
128
  }
127
- return { path: e.path, hash: e.hash };
129
+ const keys = Array.isArray(e.keys) && e.keys.every((k) => typeof k === 'string')
130
+ ? { keys: e.keys }
131
+ : {};
132
+ return { path: e.path, hash: e.hash, ...keys };
128
133
  });
129
134
  return {
130
135
  version: obj.version,
@@ -391,11 +391,13 @@ async function listNotMigrated(legacy, deps) {
391
391
  }
392
392
  return out;
393
393
  }
394
- // ---------------------------------------------------------------------------
395
- // Invite
396
- // ---------------------------------------------------------------------------
397
- /** Invite an outside collaborator with read access, after checking they are not a member. */
398
- export async function pruneInvite(repo, user, deps) {
394
+ /**
395
+ * Invite an outside collaborator, after checking they are not a member. Write (`push`) by
396
+ * default: collaborators work on branches of this repo and open PRs that `/integrate` checks.
397
+ * On GitHub Free a private repo cannot protect `main`, so write also reaches it directly —
398
+ * the owner's call, accepted for in-house devs; `--read` grants read only.
399
+ */
400
+ export async function pruneInvite(repo, user, deps, permission = 'push') {
399
401
  const { owner } = splitRepo(repo);
400
402
  if (!/^[A-Za-z0-9-]+$/.test(user))
401
403
  throw new CLIError(`\`${user}\` no es un usuario de GitHub válido.`);
@@ -412,7 +414,7 @@ export async function pruneInvite(repo, user, deps) {
412
414
  'PUT',
413
415
  `repos/${repo}/collaborators/${user}`,
414
416
  '-f',
415
- 'permission=pull',
417
+ `permission=${permission}`,
416
418
  ]);
417
419
  if (r.exitCode !== 0) {
418
420
  if (/403|Forbidden|must have admin/i.test(r.stderr)) {
@@ -0,0 +1,137 @@
1
+ /**
2
+ * `factory prune vercel` — reconnect the Vercel project to the repo the cut created.
3
+ *
4
+ * Vercel binds a project to GitHub's NUMERIC repo id, and a rename keeps the id: after
5
+ * `prune cutover` the project still deploys from `<repo>-legacy`. Re-linking to the new
6
+ * repo takes an unlink + link (`DELETE` then `POST /v9/projects/{id}/link`); the project,
7
+ * its env vars, domains and deployments stay.
8
+ *
9
+ * Order is the safety argument:
10
+ * 1. read the current link and the new repo's id — already linked → nothing to do;
11
+ * 2. without `--confirm`, print the plan and stop;
12
+ * 3. unlink → link → READ the link back and require the new repo's id.
13
+ * A failure between 1 and 3 leaves the project unlinked (it serves its last deployment, it
14
+ * just stops deploying on push); a re-run with `--confirm` links it, since step 1 sees no link.
15
+ * Never triggers a deployment.
16
+ */
17
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { CLIError } from './cli-error.js';
20
+ import { readDestroyTarget } from './provision-state.js';
21
+ import { getGitLink, linkGitRepo, unlinkGitRepo, } from './vercel-env.js';
22
+ /** GitHub's numeric id of `repo`, read with the member's `gh` session. */
23
+ async function githubRepoId(repo, deps) {
24
+ const r = await deps.gh(['api', `repos/${repo}`, '--jq', '.id']);
25
+ const id = Number(r.stdout.trim());
26
+ if (r.exitCode !== 0 || !Number.isInteger(id)) {
27
+ throw new CLIError(`No pude leer \`${repo}\` en GitHub: ${r.stderr || 'respuesta sin id'}`);
28
+ }
29
+ return id;
30
+ }
31
+ export async function pruneVercelRelink(input, deps) {
32
+ const { projectId, teamId, token } = input;
33
+ const api = deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {};
34
+ const targetId = await githubRepoId(input.repo, deps);
35
+ const from = await getGitLink(projectId, token, teamId, api);
36
+ if (from?.repoId === targetId)
37
+ return { status: 'already-linked', link: from };
38
+ if (!input.confirm)
39
+ return { status: 'plan', from, to: input.repo };
40
+ if (from)
41
+ await unlinkGitRepo(projectId, token, teamId, api);
42
+ try {
43
+ await linkGitRepo(projectId, token, teamId, input.repo, api);
44
+ }
45
+ catch (error) {
46
+ const why = error instanceof Error ? error.message : String(error);
47
+ throw new CLIError(`Desconecté el proyecto de Vercel${from ? ` de \`${from.repo}\`` : ''} pero no pude conectarlo a ` +
48
+ `\`${input.repo}\`: ${why}\nEl proyecto sigue sirviendo su último deploy; sólo dejó de desplegar ` +
49
+ 'con cada push. Corrige la causa (p. ej. la GitHub App de Vercel sin acceso al repo nuevo) y ' +
50
+ 'vuelve a correr `factory prune vercel --confirm`: ve que no hay vínculo y sólo conecta.');
51
+ }
52
+ const to = await getGitLink(projectId, token, teamId, api);
53
+ if (to?.repoId !== targetId) {
54
+ throw new CLIError(`Vercel aceptó el vínculo pero al releerlo apunta a ${to ? `\`${to.repo}\`` : 'ningún repo'}, no a ` +
55
+ `\`${input.repo}\`. Revísalo en el dashboard (Settings → Git) antes de seguir.`);
56
+ }
57
+ return { status: 'relinked', from, to };
58
+ }
59
+ /**
60
+ * The project and team to relink: flags first, then `.vercel/project.json`, then the provision
61
+ * state (`.timekast/provision.json`). Never guessed from a project listing.
62
+ */
63
+ export function resolveVercelProject(rootDir, flags) {
64
+ let projectId = flags.project;
65
+ let teamId = flags.team;
66
+ const linkFile = path.join(rootDir, '.vercel', 'project.json');
67
+ if ((!projectId || !teamId) && existsSync(linkFile)) {
68
+ try {
69
+ const link = JSON.parse(readFileSync(linkFile, 'utf8'));
70
+ projectId ??= link.projectId;
71
+ teamId ??= link.teamId ?? link.orgId;
72
+ }
73
+ catch {
74
+ // unreadable → fall through to the provision state
75
+ }
76
+ }
77
+ if (!projectId || !teamId) {
78
+ const target = readDestroyTarget(rootDir);
79
+ projectId ??= target?.vercelProjectId ?? undefined;
80
+ teamId ??= target?.scope ?? undefined;
81
+ }
82
+ if (!projectId || !teamId) {
83
+ throw new CLIError('No encontré el proyecto de Vercel de este repo (ni en `.vercel/project.json` ni en ' +
84
+ '`.timekast/provision.json`). Pásalo: `factory prune vercel --project=prj_… --team=team_…`.');
85
+ }
86
+ return { projectId, teamId };
87
+ }
88
+ /**
89
+ * Which branches deploy on push in a pruned repo: only `main` and `develop`. Vercel deploys
90
+ * when ANY matching rule is `true`, so `**: false` turns off every other branch — a
91
+ * collaborator's branch (an author without a Vercel seat) never starts a deployment Vercel
92
+ * would block. `**`, not `*`: minimatch's `*` does not cross a `/` (`feat/login`).
93
+ */
94
+ export const BRANCH_DEPLOY_RULE = {
95
+ '**': false,
96
+ main: true,
97
+ develop: true,
98
+ };
99
+ export const VERCEL_JSON = 'vercel.json';
100
+ /** Whether `vercel.json` already carries {@link BRANCH_DEPLOY_RULE}. `null` without the file. */
101
+ export function hasBranchDeployRule(rootDir) {
102
+ const file = path.join(rootDir, VERCEL_JSON);
103
+ if (!existsSync(file))
104
+ return null;
105
+ const config = JSON.parse(readFileSync(file, 'utf8'));
106
+ const rules = config.git?.deploymentEnabled;
107
+ if (!rules || typeof rules !== 'object')
108
+ return false;
109
+ return Object.entries(BRANCH_DEPLOY_RULE).every(([branch, on]) => rules[branch] === on);
110
+ }
111
+ /**
112
+ * Write {@link BRANCH_DEPLOY_RULE} into `vercel.json`, keeping every other key and any other
113
+ * branch rule. A repo without `vercel.json` (not on Vercel) is left alone.
114
+ *
115
+ * @throws {CLIError} when `vercel.json` is not valid JSON.
116
+ */
117
+ export function ensureBranchDeployRule(rootDir) {
118
+ const file = path.join(rootDir, VERCEL_JSON);
119
+ if (!existsSync(file))
120
+ return 'no-vercel-json';
121
+ let config;
122
+ try {
123
+ config = JSON.parse(readFileSync(file, 'utf8'));
124
+ }
125
+ catch {
126
+ throw new CLIError(`\`${VERCEL_JSON}\` no es JSON válido; corrígelo y vuelve a correr.`);
127
+ }
128
+ if (hasBranchDeployRule(rootDir))
129
+ return 'present';
130
+ const git = (config.git && typeof config.git === 'object' ? config.git : {});
131
+ const prior = git.deploymentEnabled && typeof git.deploymentEnabled === 'object'
132
+ ? git.deploymentEnabled
133
+ : {};
134
+ config.git = { ...git, deploymentEnabled: { ...prior, ...BRANCH_DEPLOY_RULE } };
135
+ writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
136
+ return 'added';
137
+ }
@@ -13,10 +13,12 @@
13
13
  "EMAIL_SERVER_PORT",
14
14
  "EMAIL_SERVER_SECURE",
15
15
  "EMAIL_SERVER_USER",
16
+ "MCP_ENABLED",
16
17
  "MFA_ENABLED",
17
18
  "MFA_REQUIRED_ALL",
18
19
  "NEXTAUTH_URL",
19
20
  "NODE_ENV",
21
+ "QUESTIONS_ENABLED",
20
22
  "SENTRY_ORG",
21
23
  "SENTRY_PROJECT",
22
24
  "SUPPORT_EMAIL",
package/dist/lib/rail.js CHANGED
@@ -53,8 +53,10 @@ import { VAULT_CONFIG } from './vault-config.js';
53
53
  export const RAIL_ENV = 'main';
54
54
  /** Name of the vault CLI binary on PATH. */
55
55
  export const INFISICAL_BIN = 'infisical';
56
- /** How to install the vault CLI (`fx-secrets-vault §3`). */
57
- export const INFISICAL_INSTALL_HINT = 'brew install infisical';
56
+ /** How to install the vault CLI, per platform, already code-quoted (`sk-vault §2`). */
57
+ export const INFISICAL_INSTALL_HINT = '`brew install infisical` (Windows: `winget install --id infisical.infisical` y abre una terminal nueva)';
58
+ /** How to upgrade the vault CLI, per platform, already code-quoted. */
59
+ export const INFISICAL_UPGRADE_HINT = '`brew upgrade infisical` o `winget upgrade --id infisical.infisical`';
58
60
  /** Default time budget (ms) for one rail read — a dead network or a stuck prompt must not hang. */
59
61
  export const RAIL_READ_TIMEOUT_MS = 30_000;
60
62
  /** Grace (ms) between SIGTERM and SIGKILL once the timeout fires. */
@@ -135,7 +137,7 @@ export class RailUnrecognizedError extends RailError {
135
137
  export class RailNotInstalledError extends RailError {
136
138
  constructor(config = VAULT_CONFIG) {
137
139
  super('not-installed', 'El CLI de la bóveda (`infisical`) no está instalado o no está en el PATH.\n' +
138
- `Instálalo con \`${INFISICAL_INSTALL_HINT}\` y entra con \`${loginCommand(config)}\`.`);
140
+ `Instálalo con ${INFISICAL_INSTALL_HINT} y entra con \`${loginCommand(config)}\`.`);
139
141
  this.name = 'RailNotInstalledError';
140
142
  Object.setPrototypeOf(this, new.target.prototype);
141
143
  }
@@ -744,6 +744,9 @@ export function createRailwayClient(deps = {}) {
744
744
  if (typeof settings[key] === 'string')
745
745
  input[key] = settings[key];
746
746
  }
747
+ if (typeof settings.drainingSeconds === 'number') {
748
+ input.drainingSeconds = settings.drainingSeconds;
749
+ }
747
750
  if (Object.keys(input).length === 0)
748
751
  return false;
749
752
  await call('serviceInstanceUpdate', { serviceId, environmentId, input }, `ajustar el servicio \`${serviceId}\` en su entorno`);