@timekast/factory 1.13.0 → 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/doctor.js +9 -4
- package/dist/commands/env.js +186 -0
- package/dist/commands/invite-admin.js +128 -0
- package/dist/commands/provision.js +39 -56
- 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/lib/preflight.js +60 -0
- package/dist/lib/provision-preflight.js +1 -1
- package/dist/lib/provision-state.js +8 -7
- package/dist/lib/validate-name.js +7 -2
- package/dist/tui/env-wizard-flow.js +92 -0
- package/dist/tui/menu.js +6 -0
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -246,6 +246,7 @@ const STATUS_GLYPH = {
|
|
|
246
246
|
ok: '✅',
|
|
247
247
|
fail: '❌',
|
|
248
248
|
skipped: '—',
|
|
249
|
+
warn: '🟡',
|
|
249
250
|
};
|
|
250
251
|
/**
|
|
251
252
|
* Render the launcher preflight report. Each check on its own line with a glyph,
|
|
@@ -268,13 +269,17 @@ export function renderLauncherDoctor(checks) {
|
|
|
268
269
|
}
|
|
269
270
|
}
|
|
270
271
|
}
|
|
271
|
-
const
|
|
272
|
+
const hasFail = checks.some((c) => c.status === 'fail');
|
|
273
|
+
const hasWarn = checks.some((c) => c.status === 'warn');
|
|
272
274
|
console.log('');
|
|
273
|
-
if (
|
|
274
|
-
console.log('Entorno
|
|
275
|
+
if (hasFail) {
|
|
276
|
+
console.log('Entorno incompleto: corrige los checks marcados ❌ y vuelve a intentar.');
|
|
277
|
+
}
|
|
278
|
+
else if (hasWarn) {
|
|
279
|
+
console.log('Entorno listo. Revisa los avisos 🟡 (no bloquean el launcher).');
|
|
275
280
|
}
|
|
276
281
|
else {
|
|
277
|
-
console.log('Entorno
|
|
282
|
+
console.log('Entorno listo para usar el launcher.');
|
|
278
283
|
}
|
|
279
284
|
}
|
|
280
285
|
/**
|
|
@@ -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` +
|
|
@@ -203,7 +204,7 @@ export function parseProvisionFlags(argv) {
|
|
|
203
204
|
const SERVICE_LABEL = {
|
|
204
205
|
neon: 'Neon (proyecto + branch develop)',
|
|
205
206
|
vercel: 'Vercel (env sync por target)',
|
|
206
|
-
github: 'GitHub + Vercel (sustrato git + proyecto +
|
|
207
|
+
github: 'GitHub + Vercel (sustrato git + proyecto + env + E2E)',
|
|
207
208
|
dns: 'DNS + Resend (dominio del entorno)',
|
|
208
209
|
};
|
|
209
210
|
/** Build the ordered plan: each requested service is `skip` if complete, else `create`. */
|
|
@@ -244,14 +245,17 @@ function deriveProjectName(flags, rootDir) {
|
|
|
244
245
|
// it wins over the dir name. Validate it as a subdomain label when given,
|
|
245
246
|
// since it flows into `{slug}.timekast.mx` downstream (PROV-005).
|
|
246
247
|
if (flags.slug) {
|
|
247
|
-
|
|
248
|
-
|
|
248
|
+
const slug = flags.slug.toLowerCase(); // every resource is lowercase (repo/dir/domains)
|
|
249
|
+
assertValidSubdomainSlug(slug);
|
|
250
|
+
return slug;
|
|
249
251
|
}
|
|
250
252
|
// No `--slug`: the project name is the directory provision runs in (the scaffold
|
|
251
253
|
// dir), NOT the org/team scope. A team id like `team_ldqH...` is neither a real
|
|
252
254
|
// project name nor a valid subdomain, and falling back to it produced a confusing
|
|
253
255
|
// `repo TimeKast/team_ldqH... no existe` error. Validate it like an explicit slug.
|
|
254
|
-
|
|
256
|
+
// Lowercased so a dir created with mixed case (e.g. `Edmond/`) yields a valid slug
|
|
257
|
+
// instead of being rejected for the uppercase — matches the `new` normalization.
|
|
258
|
+
const fromDir = basename(rootDir).toLowerCase();
|
|
255
259
|
assertValidSubdomainSlug(fromDir);
|
|
256
260
|
return fromDir;
|
|
257
261
|
}
|
|
@@ -677,7 +681,7 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
|
|
|
677
681
|
console.log(` ↻ ${v.key} [${v.target.join(', ')}]`);
|
|
678
682
|
}
|
|
679
683
|
// Generate the gitignored `.env.local` so the dev can run the app, migrations and
|
|
680
|
-
// seeds locally (`pnpm dev`/`db:migrate`/`
|
|
684
|
+
// seeds locally (`pnpm dev`/`db:migrate`/`invite:admin` read `.env.local`). Uses
|
|
681
685
|
// the DIRECT strings (single-process tooling/migrations, never the pooler).
|
|
682
686
|
// DATABASE_URL → develop (safe default), DATABASE_URL_MAIN → main (db:query:main).
|
|
683
687
|
const envLocal = writeEnvLocal(rootDir, neonSecrets, { force: deps.force });
|
|
@@ -727,47 +731,11 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
|
|
|
727
731
|
'El proyecto + las env vars ya están listos; pushea o redeploya a mano.');
|
|
728
732
|
}
|
|
729
733
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
*
|
|
736
|
-
* Uses the DIRECT (non-pooler) strings: `.env.local` drives single-process tooling
|
|
737
|
-
* (`drizzle-kit migrate`/`generate`, `db:seed`, `db:query`) + local `pnpm dev` — none
|
|
738
|
-
* of which want the PgBouncer pooler (the pooled host is only for the serverless
|
|
739
|
-
* runtime on Vercel). Do NOT swap these back to the pooled variants.
|
|
740
|
-
*
|
|
741
|
-
* DATABASE_URL → the develop branch (safe default for local work); DATABASE_URL_MAIN
|
|
742
|
-
* → the main branch (read by `pnpm db:query:main`). NEVER clobbers an existing
|
|
743
|
-
* `.env.local` (the dev may have edited it) unless `--force`. The `.env.example` stays
|
|
744
|
-
* the SSOT of which keys exist; the keys are appended if absent (older scaffolds).
|
|
745
|
-
*/
|
|
746
|
-
export function writeEnvLocal(rootDir, neon, opts = {}) {
|
|
747
|
-
const examplePath = join(rootDir, '.env.example');
|
|
748
|
-
if (!existsSync(examplePath))
|
|
749
|
-
return 'no-example';
|
|
750
|
-
const localPath = join(rootDir, '.env.local');
|
|
751
|
-
if (existsSync(localPath) && !opts.force)
|
|
752
|
-
return 'skipped';
|
|
753
|
-
let body = readFileSync(examplePath, 'utf8');
|
|
754
|
-
body = setEnvLine(body, 'DATABASE_URL', neon.developDirect);
|
|
755
|
-
body = setEnvLine(body, 'DATABASE_URL_MAIN', neon.mainDirect);
|
|
756
|
-
body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
|
|
757
|
-
writeFileSync(localPath, body, 'utf8');
|
|
758
|
-
return 'written';
|
|
759
|
-
}
|
|
760
|
-
/**
|
|
761
|
-
* Replace the value of `KEY=...` in an env-file body, preserving every other line.
|
|
762
|
-
* Appends `KEY="value"` when the key is absent (so an older `.env.example` without
|
|
763
|
-
* `DATABASE_URL_MAIN` still gets it). The value is double-quoted — Neon URLs (pooled
|
|
764
|
-
* or direct) carry `?`/`&` query params that an unquoted dotenv value would truncate.
|
|
765
|
-
*/
|
|
766
|
-
function setEnvLine(body, key, value) {
|
|
767
|
-
const re = new RegExp(`^${key}=.*$`, 'm');
|
|
768
|
-
const line = `${key}="${value}"`;
|
|
769
|
-
return re.test(body) ? body.replace(re, line) : `${body.trimEnd()}\n${line}\n`;
|
|
770
|
-
}
|
|
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 };
|
|
771
739
|
/**
|
|
772
740
|
* The HTTPS git URL of the repo `gh repo create` made (`new.ts` creates
|
|
773
741
|
* `${FACTORY_ORG}/${name}` — the canonical remote is its `.git` HTTPS URL). Never
|
|
@@ -833,7 +801,13 @@ async function buildGitSubstrate(rootDir, repoSlug, runGit, force) {
|
|
|
833
801
|
// 2. Add (or reset, under --force) the `origin` remote → the HTTPS repo URL.
|
|
834
802
|
const existingOrigin = await readOriginUrl(runGit, rootDir);
|
|
835
803
|
if (existingOrigin && existingOrigin !== url) {
|
|
836
|
-
|
|
804
|
+
// GitHub repo URLs are case-insensitive: an `origin` differing ONLY by case
|
|
805
|
+
// (e.g. `TimeKast/Edmond.git` vs the canonical lowercase `TimeKast/edmond.git`)
|
|
806
|
+
// points at the SAME repo. Re-case it to the canonical URL silently — no `--force`
|
|
807
|
+
// needed. A difference BEYOND case is a genuinely different repo → throw unless
|
|
808
|
+
// `--force` resets it.
|
|
809
|
+
const sameRepoDifferentCase = existingOrigin.toLowerCase() === url.toLowerCase();
|
|
810
|
+
if (!sameRepoDifferentCase && !force) {
|
|
837
811
|
throw new CLIError(`El repo ya tiene un remote \`origin\` apuntando a otro URL (\`${existingOrigin}\` ≠ \`${url}\`).\n` +
|
|
838
812
|
'Usa `--force` para sobrescribirlo, o limpia el remote si reutilizas este directorio.');
|
|
839
813
|
}
|
|
@@ -927,8 +901,9 @@ export function writeE2eWorkflow(rootDir) {
|
|
|
927
901
|
/**
|
|
928
902
|
* PROV-004 — the `github` step: build the git substrate, create the Vercel PROJECT,
|
|
929
903
|
* connect the GitHub repo (GitHub App gate), confirm `productionBranch: main`,
|
|
930
|
-
* write `.vercel/project.json
|
|
931
|
-
*
|
|
904
|
+
* write `.vercel/project.json`, and activate the E2E workflow. Migrations run in the
|
|
905
|
+
* Vercel build (`vercel-build`: `pnpm db:migrate && pnpm build`), NOT a GitHub Actions
|
|
906
|
+
* workflow. HIGH-risk: every failure is recoverable via `--resume`.
|
|
932
907
|
*
|
|
933
908
|
* Order (asserted by the unit test): git substrate (rename → remote add → push
|
|
934
909
|
* main) runs ENTIRELY before `POST /v9/projects`. The project create is skipped on
|
|
@@ -1115,7 +1090,7 @@ function cloudflareErrorToCliError(error, context) {
|
|
|
1115
1090
|
* CNAME for BOTH domains (create, or PATCH a manually-proxied record).
|
|
1116
1091
|
* 4. Resend: upsert `RESEND_API_KEY` (production+preview) + the org
|
|
1117
1092
|
* `EMAIL_FROM=noreply@updates.timekast.mx` (mail subdomain) — no-echo.
|
|
1118
|
-
* 5. Record `state.dns = { domain }` (the
|
|
1093
|
+
* 5. Record `state.dns = { domain, devDomain }` (prod + the dev domain) and persist.
|
|
1119
1094
|
*
|
|
1120
1095
|
* Requires `state.vercel.projectId` (PROV-004). No-echo: the Cloudflare token and
|
|
1121
1096
|
* the Resend key never reach stdout — only domain NAMES + key NAMES are printed.
|
|
@@ -1191,8 +1166,10 @@ export async function provisionDns(rootDir, state, teamId, deps = {}) {
|
|
|
1191
1166
|
}
|
|
1192
1167
|
// 4. Resend — wire RESEND_API_KEY (rail) + the FIXED EMAIL_FROM into Vercel env.
|
|
1193
1168
|
await wireResendEnv(projectId, vercelToken, teamId, envPath, vercelDeps);
|
|
1194
|
-
// 5. Record
|
|
1195
|
-
|
|
1169
|
+
// 5. Record BOTH interim domains in state — prod is canonical, dev tracks `develop`.
|
|
1170
|
+
// The dev domain is derivable from the slug, but persisting it keeps the state
|
|
1171
|
+
// file transparent about every domain provision created (not just prod).
|
|
1172
|
+
state.dns = { domain: prod, devDomain: dev };
|
|
1196
1173
|
writeProvisionState(rootDir, state);
|
|
1197
1174
|
}
|
|
1198
1175
|
/**
|
|
@@ -1451,7 +1428,10 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
|
|
|
1451
1428
|
if (!verified) {
|
|
1452
1429
|
// Timeout: persist the pending state (resumable) and stop with a clear error (§5).
|
|
1453
1430
|
state.dns = {
|
|
1454
|
-
...(state.dns ?? {
|
|
1431
|
+
...(state.dns ?? {
|
|
1432
|
+
domain: provisionDomains(state.projectName).prod,
|
|
1433
|
+
devDomain: provisionDomains(state.projectName).dev,
|
|
1434
|
+
}),
|
|
1455
1435
|
clientDomain: domain,
|
|
1456
1436
|
clientDomainStatus: 'pending-verification',
|
|
1457
1437
|
};
|
|
@@ -1470,7 +1450,10 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
|
|
|
1470
1450
|
await registerResendDomain(mailDomainFor(domain), envPath, fetchImpl);
|
|
1471
1451
|
// 7. Record the client domain as LIVE and persist.
|
|
1472
1452
|
state.dns = {
|
|
1473
|
-
...(state.dns ?? {
|
|
1453
|
+
...(state.dns ?? {
|
|
1454
|
+
domain: provisionDomains(state.projectName).prod,
|
|
1455
|
+
devDomain: provisionDomains(state.projectName).dev,
|
|
1456
|
+
}),
|
|
1474
1457
|
clientDomain: domain,
|
|
1475
1458
|
clientDomainStatus: 'live',
|
|
1476
1459
|
};
|
|
@@ -1577,13 +1560,13 @@ async function runServiceStep(service, rootDir, state, flags) {
|
|
|
1577
1560
|
case 'github':
|
|
1578
1561
|
// PROV-004: git substrate (rename → remote add → push main) + create the
|
|
1579
1562
|
// Vercel project + connect git (GitHub App) + PATCH production-branch +
|
|
1580
|
-
// .vercel/project.json +
|
|
1563
|
+
// .vercel/project.json + activate E2E. Migrations run in vercel-build, not a workflow.
|
|
1581
1564
|
await provisionGithubVercel(rootDir, state, flags.team, { force: flags.force });
|
|
1582
1565
|
break;
|
|
1583
1566
|
case 'dns':
|
|
1584
1567
|
// PROV-005: attach the interim domains to Vercel (dev → develop, prod →
|
|
1585
1568
|
// production) + create their Cloudflare CNAMEs (proxied:false) + wire Resend
|
|
1586
|
-
// (RESEND_API_KEY + the fixed EMAIL_FROM), then `state.dns = { domain }`.
|
|
1569
|
+
// (RESEND_API_KEY + the fixed EMAIL_FROM), then `state.dns = { domain, devDomain }`.
|
|
1587
1570
|
await provisionDns(rootDir, state, flags.team);
|
|
1588
1571
|
break;
|
|
1589
1572
|
default:
|
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;
|
package/dist/lib/preflight.js
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* --launcher` can render every step with ✅ / ❌ across all platforms (the
|
|
16
16
|
* cross-platform equivalent of the macOS launcher's `checkPrereqs`).
|
|
17
17
|
*/
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
18
21
|
import { execa } from 'execa';
|
|
19
22
|
import { PreflightError } from './cli-error.js';
|
|
20
23
|
import { FACTORY_ORG } from './constants.js';
|
|
@@ -132,12 +135,28 @@ export async function runPreflight() {
|
|
|
132
135
|
await assertGhAuthenticated();
|
|
133
136
|
await assertOrgMembership();
|
|
134
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* The methodology-rail keys the launcher expects in `~/.claude/.env` (imported
|
|
140
|
+
* via `factory secrets <file>`). Provisioning tokens + their team/org ids + the
|
|
141
|
+
* proposal tooling keys. Single source so the check + tests agree.
|
|
142
|
+
*/
|
|
143
|
+
export const REQUIRED_SECRET_KEYS = [
|
|
144
|
+
'NEON_API_KEY',
|
|
145
|
+
'VERCEL_TOKEN',
|
|
146
|
+
'RESEND_API_KEY',
|
|
147
|
+
'CLOUDFLARE_API_TOKEN',
|
|
148
|
+
'SHORTIO_API_KEY',
|
|
149
|
+
'GAMMA_API_KEY',
|
|
150
|
+
'VERCEL_TEAM_ID',
|
|
151
|
+
'NEON_ORGANIZATION_ID',
|
|
152
|
+
];
|
|
135
153
|
/** The ordered launcher labels (single source so render + tests agree). */
|
|
136
154
|
const LAUNCHER_LABELS = {
|
|
137
155
|
node: `Node ≥ ${MIN_NODE_MAJOR}`,
|
|
138
156
|
gh: 'GitHub CLI (gh) instalado',
|
|
139
157
|
auth: 'Sesión de GitHub activa',
|
|
140
158
|
org: `Membresía del org ${FACTORY_ORG}`,
|
|
159
|
+
secrets: 'Secrets del org en el rail',
|
|
141
160
|
};
|
|
142
161
|
/** A confirmation message for a passing check (the `ok` branch). */
|
|
143
162
|
const OK_MESSAGES = {
|
|
@@ -145,7 +164,45 @@ const OK_MESSAGES = {
|
|
|
145
164
|
gh: 'GitHub CLI (gh) está instalado.',
|
|
146
165
|
auth: 'Tu sesión de GitHub CLI está activa.',
|
|
147
166
|
org: `Eres miembro del org ${FACTORY_ORG}.`,
|
|
167
|
+
secrets: `Las ${REQUIRED_SECRET_KEYS.length} keys del rail están presentes.`,
|
|
148
168
|
};
|
|
169
|
+
/**
|
|
170
|
+
* Read the secret KEY NAMES present in the rail (`~/.claude/.env`), never values.
|
|
171
|
+
* A missing/unreadable rail yields an empty set (→ the check warns with all keys
|
|
172
|
+
* missing). Tests inject `railContent` to avoid touching the real home dir.
|
|
173
|
+
*/
|
|
174
|
+
function readRailKeyNames(railContent) {
|
|
175
|
+
let content = railContent;
|
|
176
|
+
if (content === undefined) {
|
|
177
|
+
try {
|
|
178
|
+
content = readFileSync(path.join(homedir(), '.claude', '.env'), 'utf8');
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
content = '';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const names = new Set();
|
|
185
|
+
for (const line of content.split(/\r?\n/)) {
|
|
186
|
+
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
187
|
+
if (match)
|
|
188
|
+
names.add(match[1]);
|
|
189
|
+
}
|
|
190
|
+
return names;
|
|
191
|
+
}
|
|
192
|
+
/** Build the secrets check (`ok` if all required keys present, else `warn`). */
|
|
193
|
+
function secretsCheck(railContent) {
|
|
194
|
+
const present = readRailKeyNames(railContent);
|
|
195
|
+
const missing = REQUIRED_SECRET_KEYS.filter((key) => !present.has(key));
|
|
196
|
+
return {
|
|
197
|
+
id: 'secrets',
|
|
198
|
+
label: LAUNCHER_LABELS.secrets,
|
|
199
|
+
status: missing.length === 0 ? 'ok' : 'warn',
|
|
200
|
+
message: missing.length === 0
|
|
201
|
+
? OK_MESSAGES.secrets
|
|
202
|
+
: `Faltan ${missing.length} de ${REQUIRED_SECRET_KEYS.length} secrets en el rail: ` +
|
|
203
|
+
`${missing.join(', ')}. Impórtalos desde el card Secrets.`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
149
206
|
/**
|
|
150
207
|
* Run the launcher preflight (LTUI-002): the cross-platform equivalent of the
|
|
151
208
|
* macOS launcher's `checkPrereqs`. Verifies, IN ORDER:
|
|
@@ -211,5 +268,8 @@ export async function runLauncherPreflight(deps = {}) {
|
|
|
211
268
|
});
|
|
212
269
|
}
|
|
213
270
|
}
|
|
271
|
+
// Secrets check — independent of the gh/auth/org chain (runs even if those
|
|
272
|
+
// failed) and non-blocking: resolves to `ok` or `warn`, never `fail`/`skipped`.
|
|
273
|
+
results.push(secretsCheck(deps.railContent));
|
|
214
274
|
return results;
|
|
215
275
|
}
|
|
@@ -111,7 +111,7 @@ async function assertGhAuthenticated(spawn) {
|
|
|
111
111
|
}
|
|
112
112
|
/**
|
|
113
113
|
* Step 1b' — the `gh` token carries the `workflow` scope. The derived scaffold ships
|
|
114
|
-
* `.github/workflows/*.yml` (ci, e2e, …) and provision
|
|
114
|
+
* `.github/workflows/*.yml` (ci, e2e, …) and provision activates more (e2e.yml);
|
|
115
115
|
* GitHub REJECTS an HTTPS push that creates/updates a workflow file unless the OAuth
|
|
116
116
|
* token has the `workflow` scope ("refusing to allow an OAuth App to create or update
|
|
117
117
|
* workflow … without `workflow` scope"). Parses `gh auth status` for the scope; the
|
|
@@ -358,14 +358,15 @@ export function readDestroyTarget(rootDir) {
|
|
|
358
358
|
'Bórralo o reconstrúyelo con `--adopt`.');
|
|
359
359
|
}
|
|
360
360
|
const lifecycle = step.lifecycle === 'ephemeral' ? 'ephemeral' : 'live';
|
|
361
|
-
// The
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// being present (no DNS step → no dev domain to remove); passed through the same
|
|
361
|
+
// The `dns` block now records the interim DEV domain (`{slug}-dev.timekast.mx`)
|
|
362
|
+
// alongside prod, but it stays derivable from the slug. Prefer the persisted value
|
|
363
|
+
// and fall back to deriving it (state files written before `devDomain` existed) so
|
|
364
|
+
// `--destroy` tears down the dev CNAME instead of orphaning it. Gated on `dns` being
|
|
365
|
+
// present (no DNS step → no dev domain to remove); passed through the same
|
|
367
366
|
// `realDomain` shape guard as every other low-trust domain field.
|
|
368
|
-
const devDomain = step.dns
|
|
367
|
+
const devDomain = step.dns
|
|
368
|
+
? realDomain(step.dns.devDomain ?? provisionDomains(step.projectName).dev)
|
|
369
|
+
: null;
|
|
369
370
|
const domains = [
|
|
370
371
|
realDomain(step.dns?.domain),
|
|
371
372
|
devDomain,
|
|
@@ -13,7 +13,12 @@ const VALID_NAME = /^[A-Za-z0-9._-]+$/;
|
|
|
13
13
|
const FORMAT_HINT = 'El nombre solo puede contener letras, números, guiones (-), guiones bajos (_) y puntos (.), ' +
|
|
14
14
|
'sin espacios, y no puede empezar con `-` ni `.`.';
|
|
15
15
|
/**
|
|
16
|
-
* Validate a project name. Returns the trimmed name on success
|
|
16
|
+
* Validate a project name. Returns the trimmed, LOWERCASED name on success — every
|
|
17
|
+
* derived resource (local dir, GitHub repo, `package.json.name`, the `{slug}` /
|
|
18
|
+
* `{slug}-dev` provision subdomains) must be lowercase, so this is the single
|
|
19
|
+
* chokepoint that normalizes it. GitHub repo names and DNS labels are
|
|
20
|
+
* case-insensitive, and `provision` rejects an uppercase slug outright — creating
|
|
21
|
+
* the repo lowercase here keeps `new` and `provision` from disagreeing on casing.
|
|
17
22
|
* @throws {CLIError} with the allowed-format hint on any invalid input.
|
|
18
23
|
*/
|
|
19
24
|
export function validateProjectName(raw) {
|
|
@@ -30,5 +35,5 @@ export function validateProjectName(raw) {
|
|
|
30
35
|
if (!VALID_NAME.test(name)) {
|
|
31
36
|
throw new CLIError(`Nombre inválido: \`${name}\` contiene caracteres no permitidos.\n${FORMAT_HINT}`);
|
|
32
37
|
}
|
|
33
|
-
return name;
|
|
38
|
+
return name.toLowerCase();
|
|
34
39
|
}
|
|
@@ -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;
|