@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.
- package/dist/commands/add.js +29 -8
- package/dist/commands/doctor.js +37 -8
- package/dist/commands/env.js +340 -8
- package/dist/commands/provision.js +327 -42
- package/dist/commands/prune.js +60 -10
- package/dist/commands/update.js +109 -85
- package/dist/commands/usage.js +303 -0
- package/dist/commands/vault.js +81 -18
- package/dist/index.js +52 -3
- package/dist/lib/backlog-mapping.js +1 -1
- package/dist/lib/claude-paths.js +8 -2
- package/dist/lib/claude-settings.js +104 -0
- package/dist/lib/constants.js +26 -0
- package/dist/lib/derived-dotfiles.js +165 -0
- package/dist/lib/email-from.js +12 -4
- package/dist/lib/env-file.js +4 -0
- package/dist/lib/env-wizard.js +1 -1
- package/dist/lib/initial-writes.js +113 -0
- package/dist/lib/kit-deps.js +163 -0
- package/dist/lib/kit-settings-history.json +13 -0
- package/dist/lib/lockfile.js +10 -5
- package/dist/lib/prune-remote.js +8 -6
- package/dist/lib/prune-vercel.js +137 -0
- package/dist/lib/public-env-keys.json +2 -0
- package/dist/lib/rail.js +5 -3
- package/dist/lib/railway-api.js +3 -0
- package/dist/lib/usage-scan.js +516 -0
- package/dist/lib/vault-adopt.js +103 -5
- package/dist/lib/vault-api.js +2 -2
- package/dist/lib/vercel-env.js +29 -0
- package/dist/lib/whats-new.js +123 -0
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* legacy auto-register lives with its safeguards) → preflight (gh) → resolve
|
|
13
13
|
* profile → download + stage + validate the manifest → install ONLY the manifest
|
|
14
14
|
* files via `applyPlan` (so a `full` tarball never lays down `src/`; `track`
|
|
15
|
-
* excludes it) → write `.timekast/lockfile.json` →
|
|
15
|
+
* excludes it) → write `.timekast/lockfile.json` → sync the managed
|
|
16
|
+
* `.gitattributes` / `.prettierignore` blocks → ensure `factory:update` (H5) →
|
|
17
|
+
* offer the npm packages the kit's files need and the repo lacks (`kit-deps.ts`).
|
|
16
18
|
*
|
|
17
19
|
* Invariants:
|
|
18
20
|
* - `add` never touches `src/`, `pj-*`, or any file outside the manifest. It
|
|
@@ -23,8 +25,11 @@
|
|
|
23
25
|
* is preserved (filtered out of writes) + a warning; only written when absent.
|
|
24
26
|
* - `add` is for a repo WITHOUT the brain. A repo that already has `.claude/`
|
|
25
27
|
* or a lockfile is routed to `update` (never a raw re-install).
|
|
26
|
-
* - `add`
|
|
27
|
-
*
|
|
28
|
+
* - `add` never prompts about FILES — the conflict prompt is exclusive to `update`
|
|
29
|
+
* (DIST-005). Its one question is the kit-deps install offer, asked only in a terminal.
|
|
30
|
+
* It writes the brain (`.claude/`, `scripts/tools/`) without looking; a kit file
|
|
31
|
+
* outside it that the repo already has with its own content (`ci.yml`, `.husky/*`)
|
|
32
|
+
* is KEPT, and the kit's copy goes to `<path>.kit-example` (`initial-writes.ts`).
|
|
28
33
|
* - Atomicity (§7.5): extract + validate in a temp dir before touching the cwd.
|
|
29
34
|
*
|
|
30
35
|
* Expected failures throw `CLIError`; the top-level handler prints + exits.
|
|
@@ -35,6 +40,9 @@ import path from 'node:path';
|
|
|
35
40
|
import { applyPlan, defaultBackupDir, validateStagedManifest } from '../lib/atomic-swap.js';
|
|
36
41
|
import { CLIError } from '../lib/cli-error.js';
|
|
37
42
|
import { CLAUDE_MD_FILE, PROFILES } from '../lib/constants.js';
|
|
43
|
+
import { syncDerivedDotfiles } from '../lib/derived-dotfiles.js';
|
|
44
|
+
import { partitionInitialWrites, reportKeptRepoFiles, writeKitExamples, } from '../lib/initial-writes.js';
|
|
45
|
+
import { offerKitDeps, readKitDeps } from '../lib/kit-deps.js';
|
|
38
46
|
import { hasLockfile, writeInitialLockfile } from '../lib/lockfile.js';
|
|
39
47
|
import { detectProfile, maintainKitScripts } from '../lib/package-json.js';
|
|
40
48
|
import { runPreflight } from '../lib/preflight.js';
|
|
@@ -125,6 +133,9 @@ export async function runAdd(flags = { core: false, full: false }) {
|
|
|
125
133
|
process.once('SIGINT', onSignal);
|
|
126
134
|
process.once('SIGTERM', onSignal);
|
|
127
135
|
let version;
|
|
136
|
+
// The packages the kit's files need (Factory#78), read from the staged tarball before the
|
|
137
|
+
// `finally` deletes it; offered at the very end, once the brain is in place.
|
|
138
|
+
let kitDeps = null;
|
|
128
139
|
try {
|
|
129
140
|
const tarball = await downloadProfileTarball(profile, tmpDir);
|
|
130
141
|
const { stagedDir, manifestRaw } = await stageProfileTarball(tarball, tmpDir);
|
|
@@ -138,19 +149,26 @@ export async function runAdd(flags = { core: false, full: false }) {
|
|
|
138
149
|
}
|
|
139
150
|
version = manifest.version;
|
|
140
151
|
// Install ONLY the manifest's files (never the raw tarball payload — so a
|
|
141
|
-
// `full` tarball cannot lay down `src/`).
|
|
142
|
-
//
|
|
152
|
+
// `full` tarball cannot lay down `src/`). The brain is always written; CLAUDE.md
|
|
153
|
+
// only when absent; any other kit file the repo already has with its OWN content
|
|
154
|
+
// is kept, and the kit's copy lands next to it as `<path>.kit-example` (#76).
|
|
143
155
|
const claudeMdExists = existsSync(path.join(root, CLAUDE_MD_FILE));
|
|
144
|
-
const writes = manifest
|
|
145
|
-
.map((f) => f.path)
|
|
146
|
-
.filter((p) => !(p === CLAUDE_MD_FILE && claudeMdExists));
|
|
156
|
+
const { writes, kept } = partitionInitialWrites(root, manifest);
|
|
147
157
|
applyPlan(root, stagedDir, { writes, deletes: [] }, defaultBackupDir(tmpDir));
|
|
158
|
+
writeKitExamples(root, stagedDir, kept);
|
|
148
159
|
// Write the lockfile = the tarball's embedded manifest (no hash recompute).
|
|
149
160
|
writeInitialLockfile(root, manifestRaw);
|
|
161
|
+
// The managed `.gitattributes` / `.prettierignore` blocks, the same sync `update` runs. Here,
|
|
162
|
+
// inside the `try`, because `stagedDir` (their source) is deleted by the `finally`. Without
|
|
163
|
+
// it the repo's first commit ran its own `prettier --write` over every kit file and drifted
|
|
164
|
+
// them all from the lockfile it had just written.
|
|
165
|
+
syncDerivedDotfiles(root, stagedDir, manifest);
|
|
166
|
+
kitDeps = readKitDeps(stagedDir);
|
|
150
167
|
if (claudeMdExists) {
|
|
151
168
|
console.log('Conservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
|
|
152
169
|
'(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
|
|
153
170
|
}
|
|
171
|
+
reportKeptRepoFiles(kept);
|
|
154
172
|
}
|
|
155
173
|
finally {
|
|
156
174
|
process.removeListener('SIGINT', onSignal);
|
|
@@ -205,4 +223,7 @@ export async function runAdd(flags = { core: false, full: false }) {
|
|
|
205
223
|
console.log('Para actualizar: `npx @timekast/factory update` desde la raíz del repo,');
|
|
206
224
|
console.log('o instala el CLI una vez (`npm i -g @timekast/factory`) y corre `factory update`.');
|
|
207
225
|
}
|
|
226
|
+
// Factory#78 — the packages the kit's files import that this repo does not declare. A
|
|
227
|
+
// terminal is asked; without one only the exact install command is printed.
|
|
228
|
+
await offerKitDeps(root, kitDeps);
|
|
208
229
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* are local modifications not yet reconciled via `update`.
|
|
11
11
|
* - A1 WARNING: a literal security notice (design §1 + §11, decision A1) that
|
|
12
12
|
* `src/` + boilerplate deps are the derived project's responsibility
|
|
13
|
-
* post-bootstrap, and that
|
|
14
|
-
* update
|
|
13
|
+
* post-bootstrap, and that kit fixes for `src/` arrive as retrofit guides
|
|
14
|
+
* under `.claude/docs/retrofits/` (shipped by `update`, applied by the agent).
|
|
15
15
|
* - MISSING INITIAL MIGRATION: a derivative that ships a Drizzle schema and no
|
|
16
16
|
* generated `.sql`, so the migration step of its deploy — `vercel-build`
|
|
17
17
|
* (`pnpm build && pnpm db:migrate`) on Vercel, the pre-deploy (`pnpm db:migrate`)
|
|
@@ -45,8 +45,10 @@ import path from 'node:path';
|
|
|
45
45
|
import { CLIError } from '../lib/cli-error.js';
|
|
46
46
|
import { KIT_RULE_PATH_RE, RULES_DIR, bareImportPaths, resolveImportFrom, } from '../lib/claude-md.js';
|
|
47
47
|
import { collectClaudePaths } from '../lib/claude-paths.js';
|
|
48
|
-
import {
|
|
48
|
+
import { effectiveSettingsHash, KIT_SETTINGS_PATH } from '../lib/claude-settings.js';
|
|
49
|
+
import { BRAIN_PREFIXES, CLAUDE_MD_FILE } from '../lib/constants.js';
|
|
49
50
|
import { MIGRATIONS_DIR, SCHEMA_DIR, needsInitialMigration, readMigrationFacts, } from '../lib/initial-migration.js';
|
|
51
|
+
import { kitExamplePath } from '../lib/initial-writes.js';
|
|
50
52
|
import { hasLockfile, normalizeThenHash, readLockfile } from '../lib/lockfile.js';
|
|
51
53
|
import { LAUNCHER_RAIL_TIMEOUT_MS, runLauncherPreflight, } from '../lib/preflight.js';
|
|
52
54
|
import { readNeonRevokePending } from '../lib/provision-state.js';
|
|
@@ -76,9 +78,9 @@ export const A1_WARNING = 'Aviso de seguridad (A1): el boilerplate `src/` y sus
|
|
|
76
78
|
'responsabilidad del proyecto derivado a partir de ahí. `factory:update` NO ' +
|
|
77
79
|
'actualiza `src/` ni las dependencias: solo refresca el cerebro (`.claude/`). ' +
|
|
78
80
|
'Un parche de seguridad para `src/` (un CVE en auth, middleware, headers o una ' +
|
|
79
|
-
'dependencia) NO llega por este canal.
|
|
80
|
-
'
|
|
81
|
-
'dependencias al día por tu cuenta
|
|
81
|
+
'dependencia) NO llega por este canal. Las correcciones del kit para `src/` llegan ' +
|
|
82
|
+
'como guías en `.claude/docs/retrofits/`: viajan con `update` y tu agente las aplica. ' +
|
|
83
|
+
'Mantén `src/` y tus dependencias al día por tu cuenta.';
|
|
82
84
|
/**
|
|
83
85
|
* The literal notice for a derivative whose schema has no generated migration.
|
|
84
86
|
* Named constant (never an inline string — CODING.md §5) and built from the SAME
|
|
@@ -260,6 +262,8 @@ export function computeDoctor(deps = {}) {
|
|
|
260
262
|
return {
|
|
261
263
|
orphans: [],
|
|
262
264
|
conflicts: [],
|
|
265
|
+
formatterDrift: [],
|
|
266
|
+
repoOwned: [],
|
|
263
267
|
unimportedRules: [],
|
|
264
268
|
claudeMdMissing: false,
|
|
265
269
|
a1Warning: A1_WARNING,
|
|
@@ -285,16 +289,26 @@ export function computeDoctor(deps = {}) {
|
|
|
285
289
|
// Pending conflicts: tracked file whose normalized on-disk hash drifted from
|
|
286
290
|
// the recorded hash (a local modification not yet resolved by `update`).
|
|
287
291
|
const conflicts = [];
|
|
292
|
+
const repoOwned = [];
|
|
288
293
|
for (const entry of lock.files) {
|
|
289
294
|
const abs = path.join(rootDir, entry.path);
|
|
290
295
|
if (!existsSync(abs) || !statSync(abs).isFile())
|
|
291
296
|
continue;
|
|
292
|
-
const
|
|
297
|
+
const raw = readFileSync(abs, 'utf8');
|
|
298
|
+
// settings.json: only the kit's keys count (the project's own are not a conflict).
|
|
299
|
+
const localHash = entry.path === KIT_SETTINGS_PATH
|
|
300
|
+
? effectiveSettingsHash(raw, [entry])
|
|
301
|
+
: normalizeThenHash(raw);
|
|
293
302
|
if (localHash !== entry.hash) {
|
|
294
|
-
|
|
303
|
+
if (existsSync(path.join(rootDir, kitExamplePath(entry.path))))
|
|
304
|
+
repoOwned.push(entry.path);
|
|
305
|
+
else
|
|
306
|
+
conflicts.push(entry.path);
|
|
295
307
|
}
|
|
296
308
|
}
|
|
297
309
|
conflicts.sort();
|
|
310
|
+
repoOwned.sort();
|
|
311
|
+
const formatterDrift = conflicts.filter((p) => p !== CLAUDE_MD_FILE && !BRAIN_PREFIXES.some((prefix) => p.startsWith(prefix)));
|
|
298
312
|
// Unimported kit rules: a kit-shipped always-on rule (`.claude/rules/*.md`)
|
|
299
313
|
// that is NOT `@import`ed by CLAUDE.md → it does NOT load at runtime. The check
|
|
300
314
|
// is strict on the `@import` directive (a prose mention does NOT count — a
|
|
@@ -322,6 +336,8 @@ export function computeDoctor(deps = {}) {
|
|
|
322
336
|
return {
|
|
323
337
|
orphans,
|
|
324
338
|
conflicts,
|
|
339
|
+
formatterDrift,
|
|
340
|
+
repoOwned,
|
|
325
341
|
unimportedRules,
|
|
326
342
|
claudeMdMissing,
|
|
327
343
|
a1Warning: A1_WARNING,
|
|
@@ -373,6 +389,12 @@ function renderDoctor(result, repoVault) {
|
|
|
373
389
|
for (const p of result.conflicts) {
|
|
374
390
|
console.log(` • ${p} — modificado localmente respecto al lockfile`);
|
|
375
391
|
}
|
|
392
|
+
if (result.formatterDrift.length > 0) {
|
|
393
|
+
console.log(` Pista: ${result.formatterDrift.map((p) => `\`${p}\``).join(', ')} ` +
|
|
394
|
+
'vive(n) fuera de `.claude/` y `scripts/tools/`. Si solo cambió el formato, ' +
|
|
395
|
+
'probablemente lo reformateó el prettier del repo. Guía: ' +
|
|
396
|
+
'.claude/docs/retrofits/prettier-drifted-kit-files.md');
|
|
397
|
+
}
|
|
376
398
|
console.log(' Sugerencia: corre `factory:update --verify` para re-chequear, o `factory:update` para reconciliar.');
|
|
377
399
|
}
|
|
378
400
|
if (result.unimportedRules.length > 0) {
|
|
@@ -388,6 +410,13 @@ function renderDoctor(result, repoVault) {
|
|
|
388
410
|
}
|
|
389
411
|
}
|
|
390
412
|
}
|
|
413
|
+
// Repo-owned kit files (#76): informative, never a conflict. Shown whatever the rest says.
|
|
414
|
+
if (result.repoOwned.length > 0) {
|
|
415
|
+
console.log('\nArchivos propios del repo (el kit dejó su versión al lado, en `.kit-example`):');
|
|
416
|
+
for (const p of result.repoOwned) {
|
|
417
|
+
console.log(` • ${p} — archivo propio del repo; compáralo con \`${kitExamplePath(p)}\``);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
391
420
|
// Initial-migration gap (GAP-002): reported, never enforced. It sits outside the
|
|
392
421
|
// orphans/conflicts block because it is about the derivative's `src/`, not about
|
|
393
422
|
// brain drift — a repo with a spotless brain can still be missing its `0000`.
|
package/dist/commands/env.js
CHANGED
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { CLIError } from '../lib/cli-error.js';
|
|
17
|
+
import { senderAddressOf } from '../lib/email-from.js';
|
|
17
18
|
import { PER_ENV_MARKER, PER_ENV_REGISTRY_FILE, PROVISION_OWNED, addToPerEnvRegistry, findInlinePerEnvMarkers, findPerEnvMarkedKeys, listEnvKeys, readPerEnvRegistry, setEnvLine, } from '../lib/env-file.js';
|
|
18
|
-
import { applyDefaultOverrides, applyWizardAnswers, applyWizardAnswersToVault, buildEnvReview, buildEnvReviewFromEnv, buildQuestionPlan, checkEnvCompleteness, generateVapidKeys, isQuestionActive, parseEnvBody, parseEnvExample, provisionEmailDefaults, readVaultLocalEnv, readVaultMain, writeSharedToVault, } from '../lib/env-wizard.js';
|
|
19
|
+
import { applyDefaultOverrides, applyWizardAnswers, applyWizardAnswersToVault, buildEnvReview, buildEnvReviewFromEnv, buildQuestionPlan, checkEnvCompleteness, generateVapidKeys, isPublicEnvKey, isQuestionActive, parseEnvBody, parseEnvExample, provisionEmailDefaults, readVaultLocalEnv, readVaultMain, writeSharedToVault, } from '../lib/env-wizard.js';
|
|
19
20
|
import { defaultRail, railTokenRejectedHint, readVercelToken } from '../lib/provision-preflight.js';
|
|
20
|
-
import { readProvisionState, readVaultLink } from '../lib/provision-state.js';
|
|
21
|
-
import { createVaultClient } from '../lib/vault-api.js';
|
|
22
|
-
import { pushEnvPlan, vercelProjectPath, VercelApiError, } from '../lib/vercel-env.js';
|
|
21
|
+
import { readProvisionState, readProvisionTarget, readVaultLink, } from '../lib/provision-state.js';
|
|
22
|
+
import { VAULT_ENV_DEVELOP, VAULT_ENV_MAIN, VAULT_ROOT_PATH, createVaultClient, } from '../lib/vault-api.js';
|
|
23
|
+
import { listEnvVars, pushEnvPlan, readEnvVarsWithValues, upsertEnvVar, vercelProjectPath, VercelApiError, } from '../lib/vercel-env.js';
|
|
23
24
|
// Re-exported so existing importers (and `cli/tests/unit/env-push.test.ts`) keep
|
|
24
25
|
// resolving PROVISION_OWNED from this module after it moved to `lib/env-file.js`.
|
|
25
26
|
export { PROVISION_OWNED };
|
|
@@ -479,16 +480,27 @@ export function runEnvWizardGenVapid(rootDir = process.cwd(), deps = {}) {
|
|
|
479
480
|
const subjectMissing = !env['VAPID_SUBJECT'];
|
|
480
481
|
if (subjectMissing) {
|
|
481
482
|
const from = env['EMAIL_FROM'];
|
|
482
|
-
body = setEnvLine(body, 'VAPID_SUBJECT', from
|
|
483
|
+
body = setEnvLine(body, 'VAPID_SUBJECT', vapidSubjectFor(from));
|
|
483
484
|
}
|
|
484
485
|
writeFileSync(localPath, body, 'utf8');
|
|
485
486
|
chmodSync(localPath, 0o600); // holds VAPID_PRIVATE_KEY → owner-only.
|
|
486
487
|
console.log('✔ Llaves VAPID generadas y escritas a `.env.local` (la privada queda oculta).');
|
|
487
488
|
console.log(` NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
|
|
488
|
-
if (subjectMissing &&
|
|
489
|
+
if (subjectMissing && vapidSubjectFor(env['EMAIL_FROM']) === VAPID_SUBJECT_PLACEHOLDER) {
|
|
489
490
|
console.log(' ℹ️ Revisa VAPID_SUBJECT — puse un placeholder (correo de contacto para push).');
|
|
490
491
|
}
|
|
491
492
|
}
|
|
493
|
+
/** Placeholder push contact when there is no sender to derive one from. */
|
|
494
|
+
const VAPID_SUBJECT_PLACEHOLDER = 'mailto:admin@example.com';
|
|
495
|
+
/**
|
|
496
|
+
* The push contact (`VAPID_SUBJECT`) derived from `EMAIL_FROM`. RFC 8292 wants a `mailto:` URL,
|
|
497
|
+
* so only the bare address goes in: the display-name form (`Name <addr>`) would yield
|
|
498
|
+
* `mailto:Name <addr>`, which push services reject (Factory#62).
|
|
499
|
+
*/
|
|
500
|
+
export function vapidSubjectFor(emailFrom) {
|
|
501
|
+
const address = emailFrom ? senderAddressOf(emailFrom) : '';
|
|
502
|
+
return address.includes('@') ? `mailto:${address}` : VAPID_SUBJECT_PLACEHOLDER;
|
|
503
|
+
}
|
|
492
504
|
/**
|
|
493
505
|
* `--gen-vapid` in a vault repo (VAULT-002): the keypair is upserted (a new pair per run, as on
|
|
494
506
|
* the file path) and `VAPID_SUBJECT` is seeded only when missing, from `main:/EMAIL_FROM`.
|
|
@@ -499,11 +511,331 @@ async function genVapidIntoVault(vault) {
|
|
|
499
511
|
const main = await readVaultMain(vault);
|
|
500
512
|
const subjectMissing = !main['VAPID_SUBJECT'];
|
|
501
513
|
const from = main['EMAIL_FROM'];
|
|
502
|
-
await writeSharedToVault(vault, { VAPID_SUBJECT: from
|
|
514
|
+
await writeSharedToVault(vault, { VAPID_SUBJECT: vapidSubjectFor(from) }, 'create-if-missing');
|
|
503
515
|
console.log('✔ Llaves VAPID generadas y escritas en la bóveda (main:/, referenciadas desde develop:/ y local; ' +
|
|
504
516
|
'la privada queda oculta).');
|
|
505
517
|
console.log(` NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
|
|
506
|
-
if (subjectMissing &&
|
|
518
|
+
if (subjectMissing && vapidSubjectFor(from) === VAPID_SUBJECT_PLACEHOLDER) {
|
|
507
519
|
console.log(' ℹ️ Revisa VAPID_SUBJECT — puse un placeholder (correo de contacto para push).');
|
|
508
520
|
}
|
|
509
521
|
}
|
|
522
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
523
|
+
// `factory env get|set` (PVMCP-003) — read or change ONE public flag of ONE deploy environment.
|
|
524
|
+
//
|
|
525
|
+
// What `/deploy` uses to see whether the questions section is still on in production and, if the
|
|
526
|
+
// developer decides so, turn it off before releasing. Two lists bound it, and both are checked
|
|
527
|
+
// BEFORE any call to the vault, to Vercel or to the rail:
|
|
528
|
+
// · `get` reads only keys `isPublicEnvKey` accepts — it can never be a secret reader;
|
|
529
|
+
// · `set` writes only {@link SETTABLE_ENV_KEYS}, and only `true`/`false`. A public key is not
|
|
530
|
+
// enough for a write: `WEBAUTHN_RP_ID` or `NEXT_PUBLIC_APP_URL` are public and moving them
|
|
531
|
+
// breaks passkeys or the login, and a free value could carry a `${…}` reference that would
|
|
532
|
+
// make the vault resolve another secret into a public variable.
|
|
533
|
+
//
|
|
534
|
+
// With the vault it touches ONE location per environment (`main:/` or `develop:/`) through the
|
|
535
|
+
// client's own read/write — never the wizard's shared-value helpers, which write `main:/` and
|
|
536
|
+
// REFERENCE it from `develop:/` (a `set --env develop` must never touch production, and the other
|
|
537
|
+
// way round). Without the vault it goes to Vercel (`main` → production, `develop` → preview);
|
|
538
|
+
// Railway without the vault is refused: its values only arrive through the vault's sync.
|
|
539
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
540
|
+
/** The only keys `factory env set` writes (SK.md §7.2: the second allowed writer of these two). */
|
|
541
|
+
export const SETTABLE_ENV_KEYS = ['QUESTIONS_ENABLED', 'MCP_ENABLED'];
|
|
542
|
+
/** The only values `factory env set` writes — literal, case-sensitive. */
|
|
543
|
+
export const SETTABLE_ENV_VALUES = ['true', 'false'];
|
|
544
|
+
/**
|
|
545
|
+
* Exit codes of `env get|set` — what `/deploy` branches on, never the text. `1` stays the generic
|
|
546
|
+
* "could not read / could not do it", which is also what an older CLI answers ("Subcomando de env
|
|
547
|
+
* desconocido"). The others avoid every code the CLI already uses (2 `update --dry-run`, 3 sealed
|
|
548
|
+
* key of `vault adopt`, 10-12 `provision --resolve-target`, 130 cancelled).
|
|
549
|
+
*/
|
|
550
|
+
export const ENV_GET_SET_EXIT = {
|
|
551
|
+
ok: 0,
|
|
552
|
+
error: 1,
|
|
553
|
+
/** The key does not exist in that environment (the location was read fine). */
|
|
554
|
+
notFound: 20,
|
|
555
|
+
/** The key (or, for `set`, the value) is outside what the command accepts. */
|
|
556
|
+
rejected: 21,
|
|
557
|
+
/** `get`: in that environment the key is a REFERENCE to another one — not a value of its own. */
|
|
558
|
+
reference: 22,
|
|
559
|
+
};
|
|
560
|
+
/** A recognized subcommand error with its own exit code. */
|
|
561
|
+
function envGetSetError(message, code) {
|
|
562
|
+
return new CLIError(message, code);
|
|
563
|
+
}
|
|
564
|
+
export const ENV_GET_SET_USAGE = 'Uso: `factory env get <KEY> --env main|develop` · `factory env set <KEY> <true|false> --env main|develop`.';
|
|
565
|
+
/**
|
|
566
|
+
* Parse `get <KEY>` / `set <KEY> <VALUE>` + `--env main|develop` (also `--env=…`). A `--help`/`-h`
|
|
567
|
+
* anywhere short-circuits: nothing else is validated and nothing runs. `--env` has no default —
|
|
568
|
+
* nobody touches production by omission.
|
|
569
|
+
*
|
|
570
|
+
* @throws {CLIError} (exit 1) on a missing/extra argument, an unknown flag or a bad `--env`.
|
|
571
|
+
*/
|
|
572
|
+
export function parseEnvGetSetArgs(argv, sub) {
|
|
573
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
574
|
+
return { help: true, key: '', value: '', env: 'main' };
|
|
575
|
+
}
|
|
576
|
+
const positional = [];
|
|
577
|
+
let env;
|
|
578
|
+
for (let i = 0; i < argv.length; i++) {
|
|
579
|
+
const arg = argv[i];
|
|
580
|
+
if (arg === '--env') {
|
|
581
|
+
env = argv[i + 1];
|
|
582
|
+
i++;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (arg.startsWith('--env=')) {
|
|
586
|
+
env = arg.slice('--env='.length);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (arg.startsWith('-')) {
|
|
590
|
+
throw envGetSetError(`Flag desconocido: \`${arg}\`. ${ENV_GET_SET_USAGE}`, ENV_GET_SET_EXIT.error);
|
|
591
|
+
}
|
|
592
|
+
positional.push(arg);
|
|
593
|
+
}
|
|
594
|
+
const expected = sub === 'get' ? 1 : 2;
|
|
595
|
+
if (positional.length !== expected) {
|
|
596
|
+
throw envGetSetError(`\`env ${sub}\` espera ${expected === 1 ? 'una clave' : 'una clave y un valor'}. ${ENV_GET_SET_USAGE}`, ENV_GET_SET_EXIT.error);
|
|
597
|
+
}
|
|
598
|
+
if (env === undefined) {
|
|
599
|
+
throw envGetSetError(`Falta \`--env\` (main o develop): no hay entorno por omisión. ${ENV_GET_SET_USAGE}`, ENV_GET_SET_EXIT.error);
|
|
600
|
+
}
|
|
601
|
+
if (env !== 'main' && env !== 'develop') {
|
|
602
|
+
throw envGetSetError(`\`--env\` acepta \`main\` o \`develop\`, no \`${env}\`.`, ENV_GET_SET_EXIT.error);
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
help: false,
|
|
606
|
+
key: positional[0],
|
|
607
|
+
value: sub === 'set' ? positional[1] : '',
|
|
608
|
+
env,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/** `get`'s gate: public keys only. Runs before any call. The rejection names the key, never a value. */
|
|
612
|
+
function assertReadableKey(key) {
|
|
613
|
+
if (isPublicEnvKey(key))
|
|
614
|
+
return;
|
|
615
|
+
throw envGetSetError(`No leo \`${key}\`: \`factory env get\` sólo opera claves públicas (las de public-env-keys.json). ` +
|
|
616
|
+
'Un secreto se consulta en la bóveda o en el dashboard del despliegue, nunca por este comando.', ENV_GET_SET_EXIT.rejected);
|
|
617
|
+
}
|
|
618
|
+
/** `set`'s gate: its own write list + `true`/`false`. Runs before any call. Never echoes the value. */
|
|
619
|
+
function assertWritable(key, value) {
|
|
620
|
+
if (!SETTABLE_ENV_KEYS.includes(key)) {
|
|
621
|
+
throw envGetSetError(`No escribo \`${key}\`: \`factory env set\` sólo cambia ${SETTABLE_ENV_KEYS.map((k) => `\`${k}\``).join(' y ')}. ` +
|
|
622
|
+
'Otra clave se cambia donde vive (la bóveda, o el dashboard del despliegue sin bóveda).', ENV_GET_SET_EXIT.rejected);
|
|
623
|
+
}
|
|
624
|
+
if (!SETTABLE_ENV_VALUES.includes(value)) {
|
|
625
|
+
throw envGetSetError(`No escribo ese valor en \`${key}\`: \`factory env set\` sólo acepta \`true\` o \`false\` (literal).`, ENV_GET_SET_EXIT.rejected);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** A raw (unexpanded) vault value that is — or contains — a `${…}` reference. */
|
|
629
|
+
const VAULT_REFERENCE_RE = /\$\{[^}]*\}/;
|
|
630
|
+
/** Where a `${env.KEY}` / `${env.folder.KEY}` reference points, for the message; `null` for `${KEY}`. */
|
|
631
|
+
function referencedEnv(raw) {
|
|
632
|
+
const match = /\$\{([^}]*)\}/.exec(raw);
|
|
633
|
+
const parts = match?.[1]?.split('.') ?? [];
|
|
634
|
+
return parts.length >= 2 ? parts[0] : null;
|
|
635
|
+
}
|
|
636
|
+
const VERCEL_TARGET_OF = {
|
|
637
|
+
main: 'production',
|
|
638
|
+
develop: 'preview',
|
|
639
|
+
};
|
|
640
|
+
/**
|
|
641
|
+
* Vault or Vercel — decided the way `env push` and `env wizard` decide it (`readVaultLink`,
|
|
642
|
+
* fail-closed on a malformed or unreadable state file). Railway without the vault is refused
|
|
643
|
+
* here, before any call.
|
|
644
|
+
*/
|
|
645
|
+
function routeOf(rootDir) {
|
|
646
|
+
const link = readVaultLink(rootDir);
|
|
647
|
+
const target = readProvisionTarget(rootDir);
|
|
648
|
+
if (link)
|
|
649
|
+
return { kind: 'vault', link, railway: target === 'railway' };
|
|
650
|
+
if (target === 'railway') {
|
|
651
|
+
throw envGetSetError('Este repo despliega en Railway y no tiene bóveda: Railway exige bóveda (sus valores sólo llegan ' +
|
|
652
|
+
'por el sync de la bóveda), así que no hay a dónde leer ni escribir. Pásalo a la bóveda con ' +
|
|
653
|
+
'`factory vault adopt` (sin `--apply` sólo enseña el plan). No consulté nada.', ENV_GET_SET_EXIT.error);
|
|
654
|
+
}
|
|
655
|
+
return { kind: 'vercel' };
|
|
656
|
+
}
|
|
657
|
+
function vaultLocationOf(env) {
|
|
658
|
+
return { env: env === 'main' ? VAULT_ENV_MAIN : VAULT_ENV_DEVELOP, path: VAULT_ROOT_PATH };
|
|
659
|
+
}
|
|
660
|
+
/** What a vault read asks for: the raw value of ONE location — never expanded, never imported. */
|
|
661
|
+
const RAW_READ = { expandReferences: false, includeImports: false };
|
|
662
|
+
/** Map a Vercel failure to a value-free CLIError (the response body may quote the request). */
|
|
663
|
+
function vercelFailure(error, operation) {
|
|
664
|
+
if (error instanceof VercelApiError) {
|
|
665
|
+
if (error.status === 401) {
|
|
666
|
+
throw new CLIError(`El \`VERCEL_TOKEN\` del rail es inválido o expiró (Vercel respondió 401 al ${operation}).\n` +
|
|
667
|
+
railTokenRejectedHint('VERCEL_TOKEN'));
|
|
668
|
+
}
|
|
669
|
+
throw new CLIError(`Vercel no respondió bien al ${operation} (status ${error.status}).`);
|
|
670
|
+
}
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
/** The record of `key` for the whole `target` (not pinned to a git branch), if any. */
|
|
674
|
+
function recordFor(records, key, target) {
|
|
675
|
+
return records.find((r) => r.key === key && !r.gitBranch && (r.target ?? []).includes(target));
|
|
676
|
+
}
|
|
677
|
+
/** Read the project's values from Vercel; a response without the list is an error, never "absent". */
|
|
678
|
+
async function readVercelValues(rootDir, deps) {
|
|
679
|
+
const { projectId, teamId } = readProjectLink(rootDir);
|
|
680
|
+
const token = await readVercelToken(deps.rail ?? defaultRail());
|
|
681
|
+
const values = await readEnvVarsWithValues(projectId, token, teamId, deps.vercel ?? {});
|
|
682
|
+
if (values === null) {
|
|
683
|
+
throw new CLIError('Vercel respondió sin la lista de valores del proyecto: no puedo saber si la clave existe. ' +
|
|
684
|
+
'Vuelve a intentar; no escribí nada.');
|
|
685
|
+
}
|
|
686
|
+
return { projectId, teamId, token, values };
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* `factory env get <KEY> --env main|develop` — prints ONLY the value on stdout (for
|
|
690
|
+
* `$(factory env get …)`). Exit codes: {@link ENV_GET_SET_EXIT}.
|
|
691
|
+
*/
|
|
692
|
+
export async function runEnvGet(argv, rootDir = process.cwd(), deps = {}) {
|
|
693
|
+
const args = parseEnvGetSetArgs(argv, 'get');
|
|
694
|
+
if (args.help) {
|
|
695
|
+
console.log(ENV_GET_SET_USAGE);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
assertReadableKey(args.key);
|
|
699
|
+
const route = routeOf(rootDir);
|
|
700
|
+
const writeOut = deps.writeOut ?? ((text) => process.stdout.write(text));
|
|
701
|
+
if (route.kind === 'vault') {
|
|
702
|
+
const client = deps.vaultClient ?? createVaultClient();
|
|
703
|
+
const location = vaultLocationOf(args.env);
|
|
704
|
+
const raw = (await client.readSecrets(route.link.projectId, location, RAW_READ)).get(args.key);
|
|
705
|
+
if (raw === undefined) {
|
|
706
|
+
throw envGetSetError(`\`${args.key}\` no existe en \`${location.env}:/\` de la bóveda.`, ENV_GET_SET_EXIT.notFound);
|
|
707
|
+
}
|
|
708
|
+
if (VAULT_REFERENCE_RE.test(raw)) {
|
|
709
|
+
// Not a value of its own: print the reference as such and say where the value lives.
|
|
710
|
+
writeOut(`${raw}\n`);
|
|
711
|
+
const where = referencedEnv(raw);
|
|
712
|
+
console.error(`ℹ️ En \`${location.env}:/\` \`${args.key}\` es una referencia, no un valor propio` +
|
|
713
|
+
(where
|
|
714
|
+
? `: el valor vive en \`${where}\` (\`factory env get ${args.key} --env ${where}\`).`
|
|
715
|
+
: '.'));
|
|
716
|
+
process.exitCode = ENV_GET_SET_EXIT.reference;
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
writeOut(`${raw}\n`);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const target = VERCEL_TARGET_OF[args.env];
|
|
723
|
+
let values;
|
|
724
|
+
try {
|
|
725
|
+
({ values } = await readVercelValues(rootDir, deps));
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
vercelFailure(error, 'leer los valores del proyecto');
|
|
729
|
+
}
|
|
730
|
+
const record = recordFor(values, args.key, target);
|
|
731
|
+
if (!record) {
|
|
732
|
+
throw envGetSetError(`\`${args.key}\` no existe en Vercel ${target}.`, ENV_GET_SET_EXIT.notFound);
|
|
733
|
+
}
|
|
734
|
+
if (record.value === null) {
|
|
735
|
+
throw new CLIError(`Vercel no devuelve el valor de \`${args.key}\` en ${target} (tipo ${record.type ?? 'desconocido'}).`);
|
|
736
|
+
}
|
|
737
|
+
writeOut(`${record.value}\n`);
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* `factory env set <KEY> <true|false> --env main|develop` — writes ONE environment, never the
|
|
741
|
+
* other. With the vault: one `writeSecrets` on `main:/` or `develop:/` (never a reference, never
|
|
742
|
+
* the wizard's shared-value path). Without it: Vercel, the requested target only.
|
|
743
|
+
*/
|
|
744
|
+
export async function runEnvSet(argv, rootDir = process.cwd(), deps = {}) {
|
|
745
|
+
const args = parseEnvGetSetArgs(argv, 'set');
|
|
746
|
+
if (args.help) {
|
|
747
|
+
console.log(ENV_GET_SET_USAGE);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
assertWritable(args.key, args.value);
|
|
751
|
+
const route = routeOf(rootDir);
|
|
752
|
+
if (route.kind === 'vault') {
|
|
753
|
+
const client = deps.vaultClient ?? createVaultClient();
|
|
754
|
+
const location = vaultLocationOf(args.env);
|
|
755
|
+
const current = (await client.readSecrets(route.link.projectId, location, RAW_READ)).get(args.key);
|
|
756
|
+
if (current !== undefined && VAULT_REFERENCE_RE.test(current)) {
|
|
757
|
+
const where = referencedEnv(current);
|
|
758
|
+
throw new CLIError(`No cambio \`${args.key}\` en \`${location.env}:/\`: ahí es una referencia` +
|
|
759
|
+
(where ? ` y el valor vive en \`${where}:/\`` : '') +
|
|
760
|
+
', y pisarla en silencio separaría los entornos sin que nadie lo decidiera. ' +
|
|
761
|
+
(where
|
|
762
|
+
? `Para cambiar el valor compartido: \`factory env set ${args.key} <true|false> --env ${where}\` ` +
|
|
763
|
+
'(afecta a los dos entornos). '
|
|
764
|
+
: '') +
|
|
765
|
+
'Para un valor propio de este entorno, reemplaza la referencia en el dashboard de la bóveda.');
|
|
766
|
+
}
|
|
767
|
+
// One location, one write — the `develop:/` copy is never a reference to `main:/` here.
|
|
768
|
+
const result = await client.writeSecrets(route.link.projectId, location, { [args.key]: args.value }, 'upsert');
|
|
769
|
+
const outcome = result.created.includes(args.key)
|
|
770
|
+
? 'creada'
|
|
771
|
+
: result.updated.includes(args.key)
|
|
772
|
+
? 'actualizada'
|
|
773
|
+
: 'sin cambios';
|
|
774
|
+
const destination = route.railway
|
|
775
|
+
? `Railway \`${args.env}\``
|
|
776
|
+
: `Vercel ${VERCEL_TARGET_OF[args.env]}`;
|
|
777
|
+
console.log(`✔ \`${args.key}\` en \`${location.env}:/\` de la bóveda: ${outcome}.`);
|
|
778
|
+
if (outcome !== 'sin cambios') {
|
|
779
|
+
const syncs = route.railway ? 'railway' : readVaultVercelSyncStatus(rootDir);
|
|
780
|
+
console.log(syncs === 'pending'
|
|
781
|
+
? `ℹ️ Los syncs de la bóveda hacia Vercel están pendientes: el valor no llega a ${destination} ` +
|
|
782
|
+
'hasta que existan (`factory provision --resume`).'
|
|
783
|
+
: `ℹ️ El sync de la bóveda lo lleva a ${destination}; aplica en el SIGUIENTE deploy ` +
|
|
784
|
+
'(el que ya corre no cambia).');
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
// Without the vault — Vercel, the requested target only; the other target keeps its value.
|
|
789
|
+
const target = VERCEL_TARGET_OF[args.env];
|
|
790
|
+
try {
|
|
791
|
+
const { projectId, teamId, token, values } = await readVercelValues(rootDir, deps);
|
|
792
|
+
const vercelDeps = deps.vercel ?? {};
|
|
793
|
+
const withValue = recordFor(values, args.key, target);
|
|
794
|
+
let outcome;
|
|
795
|
+
if (withValue && withValue.value === args.value) {
|
|
796
|
+
outcome = 'sin cambios';
|
|
797
|
+
}
|
|
798
|
+
else if (!withValue) {
|
|
799
|
+
// Nothing covers this target (the other one may have its own record — not touched).
|
|
800
|
+
await upsertEnvVar(projectId, token, teamId, { key: args.key, value: args.value, target: [target] }, [], vercelDeps);
|
|
801
|
+
outcome = 'creada';
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
const withId = recordFor((await listEnvVars(projectId, token, teamId, vercelDeps)), args.key, target);
|
|
805
|
+
if (!withId) {
|
|
806
|
+
throw new CLIError(`Vercel listó \`${args.key}\` en ${target} con valor pero no con id: no escribo a ciegas. Vuelve a intentar.`);
|
|
807
|
+
}
|
|
808
|
+
const others = (withId.target ?? []).filter((t) => t !== target);
|
|
809
|
+
if (others.length === 0) {
|
|
810
|
+
// A record of this target alone: update it in place.
|
|
811
|
+
await upsertEnvVar(projectId, token, teamId, { key: args.key, value: args.value, target: [target] }, [withId], vercelDeps);
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
// A COMBINED record (e.g. production + preview). Re-pointing it whole would drag the other
|
|
815
|
+
// target along, so it is SPLIT: the record keeps its value for the other target(s), and the
|
|
816
|
+
// requested target gets a record of its own. Needs the current value to keep it.
|
|
817
|
+
if (withValue.value === null) {
|
|
818
|
+
throw new CLIError(`\`${args.key}\` vive en un solo registro para ${(withId.target ?? []).join(' y ')} y Vercel no ` +
|
|
819
|
+
'devuelve su valor, así que no puedo separarlo sin perder el de los otros entornos. Sepáralo en ' +
|
|
820
|
+
'el dashboard de Vercel (un registro por entorno) y repite. No escribí nada.');
|
|
821
|
+
}
|
|
822
|
+
await upsertEnvVar(projectId, token, teamId, { key: args.key, value: withValue.value, target: others }, [withId], vercelDeps);
|
|
823
|
+
try {
|
|
824
|
+
await upsertEnvVar(projectId, token, teamId, { key: args.key, value: args.value, target: [target] }, [], vercelDeps);
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
throw new CLIError(`Separé el registro combinado de \`${args.key}\` (${others.join(', ')} conserva su valor), pero no ` +
|
|
828
|
+
`pude crear el de ${target}: ${target} quedó SIN \`${args.key}\`. Repite el mismo comando para crearlo.` +
|
|
829
|
+
(error instanceof VercelApiError ? ` (Vercel status ${error.status}.)` : ''));
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
outcome = 'actualizada';
|
|
833
|
+
}
|
|
834
|
+
console.log(`✔ \`${args.key}\` en Vercel ${target}: ${outcome}.`);
|
|
835
|
+
if (outcome !== 'sin cambios')
|
|
836
|
+
console.log(REDEPLOY_NOTICE);
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
vercelFailure(error, `escribir \`${args.key}\` en ${target}`);
|
|
840
|
+
}
|
|
841
|
+
}
|