@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
JavaScript
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;
}
}