UNPKG

@reliverse/rse

Version:

@reliverse/rse is your all-in-one companion for bootstrapping and improving any kind of projects (especially web apps built with frameworks like Next.js) — whether you're kicking off something new or upgrading an existing app. It is also a little AI-power

439 lines (438 loc) 14.9 kB
import path from "@reliverse/pathkit"; import fs from "@reliverse/relifso"; import { relinka } from "@reliverse/relinka"; import { inputPrompt, multiselectPrompt, confirmPrompt } from "@reliverse/rempts"; import { eq } from "drizzle-orm"; import { ofetch } from "ofetch"; import open from "open"; import { getRandomValues } from "uncrypto"; import { db } from "../../../../db/client.js"; import { encrypt, decrypt } from "../../../../db/config.js"; import { userDataTable } from "../../../../db/schema.js"; import { KNOWN_SERVICES } from "./cef-keys.js"; function getEnvPaths(projectPath) { const projectRoot = path.resolve(projectPath); return { projectRoot, exampleEnvPath: path.join(projectRoot, ".env.example"), envPath: path.join(projectRoot, ".env") }; } async function safeReadFile(filePath) { try { return await fs.readFile(filePath, "utf8"); } catch { return null; } } async function safeWriteFile(filePath, content) { try { await fs.writeFile(filePath, content); return true; } catch { return false; } } export async function ensureExampleExists(projectPath, fallbackEnvExampleURL) { const { exampleEnvPath } = getEnvPaths(projectPath); try { if (await fs.pathExists(exampleEnvPath)) { return true; } relinka("info", "Fetching .env.example file..."); const content = await fetchEnvExampleContent(fallbackEnvExampleURL); if (!content) { relinka("error", "Failed to fetch .env.example content."); return false; } if (!await safeWriteFile(exampleEnvPath, content)) { relinka("error", "Failed to write .env.example file."); return false; } relinka("success", ".env.example file fetched and saved."); return true; } catch { relinka("error", "Failed to ensure .env.example exists."); return false; } } export async function ensureEnvExists(projectPath) { const { envPath, exampleEnvPath } = getEnvPaths(projectPath); try { if (await fs.pathExists(envPath)) { return true; } const exampleContent = await safeReadFile(exampleEnvPath); if (!exampleContent) { relinka("error", "Failed to read .env.example file."); return false; } if (!await safeWriteFile(envPath, exampleContent)) { relinka("error", "Failed to create .env file."); return false; } relinka( "verbose", ".env file created from .env.example provided by the template." ); return true; } catch { relinka("error", "Failed to ensure .env exists."); return false; } } function parseEnvKeys(envContents) { const result = {}; const lines = envContents.split("\n").map((l) => l.trim()).filter((l) => !!l && !l.startsWith("#")); for (const line of lines) { const [rawKey, ...rest] = line.split("="); if (!rawKey) continue; const key = rawKey.trim(); let rawValue = rest.join("="); rawValue = rawValue.replace(/^["']|["']$/g, ""); result[key] = rawValue; } return result; } export async function getMissingKeys(projectPath) { const { envPath, exampleEnvPath } = getEnvPaths(projectPath); try { const envContent = await safeReadFile(envPath); if (!envContent) { relinka("error", "Failed to read .env file."); return []; } const exampleContent = await safeReadFile(exampleEnvPath); if (!exampleContent) { relinka("error", "Failed to read .env.example file."); return []; } const requiredKeys = await getRequiredKeys(exampleEnvPath); const existingEnvKeys = parseEnvKeys(envContent); const missing = requiredKeys.filter((key) => { const val = existingEnvKeys[key]; return !val; }); return missing; } catch { relinka("error", "Failed to get missing keys."); return []; } } async function getRequiredKeys(exampleEnvPath) { const content = await safeReadFile(exampleEnvPath); if (!content) return []; return content.split("\n").map((line) => line.trim()).filter((line) => !!line && !line.startsWith("#")).map((line) => line.split("=")[0]).filter((key) => !!key).map((key) => key.trim()); } export async function copyFromExisting(projectPath, sourcePath) { const { envPath } = getEnvPaths(projectPath); try { let fullEnvPath = sourcePath; const stats = await fs.stat(sourcePath).catch(() => null); if (stats?.isDirectory()) { fullEnvPath = path.join(sourcePath, ".env"); } if (!await fs.pathExists(fullEnvPath)) { relinka("error", `Could not find .env file at ${fullEnvPath}`); return false; } await fs.copy(fullEnvPath, envPath); relinka("verbose", "Existing .env file has been copied successfully!"); return true; } catch { relinka("error", "Failed to copy existing .env file."); return false; } } export function getEnvPath(projectPath) { return getEnvPaths(projectPath).envPath; } export async function fetchEnvExampleContent(urlResource) { try { const response = await ofetch(urlResource); if (!response.ok) { throw new Error(`Failed to fetch .env.example from ${urlResource}`); } const text = await response.text(); return typeof text === "string" ? text : null; } catch (error) { relinka( "error", `Error fetching .env.example: ${error instanceof Error ? error.message : String(error)}` ); return null; } } const LAST_ENV_FILE_KEY = "last_env_file"; async function updateEnvValue(envPath, key, value) { const envContent = await fs.readFile(envPath, "utf8"); const envLines = envContent.split("\n"); const newLine = `${key}="${value}"`; const lineIndex = envLines.findIndex((line) => { const [existingKey] = line.split("="); return existingKey?.trim() === key; }); if (lineIndex !== -1) { envLines[lineIndex] = newLine; } else { envLines.push(newLine); } await fs.writeFile(envPath, `${envLines.join("\n").trim()} `); } function validateKeyValue(value, keyType) { const trimmed = value.trim(); switch (keyType) { case "string": case "password": case "database": return true; case "email": { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(trimmed) ? true : "Please enter a valid email address."; } case "boolean": { const lower = trimmed.toLowerCase(); if (lower === "true" || lower === "false") return true; return 'Please enter "true" or "false".'; } case "number": { return isNaN(Number(trimmed)) ? "Please enter a valid number." : true; } default: return true; } } function generateSecureString(length = 64) { const randomBytesArray = new Uint8Array(Math.ceil(length / 2)); getRandomValues(randomBytesArray); return Array.from(randomBytesArray).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, length); } export async function promptAndSetMissingValues(missingKeys, envPath, maskInput, config, wasEnvCopied = false, isMrse = false, projectPath = "", skipPrompts = false) { if (missingKeys.length === 0 || wasEnvCopied) { relinka( "verbose", wasEnvCopied ? "Using values from copied .env file" : "No missing keys to process." ); return; } relinka("verbose", `Processing missing values: ${missingKeys.join(", ")}`); let mrseEnvPath = null; let projectName = ""; if (isMrse && projectPath) { projectName = path.basename(projectPath); mrseEnvPath = path.join( projectPath, ".config", "mrse", `${projectName}.env` ); const mrseEnvExists = await fs.pathExists(mrseEnvPath).catch(() => false); if (skipPrompts && mrseEnvExists) { relinka( "info", `Using environment variables from .config/mrse/${projectName}.env` ); if (await copyFromExisting(projectPath, mrseEnvPath)) { relinka( "success", "Environment variables copied from mrse environment file." ); const remainingMissingKeys = await getMissingKeys(projectPath); if (remainingMissingKeys.length > 0) { relinka( "info", `The following keys are still missing in the copied .env file: ${remainingMissingKeys.join(", ")}` ); await promptAndSetMissingValues( remainingMissingKeys, envPath, maskInput, config, true, isMrse, projectPath, skipPrompts ); } return; } } } const servicesWithMissingKeys = Object.entries(KNOWN_SERVICES).filter( ([, service]) => service.keys.some((k) => missingKeys.includes(k.key)) ); const selectedServicesMsg = config.envComposerOpenBrowser ? "\u2728 I'll open the service dashboards for you. Remember to come back to the terminal after accessing them!" : "\u2728 I'll show you the dashboard links for your selected services. You can open them in your browser (use Ctrl+Click if your terminal supports it)."; const validServices = servicesWithMissingKeys.map(([key, service]) => ({ label: service.name, value: key })); if (validServices.length === 0) { relinka( "verbose", "No known services require missing keys. Possibly custom keys missing?" ); return; } let options = validServices; if (isMrse && mrseEnvPath && await fs.pathExists(mrseEnvPath)) { options = [ { label: `Get keys from .config/mrse/${projectName}.env`, value: "mrse_env" }, ...validServices ]; } const selectedServices = await multiselectPrompt({ title: "Great! Which services do you want to configure?", content: selectedServicesMsg, defaultValue: options.map((srv) => srv.value), options }); if (selectedServices.includes("mrse_env") && mrseEnvPath) { relinka( "info", `Using environment variables from .config/mrse/${projectName}.env` ); if (await copyFromExisting(projectPath, mrseEnvPath)) { relinka( "success", "Environment variables copied from mrse environment file." ); const remainingMissingKeys = await getMissingKeys(projectPath); if (remainingMissingKeys.length > 0) { relinka( "info", `The following keys are still missing in the copied .env file: ${remainingMissingKeys.join(", ")}` ); const filteredServices = selectedServices.filter( (s) => s !== "mrse_env" ); if (filteredServices.length > 0) { for (const serviceKey of filteredServices) { await processService( serviceKey, missingKeys, envPath, maskInput, config ); } } } return; } } for (const serviceKey of selectedServices) { if (serviceKey === "skip" || serviceKey === "mrse_env") continue; await processService(serviceKey, missingKeys, envPath, maskInput, config); } } async function processService(serviceKey, missingKeys, envPath, maskInput, config) { const service = KNOWN_SERVICES[serviceKey]; if (!service) return; if (service.dashboardUrl && service.dashboardUrl !== "none") { relinka("verbose", `Opening ${service.name} dashboard...`); if (config.envComposerOpenBrowser) { await open(service.dashboardUrl); } else { relinka("info", `Dashboard link: ${service.dashboardUrl}`); } } for (const keyConfig of service.keys) { if (!missingKeys.includes(keyConfig.key)) { continue; } if (keyConfig.optional) { const displayValue = maskInput && keyConfig.defaultValue ? "[hidden]" : keyConfig.defaultValue === "generate-64-chars" ? "[will generate secure string]" : keyConfig.defaultValue ? `"${keyConfig.defaultValue}"` : ""; const shouldFill = await confirmPrompt({ title: `Do you want to configure ${keyConfig.key}?${displayValue ? ` (default: ${displayValue})` : ""}`, defaultValue: false }); if (!shouldFill) { if (keyConfig.defaultValue) { const value = keyConfig.defaultValue === "generate-64-chars" ? generateSecureString() : keyConfig.defaultValue; await updateEnvValue(envPath, keyConfig.key, value); relinka( "verbose", `Using ${keyConfig.defaultValue === "generate-64-chars" ? "generated" : "default"} value for ${keyConfig.key}${maskInput ? "" : `: ${value}`}` ); } continue; } } let isValid = false; let userInput = ""; while (!isValid) { const defaultVal = keyConfig.defaultValue === "generate-64-chars" ? generateSecureString() : keyConfig.defaultValue; userInput = await inputPrompt({ title: `Enter value for ${keyConfig.key}:`, placeholder: defaultVal ? `Press Enter to use default: ${maskInput ? "[hidden]" : defaultVal}` : "Paste your value here...", defaultValue: defaultVal ?? "", mode: maskInput ? "password" : "plain", ...keyConfig.instruction && { content: keyConfig.instruction, contentColor: "yellowBright" }, ...service.dashboardUrl && service.dashboardUrl !== "none" && { hint: `Visit ${service.dashboardUrl} to get your key` } }); if (!userInput.trim() && keyConfig.defaultValue) { userInput = keyConfig.defaultValue; } const validationResult = validateKeyValue(userInput, keyConfig.type); if (validationResult === true) { isValid = true; } else { relinka("warn", validationResult); } } const rawValue = userInput.startsWith(`${keyConfig.key}=`) ? userInput.substring(userInput.indexOf("=") + 1) : userInput; const cleanValue = rawValue.trim().replace(/^['"](.*)['"]$/, "$1"); await updateEnvValue(envPath, keyConfig.key, cleanValue); } } export async function saveLastEnvFilePath(envPath) { try { const encryptedPath = await encrypt(envPath); await db.insert(userDataTable).values({ key: LAST_ENV_FILE_KEY, value: encryptedPath }).onConflictDoUpdate({ target: userDataTable.key, set: { value: encryptedPath } }); relinka( "success", "Environment file path saved securely. You can use it later." ); } catch (error) { relinka( "error", "Failed to save env file path:", error instanceof Error ? error.message : String(error) ); } } export async function getLastEnvFilePath() { try { const result = await db.select().from(userDataTable).where(eq(userDataTable.key, LAST_ENV_FILE_KEY)).get(); if (result?.value) { return await decrypt(result.value); } return null; } catch (error) { relinka( "error", "Failed to get last env file path:", error instanceof Error ? error.message : String(error) ); return null; } }