@timekast/factory 1.14.2 → 1.15.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/env.js +186 -0
- package/dist/commands/invite-admin.js +128 -0
- package/dist/commands/provision.js +7 -42
- package/dist/index.js +56 -0
- package/dist/lib/constants.js +16 -0
- package/dist/lib/env-file.js +73 -0
- package/dist/lib/env-wizard.js +184 -0
- package/dist/lib/package-json.js +3 -1
- package/dist/tui/env-wizard-flow.js +92 -0
- package/dist/tui/menu.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory env push` — day-2 environment variable sync to Vercel.
|
|
3
|
+
*
|
|
4
|
+
* Reads `.env.local` and pushes ONLY the product vars (everything EXCEPT the
|
|
5
|
+
* provision-owned, per-environment vars) to `production` + `preview` via the
|
|
6
|
+
* Vercel REST API. Reuses `pushEnvPlan` + `readVercelToken` + the existing
|
|
7
|
+
* `.vercel/project.json` link — no `vercel link`, no re-implemented REST push,
|
|
8
|
+
* no risk of clobbering the per-env DATABASE_URL/AUTH_SECRET that provision set.
|
|
9
|
+
*
|
|
10
|
+
* Exposed to derivatives as `pnpm env:push` (an npx alias). Run it after editing
|
|
11
|
+
* `.env.local` to add a new product variable post-provision.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { CLIError } from '../lib/cli-error.js';
|
|
16
|
+
import { PROVISION_OWNED } from '../lib/env-file.js';
|
|
17
|
+
import { applyWizardAnswers, buildQuestionPlan, parseEnvExample, } from '../lib/env-wizard.js';
|
|
18
|
+
import { defaultEnvPath, readVercelToken } from '../lib/provision-preflight.js';
|
|
19
|
+
import { pushEnvPlan, vercelProjectPath, VercelApiError, } from '../lib/vercel-env.js';
|
|
20
|
+
// Re-exported so existing importers (and `cli/tests/unit/env-push.test.ts`) keep
|
|
21
|
+
// resolving PROVISION_OWNED from this module after it moved to `lib/env-file.js`.
|
|
22
|
+
export { PROVISION_OWNED };
|
|
23
|
+
export function parseEnvPushFlags(argv) {
|
|
24
|
+
return { dryRun: argv.includes('--dry-run') };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Parse `.env.local` into the product vars to push: skip comments/blanks, the
|
|
28
|
+
* provision-owned keys, and empty values. First-`=` split; surrounding quotes
|
|
29
|
+
* stripped (mirrors the rail reader in provision.ts).
|
|
30
|
+
*/
|
|
31
|
+
export function readProductVars(filePath) {
|
|
32
|
+
const out = {};
|
|
33
|
+
if (!existsSync(filePath))
|
|
34
|
+
return out;
|
|
35
|
+
let text;
|
|
36
|
+
try {
|
|
37
|
+
text = readFileSync(filePath, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
for (const line of text.split('\n')) {
|
|
43
|
+
const trimmed = line.trimStart();
|
|
44
|
+
if (trimmed === '' || trimmed.startsWith('#'))
|
|
45
|
+
continue;
|
|
46
|
+
const eq = line.indexOf('=');
|
|
47
|
+
if (eq === -1)
|
|
48
|
+
continue;
|
|
49
|
+
const key = line.slice(0, eq).trim();
|
|
50
|
+
if (key === '' || PROVISION_OWNED.has(key))
|
|
51
|
+
continue;
|
|
52
|
+
let value = line.slice(eq + 1).replace(/\s+$/, '');
|
|
53
|
+
if (value.length >= 2 &&
|
|
54
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
55
|
+
(value.startsWith("'") && value.endsWith("'")))) {
|
|
56
|
+
value = value.slice(1, -1);
|
|
57
|
+
}
|
|
58
|
+
if (value === '')
|
|
59
|
+
continue;
|
|
60
|
+
out[key] = value;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Read `{ projectId, teamId }` from `.vercel/project.json` (written by provision). */
|
|
65
|
+
function readProjectLink(rootDir) {
|
|
66
|
+
const file = vercelProjectPath(rootDir);
|
|
67
|
+
if (!existsSync(file)) {
|
|
68
|
+
throw new CLIError('No encontré `.vercel/project.json`. Corre `/provision` (o `factory provision`) ' +
|
|
69
|
+
'para vincular el proyecto Vercel antes de subir variables.');
|
|
70
|
+
}
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new CLIError('No pude parsear `.vercel/project.json`.');
|
|
77
|
+
}
|
|
78
|
+
const projectId = parsed.projectId;
|
|
79
|
+
const teamId = parsed.teamId ?? parsed.orgId;
|
|
80
|
+
if (!projectId || !teamId) {
|
|
81
|
+
throw new CLIError('`.vercel/project.json` no tiene `projectId`/`teamId`.');
|
|
82
|
+
}
|
|
83
|
+
return { projectId, teamId };
|
|
84
|
+
}
|
|
85
|
+
export async function runEnvPush(flags, rootDir = process.cwd()) {
|
|
86
|
+
const productVars = readProductVars(path.join(rootDir, '.env.local'));
|
|
87
|
+
const keys = Object.keys(productVars).sort();
|
|
88
|
+
if (keys.length === 0) {
|
|
89
|
+
console.log('No hay variables de producto que subir en `.env.local` ' +
|
|
90
|
+
'(todo es provision-owned o está vacío).');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const targets = ['production', 'preview'];
|
|
94
|
+
const plan = keys.map((key) => ({
|
|
95
|
+
key,
|
|
96
|
+
value: productVars[key],
|
|
97
|
+
target: targets,
|
|
98
|
+
}));
|
|
99
|
+
if (flags.dryRun) {
|
|
100
|
+
console.log(`Plan (${plan.length} vars → production, preview):`);
|
|
101
|
+
for (const key of keys)
|
|
102
|
+
console.log(` ${key}`);
|
|
103
|
+
console.log('\n(--dry-run: nada se envió a Vercel.)');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const { projectId, teamId } = readProjectLink(rootDir);
|
|
107
|
+
const token = readVercelToken(defaultEnvPath());
|
|
108
|
+
if (!token) {
|
|
109
|
+
throw new CLIError('No se pudo leer `VERCEL_TOKEN` de `~/.claude/.env`. ' +
|
|
110
|
+
'Corre `factory secrets <archivo>` para bajar el token y reintenta.');
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const result = await pushEnvPlan(projectId, token, teamId, plan);
|
|
114
|
+
console.log(`✔ env push: ${result.created.length} creadas, ${result.updated.length} actualizadas ` +
|
|
115
|
+
'(production, preview).');
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof VercelApiError) {
|
|
119
|
+
throw new CLIError(`Vercel rechazó el push de env: ${error.message}`);
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function parseEnvWizardFlags(argv) {
|
|
125
|
+
return {
|
|
126
|
+
plan: argv.includes('--plan'),
|
|
127
|
+
apply: argv.includes('--apply'),
|
|
128
|
+
json: argv.includes('--json'),
|
|
129
|
+
push: argv.includes('--push'),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function readEnvExamplePlan(rootDir) {
|
|
133
|
+
const examplePath = path.join(rootDir, '.env.example');
|
|
134
|
+
if (!existsSync(examplePath)) {
|
|
135
|
+
throw new CLIError('No encontré `.env.example` en este proyecto. El wizard deriva las preguntas de ahí.');
|
|
136
|
+
}
|
|
137
|
+
return buildQuestionPlan(parseEnvExample(readFileSync(examplePath, 'utf8')));
|
|
138
|
+
}
|
|
139
|
+
/** `factory env wizard --plan [--json]` — emit the question plan. */
|
|
140
|
+
export function runEnvWizardPlan(flags, rootDir = process.cwd()) {
|
|
141
|
+
const questions = readEnvExamplePlan(rootDir);
|
|
142
|
+
if (flags.json) {
|
|
143
|
+
process.stdout.write(`${JSON.stringify({ questions }, null, 2)}\n`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
console.log(`Plan del wizard de env — ${questions.length} variables:\n`);
|
|
147
|
+
let section = '';
|
|
148
|
+
for (const q of questions) {
|
|
149
|
+
if (q.section !== section) {
|
|
150
|
+
section = q.section;
|
|
151
|
+
console.log(`## ${section}`);
|
|
152
|
+
}
|
|
153
|
+
const gate = q.showWhen ? ` (si ${q.showWhen.key} ∈ {${q.showWhen.in.join(', ')}})` : '';
|
|
154
|
+
console.log(` - ${q.key} [${q.kind}]${gate}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function readStdinJson() {
|
|
158
|
+
const chunks = [];
|
|
159
|
+
for await (const chunk of process.stdin)
|
|
160
|
+
chunks.push(chunk);
|
|
161
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* `factory env wizard --apply [--push]` — read `{ answers, push? }` JSON from
|
|
165
|
+
* stdin, write the answers to `.env.local`, and optionally push product vars.
|
|
166
|
+
*/
|
|
167
|
+
export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {}) {
|
|
168
|
+
const raw = await (deps.readStdin ?? readStdinJson)();
|
|
169
|
+
let parsed;
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(raw);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new CLIError('Esperaba JSON `{ "answers": { KEY: "value" }, "push"?: bool }` por stdin.');
|
|
175
|
+
}
|
|
176
|
+
const answers = parsed.answers ?? {};
|
|
177
|
+
const written = applyWizardAnswers(rootDir, answers);
|
|
178
|
+
console.log(`✔ Escribí ${written.length} variable(s) en \`.env.local\`.`);
|
|
179
|
+
const shouldPush = flags.push || parsed.push === true;
|
|
180
|
+
if (!shouldPush) {
|
|
181
|
+
console.log('ℹ️ Para subirlas a Vercel cuando quieras: `pnpm env:push`.');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const push = deps.push ?? ((dir) => runEnvPush({ dryRun: false }, dir));
|
|
185
|
+
await push(rootDir);
|
|
186
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory invite-admin` — mint a single-use super_admin invite for a derivative.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the old `SUPER_ADMIN_*` seed. The actual minting lives in the kit
|
|
5
|
+
* script `scripts/tools/invite-admin.ts` (it needs the kit's `@/lib/invites` +
|
|
6
|
+
* `@/lib/email`, which this CLI must not import). This command's only job is to
|
|
7
|
+
* resolve the TARGET branch's connection string from `.env.local` and spawn that
|
|
8
|
+
* script with it:
|
|
9
|
+
*
|
|
10
|
+
* - `--target develop` (default) → `DATABASE_URL` (the develop branch)
|
|
11
|
+
* - `--target main` → `DATABASE_URL_MAIN` (production)
|
|
12
|
+
*
|
|
13
|
+
* `--target main` is consumed by SETUP-002 after the first deploy+migrate; the
|
|
14
|
+
* accept link must point at production, so pass `--app-url=https://...` there.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* factory invite-admin --email=you@example.com [--target develop|main] [--app-url=<url>]
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { execa } from 'execa';
|
|
22
|
+
import { CLIError } from '../lib/cli-error.js';
|
|
23
|
+
/** Map a target branch to the `.env.local` var holding its connection string. */
|
|
24
|
+
export const TARGET_DB_VAR = {
|
|
25
|
+
develop: 'DATABASE_URL',
|
|
26
|
+
main: 'DATABASE_URL_MAIN',
|
|
27
|
+
};
|
|
28
|
+
/** Repo-relative path to the kit script this command spawns. */
|
|
29
|
+
export const INVITE_ADMIN_SCRIPT = 'scripts/tools/invite-admin.ts';
|
|
30
|
+
function parseTarget(value) {
|
|
31
|
+
if (value === 'develop' || value === 'main')
|
|
32
|
+
return value;
|
|
33
|
+
throw new CLIError(`\`--target\` inválido: \`${value ?? '(vacío)'}\`. Usa \`develop\` o \`main\`.`);
|
|
34
|
+
}
|
|
35
|
+
export function parseInviteAdminFlags(argv) {
|
|
36
|
+
let email = null;
|
|
37
|
+
let target = 'develop';
|
|
38
|
+
let appUrl = null;
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const arg = argv[i];
|
|
41
|
+
if (arg.startsWith('--email='))
|
|
42
|
+
email = arg.slice('--email='.length);
|
|
43
|
+
else if (arg === '--email')
|
|
44
|
+
email = argv[++i] ?? null;
|
|
45
|
+
else if (arg.startsWith('--target='))
|
|
46
|
+
target = parseTarget(arg.slice('--target='.length));
|
|
47
|
+
else if (arg === '--target')
|
|
48
|
+
target = parseTarget(argv[++i]);
|
|
49
|
+
else if (arg.startsWith('--app-url='))
|
|
50
|
+
appUrl = arg.slice('--app-url='.length);
|
|
51
|
+
else if (arg === '--app-url')
|
|
52
|
+
appUrl = argv[++i] ?? null;
|
|
53
|
+
else if (!arg.startsWith('-') && email === null)
|
|
54
|
+
email = arg; // bare positional email
|
|
55
|
+
}
|
|
56
|
+
return { email, target, appUrl };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read a single KEY from a derivative's `.env.local`. Split on the first `=`,
|
|
60
|
+
* strip surrounding quotes (mirrors `readProductVars` in `env.ts`). Returns null
|
|
61
|
+
* when the file or the key is absent/empty.
|
|
62
|
+
*/
|
|
63
|
+
export function readEnvLocalVar(rootDir, key) {
|
|
64
|
+
const file = path.join(rootDir, '.env.local');
|
|
65
|
+
if (!existsSync(file))
|
|
66
|
+
return null;
|
|
67
|
+
let text;
|
|
68
|
+
try {
|
|
69
|
+
text = readFileSync(file, 'utf8');
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
for (const line of text.split('\n')) {
|
|
75
|
+
const trimmed = line.trimStart();
|
|
76
|
+
if (trimmed === '' || trimmed.startsWith('#'))
|
|
77
|
+
continue;
|
|
78
|
+
const eq = line.indexOf('=');
|
|
79
|
+
if (eq === -1 || line.slice(0, eq).trim() !== key)
|
|
80
|
+
continue;
|
|
81
|
+
let value = line.slice(eq + 1).trim();
|
|
82
|
+
if (value.length >= 2 &&
|
|
83
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
84
|
+
(value.startsWith("'") && value.endsWith("'")))) {
|
|
85
|
+
value = value.slice(1, -1);
|
|
86
|
+
}
|
|
87
|
+
return value || null;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
export async function runInviteAdmin(flags, rootDir = process.cwd(), deps = {}) {
|
|
92
|
+
if (!flags.email) {
|
|
93
|
+
throw new CLIError('Falta el email. Uso: `factory invite-admin --email=you@example.com [--target develop|main]`.');
|
|
94
|
+
}
|
|
95
|
+
if (!existsSync(path.join(rootDir, INVITE_ADMIN_SCRIPT))) {
|
|
96
|
+
throw new CLIError(`No encontré \`${INVITE_ADMIN_SCRIPT}\`. Corre \`factory update\` para traer el ` +
|
|
97
|
+
'script del kit antes de invitar al admin.');
|
|
98
|
+
}
|
|
99
|
+
// Resolve the target branch's connection string. develop is the local default
|
|
100
|
+
// (the script's own dotenv loads DATABASE_URL from .env.local), but we resolve
|
|
101
|
+
// it anyway to fail early with a clear message; for main we MUST override.
|
|
102
|
+
const dbVar = TARGET_DB_VAR[flags.target];
|
|
103
|
+
const dbUrl = readEnvLocalVar(rootDir, dbVar);
|
|
104
|
+
if (!dbUrl) {
|
|
105
|
+
throw new CLIError(`No encontré \`${dbVar}\` en \`.env.local\` (target: ${flags.target}). ` +
|
|
106
|
+
'Corre `/provision` (o `factory provision`) para escribir las connection strings.');
|
|
107
|
+
}
|
|
108
|
+
const env = { ...process.env, DATABASE_URL: dbUrl };
|
|
109
|
+
// For main the accept link must point at production, not localhost — the
|
|
110
|
+
// caller (SETUP-002) passes the deployed domain via --app-url.
|
|
111
|
+
if (flags.appUrl)
|
|
112
|
+
env.NEXT_PUBLIC_APP_URL = flags.appUrl;
|
|
113
|
+
console.log(`→ Creando invitación de super_admin (${flags.target}) para ${flags.email}…`);
|
|
114
|
+
// `pnpm exec tsx` resolves the kit's local tsx (a devDependency) — same
|
|
115
|
+
// package-manager assumption provision already makes (`pnpm install`). The
|
|
116
|
+
// script reads .env.local for the rest of its config (email provider, app URL)
|
|
117
|
+
// and uses the DATABASE_URL we pass for the chosen branch.
|
|
118
|
+
const run = deps.run ?? ((file, args, options) => execa(file, args, { ...options, stdio: 'inherit' }));
|
|
119
|
+
try {
|
|
120
|
+
await run('pnpm', ['exec', 'tsx', INVITE_ADMIN_SCRIPT, `--email=${flags.email}`], {
|
|
121
|
+
cwd: rootDir,
|
|
122
|
+
env,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new CLIError('El script `invite-admin` falló. Revisa la salida de arriba.');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -29,6 +29,7 @@ import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, ensureCnameRecord,
|
|
|
29
29
|
import { createDevelopBranch, createNeonProject, createProjectApiKey, deleteNeonApiKeysByName, deleteNeonProject, getConnectionUri, listNeonBranches, listNeonProjects, NeonApiError, NEON_API_BASE, NEON_DEVELOP_BRANCH, } from '../lib/neon-api.js';
|
|
30
30
|
import { defaultEnvPath, readVercelToken, runProvisionPreflight, } from '../lib/provision-preflight.js';
|
|
31
31
|
import { addVercelDomain, buildEnvPlan, createVercelProject, generateAuthSecret, getLinkedRepoId, getVercelDomainStatus, GitHubAppNotInstalledError, linkGitRepo, listVercelProjects, patchProjectSettings, pushEnvPlan, removeVercelDomain, triggerDeployment, upsertEnvVar, listEnvVars, VercelApiError, writeVercelProjectJson, } from '../lib/vercel-env.js';
|
|
32
|
+
import { writeEnvLocal } from '../lib/env-file.js';
|
|
32
33
|
import { adoptState, ADOPT_UNKNOWN, clearProvisionState, clearState, hasProvisionState, isServiceComplete, PROVISION_DIR, PROVISION_STATE_FILE, readDestroyTarget, readProvisionState, writeProvisionState, } from '../lib/provision-state.js';
|
|
33
34
|
/**
|
|
34
35
|
* The THREE GitHub Secrets the E2E runner (`pnpm test:e2e` → `e2e-runner.ts` +
|
|
@@ -680,7 +681,7 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
|
|
|
680
681
|
console.log(` ↻ ${v.key} [${v.target.join(', ')}]`);
|
|
681
682
|
}
|
|
682
683
|
// Generate the gitignored `.env.local` so the dev can run the app, migrations and
|
|
683
|
-
// seeds locally (`pnpm dev`/`db:migrate`/`
|
|
684
|
+
// seeds locally (`pnpm dev`/`db:migrate`/`invite:admin` read `.env.local`). Uses
|
|
684
685
|
// the DIRECT strings (single-process tooling/migrations, never the pooler).
|
|
685
686
|
// DATABASE_URL → develop (safe default), DATABASE_URL_MAIN → main (db:query:main).
|
|
686
687
|
const envLocal = writeEnvLocal(rootDir, neonSecrets, { force: deps.force });
|
|
@@ -730,47 +731,11 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
|
|
|
730
731
|
'El proyecto + las env vars ya están listos; pushea o redeploya a mano.');
|
|
731
732
|
}
|
|
732
733
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
*
|
|
739
|
-
* Uses the DIRECT (non-pooler) strings: `.env.local` drives single-process tooling
|
|
740
|
-
* (`drizzle-kit migrate`/`generate`, `db:seed`, `db:query`) + local `pnpm dev` — none
|
|
741
|
-
* of which want the PgBouncer pooler (the pooled host is only for the serverless
|
|
742
|
-
* runtime on Vercel). Do NOT swap these back to the pooled variants.
|
|
743
|
-
*
|
|
744
|
-
* DATABASE_URL → the develop branch (safe default for local work); DATABASE_URL_MAIN
|
|
745
|
-
* → the main branch (read by `pnpm db:query:main`). NEVER clobbers an existing
|
|
746
|
-
* `.env.local` (the dev may have edited it) unless `--force`. The `.env.example` stays
|
|
747
|
-
* the SSOT of which keys exist; the keys are appended if absent (older scaffolds).
|
|
748
|
-
*/
|
|
749
|
-
export function writeEnvLocal(rootDir, neon, opts = {}) {
|
|
750
|
-
const examplePath = join(rootDir, '.env.example');
|
|
751
|
-
if (!existsSync(examplePath))
|
|
752
|
-
return 'no-example';
|
|
753
|
-
const localPath = join(rootDir, '.env.local');
|
|
754
|
-
if (existsSync(localPath) && !opts.force)
|
|
755
|
-
return 'skipped';
|
|
756
|
-
let body = readFileSync(examplePath, 'utf8');
|
|
757
|
-
body = setEnvLine(body, 'DATABASE_URL', neon.developDirect);
|
|
758
|
-
body = setEnvLine(body, 'DATABASE_URL_MAIN', neon.mainDirect);
|
|
759
|
-
body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
|
|
760
|
-
writeFileSync(localPath, body, 'utf8');
|
|
761
|
-
return 'written';
|
|
762
|
-
}
|
|
763
|
-
/**
|
|
764
|
-
* Replace the value of `KEY=...` in an env-file body, preserving every other line.
|
|
765
|
-
* Appends `KEY="value"` when the key is absent (so an older `.env.example` without
|
|
766
|
-
* `DATABASE_URL_MAIN` still gets it). The value is double-quoted — Neon URLs (pooled
|
|
767
|
-
* or direct) carry `?`/`&` query params that an unquoted dotenv value would truncate.
|
|
768
|
-
*/
|
|
769
|
-
function setEnvLine(body, key, value) {
|
|
770
|
-
const re = new RegExp(`^${key}=.*$`, 'm');
|
|
771
|
-
const line = `${key}="${value}"`;
|
|
772
|
-
return re.test(body) ? body.replace(re, line) : `${body.trimEnd()}\n${line}\n`;
|
|
773
|
-
}
|
|
734
|
+
// `writeEnvLocal` + `setEnvLine` moved to `../lib/env-file.js` (shared with the
|
|
735
|
+
// day-2 env wizard); `writeEnvLocal` is imported above for internal use and
|
|
736
|
+
// re-exported here so existing importers (and `cli/tests/unit/provision-env.test.ts`)
|
|
737
|
+
// keep resolving it from this module.
|
|
738
|
+
export { writeEnvLocal };
|
|
774
739
|
/**
|
|
775
740
|
* The HTTPS git URL of the repo `gh repo create` made (`new.ts` creates
|
|
776
741
|
* `${FACTORY_ORG}/${name}` — the canonical remote is its `.git` HTTPS URL). Never
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,9 @@ import { dirname, join } from 'node:path';
|
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { CLIError } from './lib/cli-error.js';
|
|
13
13
|
import { parseAddFlags, runAdd } from './commands/add.js';
|
|
14
|
+
import { parseEnvPushFlags, parseEnvWizardFlags, runEnvPush, runEnvWizardApply, runEnvWizardPlan, } from './commands/env.js';
|
|
15
|
+
import { parseInviteAdminFlags, runInviteAdmin } from './commands/invite-admin.js';
|
|
16
|
+
import { envWizardFlow } from './tui/env-wizard-flow.js';
|
|
14
17
|
import { parseDoctorFlags, runDoctor, runLauncherDoctor } from './commands/doctor.js';
|
|
15
18
|
import { parseNewFlags, runNew } from './commands/new.js';
|
|
16
19
|
import { parseProvisionFlags, runProvision } from './commands/provision.js';
|
|
@@ -39,6 +42,8 @@ Comandos:
|
|
|
39
42
|
unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
|
|
40
43
|
provision Aprovisiona la infra de un derivado (Neon/Vercel/GitHub/DNS) — flag-driven, --json
|
|
41
44
|
secrets <file> Mergea un archivo KEY=value de secretos de metodología a ~/.claude/.env (chmod 600)
|
|
45
|
+
env <sub> push: sube product vars a Vercel (day-2) · wizard: configura .env.local
|
|
46
|
+
invite-admin Crea una invitación de super_admin para un derivado (sin password en texto plano)
|
|
42
47
|
|
|
43
48
|
Sobre \`new\`:
|
|
44
49
|
Pregunta el perfil de forma interactiva. Los flags \`--full\` / \`--core\` lo
|
|
@@ -105,6 +110,23 @@ Sobre \`status\` y \`doctor\`:
|
|
|
105
110
|
gh auth, membresía del org) — termina con código 1 si falla algún check, para
|
|
106
111
|
que un lanzador GUI / CI / el TUI puedan ramificar sobre el resultado.
|
|
107
112
|
|
|
113
|
+
Sobre \`env\`:
|
|
114
|
+
\`env push\` sube las product vars de \`.env.local\` a Vercel (production+preview),
|
|
115
|
+
sin tocar las provision-owned. \`env wizard\` configura \`.env.local\`:
|
|
116
|
+
(sin flags) Wizard interactivo en la terminal (deriva las preguntas de \`.env.example\`)
|
|
117
|
+
--plan [--json] Emite el plan de preguntas (lo consume el agente / la skill tk-provision)
|
|
118
|
+
--apply [--push] Lee \`{ answers, push? }\` JSON por stdin y escribe \`.env.local\`
|
|
119
|
+
|
|
120
|
+
Sobre \`invite-admin\`:
|
|
121
|
+
Materializa el primer super_admin de un derivado por invitación (reemplaza el
|
|
122
|
+
viejo seed con password en texto plano). Resuelve la connection string del
|
|
123
|
+
target desde \`.env.local\` y spawnea el script del kit con ella:
|
|
124
|
+
--email=<addr> Email que recibe la invitación y será super_admin (requerido)
|
|
125
|
+
--target=<t> develop (default, usa DATABASE_URL) | main (usa DATABASE_URL_MAIN)
|
|
126
|
+
--app-url=<url> Override del NEXT_PUBLIC_APP_URL del link de aceptación
|
|
127
|
+
(úsalo con --target main para que apunte a producción)
|
|
128
|
+
Alias en el derivado: \`pnpm invite:admin --email=...\`.
|
|
129
|
+
|
|
108
130
|
Opciones:
|
|
109
131
|
-h, --help Muestra esta ayuda
|
|
110
132
|
-v, --version Muestra la versión del CLI
|
|
@@ -211,6 +233,40 @@ async function main(argv) {
|
|
|
211
233
|
await secretsCommand(file);
|
|
212
234
|
return;
|
|
213
235
|
}
|
|
236
|
+
case 'env': {
|
|
237
|
+
const sub = rest[0];
|
|
238
|
+
// `factory env push` — day-2 product-var sync to Vercel (reuses the
|
|
239
|
+
// provision push path; never touches the per-env provision-owned vars).
|
|
240
|
+
if (sub === 'push') {
|
|
241
|
+
await runEnvPush(parseEnvPushFlags(rest.slice(1)));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// `factory env wizard` — onboarding env config. `--plan [--json]` and
|
|
245
|
+
// `--apply` are the non-interactive primitives (the agent/skill drives the
|
|
246
|
+
// questions); no flags → the interactive TUI flow (TTY-only, hints in headless).
|
|
247
|
+
if (sub === 'wizard') {
|
|
248
|
+
const wflags = parseEnvWizardFlags(rest.slice(1));
|
|
249
|
+
if (wflags.plan) {
|
|
250
|
+
runEnvWizardPlan(wflags);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (wflags.apply) {
|
|
254
|
+
await runEnvWizardApply(wflags);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
await envWizardFlow();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
throw new CLIError(`Subcomando de env desconocido: \`${sub ?? '(ninguno)'}\`. ` +
|
|
261
|
+
'Uso: `factory env push` o `factory env wizard`.');
|
|
262
|
+
}
|
|
263
|
+
case 'invite-admin': {
|
|
264
|
+
// Mint a single-use super_admin invite against the target branch's DB.
|
|
265
|
+
// Spawns the kit script `scripts/tools/invite-admin.ts` with the resolved
|
|
266
|
+
// DATABASE_URL — no plaintext admin password anywhere.
|
|
267
|
+
await runInviteAdmin(parseInviteAdminFlags(rest));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
214
270
|
default:
|
|
215
271
|
throw new CLIError(`Comando desconocido: \`${command}\`.\nEjecuta \`factory --help\` para ver los comandos disponibles.`);
|
|
216
272
|
}
|
package/dist/lib/constants.js
CHANGED
|
@@ -81,6 +81,11 @@ export const PUBLISH_SCRIPT_NAME = 'factory:publish';
|
|
|
81
81
|
export const PUBLISH_SCRIPT_CMD = 'npx @timekast/factory publish';
|
|
82
82
|
export const UNPUBLISH_SCRIPT_NAME = 'factory:unpublish';
|
|
83
83
|
export const UNPUBLISH_SCRIPT_CMD = 'npx @timekast/factory unpublish';
|
|
84
|
+
// Day-2 env var sync. Named `env:push` (parallel to the kit's `env:check`),
|
|
85
|
+
// not `factory:*`, because devs reach for it like a normal env tool. Still goes
|
|
86
|
+
// through `npx` so it resolves without a global install.
|
|
87
|
+
export const ENV_PUSH_SCRIPT_NAME = 'env:push';
|
|
88
|
+
export const ENV_PUSH_SCRIPT_CMD = 'npx @timekast/factory env push';
|
|
84
89
|
/**
|
|
85
90
|
* All convenience scripts the installer ensures in a Node derivative's
|
|
86
91
|
* package.json (each: add if missing, never overwrite a divergent value). Keyed
|
|
@@ -93,6 +98,7 @@ export const FACTORY_SCRIPTS = {
|
|
|
93
98
|
[STATUS_SCRIPT_NAME]: STATUS_SCRIPT_CMD,
|
|
94
99
|
[PUBLISH_SCRIPT_NAME]: PUBLISH_SCRIPT_CMD,
|
|
95
100
|
[UNPUBLISH_SCRIPT_NAME]: UNPUBLISH_SCRIPT_CMD,
|
|
101
|
+
[ENV_PUSH_SCRIPT_NAME]: ENV_PUSH_SCRIPT_CMD,
|
|
96
102
|
};
|
|
97
103
|
/**
|
|
98
104
|
* Kit-local convenience script: the readiness sweep (`pnpm preflight`, consumed
|
|
@@ -105,6 +111,16 @@ export const FACTORY_SCRIPTS = {
|
|
|
105
111
|
export const PREFLIGHT_SCRIPT_NAME = 'preflight';
|
|
106
112
|
export const PREFLIGHT_SCRIPT_CMD = 'tsx scripts/tools/preflight.ts';
|
|
107
113
|
export const PREFLIGHT_SCRIPT_FILE = 'scripts/tools/preflight.ts';
|
|
114
|
+
/**
|
|
115
|
+
* Kit-local super_admin invite alias. npx-based (resolves the CLI so it gets the
|
|
116
|
+
* develop/main target switching) but FILE-GATED on the script the CLI spawns —
|
|
117
|
+
* a core/non-Factory derivative without `scripts/tools/invite-admin.ts` never
|
|
118
|
+
* gets a broken `invite:admin`. Insert-if-missing; self-heals on the next
|
|
119
|
+
* update once the script lands (same contract as `preflight`).
|
|
120
|
+
*/
|
|
121
|
+
export const INVITE_ADMIN_SCRIPT_NAME = 'invite:admin';
|
|
122
|
+
export const INVITE_ADMIN_SCRIPT_CMD = 'npx @timekast/factory invite-admin';
|
|
123
|
+
export const INVITE_ADMIN_SCRIPT_FILE = 'scripts/tools/invite-admin.ts';
|
|
108
124
|
/**
|
|
109
125
|
* Kit-local reference generators — autogen of `project/reference/*` (INVENTORY,
|
|
110
126
|
* CODEBASE, HOOKS, SCHEMA, API). Each shells a `scripts/tools/generate-*.mjs`
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.env.local` authoring primitives — shared between `provision` (the first-run
|
|
3
|
+
* write of the Neon strings + AUTH_SECRET) and the day-2 env wizard (`env
|
|
4
|
+
* wizard`, which fills the rest of `.env.example` interactively).
|
|
5
|
+
*
|
|
6
|
+
* Both are pure file ops (no network, no prompts): the interactive layer (the
|
|
7
|
+
* `tk-provision` skill or the TUI) collects answers and calls `setEnvLine`; the
|
|
8
|
+
* CLI never prompts here, so it stays headless-safe.
|
|
9
|
+
*/
|
|
10
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { generateAuthSecret } from './vercel-env.js';
|
|
13
|
+
/**
|
|
14
|
+
* Vars that provision owns and sets PER-ENVIRONMENT (or keeps local-only). The
|
|
15
|
+
* day-2 paths (`env push`, the env wizard) NEVER touch these: re-pushing
|
|
16
|
+
* `DATABASE_URL` / `AUTH_SECRET` uniformly would overwrite the distinct
|
|
17
|
+
* prod↔preview values provision wrote, cross-wiring production to develop.
|
|
18
|
+
* `DATABASE_URL_MAIN` is local-only (`db:query:main`) and must never reach Vercel.
|
|
19
|
+
*
|
|
20
|
+
* MUST be a superset of `buildEnvPlan`'s per-env keys (vercel-env.ts) — enforced
|
|
21
|
+
* by the anti-drift test in `cli/tests/unit/env-push.test.ts`.
|
|
22
|
+
*/
|
|
23
|
+
export const PROVISION_OWNED = new Set([
|
|
24
|
+
'DATABASE_URL',
|
|
25
|
+
'DATABASE_URL_MAIN',
|
|
26
|
+
'AUTH_SECRET',
|
|
27
|
+
'NEXT_PUBLIC_APP_ENV',
|
|
28
|
+
]);
|
|
29
|
+
/**
|
|
30
|
+
* Replace the value of `KEY=...` in an env-file body, preserving every other line.
|
|
31
|
+
* Appends `KEY="value"` when the key is absent (so an older `.env.example` without
|
|
32
|
+
* `DATABASE_URL_MAIN` still gets it). The value is double-quoted — Neon URLs (pooled
|
|
33
|
+
* or direct) carry `?`/`&` query params that an unquoted dotenv value would truncate.
|
|
34
|
+
*/
|
|
35
|
+
export function setEnvLine(body, key, value) {
|
|
36
|
+
const re = new RegExp(`^${key}=.*$`, 'm');
|
|
37
|
+
const line = `${key}="${value}"`;
|
|
38
|
+
return re.test(body) ? body.replace(re, line) : `${body.trimEnd()}\n${line}\n`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generate the gitignored `.env.local` from the derivative's `.env.example`, filling
|
|
42
|
+
* the two Neon connection strings + a fresh AUTH_SECRET. Without this the dev cannot
|
|
43
|
+
* run `pnpm dev`/`db:migrate`/`invite:admin` locally — those read `.env.local`, which
|
|
44
|
+
* provision otherwise never creates (it only syncs env to Vercel).
|
|
45
|
+
*
|
|
46
|
+
* Uses the DIRECT (non-pooler) strings: `.env.local` drives single-process tooling
|
|
47
|
+
* (`drizzle-kit migrate`/`generate`, `db:seed`, `db:query`) + local `pnpm dev` — none
|
|
48
|
+
* of which want the PgBouncer pooler (the pooled host is only for the serverless
|
|
49
|
+
* runtime on Vercel). Do NOT swap these back to the pooled variants.
|
|
50
|
+
*
|
|
51
|
+
* DATABASE_URL → the develop branch (safe default for local work); DATABASE_URL_MAIN
|
|
52
|
+
* → the main branch (read by `pnpm db:query:main`). NEVER clobbers an existing
|
|
53
|
+
* `.env.local` (the dev may have edited it) unless `--force`. The `.env.example` stays
|
|
54
|
+
* the SSOT of which keys exist; the keys are appended if absent (older scaffolds).
|
|
55
|
+
*/
|
|
56
|
+
export function writeEnvLocal(rootDir, neon, opts = {}) {
|
|
57
|
+
const examplePath = join(rootDir, '.env.example');
|
|
58
|
+
if (!existsSync(examplePath))
|
|
59
|
+
return 'no-example';
|
|
60
|
+
const localPath = join(rootDir, '.env.local');
|
|
61
|
+
if (existsSync(localPath) && !opts.force)
|
|
62
|
+
return 'skipped';
|
|
63
|
+
let body = readFileSync(examplePath, 'utf8');
|
|
64
|
+
body = setEnvLine(body, 'DATABASE_URL', neon.developDirect);
|
|
65
|
+
body = setEnvLine(body, 'DATABASE_URL_MAIN', neon.mainDirect);
|
|
66
|
+
body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
|
|
67
|
+
writeFileSync(localPath, body, 'utf8');
|
|
68
|
+
// `.env.local` holds DB strings + AUTH_SECRET → owner-only (0600). Same
|
|
69
|
+
// discipline the secrets rail uses; matters on the headless multi-tenant
|
|
70
|
+
// Agent Server where the default umask would leave it world-readable.
|
|
71
|
+
chmodSync(localPath, 0o600);
|
|
72
|
+
return 'written';
|
|
73
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env wizard — parse a derivative's `.env.example` into a question plan the
|
|
3
|
+
* INTERACTIVE layer renders (the `tk-provision` skill via AskUserQuestion, or the
|
|
4
|
+
* TUI via `prompts`). Everything here is pure + non-interactive: the CLI never
|
|
5
|
+
* prompts, so it stays headless-safe — the front-end collects the answers and
|
|
6
|
+
* hands them back to `applyWizardAnswers`.
|
|
7
|
+
*
|
|
8
|
+
* `.env.example` is the SSOT of WHICH keys exist; this module derives the
|
|
9
|
+
* questions (+ their help text) from it. Only the few real conditionals
|
|
10
|
+
* (EMAIL_PROVIDER → its creds, the OAuth flags → their creds) are encoded as a
|
|
11
|
+
* small `GATES` map — the one piece of domain knowledge the file can't express.
|
|
12
|
+
*/
|
|
13
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { CLIError } from './cli-error.js';
|
|
16
|
+
import { PROVISION_OWNED, setEnvLine } from './env-file.js';
|
|
17
|
+
/**
|
|
18
|
+
* The real conditionals `.env.example` can't express. Keyed by the DEPENDENT var
|
|
19
|
+
* → the driver answer that reveals it. Kept deliberately small — adding a new
|
|
20
|
+
* env var does NOT require touching this unless it is genuinely conditional.
|
|
21
|
+
*/
|
|
22
|
+
const GATES = {
|
|
23
|
+
EMAIL_FROM: { key: 'EMAIL_PROVIDER', in: ['resend', 'smtp'] },
|
|
24
|
+
RESEND_API_KEY: { key: 'EMAIL_PROVIDER', in: ['resend'] },
|
|
25
|
+
EMAIL_SERVER_HOST: { key: 'EMAIL_PROVIDER', in: ['smtp'] },
|
|
26
|
+
EMAIL_SERVER_PORT: { key: 'EMAIL_PROVIDER', in: ['smtp'] },
|
|
27
|
+
EMAIL_SERVER_USER: { key: 'EMAIL_PROVIDER', in: ['smtp'] },
|
|
28
|
+
EMAIL_SERVER_PASSWORD: { key: 'EMAIL_PROVIDER', in: ['smtp'] },
|
|
29
|
+
EMAIL_SERVER_SECURE: { key: 'EMAIL_PROVIDER', in: ['smtp'] },
|
|
30
|
+
AUTH_GOOGLE_ID: { key: 'NEXT_PUBLIC_AUTH_GOOGLE', in: ['true'] },
|
|
31
|
+
AUTH_GOOGLE_SECRET: { key: 'NEXT_PUBLIC_AUTH_GOOGLE', in: ['true'] },
|
|
32
|
+
AUTH_GITHUB_ID: { key: 'NEXT_PUBLIC_AUTH_GITHUB', in: ['true'] },
|
|
33
|
+
AUTH_GITHUB_SECRET: { key: 'NEXT_PUBLIC_AUTH_GITHUB', in: ['true'] },
|
|
34
|
+
};
|
|
35
|
+
/** Vars that get a `select` with a fixed choice set instead of free text. */
|
|
36
|
+
const SELECTS = {
|
|
37
|
+
EMAIL_PROVIDER: ['none', 'resend', 'smtp'],
|
|
38
|
+
};
|
|
39
|
+
const DIVIDER = /^#\s*-{3,}\s*$/;
|
|
40
|
+
const ACTIVE = /^([A-Z][A-Z0-9_]*)=(.*)$/;
|
|
41
|
+
const COMMENTED = /^#\s*([A-Z][A-Z0-9_]*)=(.*)$/;
|
|
42
|
+
const SECRET_KEY = /(_SECRET|_KEY|_TOKEN|_PASSWORD)$/;
|
|
43
|
+
function unquote(raw) {
|
|
44
|
+
const v = raw.trim();
|
|
45
|
+
if (v.length >= 2 &&
|
|
46
|
+
((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
|
|
47
|
+
return v.slice(1, -1);
|
|
48
|
+
}
|
|
49
|
+
return v;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parse `.env.example` into entries, preserving file order. A section title is a
|
|
53
|
+
* comment line sandwiched between two `# ----` dividers; other comment lines
|
|
54
|
+
* become the help text of the next var.
|
|
55
|
+
*/
|
|
56
|
+
export function parseEnvExample(text) {
|
|
57
|
+
const lines = text.split('\n');
|
|
58
|
+
const entries = [];
|
|
59
|
+
let section = '';
|
|
60
|
+
let comment = [];
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
const line = lines[i].replace(/\s+$/, '');
|
|
63
|
+
const prev = (lines[i - 1] ?? '').trim();
|
|
64
|
+
const next = (lines[i + 1] ?? '').trim();
|
|
65
|
+
if (DIVIDER.test(line))
|
|
66
|
+
continue; // structural — skip
|
|
67
|
+
// Section header: a comment sandwiched between two dividers.
|
|
68
|
+
if (line.startsWith('#') && DIVIDER.test(prev) && DIVIDER.test(next)) {
|
|
69
|
+
section = line.replace(/^#\s*/, '').trim();
|
|
70
|
+
comment = [];
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const active = line.match(ACTIVE);
|
|
74
|
+
if (active) {
|
|
75
|
+
entries.push({
|
|
76
|
+
key: active[1],
|
|
77
|
+
section,
|
|
78
|
+
defaultValue: unquote(active[2]),
|
|
79
|
+
commented: false,
|
|
80
|
+
help: comment.join(' ').trim(),
|
|
81
|
+
});
|
|
82
|
+
comment = [];
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const commented = line.match(COMMENTED);
|
|
86
|
+
if (commented) {
|
|
87
|
+
entries.push({
|
|
88
|
+
key: commented[1],
|
|
89
|
+
section,
|
|
90
|
+
defaultValue: unquote(commented[2]),
|
|
91
|
+
commented: true,
|
|
92
|
+
help: comment.join(' ').trim(),
|
|
93
|
+
});
|
|
94
|
+
comment = [];
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (line.startsWith('#')) {
|
|
98
|
+
comment.push(line.replace(/^#\s*/, '').trim());
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Blank / other line → break the comment run.
|
|
102
|
+
comment = [];
|
|
103
|
+
}
|
|
104
|
+
return entries;
|
|
105
|
+
}
|
|
106
|
+
function questionKind(key, defaultValue) {
|
|
107
|
+
if (SELECTS[key])
|
|
108
|
+
return 'select';
|
|
109
|
+
if (SECRET_KEY.test(key))
|
|
110
|
+
return 'secret';
|
|
111
|
+
if (defaultValue === 'true' || defaultValue === 'false')
|
|
112
|
+
return 'boolean';
|
|
113
|
+
return 'text';
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Turn parsed entries into the ordered question plan. Includes:
|
|
117
|
+
* - every ACTIVE non-provision-owned var (the product config the dev reviews), and
|
|
118
|
+
* - the GATED dependents (email creds, OAuth creds), active or commented.
|
|
119
|
+
* Excludes provision-owned vars and advanced commented vars (rate-limit, VAPID,
|
|
120
|
+
* Upstash, Sentry, NextAuth aliases) that aren't gated — those stay manual +
|
|
121
|
+
* `pnpm env:push`.
|
|
122
|
+
*/
|
|
123
|
+
export function buildQuestionPlan(entries) {
|
|
124
|
+
const questions = [];
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
for (const e of entries) {
|
|
127
|
+
if (seen.has(e.key))
|
|
128
|
+
continue;
|
|
129
|
+
if (PROVISION_OWNED.has(e.key))
|
|
130
|
+
continue;
|
|
131
|
+
const gate = GATES[e.key];
|
|
132
|
+
// A commented var is only included when it's a gated dependent; advanced
|
|
133
|
+
// commented vars (no gate) are skipped.
|
|
134
|
+
if (e.commented && !gate)
|
|
135
|
+
continue;
|
|
136
|
+
seen.add(e.key);
|
|
137
|
+
const q = {
|
|
138
|
+
key: e.key,
|
|
139
|
+
section: e.section,
|
|
140
|
+
help: e.help,
|
|
141
|
+
defaultValue: e.defaultValue,
|
|
142
|
+
kind: questionKind(e.key, e.defaultValue),
|
|
143
|
+
};
|
|
144
|
+
if (SELECTS[e.key])
|
|
145
|
+
q.choices = SELECTS[e.key];
|
|
146
|
+
if (gate)
|
|
147
|
+
q.showWhen = gate;
|
|
148
|
+
questions.push(q);
|
|
149
|
+
}
|
|
150
|
+
return questions;
|
|
151
|
+
}
|
|
152
|
+
/** True when `q`'s gate is satisfied by the answers collected so far. */
|
|
153
|
+
export function isQuestionActive(q, answers) {
|
|
154
|
+
if (!q.showWhen)
|
|
155
|
+
return true;
|
|
156
|
+
const driver = answers[q.showWhen.key];
|
|
157
|
+
return driver !== undefined && q.showWhen.in.includes(driver);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Write the collected answers into `.env.local` via `setEnvLine`, preserving
|
|
161
|
+
* every other line. Falls back to `.env.example` as the base when `.env.local`
|
|
162
|
+
* doesn't exist yet. NEVER writes provision-owned keys (provision sets those
|
|
163
|
+
* per-environment). Returns the keys actually written.
|
|
164
|
+
*/
|
|
165
|
+
export function applyWizardAnswers(rootDir, answers) {
|
|
166
|
+
const localPath = join(rootDir, '.env.local');
|
|
167
|
+
const examplePath = join(rootDir, '.env.example');
|
|
168
|
+
const basePath = existsSync(localPath) ? localPath : examplePath;
|
|
169
|
+
if (!existsSync(basePath)) {
|
|
170
|
+
throw new CLIError('No encontré `.env.local` ni `.env.example` para escribir las variables.');
|
|
171
|
+
}
|
|
172
|
+
let body = readFileSync(basePath, 'utf8');
|
|
173
|
+
const written = [];
|
|
174
|
+
for (const [key, value] of Object.entries(answers)) {
|
|
175
|
+
if (PROVISION_OWNED.has(key))
|
|
176
|
+
continue; // provision owns these per-env
|
|
177
|
+
body = setEnvLine(body, key, value);
|
|
178
|
+
written.push(key);
|
|
179
|
+
}
|
|
180
|
+
writeFileSync(localPath, body, 'utf8');
|
|
181
|
+
// `.env.local` may now hold secrets (API keys, OAuth) → owner-only (0600).
|
|
182
|
+
chmodSync(localPath, 0o600);
|
|
183
|
+
return written;
|
|
184
|
+
}
|
package/dist/lib/package-json.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { CLIError } from './cli-error.js';
|
|
11
|
-
import { FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, UPDATE_SCRIPT_NAME, } from './constants.js';
|
|
11
|
+
import { FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, UPDATE_SCRIPT_NAME, } from './constants.js';
|
|
12
12
|
/**
|
|
13
13
|
* Auto-detect the profile to install into an existing repo from its
|
|
14
14
|
* `package.json`: a `factoryVersion` field means the repo was born from the
|
|
@@ -111,6 +111,8 @@ export function kitLocalScripts(rootDir) {
|
|
|
111
111
|
const scripts = {};
|
|
112
112
|
if (has(PREFLIGHT_SCRIPT_FILE))
|
|
113
113
|
scripts[PREFLIGHT_SCRIPT_NAME] = PREFLIGHT_SCRIPT_CMD;
|
|
114
|
+
if (has(INVITE_ADMIN_SCRIPT_FILE))
|
|
115
|
+
scripts[INVITE_ADMIN_SCRIPT_NAME] = INVITE_ADMIN_SCRIPT_CMD;
|
|
114
116
|
for (const gen of GENERATOR_SCRIPTS) {
|
|
115
117
|
if (has(gen.file))
|
|
116
118
|
scripts[gen.name] = gen.cmd;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env wizard — interactive TUI flow (Capa B). Walks the question plan derived
|
|
3
|
+
* from `.env.example` with `prompts`, respecting the gates as answers come in,
|
|
4
|
+
* then applies the answers to `.env.local` and offers to push to Vercel.
|
|
5
|
+
*
|
|
6
|
+
* Shares the SAME pure primitives as the agent path (`lib/env-wizard.ts` +
|
|
7
|
+
* `commands/env.ts`): this file only adds the terminal interaction. No TTY →
|
|
8
|
+
* degrade with a hint to the headless `--plan`/`--apply` primitives.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import prompts from 'prompts';
|
|
13
|
+
import { runEnvPush } from '../commands/env.js';
|
|
14
|
+
import { CLIError } from '../lib/cli-error.js';
|
|
15
|
+
import { applyWizardAnswers, buildQuestionPlan, isQuestionActive, parseEnvExample, } from '../lib/env-wizard.js';
|
|
16
|
+
/** Ask one question by kind. Returns `undefined` when the user cancels (Ctrl+C). */
|
|
17
|
+
async function askOne(q) {
|
|
18
|
+
const message = q.help ? `${q.key} — ${q.help}` : q.key;
|
|
19
|
+
const onCancel = { onCancel: () => true };
|
|
20
|
+
if (q.kind === 'select') {
|
|
21
|
+
const choices = (q.choices ?? []).map((c) => ({ title: c, value: c }));
|
|
22
|
+
const initial = Math.max(0, (q.choices ?? []).indexOf(q.defaultValue));
|
|
23
|
+
const { v } = await prompts({ type: 'select', name: 'v', message, choices, initial }, onCancel);
|
|
24
|
+
return v === undefined ? undefined : String(v);
|
|
25
|
+
}
|
|
26
|
+
if (q.kind === 'boolean') {
|
|
27
|
+
const { v } = await prompts({
|
|
28
|
+
type: 'toggle',
|
|
29
|
+
name: 'v',
|
|
30
|
+
message,
|
|
31
|
+
initial: q.defaultValue === 'true',
|
|
32
|
+
active: 'true',
|
|
33
|
+
inactive: 'false',
|
|
34
|
+
}, onCancel);
|
|
35
|
+
return v === undefined ? undefined : v ? 'true' : 'false';
|
|
36
|
+
}
|
|
37
|
+
if (q.kind === 'secret') {
|
|
38
|
+
const { v } = await prompts({ type: 'password', name: 'v', message }, onCancel);
|
|
39
|
+
return v === undefined ? undefined : String(v);
|
|
40
|
+
}
|
|
41
|
+
const { v } = await prompts({ type: 'text', name: 'v', message, initial: q.defaultValue }, onCancel);
|
|
42
|
+
return v === undefined ? undefined : String(v);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Run the interactive env wizard in `rootDir`. Reachable from `factory env
|
|
46
|
+
* wizard` (no flags) and from the TUI menu.
|
|
47
|
+
*/
|
|
48
|
+
export async function envWizardFlow(rootDir = process.cwd(), deps = {}) {
|
|
49
|
+
const isTTY = deps.isTTY ?? Boolean(process.stdout.isTTY);
|
|
50
|
+
if (!isTTY) {
|
|
51
|
+
throw new CLIError('El wizard interactivo necesita una terminal. En headless usa ' +
|
|
52
|
+
'`factory env wizard --plan --json` y `factory env wizard --apply`.');
|
|
53
|
+
}
|
|
54
|
+
const examplePath = path.join(rootDir, '.env.example');
|
|
55
|
+
if (!existsSync(examplePath)) {
|
|
56
|
+
throw new CLIError('No encontré `.env.example` en este proyecto. El wizard deriva las preguntas de ahí.');
|
|
57
|
+
}
|
|
58
|
+
const plan = buildQuestionPlan(parseEnvExample(readFileSync(examplePath, 'utf8')));
|
|
59
|
+
console.log('\nConfiguremos las variables de entorno del proyecto.\n' +
|
|
60
|
+
'(Enter conserva el valor por defecto · Ctrl+C cancela sin escribir nada.)\n');
|
|
61
|
+
const answers = {};
|
|
62
|
+
let section = '';
|
|
63
|
+
for (const q of plan) {
|
|
64
|
+
if (!isQuestionActive(q, answers))
|
|
65
|
+
continue;
|
|
66
|
+
if (q.section !== section) {
|
|
67
|
+
section = q.section;
|
|
68
|
+
console.log(`\n— ${section} —`);
|
|
69
|
+
}
|
|
70
|
+
const value = await askOne(q);
|
|
71
|
+
if (value === undefined) {
|
|
72
|
+
console.log('\nCancelado. No se escribió nada en `.env.local`.');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
answers[q.key] = value;
|
|
76
|
+
}
|
|
77
|
+
const written = applyWizardAnswers(rootDir, answers);
|
|
78
|
+
console.log(`\n✔ Escribí ${written.length} variable(s) en \`.env.local\`.`);
|
|
79
|
+
const { doPush } = await prompts({
|
|
80
|
+
type: 'confirm',
|
|
81
|
+
name: 'doPush',
|
|
82
|
+
message: '¿Subir las variables de producto a Vercel ahora? (production + preview)',
|
|
83
|
+
initial: false,
|
|
84
|
+
}, { onCancel: () => true });
|
|
85
|
+
if (doPush) {
|
|
86
|
+
const push = deps.push ?? ((dir) => runEnvPush({ dryRun: false }, dir));
|
|
87
|
+
await push(rootDir);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
console.log('ℹ️ Cuando quieras subirlas: `pnpm env:push`.');
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/tui/menu.js
CHANGED
|
@@ -23,6 +23,7 @@ import prompts from 'prompts';
|
|
|
23
23
|
import { parseNewFlags, runNew } from '../commands/new.js';
|
|
24
24
|
import { secretsCommand } from '../commands/secrets.js';
|
|
25
25
|
import { parseUpdateFlags, runUpdate } from '../commands/update.js';
|
|
26
|
+
import { envWizardFlow } from './env-wizard-flow.js';
|
|
26
27
|
import { provisionFlow } from './provision-flow.js';
|
|
27
28
|
/** Prompt for the top-level action. Returns `undefined` if the user cancels. */
|
|
28
29
|
async function promptAction() {
|
|
@@ -34,6 +35,7 @@ async function promptAction() {
|
|
|
34
35
|
{ title: 'Nuevo proyecto', value: 'new' },
|
|
35
36
|
{ title: 'Actualizar proyecto', value: 'update' },
|
|
36
37
|
{ title: 'Aprovisionar (ephemeral)', value: 'provision' },
|
|
38
|
+
{ title: 'Configurar variables de entorno', value: 'env' },
|
|
37
39
|
{ title: 'Secrets', value: 'secrets' },
|
|
38
40
|
],
|
|
39
41
|
initial: 0,
|
|
@@ -107,6 +109,7 @@ export async function runTuiMenu(deps = {}) {
|
|
|
107
109
|
const updateProject = deps.updateProject ?? runUpdate;
|
|
108
110
|
const secrets = deps.secrets ?? secretsCommand;
|
|
109
111
|
const provision = deps.provision ?? provisionFlow;
|
|
112
|
+
const envWizard = deps.envWizard ?? envWizardFlow;
|
|
110
113
|
// No TTY (CI / pipe): can't read interactive input. Degrade to HELP and return
|
|
111
114
|
// immediately — never hang waiting for input that can't arrive.
|
|
112
115
|
if (!isTTY) {
|
|
@@ -128,6 +131,9 @@ export async function runTuiMenu(deps = {}) {
|
|
|
128
131
|
case 'provision':
|
|
129
132
|
await dispatchProvision({ provision });
|
|
130
133
|
return;
|
|
134
|
+
case 'env':
|
|
135
|
+
await envWizard();
|
|
136
|
+
return;
|
|
131
137
|
case 'secrets':
|
|
132
138
|
await dispatchSecrets({ secrets });
|
|
133
139
|
return;
|