From 40246d35fad78f9d26e0e9de9927d7f6dc13f987 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Wed, 19 Aug 2026 16:16:41 -0600 Subject: [PATCH] feat(dev): prompt for a base URL in setup-env, defaulting to unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a BASE_URL prompt at the start of pnpm dev:setup-env, before the NEXTAUTH_URL prompt. The prompt asks for 'the URL you use to access the dev server' (e.g. http://192.168.1.82:3100 for LAN testing, https://clouddev.example.com behind Cloudflare Access). When provided, it becomes the default for the NEXTAUTH_URL prompt — the user can still override it — and APP_URL_OVERRIDE is also written so auth and server-side redirects resolve at the same public origin. Pressing enter at the BASE_URL prompt leaves both unset, so the NEXTAUTH_URL prompt falls back to the http://localhost:3000 example default. CI mode skips the BASE_URL prompt and preserves the existing CI placeholders. APP_URL_OVERRIDE is appended to .env.local since it is not present in .env.local.example; the file-write path already handles that. Both prompts validate the URL: no whitespace/quotes/newlines/'#' (env-reserved), parseable, http or https protocol, non-empty hostname. On invalid input the user gets a non-fatal recovery menu with options to re-enter, accept a suggested fix (e.g. prepend http:// for a protocol-less host:port), or fall back to the default / clear — no fatal errors. --- dev/local/setup-env.ts | 121 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/dev/local/setup-env.ts b/dev/local/setup-env.ts index dbc3100d93..396cfadd0d 100644 --- a/dev/local/setup-env.ts +++ b/dev/local/setup-env.ts @@ -216,6 +216,92 @@ async function promptForValue( }); } +// Prompts for a URL and validates it. On invalid input, offers a non-fatal +// recovery menu: re-enter, use a suggested fix (when available), or use the +// default / clear. Returns the validated URL string, or '' to mean +// "use the default / clear" — callers map '' to the appropriate fallback. +async function promptForUrl(args: { + key: string; + defaultValue: string; + description?: string; +}): Promise { + while (true) { + const raw = await promptForValue(args.key, args.defaultValue, args.description, false); + if (raw === '') return ''; + const result = validateUrl(raw); + if (result.ok) return result.value; + if (result.suggestion) { + // Make sure the suggestion itself is valid before offering it. + const suggestionCheck = validateUrl(result.suggestion); + if (!suggestionCheck.ok) delete result.suggestion; + } + console.log(` ${RED}✗ ${args.key}: ${result.error}${RESET}`); + if (result.suggestion) { + console.log(` ${YELLOW}Suggested: ${result.suggestion}${RESET}`); + } + console.log(' Options:'); + console.log(' [r] Re-enter'); + if (result.suggestion) console.log(' [s] Use suggested'); + console.log(' [d] Use default / clear'); + const choice = await promptForUrlRecoveryChoice(Boolean(result.suggestion)); + if (choice === 'r') continue; + if (choice === 's' && result.suggestion) return result.suggestion; + return ''; + } +} + +async function promptForUrlRecoveryChoice(hasSuggestion: boolean): Promise<'r' | 's' | 'd'> { + const hint = hasSuggestion ? '[r/s/d]' : '[r/d]'; + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + rl.question(` Choice ${hint} (default d) > `, answer => { + rl.close(); + const a = answer.trim().toLowerCase(); + if (a === 'r' || a === 're-enter') return resolve('r'); + if (hasSuggestion && (a === 's' || a === 'suggested')) return resolve('s'); + resolve('d'); + }); + }); +} + +type UrlValidation = + | { ok: true; value: string } + | { ok: false; error: string; suggestion?: string }; + +// Validates a URL string for use as NEXTAUTH_URL / APP_URL_OVERRIDE: +// - no whitespace, quotes, or '#' (which would corrupt .env files) +// - parseable by the URL parser +// - http or https protocol +// - non-empty hostname +// On failure, returns an error and (when possible) a suggested fix such as +// prepending http:// for a protocol-less input. +function validateUrl(raw: string): UrlValidation { + if (/[\s"'#\n\r]/.test(raw)) { + return { + ok: false, + error: 'must not contain whitespace, quotes, newlines, or "#" (reserved in .env files)', + }; + } + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + const suggestion = raw.includes('://') ? undefined : `http://${raw.replace(/^\/+/, '')}`; + return { + ok: false, + error: 'not a valid URL — include the protocol (e.g. http:// or https://)', + suggestion, + }; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, error: `protocol must be http or https (got "${parsed.protocol}")` }; + } + if (!parsed.hostname) { + return { ok: false, error: 'URL is missing a hostname' }; + } + return { ok: true, value: raw }; +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -276,8 +362,37 @@ async function main(): Promise { const collected = new Map(); + // Optional base URL. When provided, it becomes the default for the + // NEXTAUTH_URL prompt (which the user can still override) and is also + // written as APP_URL_OVERRIDE so auth and server-side redirects resolve + // at the same public origin (LAN IP, Cloudflare Access, etc.). Left + // empty in CI and when the user presses enter (or chooses to clear + // during the recovery menu for an invalid value). + const baseUrl = ciMode + ? '' + : await promptForUrl({ + key: 'BASE_URL', + defaultValue: '', + description: + 'Base URL — the URL you use to access the dev server. Sets NEXTAUTH_URL (as its default) and APP_URL_OVERRIDE so auth and server-side redirects resolve at the same origin. Press enter to leave unset.', + }); + for (const key of REQUIRED_KEYS) { - const defaultValue = exampleValues.get(key) ?? ''; + if (key === 'NEXTAUTH_URL') { + const exampleDefault = exampleValues.get(key) ?? ''; + const defaultValue = baseUrl || exampleDefault; + const answer = ciMode + ? collectCiValue(key, defaultValue, false) + : await promptForUrl({ + key, + defaultValue, + description: buildDescription(key), + }); + collected.set(key, answer === '' ? defaultValue : answer); + continue; + } + const exampleDefault = exampleValues.get(key) ?? ''; + const defaultValue = exampleDefault; const description = buildDescription(key); const isSecret = SECRET_KEYS.has(key); @@ -299,6 +414,10 @@ async function main(): Promise { } } + if (baseUrl) { + collected.set('APP_URL_OVERRIDE', baseUrl); + } + // ----------------------------------------------------------------------- // Step 6: Build final content and write atomically once // -----------------------------------------------------------------------