@timekast/factory 1.16.0 → 1.17.1
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 +96 -4
- package/dist/commands/provision.js +64 -1
- package/dist/index.js +9 -1
- package/dist/lib/atomic-swap.js +4 -4
- package/dist/lib/env-file.js +28 -0
- package/dist/lib/env-wizard.js +123 -0
- package/package.json +1 -1
package/dist/commands/env.js
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
* Exposed to derivatives as `pnpm env:push` (an npx alias). Run it after editing
|
|
11
11
|
* `.env.local` to add a new product variable post-provision.
|
|
12
12
|
*/
|
|
13
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
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';
|
|
16
|
+
import { PROVISION_OWNED, setEnvLine } from '../lib/env-file.js';
|
|
17
|
+
import { applyDefaultOverrides, applyWizardAnswers, buildEnvReview, buildQuestionPlan, checkEnvCompleteness, generateVapidKeys, isQuestionActive, parseEnvBody, parseEnvExample, provisionEmailDefaults, } from '../lib/env-wizard.js';
|
|
18
18
|
import { defaultEnvPath, readVercelToken } from '../lib/provision-preflight.js';
|
|
19
|
+
import { readProvisionState } from '../lib/provision-state.js';
|
|
19
20
|
import { pushEnvPlan, vercelProjectPath, VercelApiError, } from '../lib/vercel-env.js';
|
|
20
21
|
// Re-exported so existing importers (and `cli/tests/unit/env-push.test.ts`) keep
|
|
21
22
|
// resolving PROVISION_OWNED from this module after it moved to `lib/env-file.js`.
|
|
@@ -127,6 +128,8 @@ export function parseEnvWizardFlags(argv) {
|
|
|
127
128
|
apply: argv.includes('--apply'),
|
|
128
129
|
json: argv.includes('--json'),
|
|
129
130
|
push: argv.includes('--push'),
|
|
131
|
+
review: argv.includes('--review'),
|
|
132
|
+
genVapid: argv.includes('--gen-vapid'),
|
|
130
133
|
};
|
|
131
134
|
}
|
|
132
135
|
function readEnvExamplePlan(rootDir) {
|
|
@@ -136,9 +139,25 @@ function readEnvExamplePlan(rootDir) {
|
|
|
136
139
|
}
|
|
137
140
|
return buildQuestionPlan(parseEnvExample(readFileSync(examplePath, 'utf8')));
|
|
138
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Read the question plan and override defaults with what provision already
|
|
144
|
+
* configured (e.g. EMAIL_PROVIDER → `resend` once the DNS/Resend step ran), so the
|
|
145
|
+
* wizard never re-asks a decision provision already made. A missing/corrupt state
|
|
146
|
+
* file is non-fatal — the plan just falls back to the `.env.example` defaults.
|
|
147
|
+
*/
|
|
148
|
+
function planWithProvisionDefaults(rootDir) {
|
|
149
|
+
let state = null;
|
|
150
|
+
try {
|
|
151
|
+
state = readProvisionState(rootDir);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
state = null;
|
|
155
|
+
}
|
|
156
|
+
return applyDefaultOverrides(readEnvExamplePlan(rootDir), provisionEmailDefaults(state));
|
|
157
|
+
}
|
|
139
158
|
/** `factory env wizard --plan [--json]` — emit the question plan. */
|
|
140
159
|
export function runEnvWizardPlan(flags, rootDir = process.cwd()) {
|
|
141
|
-
const questions =
|
|
160
|
+
const questions = planWithProvisionDefaults(rootDir);
|
|
142
161
|
if (flags.json) {
|
|
143
162
|
process.stdout.write(`${JSON.stringify({ questions }, null, 2)}\n`);
|
|
144
163
|
return;
|
|
@@ -176,6 +195,11 @@ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {
|
|
|
176
195
|
const answers = parsed.answers ?? {};
|
|
177
196
|
const written = applyWizardAnswers(rootDir, answers);
|
|
178
197
|
console.log(`✔ Escribí ${written.length} variable(s) en \`.env.local\`.`);
|
|
198
|
+
// Completeness guards (OAuth creds, VAPID) — warn, never block the write. Read
|
|
199
|
+
// the resolved file so an already-present cred (from provision) counts as set.
|
|
200
|
+
const finalEnv = parseEnvBody(readFileSync(path.join(rootDir, '.env.local'), 'utf8'));
|
|
201
|
+
for (const w of checkEnvCompleteness(finalEnv))
|
|
202
|
+
console.log(`⚠ ${w}`);
|
|
179
203
|
const shouldPush = flags.push || parsed.push === true;
|
|
180
204
|
if (!shouldPush) {
|
|
181
205
|
console.log('ℹ️ Para subirlas a Vercel cuando quieras: `pnpm env:push`.');
|
|
@@ -184,3 +208,71 @@ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {
|
|
|
184
208
|
const push = deps.push ?? ((dir) => runEnvPush({ dryRun: false }, dir));
|
|
185
209
|
await push(rootDir);
|
|
186
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* `factory env wizard --review [--json]` — a secret-safe summary of the resolved
|
|
213
|
+
* `.env.local`. Non-secret values are shown; secret values are masked; empty ones
|
|
214
|
+
* are flagged. This is what the guided flow shows the dev at the end (the agent
|
|
215
|
+
* NEVER echoes raw secrets) — the dev opens `.env.local` to see the full values.
|
|
216
|
+
*/
|
|
217
|
+
export function runEnvWizardReview(flags, rootDir = process.cwd()) {
|
|
218
|
+
const localPath = path.join(rootDir, '.env.local');
|
|
219
|
+
if (!existsSync(localPath)) {
|
|
220
|
+
throw new CLIError('No encontré `.env.local`. Corre el wizard (`factory env wizard`) primero.');
|
|
221
|
+
}
|
|
222
|
+
const body = readFileSync(localPath, 'utf8');
|
|
223
|
+
const answers = parseEnvBody(body);
|
|
224
|
+
const questions = planWithProvisionDefaults(rootDir).filter((q) => isQuestionActive(q, answers));
|
|
225
|
+
const rows = buildEnvReview(questions, body);
|
|
226
|
+
const warnings = checkEnvCompleteness(answers);
|
|
227
|
+
if (flags.json) {
|
|
228
|
+
process.stdout.write(`${JSON.stringify({ rows, warnings }, null, 2)}\n`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
console.log('Configuración de `.env.local` (los secretos van enmascarados):\n');
|
|
232
|
+
let section = '';
|
|
233
|
+
for (const row of rows) {
|
|
234
|
+
if (row.section !== section) {
|
|
235
|
+
section = row.section;
|
|
236
|
+
console.log(`## ${section}`);
|
|
237
|
+
}
|
|
238
|
+
const flag = row.status === 'empty' ? ' ⚠' : '';
|
|
239
|
+
console.log(` ${row.key} = ${row.display}${flag}`);
|
|
240
|
+
}
|
|
241
|
+
if (warnings.length) {
|
|
242
|
+
console.log('\nAvisos:');
|
|
243
|
+
for (const w of warnings)
|
|
244
|
+
console.log(` ⚠ ${w}`);
|
|
245
|
+
}
|
|
246
|
+
console.log('\nAbre `.env.local` para ver los valores completos (los secretos no se imprimen).');
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* `factory env wizard --gen-vapid` — generate a VAPID keypair and write it to
|
|
250
|
+
* `.env.local`. The private key is a SECRET: it is written to the file (0600) and
|
|
251
|
+
* NEVER printed. The public key is public by design, so it is safe to echo.
|
|
252
|
+
*/
|
|
253
|
+
export function runEnvWizardGenVapid(rootDir = process.cwd()) {
|
|
254
|
+
const localPath = path.join(rootDir, '.env.local');
|
|
255
|
+
const examplePath = path.join(rootDir, '.env.example');
|
|
256
|
+
const basePath = existsSync(localPath) ? localPath : examplePath;
|
|
257
|
+
if (!existsSync(basePath)) {
|
|
258
|
+
throw new CLIError('No encontré `.env.local` ni `.env.example` para escribir las llaves VAPID.');
|
|
259
|
+
}
|
|
260
|
+
const { publicKey, privateKey } = generateVapidKeys();
|
|
261
|
+
let body = readFileSync(basePath, 'utf8');
|
|
262
|
+
body = setEnvLine(body, 'NEXT_PUBLIC_VAPID_PUBLIC_KEY', publicKey);
|
|
263
|
+
body = setEnvLine(body, 'VAPID_PRIVATE_KEY', privateKey);
|
|
264
|
+
// Seed a sensible VAPID_SUBJECT (push contact) from EMAIL_FROM if it's unset.
|
|
265
|
+
const env = parseEnvBody(body);
|
|
266
|
+
const subjectMissing = !env['VAPID_SUBJECT'];
|
|
267
|
+
if (subjectMissing) {
|
|
268
|
+
const from = env['EMAIL_FROM'];
|
|
269
|
+
body = setEnvLine(body, 'VAPID_SUBJECT', from ? `mailto:${from}` : 'mailto:admin@example.com');
|
|
270
|
+
}
|
|
271
|
+
writeFileSync(localPath, body, 'utf8');
|
|
272
|
+
chmodSync(localPath, 0o600); // holds VAPID_PRIVATE_KEY → owner-only.
|
|
273
|
+
console.log('✔ Llaves VAPID generadas y escritas a `.env.local` (la privada queda oculta).');
|
|
274
|
+
console.log(` NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
|
|
275
|
+
if (subjectMissing && !env['EMAIL_FROM']) {
|
|
276
|
+
console.log(' ℹ️ Revisa VAPID_SUBJECT — puse un placeholder (correo de contacto para push).');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -29,7 +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, generateMfaEncryptionKey, 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
|
+
import { writeE2eLocalCreds, writeEnvLocal } from '../lib/env-file.js';
|
|
33
33
|
import { adoptState, ADOPT_UNKNOWN, clearProvisionState, clearState, hasProvisionState, isServiceComplete, PROVISION_DIR, PROVISION_STATE_FILE, readDestroyTarget, readProvisionState, writeProvisionState, } from '../lib/provision-state.js';
|
|
34
34
|
/**
|
|
35
35
|
* The THREE GitHub Secrets the E2E runner (`pnpm test:e2e` → `e2e-runner.ts` +
|
|
@@ -255,10 +255,53 @@ function deriveProjectName(flags, rootDir) {
|
|
|
255
255
|
// `repo TimeKast/team_ldqH... no existe` error. Validate it like an explicit slug.
|
|
256
256
|
// Lowercased so a dir created with mixed case (e.g. `Edmond/`) yields a valid slug
|
|
257
257
|
// instead of being rejected for the uppercase — matches the `new` normalization.
|
|
258
|
+
// No `--slug`: prefer the slug documented in project-config.md over the directory
|
|
259
|
+
// name, so the provisioned domains/project match the project's identity doc rather
|
|
260
|
+
// than an incidental dir/repo name (e.g. `factory-test` vs a documented
|
|
261
|
+
// `factory-testlab`). Best-effort: fall back to the dir on any miss.
|
|
258
262
|
const fromDir = basename(rootDir).toLowerCase();
|
|
263
|
+
const fromConfig = readConfigSlug(rootDir);
|
|
264
|
+
if (fromConfig && fromConfig !== fromDir) {
|
|
265
|
+
console.log(`ℹ️ Slug tomado de project-config.md: \`${fromConfig}\` ` +
|
|
266
|
+
`(el directorio es \`${fromDir}\`). Pasa \`--slug\` para forzar otro.`);
|
|
267
|
+
return fromConfig; // readConfigSlug already validated it as a subdomain label
|
|
268
|
+
}
|
|
259
269
|
assertValidSubdomainSlug(fromDir);
|
|
260
270
|
return fromDir;
|
|
261
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* Best-effort read of the documented slug from `project/planning/project-config.md`.
|
|
274
|
+
* Matches both the table form (`| **Slug** | my-app |`) and prose (`**Slug:** my-app`).
|
|
275
|
+
* Returns `null` when the file is absent/unparsable or the value isn't a valid
|
|
276
|
+
* subdomain label — the caller then falls back to the directory name.
|
|
277
|
+
*/
|
|
278
|
+
export function readConfigSlug(rootDir) {
|
|
279
|
+
const configPath = join(rootDir, 'project', 'planning', 'project-config.md');
|
|
280
|
+
if (!existsSync(configPath))
|
|
281
|
+
return null;
|
|
282
|
+
let text;
|
|
283
|
+
try {
|
|
284
|
+
text = readFileSync(configPath, 'utf8');
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const line = text.split('\n').find((l) => /(^|\|)\s*\**\s*Slug\b/i.test(l));
|
|
290
|
+
if (!line)
|
|
291
|
+
return null;
|
|
292
|
+
// Drop everything up to and including "Slug", strip the markdown separators, then
|
|
293
|
+
// take the WHOLE first token that remains and validate it as a unit (so a value
|
|
294
|
+
// like `not_a_slug` is rejected outright, not truncated to a valid-looking prefix).
|
|
295
|
+
const token = line
|
|
296
|
+
.slice(line.search(/Slug\b/i) + 4)
|
|
297
|
+
.replace(/[*|:]/g, ' ')
|
|
298
|
+
.trim()
|
|
299
|
+
.split(/\s+/)[0];
|
|
300
|
+
if (!token)
|
|
301
|
+
return null;
|
|
302
|
+
const slug = token.toLowerCase();
|
|
303
|
+
return isValidSubdomainSlug(slug) ? slug : null;
|
|
304
|
+
}
|
|
262
305
|
/**
|
|
263
306
|
* Read the VALUE of `NEON_API_KEY` (the shared org key) from the rail
|
|
264
307
|
* (`~/.claude/.env`). Mirrors `readVercelToken`: split on the FIRST `=`, strip a
|
|
@@ -701,6 +744,14 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
|
|
|
701
744
|
else if (envLocal === 'skipped') {
|
|
702
745
|
console.log(' ⏭ .env.local ya existía (no se sobrescribe — usa --force para regenerarlo)');
|
|
703
746
|
}
|
|
747
|
+
// Local E2E creds: write NEON_API_KEY (the org key from the rail — already on the
|
|
748
|
+
// dev's host) + NEON_PROJECT_ID so `pnpm test:e2e` runs locally with zero manual
|
|
749
|
+
// `setup:e2e`. Both are PROVISION_OWNED → never pushed to Vercel. Safe on a skipped
|
|
750
|
+
// `.env.local` (setEnvLine touches only these two keys). The key value is never printed.
|
|
751
|
+
if (state.neon?.projectId &&
|
|
752
|
+
writeE2eLocalCreds(rootDir, state.neon.projectId, readNeonApiKey(envPath))) {
|
|
753
|
+
console.log(' + .env.local (NEON_API_KEY · NEON_PROJECT_ID — para `pnpm test:e2e` local)');
|
|
754
|
+
}
|
|
704
755
|
// `pnpm install` the derived dir (issue §4). A missing package.json (non-Node
|
|
705
756
|
// derivative) is reported, NOT fatal (issue §8).
|
|
706
757
|
if (existsSync(join(rootDir, 'package.json'))) {
|
|
@@ -2424,6 +2475,18 @@ export async function runProvision(flags) {
|
|
|
2424
2475
|
// this write keeps the baseline durable for them too.
|
|
2425
2476
|
writeProvisionState(rootDir, runState);
|
|
2426
2477
|
}
|
|
2478
|
+
// 5a. Retry the E2E GitHub Secrets if Neon recorded them pending. Neon runs BEFORE
|
|
2479
|
+
// the git substrate, so on a fresh bootstrap the repo may not exist yet when it
|
|
2480
|
+
// first tries `gh secret set` → it records `apiKeySecretPending` (non-fatal)
|
|
2481
|
+
// instead of aborting. Now that the substrate confirmed the repo
|
|
2482
|
+
// (`runState.github.repo`), re-run Neon: it is idempotent (skips the project +
|
|
2483
|
+
// branch create) and re-mints the unpersisted key to store the three secrets.
|
|
2484
|
+
// Without this, CI E2E stays credential-less until a MANUAL `--resume`.
|
|
2485
|
+
if (!flags.dryRun && runState.neon?.apiKeySecretPending && runState.github?.repo) {
|
|
2486
|
+
console.log('\n↻ Reintentando los secrets de E2E ahora que el repo existe...');
|
|
2487
|
+
await provisionNeon(rootDir, runState);
|
|
2488
|
+
writeProvisionState(rootDir, runState);
|
|
2489
|
+
}
|
|
2427
2490
|
// 5b. Client domain (PROV-006) — runs AFTER the services so the Vercel project
|
|
2428
2491
|
// (and the interim domains) already exist. Standalone patch or post-services.
|
|
2429
2492
|
if (hasDomain) {
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ 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';
|
|
14
|
+
import { parseEnvPushFlags, parseEnvWizardFlags, runEnvPush, runEnvWizardApply, runEnvWizardGenVapid, runEnvWizardPlan, runEnvWizardReview, } from './commands/env.js';
|
|
15
15
|
import { parseInviteAdminFlags, runInviteAdmin } from './commands/invite-admin.js';
|
|
16
16
|
import { envWizardFlow } from './tui/env-wizard-flow.js';
|
|
17
17
|
import { parseDoctorFlags, runDoctor, runLauncherDoctor } from './commands/doctor.js';
|
|
@@ -250,6 +250,14 @@ async function main(argv) {
|
|
|
250
250
|
runEnvWizardPlan(wflags);
|
|
251
251
|
return;
|
|
252
252
|
}
|
|
253
|
+
if (wflags.review) {
|
|
254
|
+
runEnvWizardReview(wflags);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (wflags.genVapid) {
|
|
258
|
+
runEnvWizardGenVapid();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
253
261
|
if (wflags.apply) {
|
|
254
262
|
await runEnvWizardApply(wflags);
|
|
255
263
|
return;
|
package/dist/lib/atomic-swap.js
CHANGED
|
@@ -26,21 +26,21 @@ import { CLIError } from './cli-error.js';
|
|
|
26
26
|
import { CLAUDE_MD_FILE, TIMEKAST_DIR } from './constants.js';
|
|
27
27
|
import { parseLockfile } from './lockfile.js';
|
|
28
28
|
/** Roots a manifest path may live under (mirrors `profiles.json#track`). */
|
|
29
|
-
const ALLOWED_ROOTS = ['.claude/', 'scripts/'];
|
|
29
|
+
const ALLOWED_ROOTS = ['.claude/', 'scripts/', '.husky/'];
|
|
30
30
|
/**
|
|
31
31
|
* Reject a manifest path that is unsafe to write: absolute, escaping via `..`, or
|
|
32
|
-
* outside the tracked scope (`.claude/**`, `scripts/**`, `CLAUDE.md
|
|
32
|
+
* outside the tracked scope (`.claude/**`, `scripts/**`, `CLAUDE.md`, `.husky/**`). A correct
|
|
33
33
|
* release never produces these — this guards a mispublished/tampered tarball from
|
|
34
34
|
* writing `src/` or escaping the repo root. Separator-agnostic (Windows-safe).
|
|
35
35
|
*/
|
|
36
|
-
function assertSafeTrackedPath(rel) {
|
|
36
|
+
export function assertSafeTrackedPath(rel) {
|
|
37
37
|
const segments = rel.split(/[\\/]/);
|
|
38
38
|
if (rel.length === 0 || path.isAbsolute(rel) || segments.includes('..')) {
|
|
39
39
|
throw new CLIError(`El manifest declara un path inseguro \`${rel}\` (absoluto o con \`..\`); no se tocó nada.`);
|
|
40
40
|
}
|
|
41
41
|
const tracked = rel === CLAUDE_MD_FILE || ALLOWED_ROOTS.some((root) => rel.startsWith(root));
|
|
42
42
|
if (!tracked) {
|
|
43
|
-
throw new CLIError(`El manifest declara \`${rel}\`, fuera del scope rastreado (\`.claude/\`, \`scripts/\`, \`CLAUDE.md
|
|
43
|
+
throw new CLIError(`El manifest declara \`${rel}\`, fuera del scope rastreado (\`.claude/\`, \`scripts/\`, \`CLAUDE.md\`, \`.husky/\`). ` +
|
|
44
44
|
'El release está mal construido; no se tocó nada.');
|
|
45
45
|
}
|
|
46
46
|
}
|
package/dist/lib/env-file.js
CHANGED
|
@@ -30,6 +30,11 @@ export const PROVISION_OWNED = new Set([
|
|
|
30
30
|
// (rpID must match the env's host) or the at-rest TOTP crypto.
|
|
31
31
|
'MFA_ENCRYPTION_KEY',
|
|
32
32
|
'WEBAUTHN_RP_ID',
|
|
33
|
+
// LOCAL-ONLY E2E credentials (like DATABASE_URL_MAIN): the E2E runner reads them
|
|
34
|
+
// from `.env.local` to spin an isolated Neon branch per run. CI gets them as GitHub
|
|
35
|
+
// Secrets; they must NEVER reach Vercel (a day-2 push would leak the Neon key).
|
|
36
|
+
'NEON_API_KEY',
|
|
37
|
+
'NEON_PROJECT_ID',
|
|
33
38
|
]);
|
|
34
39
|
/**
|
|
35
40
|
* Replace the value of `KEY=...` in an env-file body, preserving every other line.
|
|
@@ -76,3 +81,26 @@ export function writeEnvLocal(rootDir, neon, opts = {}) {
|
|
|
76
81
|
chmodSync(localPath, 0o600);
|
|
77
82
|
return 'written';
|
|
78
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Write the LOCAL E2E Neon credentials into `.env.local` so `pnpm test:e2e` runs on
|
|
86
|
+
* the dev's machine without a manual `setup:e2e`. The E2E runner reads NEON_API_KEY +
|
|
87
|
+
* NEON_PROJECT_ID from `.env.local` to spin an isolated branch per run. CI gets these
|
|
88
|
+
* as GitHub Secrets (a project-scoped key); locally we reuse the dev's ORG key from
|
|
89
|
+
* the rail (already on their host). Both stay LOCAL-ONLY (PROVISION_OWNED → never
|
|
90
|
+
* pushed to Vercel). Returns false (no-op) when the key/project id is missing or
|
|
91
|
+
* `.env.local` does not exist yet (`writeEnvLocal` creates it first). Safe on an
|
|
92
|
+
* existing file: `setEnvLine` only touches these two keys, preserving the rest.
|
|
93
|
+
*/
|
|
94
|
+
export function writeE2eLocalCreds(rootDir, projectId, apiKey) {
|
|
95
|
+
if (!apiKey || !projectId)
|
|
96
|
+
return false;
|
|
97
|
+
const localPath = join(rootDir, '.env.local');
|
|
98
|
+
if (!existsSync(localPath))
|
|
99
|
+
return false;
|
|
100
|
+
let body = readFileSync(localPath, 'utf8');
|
|
101
|
+
body = setEnvLine(body, 'NEON_PROJECT_ID', projectId);
|
|
102
|
+
body = setEnvLine(body, 'NEON_API_KEY', apiKey);
|
|
103
|
+
writeFileSync(localPath, body, 'utf8');
|
|
104
|
+
chmodSync(localPath, 0o600); // holds NEON_API_KEY → owner-only.
|
|
105
|
+
return true;
|
|
106
|
+
}
|
package/dist/lib/env-wizard.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* (EMAIL_PROVIDER → its creds, the OAuth flags → their creds) are encoded as a
|
|
11
11
|
* small `GATES` map — the one piece of domain knowledge the file can't express.
|
|
12
12
|
*/
|
|
13
|
+
import { createECDH } from 'node:crypto';
|
|
13
14
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
15
|
import { join } from 'node:path';
|
|
15
16
|
import { CLIError } from './cli-error.js';
|
|
@@ -182,3 +183,125 @@ export function applyWizardAnswers(rootDir, answers) {
|
|
|
182
183
|
chmodSync(localPath, 0o600);
|
|
183
184
|
return written;
|
|
184
185
|
}
|
|
186
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
187
|
+
// Guided-wizard helpers — the pieces the `tk-provision` skill (or the TUI) uses
|
|
188
|
+
// to (a) pre-fill defaults from what provision already configured, (b) never leak
|
|
189
|
+
// secret values, (c) auto-generate what a dev can't type, and (d) catch broken
|
|
190
|
+
// setups (a provider flag on with empty creds). All pure over their inputs.
|
|
191
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
192
|
+
/** True when `key`'s value is a secret (never printed in review/summary output). */
|
|
193
|
+
export function isSecretKey(key) {
|
|
194
|
+
return SECRET_KEY.test(key);
|
|
195
|
+
}
|
|
196
|
+
/** Parse an env-file body into `{ KEY: value }` (unquoted), skipping comments/blanks. */
|
|
197
|
+
export function parseEnvBody(body) {
|
|
198
|
+
const out = {};
|
|
199
|
+
for (const line of body.split('\n')) {
|
|
200
|
+
const trimmed = line.trimStart();
|
|
201
|
+
if (trimmed === '' || trimmed.startsWith('#'))
|
|
202
|
+
continue;
|
|
203
|
+
const eq = line.indexOf('=');
|
|
204
|
+
if (eq === -1)
|
|
205
|
+
continue;
|
|
206
|
+
const key = line.slice(0, eq).trim();
|
|
207
|
+
if (key)
|
|
208
|
+
out[key] = unquote(line.slice(eq + 1));
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
/** Mask a value for display: secrets → `••••••••`, everything else verbatim. */
|
|
213
|
+
export function maskValue(key, value) {
|
|
214
|
+
if (!value)
|
|
215
|
+
return value;
|
|
216
|
+
return isSecretKey(key) ? '••••••••' : value;
|
|
217
|
+
}
|
|
218
|
+
/** Override the `defaultValue` of any question whose key appears in `overrides`. */
|
|
219
|
+
export function applyDefaultOverrides(questions, overrides) {
|
|
220
|
+
return questions.map((q) => (q.key in overrides ? { ...q, defaultValue: overrides[q.key] } : q));
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Defaults derived from what provision already did. Provision wires Resend as part
|
|
224
|
+
* of its `dns` step (RESEND_API_KEY + EMAIL_FROM into Vercel); when the `dns` block
|
|
225
|
+
* exists in the state, the derivative's email IS Resend — so default the wizard to
|
|
226
|
+
* `resend`, closing the `.env.local` (none) ↔ Vercel (resend) drift. EMAIL_FROM
|
|
227
|
+
* stays the dev's to confirm. Structural typing keeps this module decoupled from
|
|
228
|
+
* the full `ProvisionState` shape.
|
|
229
|
+
*/
|
|
230
|
+
export function provisionEmailDefaults(state) {
|
|
231
|
+
return state?.dns ? { EMAIL_PROVIDER: 'resend' } : {};
|
|
232
|
+
}
|
|
233
|
+
/** OAuth providers wired in `.env.example`: a flag that reveals an id + secret. */
|
|
234
|
+
const OAUTH_PROVIDERS = [
|
|
235
|
+
{
|
|
236
|
+
flag: 'NEXT_PUBLIC_AUTH_GOOGLE',
|
|
237
|
+
id: 'AUTH_GOOGLE_ID',
|
|
238
|
+
secret: 'AUTH_GOOGLE_SECRET',
|
|
239
|
+
label: 'Google',
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
flag: 'NEXT_PUBLIC_AUTH_GITHUB',
|
|
243
|
+
id: 'AUTH_GITHUB_ID',
|
|
244
|
+
secret: 'AUTH_GITHUB_SECRET',
|
|
245
|
+
label: 'GitHub',
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
/**
|
|
249
|
+
* Warn (do NOT block) about half-configured setups that would ship a broken app:
|
|
250
|
+
* an OAuth provider enabled with empty creds → broken login button; notifications
|
|
251
|
+
* enabled with no VAPID keys → dead push. Pure over the resolved env map.
|
|
252
|
+
*/
|
|
253
|
+
export function checkEnvCompleteness(env) {
|
|
254
|
+
const warnings = [];
|
|
255
|
+
for (const p of OAUTH_PROVIDERS) {
|
|
256
|
+
if (env[p.flag] !== 'true')
|
|
257
|
+
continue;
|
|
258
|
+
const missing = [p.id, p.secret].filter((k) => !env[k]);
|
|
259
|
+
if (missing.length) {
|
|
260
|
+
warnings.push(`Login con ${p.label} activado pero falta ${missing.join(' + ')} → el botón de login ` +
|
|
261
|
+
`quedará roto. Complétalas o pon ${p.flag}="false".`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (env['NEXT_PUBLIC_NOTIFICATIONS_ENABLED'] === 'true' &&
|
|
265
|
+
(!env['NEXT_PUBLIC_VAPID_PUBLIC_KEY'] || !env['VAPID_PRIVATE_KEY'])) {
|
|
266
|
+
warnings.push('Notificaciones activadas pero faltan las llaves VAPID → el push web no funcionará. ' +
|
|
267
|
+
'Corre `factory env wizard --gen-vapid` para generarlas.');
|
|
268
|
+
}
|
|
269
|
+
return warnings;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Generate a VAPID keypair (P-256, the Web Push standard) with `node:crypto` — no
|
|
273
|
+
* dependency on `web-push`. Public key = 65-byte uncompressed EC point (public by
|
|
274
|
+
* design, ships to the browser); private key = the 32-byte scalar (a SECRET —
|
|
275
|
+
* callers write it to `.env.local` and NEVER print it). Both base64url.
|
|
276
|
+
*/
|
|
277
|
+
export function generateVapidKeys() {
|
|
278
|
+
const ecdh = createECDH('prime256v1');
|
|
279
|
+
ecdh.generateKeys();
|
|
280
|
+
const priv = ecdh.getPrivateKey();
|
|
281
|
+
// Left-pad to a fixed 32 bytes (the scalar can be shorter when it has leading
|
|
282
|
+
// zero bytes) so the key is always the canonical VAPID length.
|
|
283
|
+
const priv32 = priv.length === 32 ? priv : Buffer.concat([Buffer.alloc(32 - priv.length), priv]);
|
|
284
|
+
return {
|
|
285
|
+
publicKey: ecdh.getPublicKey().toString('base64url'),
|
|
286
|
+
privateKey: priv32.toString('base64url'),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Build a secret-safe review of `.env.local` for the final "here's how your app is
|
|
291
|
+
* configured" summary. Secret values are masked; empty values are flagged. Callers
|
|
292
|
+
* pass the questions they want shown (typically the ACTIVE ones for the resolved env).
|
|
293
|
+
*/
|
|
294
|
+
export function buildEnvReview(questions, body) {
|
|
295
|
+
const env = parseEnvBody(body);
|
|
296
|
+
return questions.map((q) => {
|
|
297
|
+
const value = env[q.key] ?? '';
|
|
298
|
+
const status = value ? 'set' : 'empty';
|
|
299
|
+
return {
|
|
300
|
+
key: q.key,
|
|
301
|
+
section: q.section,
|
|
302
|
+
kind: q.kind,
|
|
303
|
+
status,
|
|
304
|
+
display: status === 'empty' ? '(sin configurar)' : maskValue(q.key, value),
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
}
|