@timekast/factory 2.3.1 → 2.5.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.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * The CLI's reader of `.claude/policy/prune-tiers.json` (`factory prune`) — which kit paths a
3
+ * pruned repo shares with its collaborators and which stay local to the members.
4
+ *
5
+ * TWIN of `scripts/tools/lib/prune-tiers.mjs`, which the pre-commit guard and skill-lint use:
6
+ * the published CLI cannot import repo code, so the rule lives twice and
7
+ * `cli/tests/unit/prune-tiers-parity.test.ts` runs one corpus through both. Change one, change
8
+ * the other.
9
+ *
10
+ * The rule: an entry is a path pattern; a trailing `/` names a directory (everything below
11
+ * it), otherwise one file; `*` matches within a single segment. The matching entry with the
12
+ * most segments decides (`restrict` | `share`); none → `unclassified`; a tie between the two
13
+ * kinds → `ambiguous`.
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { CLIError } from './cli-error.js';
18
+ import { MANAGED_BLOCK_END, MANAGED_BLOCK_START, PRUNE_FILE, TIMEKAST_DIR } from './constants.js';
19
+ /** Repo-relative path of the registry. */
20
+ export const PRUNE_TIERS_PATH = '.claude/policy/prune-tiers.json';
21
+ /** Repo-relative path of the switch. */
22
+ export const PRUNE_MARKER_PATH = `${TIMEKAST_DIR}/${PRUNE_FILE}`;
23
+ /** The tier `prepare` applies when none is named — the only one today. */
24
+ export const DEFAULT_PRUNE_TIER = 'collab';
25
+ /**
26
+ * Markers of the block CMLL's cut wrote by hand before this command existed. `syncManagedBlock`
27
+ * only knows the kit's markers, so it would add a second block beside this one; `prepare`
28
+ * removes it first.
29
+ */
30
+ export const LEGACY_COLLAB_START = '# >>> timekast:collab';
31
+ export const LEGACY_COLLAB_END = '# <<< timekast:collab';
32
+ function segmentsOf(entry) {
33
+ return entry.replace(/\/$/, '').split('/');
34
+ }
35
+ function segmentMatches(pattern, segment) {
36
+ if (!pattern.includes('*'))
37
+ return pattern === segment;
38
+ const rx = new RegExp(`^${pattern
39
+ .split('*')
40
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
41
+ .join('[^/]*')}$`);
42
+ return rx.test(segment);
43
+ }
44
+ /** Does `entry` match `relPath`? A directory entry matches anything strictly below it. */
45
+ export function entryMatches(entry, relPath) {
46
+ const isDir = entry.endsWith('/');
47
+ const want = segmentsOf(entry);
48
+ const have = relPath.split('/');
49
+ if (isDir ? have.length <= want.length : have.length !== want.length)
50
+ return false;
51
+ return want.every((pattern, i) => segmentMatches(pattern, have[i]));
52
+ }
53
+ /** Classify one repo-relative POSIX path under a tier. */
54
+ export function classifyPath(tier, relPath) {
55
+ let bestDepth = -1;
56
+ let kinds = new Set();
57
+ for (const kind of ['restrict', 'share']) {
58
+ for (const entry of tier[kind]) {
59
+ if (!entryMatches(entry, relPath))
60
+ continue;
61
+ const depth = segmentsOf(entry).length;
62
+ if (depth > bestDepth) {
63
+ bestDepth = depth;
64
+ kinds = new Set([kind]);
65
+ }
66
+ else if (depth === bestDepth) {
67
+ kinds.add(kind);
68
+ }
69
+ }
70
+ }
71
+ if (kinds.size === 0)
72
+ return 'unclassified';
73
+ if (kinds.size > 1)
74
+ return 'ambiguous';
75
+ return [...kinds][0];
76
+ }
77
+ export function isRestricted(tier, relPath) {
78
+ return classifyPath(tier, relPath) === 'restrict';
79
+ }
80
+ /** Form errors of a parsed registry (empty = valid). Mirror of the `.mjs` validator. */
81
+ export function validatePruneTiers(data) {
82
+ const errors = [];
83
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
84
+ return ['(root) — must be an object'];
85
+ }
86
+ const tiers = data.tiers;
87
+ if (typeof tiers !== 'object' || tiers === null || Array.isArray(tiers)) {
88
+ return ['tiers — must be an object of named tiers'];
89
+ }
90
+ if (Object.keys(tiers).length === 0)
91
+ errors.push('tiers — must declare at least one tier');
92
+ for (const [name, raw] of Object.entries(tiers)) {
93
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
94
+ errors.push(`tiers.${name} — the tier name must be kebab-case`);
95
+ }
96
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
97
+ errors.push(`tiers.${name} — must be an object`);
98
+ continue;
99
+ }
100
+ const tier = raw;
101
+ for (const key of Object.keys(tier)) {
102
+ if (!['description', 'restrict', 'share'].includes(key)) {
103
+ errors.push(`tiers.${name}.${key} — unknown key (allowed: description, restrict, share)`);
104
+ }
105
+ }
106
+ for (const kind of ['restrict', 'share']) {
107
+ const list = tier[kind];
108
+ if (!Array.isArray(list) || list.length === 0) {
109
+ errors.push(`tiers.${name}.${kind} — must be a non-empty array of path patterns`);
110
+ continue;
111
+ }
112
+ list.forEach((entry, i) => {
113
+ if (typeof entry !== 'string' || entry.trim() !== entry || entry === '') {
114
+ errors.push(`tiers.${name}.${kind}[${i}] — must be a non-empty string without surrounding spaces`);
115
+ return;
116
+ }
117
+ if (entry.startsWith('/') || entry.includes('\\') || entry.includes('//')) {
118
+ errors.push(`tiers.${name}.${kind}[${i}] — "${entry}" must be repo-relative, POSIX`);
119
+ }
120
+ if (/[?[\]{}!]/.test(entry) || entry.includes('**')) {
121
+ errors.push(`tiers.${name}.${kind}[${i}] — "${entry}" may only use \`*\` within one segment`);
122
+ }
123
+ if (segmentsOf(entry).some((seg) => seg === '' || seg === '.' || seg === '..')) {
124
+ errors.push(`tiers.${name}.${kind}[${i}] — "${entry}" has an empty or relative segment`);
125
+ }
126
+ });
127
+ }
128
+ if (!Array.isArray(tier.restrict) || !Array.isArray(tier.share))
129
+ continue;
130
+ const restrict = tier.restrict;
131
+ const share = tier.share;
132
+ const all = [...restrict, ...share];
133
+ const dupes = all.filter((e, i) => all.indexOf(e) !== i);
134
+ for (const dupe of new Set(dupes)) {
135
+ errors.push(`tiers.${name} — "${dupe}" is listed more than once`);
136
+ }
137
+ for (const entry of share) {
138
+ for (const dir of restrict.filter((r) => r.endsWith('/'))) {
139
+ if (!entry.startsWith(dir))
140
+ continue;
141
+ if (segmentsOf(entry).length !== segmentsOf(dir).length + 1) {
142
+ errors.push(`tiers.${name}.share — "${entry}" sits more than one level below the restricted ` +
143
+ `directory "${dir}"; a re-include only works for a direct child`);
144
+ }
145
+ }
146
+ }
147
+ for (const entry of restrict) {
148
+ for (const dir of restrict.filter((r) => r !== entry && r.endsWith('/'))) {
149
+ if (entry.startsWith(dir)) {
150
+ errors.push(`tiers.${name}.restrict — "${entry}" is already covered by "${dir}"; drop one`);
151
+ }
152
+ }
153
+ }
154
+ }
155
+ return errors;
156
+ }
157
+ /** The tier's `.gitignore` lines, without markers. Mirror of the `.mjs` `gitignoreLines`. */
158
+ export function gitignoreLines(tier) {
159
+ const depth = (e) => segmentsOf(e).length;
160
+ const ordered = [...tier.restrict].sort((a, b) => depth(a) - depth(b) || a.localeCompare(b));
161
+ const lines = [];
162
+ for (const entry of ordered) {
163
+ const children = entry.endsWith('/')
164
+ ? tier.share.filter((s) => s.startsWith(entry)).sort()
165
+ : [];
166
+ if (children.length === 0) {
167
+ lines.push(`/${entry}`);
168
+ continue;
169
+ }
170
+ lines.push(`/${entry}*`);
171
+ for (const child of children)
172
+ lines.push(`!/${child}`);
173
+ }
174
+ return lines;
175
+ }
176
+ /** The managed `.gitignore` block (markers inclusive) of one tier. */
177
+ export function renderGitignoreBlock(tierName, tier) {
178
+ return [
179
+ `${MANAGED_BLOCK_START} (prune tier \`${tierName}\` — regenerated by \`factory prune\` and \`factory update\`; do not edit) >>>`,
180
+ '# Kit paths kept LOCAL to the members of the org: they never enter this repository, so a',
181
+ '# collaborator never sees them. Generated from .claude/policy/prune-tiers.json; the switch',
182
+ '# is .timekast/prune.json. Anything not listed here is shared.',
183
+ ...gitignoreLines(tier),
184
+ `${MANAGED_BLOCK_END} <<<`,
185
+ ].join('\n');
186
+ }
187
+ /** Remove the hand-written `timekast:collab` block (markers inclusive), if present. */
188
+ export function stripLegacyCollabBlock(content) {
189
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
190
+ const start = lines.findIndex((l) => l.trimStart().startsWith(LEGACY_COLLAB_START));
191
+ if (start === -1)
192
+ return { content, removed: false };
193
+ let end = lines.length - 1;
194
+ for (let i = start + 1; i < lines.length; i++) {
195
+ if (lines[i].trimStart().startsWith(LEGACY_COLLAB_END)) {
196
+ end = i;
197
+ break;
198
+ }
199
+ }
200
+ const kept = [...lines.slice(0, start), ...lines.slice(end + 1)];
201
+ while (kept.length > 0 && kept[kept.length - 1].trim() === '')
202
+ kept.pop();
203
+ return { content: kept.length > 0 ? `${kept.join('\n')}\n` : '', removed: true };
204
+ }
205
+ /** Read + validate the registry under `dir` (a repo root or a staged tarball). */
206
+ export function readPruneTiers(dir) {
207
+ const file = path.join(dir, PRUNE_TIERS_PATH);
208
+ if (!existsSync(file)) {
209
+ throw new CLIError(`No existe \`${PRUNE_TIERS_PATH}\`. Corre \`factory update\` para traer la versión del kit que lo incluye.`);
210
+ }
211
+ let data;
212
+ try {
213
+ data = JSON.parse(readFileSync(file, 'utf8'));
214
+ }
215
+ catch (err) {
216
+ throw new CLIError(`\`${PRUNE_TIERS_PATH}\` no es JSON válido: ${err instanceof Error ? err.message : String(err)}`);
217
+ }
218
+ const errors = validatePruneTiers(data);
219
+ if (errors.length > 0) {
220
+ throw new CLIError(`\`${PRUNE_TIERS_PATH}\` tiene errores de forma:\n • ${errors.join('\n • ')}`);
221
+ }
222
+ return data;
223
+ }
224
+ /** Look up one tier, naming the valid ones when it does not exist. */
225
+ export function getTier(tiers, name) {
226
+ const tier = tiers.tiers[name];
227
+ if (!tier) {
228
+ throw new CLIError(`El tier \`${name}\` no existe en \`${PRUNE_TIERS_PATH}\`. Tiers disponibles: ${Object.keys(tiers.tiers).join(', ')}.`);
229
+ }
230
+ return tier;
231
+ }
232
+ /** The switch, or `null` when this repo is not pruned. */
233
+ export function readPruneMarker(rootDir) {
234
+ const file = path.join(rootDir, PRUNE_MARKER_PATH);
235
+ if (!existsSync(file))
236
+ return null;
237
+ let data;
238
+ try {
239
+ data = JSON.parse(readFileSync(file, 'utf8'));
240
+ }
241
+ catch {
242
+ throw new CLIError(`\`${PRUNE_MARKER_PATH}\` no es JSON válido; corrígelo o vuelve a correr \`factory prune prepare\`.`);
243
+ }
244
+ const marker = data;
245
+ if (typeof marker?.tier !== 'string' || typeof marker.at !== 'string') {
246
+ throw new CLIError(`\`${PRUNE_MARKER_PATH}\` debe tener la forma { "tier": "<nombre>", "at": "<fecha ISO>" }.`);
247
+ }
248
+ return { tier: marker.tier, at: marker.at };
249
+ }
250
+ export function writePruneMarker(rootDir, marker) {
251
+ mkdirSync(path.join(rootDir, TIMEKAST_DIR), { recursive: true });
252
+ writeFileSync(path.join(rootDir, PRUNE_MARKER_PATH), `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
253
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `factory update` in a pruned repo (`factory prune`): keep the tier's `.gitignore` block in
3
+ * step with the kit, and compute what the brain commit must untrack and publish.
4
+ *
5
+ * Everything here is gated on `.timekast/prune.json`. Without it `planPruneMaintenance`
6
+ * returns `null` and the update behaves exactly as in a repo that was never pruned.
7
+ *
8
+ * The registry is read from the STAGED tarball, not from the repo: the tier the release ships
9
+ * is the one the commit has to honor, including a path that moved from restricted to shared.
10
+ * Such a path is in the `unchanged` bucket when its content did not change, so the update
11
+ * would never write it — it sits on the member's disk, untracked, and would never be
12
+ * published unless this module names it.
13
+ */
14
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { GITIGNORE_FILE, MANAGED_BLOCK_START } from './constants.js';
17
+ import { syncManagedBlock } from './gitattributes.js';
18
+ import { trackedFiles } from './prune-git.js';
19
+ import { LEGACY_COLLAB_START, PRUNE_MARKER_PATH, PRUNE_TIERS_PATH, getTier, isRestricted, readPruneMarker, readPruneTiers, renderGitignoreBlock, stripLegacyCollabBlock, } from './prune-tiers.js';
20
+ /** Does `.gitignore` carry a prune block (the kit's or CMLL's hand-written one)? */
21
+ function hasPruneBlock(rootDir) {
22
+ const file = path.join(rootDir, GITIGNORE_FILE);
23
+ if (!existsSync(file))
24
+ return false;
25
+ const content = readFileSync(file, 'utf8');
26
+ return (content.includes(LEGACY_COLLAB_START) ||
27
+ content.split('\n').some((l) => l.startsWith(MANAGED_BLOCK_START) && l.includes('prune tier')));
28
+ }
29
+ /**
30
+ * Regenerate the block and plan the commit's untrack/publish sets. Best-effort like the other
31
+ * dotfile syncs: a problem is reported and the update (already applied) stands.
32
+ *
33
+ * @param manifestPaths every path of the NEW manifest (what the kit ships now).
34
+ */
35
+ export async function planPruneMaintenance(rootDir, stagedDir, manifestPaths) {
36
+ let marker;
37
+ try {
38
+ marker = readPruneMarker(rootDir);
39
+ }
40
+ catch (err) {
41
+ console.warn(`Aviso: ${err instanceof Error ? err.message : String(err)} No mantuve el tier.`);
42
+ return null;
43
+ }
44
+ if (!marker) {
45
+ if (hasPruneBlock(rootDir)) {
46
+ console.warn(`Aviso: \`${GITIGNORE_FILE}\` tiene el bloque de un repo compartido pero falta ` +
47
+ `\`${PRUNE_MARKER_PATH}\`, así que el guard del pre-commit está apagado. ` +
48
+ 'Si el repo se comparte con colaboradores, corre `factory prune prepare`.');
49
+ }
50
+ return null;
51
+ }
52
+ try {
53
+ const source = existsSync(path.join(stagedDir, PRUNE_TIERS_PATH)) ? stagedDir : rootDir;
54
+ const tier = getTier(readPruneTiers(source), marker.tier);
55
+ const gitignorePath = path.join(rootDir, GITIGNORE_FILE);
56
+ if (existsSync(gitignorePath)) {
57
+ const stripped = stripLegacyCollabBlock(readFileSync(gitignorePath, 'utf8'));
58
+ if (stripped.removed)
59
+ writeFileSync(gitignorePath, stripped.content, 'utf8');
60
+ }
61
+ const { action } = syncManagedBlock(rootDir, renderGitignoreBlock(marker.tier, tier), GITIGNORE_FILE);
62
+ if (action !== 'unchanged') {
63
+ console.log(`✔ \`${GITIGNORE_FILE}\`: bloque del tier \`${marker.tier}\` sincronizado.`);
64
+ }
65
+ const tracked = new Set(await trackedFiles(rootDir));
66
+ return {
67
+ tierName: marker.tier,
68
+ tier,
69
+ untrack: [...tracked].filter((p) => isRestricted(tier, p)),
70
+ publish: manifestPaths.filter((p) => !tracked.has(p) && !isRestricted(tier, p) && existsSync(path.join(rootDir, p))),
71
+ };
72
+ }
73
+ catch (err) {
74
+ console.warn(`Aviso: no se pudo mantener el tier \`${marker.tier}\` ` +
75
+ `(${err instanceof Error ? err.message : String(err)}); el cerebro se instaló igual.`);
76
+ return null;
77
+ }
78
+ }
@@ -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
+ }
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`);