nuxt-safe-runtime-config
Version:
Validate Nuxt runtime config with Standard Schema at build time
604 lines (591 loc) • 23.1 kB
JavaScript
import { createRequire } from 'node:module';
import { join, resolve } from 'node:path';
import process from 'node:process';
import { useLogger, defineNuxtModule, createResolver, getNuxtVersion, addTemplate, addServerPlugin, addTypeTemplate, addPlugin, addImports, addServerImports } from '@nuxt/kit';
import defu from 'defu';
import { isCI, isTest } from 'std-env';
import { e as errorMessage, r as resolveRuntimeConfigImport, a as resolveValidationOptions, c as createRuntimeValidationArtifacts, s as safeRuntimeConfigNitroModule } from './shared/nuxt-safe-runtime-config.DhT_lf0f.mjs';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { DEFAULT_URL, createShelveClient } from '../dist/runtime/shelve-client.js';
import { transformEnvVars, buildConfigStructureFromEnvKeys } from '../dist/runtime/utils/transform.js';
export { transformEnvVars } from '../dist/runtime/utils/transform.js';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { consola } from 'consola';
import { loadFile, builders, writeFile } from 'magicast';
import { getDefaultExportOptions } from 'magicast/helpers';
import 'node:fs/promises';
import 'node:url';
import 'jiti';
import '../dist/runtime/validate.js';
import '@standard-community/standard-json';
const version = "0.2.1";
const secretsCache = /* @__PURE__ */ new Map();
const CACHE_TTL = 3e4;
function readPackageName(cwd) {
const pkgPath = join(cwd, "package.json");
if (!existsSync(pkgPath))
return null;
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
return pkg.name || null;
} catch {
return null;
}
}
function getUserToken() {
const shelveRcPath = join(homedir(), ".shelve");
if (!existsSync(shelveRcPath))
return null;
try {
const content = readFileSync(shelveRcPath, "utf-8");
const match = content.match(/^token\s*=\s*["']?([^"'\r\n]+)["']?/m);
return match?.[1]?.trim() || null;
} catch {
return null;
}
}
function resolveShelveOptions(options) {
if (options === false || options === void 0)
return null;
if (options === true)
return {};
if (options.enabled === false)
return null;
if (options.enabled === true || options.project || options.slug || options.team)
return options;
return null;
}
function resolveShelveConfig(options, cwd, isDev) {
const project = options.project || process.env.SHELVE_PROJECT || readPackageName(cwd);
if (!project)
throw new Error("[safe-runtime-config] Shelve project name required. Set in nuxt.config or package.json name");
const slug = options.slug || options.team || process.env.SHELVE_TEAM || process.env.SHELVE_TEAM_SLUG;
if (!slug)
throw new Error("[safe-runtime-config] Shelve team slug required. Set in nuxt.config or SHELVE_TEAM env");
const environment = options.environment || process.env.SHELVE_ENV || (isDev ? "development" : "production");
const url = (options.url || process.env.SHELVE_URL || DEFAULT_URL).replace(/\/+$/, "");
const token = process.env.SHELVE_TOKEN || getUserToken();
if (!token)
throw new Error("[safe-runtime-config] Shelve token required. Set SHELVE_TOKEN env or run `shelve login`");
return { project, slug, environment, url, token };
}
async function fetchShelveSecrets(config, useCache = true) {
const tokenHash = createHash("sha256").update(config.token).digest("hex").slice(0, 8);
const cacheKey = `${config.slug}:${config.project}:${config.environment}:${tokenHash}`;
if (useCache) {
const cached = secretsCache.get(cacheKey);
if (cached && cached.expires > Date.now())
return cached.data;
}
const client = createShelveClient({ token: config.token, url: config.url });
const [project, environment] = await Promise.all([
client.getProjectByName(config.slug, config.project).catch((error) => {
const statusCode = error?.statusCode;
if (statusCode === 400 || statusCode === 404)
throw new Error(`[safe-runtime-config] Project '${config.project}' not found in team '${config.slug}'. Run \`shelve create ${config.project}\``);
throw new Error(`[safe-runtime-config] Failed to fetch project: ${errorMessage(error)}`);
}),
client.getEnvironment(config.slug, config.environment).catch((error) => {
throw new Error(`[safe-runtime-config] Environment '${config.environment}' not found: ${errorMessage(error)}`);
})
]);
const variables = await client.getVariables(config.slug, project.id, environment.id);
const transformed = transformEnvVars(variables);
secretsCache.set(cacheKey, { data: transformed, expires: Date.now() + CACHE_TTL });
return transformed;
}
const execAsync = promisify(exec);
const SHELVE_RC_PATH = join(homedir(), ".shelve");
function readShelveRc() {
if (!existsSync(SHELVE_RC_PATH))
return {};
try {
const content = readFileSync(SHELVE_RC_PATH, "utf-8");
const result = {};
for (const line of content.split("\n")) {
const match = line.match(/^(\w+)\s*=\s*["']?([^"'\r\n]+)["']?/);
if (match?.[1] && match[2])
result[match[1]] = match[2].trim();
}
return result;
} catch {
return {};
}
}
function writeShelveRc(data) {
const lines = Object.entries(data).map(([k, v]) => `${k}=${v}`);
writeFileSync(SHELVE_RC_PATH, `${lines.join("\n")}
`, { mode: 384 });
}
function detectValidationLibrary(rootDir) {
const pkgPath = join(rootDir, "package.json");
if (!existsSync(pkgPath))
return null;
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
const hasValibot = "valibot" in deps;
const hasZod = "zod" in deps;
if (hasValibot && !hasZod)
return "valibot";
if (hasZod && !hasValibot)
return "zod";
return null;
} catch {
return null;
}
}
function detectPackageManager(rootDir) {
if (existsSync(join(rootDir, "pnpm-lock.yaml")))
return "pnpm";
if (existsSync(join(rootDir, "yarn.lock")))
return "yarn";
return "npm";
}
async function installValidationLibrary(rootDir, library, logger) {
const pm = detectPackageManager(rootDir);
const packages = library === "valibot" ? ["valibot", "@valibot/to-json-schema"] : ["zod"];
const cmd = pm === "npm" ? `npm install ${packages.join(" ")}` : `${pm} add ${packages.join(" ")}`;
logger.info(`Installing ${packages.join(", ")}...`);
try {
await execAsync(cmd, { cwd: rootDir });
logger.success(`Installed ${library} dependencies`);
} catch (error) {
logger.error(`Failed to install dependencies: ${error}`);
}
}
function generateSchemaCode(keys, library) {
const s = library === "valibot" ? "v.string()" : "z.string()";
const o = library === "valibot" ? "v.object" : "z.object";
const imports = library === "valibot" ? `import * as v from 'valibot'` : `import { z } from 'zod'`;
if (!keys || keys.length === 0)
return { imports, schemaExpr: `${o}({})` };
const structure = buildConfigStructureFromEnvKeys(keys);
function renderObject(obj, indent = 2) {
const entries = Object.entries(obj);
if (entries.length === 0)
return "{}";
const pad = " ".repeat(indent);
const closePad = " ".repeat(indent - 2);
const lines = entries.map(([k, v]) => {
if (v === true)
return `${pad}${k}: ${s},`;
const nestedKeys = Object.keys(v);
if (nestedKeys.length <= 2)
return `${pad}${k}: ${o}({ ${nestedKeys.map((nk) => `${nk}: ${s}`).join(", ")} }),`;
const nestedPad = " ".repeat(indent + 2);
const nestedLines = nestedKeys.map((nk) => `${nestedPad}${nk}: ${s},`);
return `${pad}${k}: ${o}({
${nestedLines.join("\n")}
${pad}}),`;
});
return `{
${lines.join("\n")}
${closePad}}`;
}
return { imports, schemaExpr: `${o}(${renderObject(structure)})` };
}
function findNuxtConfig(rootDir) {
for (const name of ["nuxt.config.ts", "nuxt.config.js", "nuxt.config.mts", "nuxt.config.mjs"]) {
const path = resolve(rootDir, name);
if (existsSync(path))
return path;
}
return null;
}
async function updateNuxtConfig(rootDir, options) {
const { logger } = options;
const configPath = findNuxtConfig(rootDir);
if (!configPath) {
logger.warn("Could not find nuxt.config file");
return false;
}
try {
if (options.schema) {
let content = readFileSync(configPath, "utf-8");
if (content.includes("$schema")) {
logger.info("Schema already exists in nuxt.config, skipping schema generation");
} else {
const importStatement = options.schema.imports;
if (!content.includes(importStatement)) {
const importMatch = content.match(/^(import[\s\S]*?(?:\n(?!import)|\n$))/m);
if (importMatch) {
const lastImportEnd = importMatch.index + importMatch[0].length;
content = `${content.slice(0, lastImportEnd)}${importStatement}
${content.slice(lastImportEnd)}`;
} else {
content = `${importStatement}
${content}`;
}
writeFileSync(configPath, content);
}
}
}
const mod = await loadFile(configPath);
const config = getDefaultExportOptions(mod);
if (options.schema && !config.safeRuntimeConfig?.$schema) {
if (!config.safeRuntimeConfig)
config.safeRuntimeConfig = {};
config.safeRuntimeConfig.$schema = builders.raw(options.schema.schemaExpr);
}
if (options.shelve) {
if (!config.safeRuntimeConfig)
config.safeRuntimeConfig = {};
config.safeRuntimeConfig.shelve = { project: options.shelve.project, slug: options.shelve.slug };
}
await writeFile(mod, configPath);
return true;
} catch (error) {
logger.error("Failed to update nuxt.config:", error);
return false;
}
}
async function authenticateShelve(url, logger) {
const rc = readShelveRc();
if (rc.token) {
const user2 = await createShelveClient({ token: rc.token, url }).getMe().catch(() => null);
if (user2) {
logger.info(`Logged in as ${user2.username}`);
return { token: rc.token, user: user2, shouldPersistToken: false, nextRc: rc };
}
}
logger.info(`Generate a token at ${url}/user/tokens`);
const inputToken = await consola.prompt("Enter your Shelve API token:", { type: "text" });
if (!inputToken || typeof inputToken !== "string") {
logger.warn("No token provided, skipping Shelve setup");
return null;
}
const token = inputToken.trim();
const user = await createShelveClient({ token, url }).getMe().catch(() => null);
if (!user) {
logger.error("Invalid token. Please check and try again.");
return null;
}
return {
token,
user,
shouldPersistToken: true,
nextRc: { ...rc, token, email: user.email, username: user.username }
};
}
async function selectTeamAndProject(token, url, logger) {
const client = createShelveClient({ token, url });
const teams = await client.getTeams().catch(() => []);
if (!teams.length) {
logger.error("No teams found. Create a team at Shelve first.");
return null;
}
let team = null;
if (teams.length === 1) {
team = teams[0];
logger.info(`Using team: ${team.name}`);
} else {
const choice = await consola.prompt("Select team:", {
type: "select",
options: teams.map((t) => ({ label: t.name, value: t.slug }))
});
if (typeof choice === "string")
team = teams.find((t) => t.slug === choice) ?? null;
}
if (!team)
return null;
const projects = await client.getProjects(team.slug);
if (!projects.length) {
logger.warn(`No projects in team "${team.name}". Create one at Shelve first.`);
return null;
}
let project = null;
if (projects.length === 1) {
project = projects[0];
logger.info(`Using project: ${project.name}`);
} else {
const choice = await consola.prompt("Select project:", {
type: "select",
options: projects.map((p) => ({ label: p.name, value: p.name }))
});
if (typeof choice === "string")
project = projects.find((p) => p.name === choice) ?? null;
}
if (!project)
return null;
return { team, project };
}
function summarizePlannedActions(plan) {
const actions = [];
if (plan.installLibrary) {
const packages = plan.installLibrary === "valibot" ? "valibot, @valibot/to-json-schema" : "zod";
actions.push(`Install dependencies: ${packages}`);
}
if (plan.persistToken?.shouldPersistToken)
actions.push(`Write Shelve auth token to ${SHELVE_RC_PATH}`);
if (plan.updateNuxtConfig)
actions.push("Update nuxt.config with safeRuntimeConfig setup");
return actions;
}
async function runShelveWizard(nuxt) {
const logger = consola.withTag("shelve-setup");
const existingConfig = nuxt.options.safeRuntimeConfig;
if (existingConfig?.shelve && typeof existingConfig.shelve === "object" && (existingConfig.shelve.project || existingConfig.shelve.slug))
return;
let library = detectValidationLibrary(nuxt.options.rootDir);
let needsInstall = false;
if (library) {
logger.info(`Detected ${library} for schema validation`);
} else {
const choice = await consola.prompt("Which validation library?", {
type: "select",
options: [
{ label: "Valibot (recommended)", value: "valibot" },
{ label: "Zod", value: "zod" },
{ label: "Other / Skip schema generation", value: "skip" }
]
});
if (!choice || typeof choice !== "string" || choice === "skip") {
library = null;
} else {
library = choice;
needsInstall = true;
}
}
const enableShelve = await consola.prompt("Enable Shelve secrets integration?", { type: "confirm", initial: false });
let variables = [];
let shelveConfig = null;
let authResult = null;
if (enableShelve) {
const url = process.env.SHELVE_URL || DEFAULT_URL;
authResult = await authenticateShelve(url, logger);
if (authResult) {
const selection = await selectTeamAndProject(authResult.token, url, logger);
if (selection) {
shelveConfig = { project: selection.project.name, slug: selection.team.slug };
const client = createShelveClient({ token: authResult.token, url });
try {
const env = await client.getEnvironment(selection.team.slug, "development");
variables = await client.getVariables(selection.team.slug, selection.project.id, env.id);
if (variables.length === 0)
logger.warn("No variables found in Shelve project, generating placeholder schema");
else
logger.info(`Found ${variables.length} variable(s) in Shelve`);
} catch (error) {
logger.warn(`Could not fetch variables: ${errorMessage(error)}`);
}
}
}
}
let schemaCode = null;
if (library) {
const keys = variables.length > 0 ? variables.map((v) => v.key) : null;
schemaCode = generateSchemaCode(keys, library);
}
const wizardPlan = {
installLibrary: library && needsInstall ? library : null,
persistToken: authResult,
updateNuxtConfig: Boolean(schemaCode || shelveConfig)
};
const actions = summarizePlannedActions(wizardPlan);
if (actions.length === 0) {
logger.info("No configuration changes needed");
return;
}
logger.info("Planned changes:");
for (const action of actions)
logger.info(` - ${action}`);
const applyChanges = await consola.prompt("Apply these changes?", { type: "confirm", initial: true });
if (!applyChanges) {
logger.info("Setup cancelled. No changes were made.");
return;
}
if (wizardPlan.persistToken?.shouldPersistToken) {
writeShelveRc(wizardPlan.persistToken.nextRc);
logger.success(`Token saved to ${SHELVE_RC_PATH}`);
logger.info(`Logged in as ${wizardPlan.persistToken.user.username}`);
}
if (wizardPlan.installLibrary)
await installValidationLibrary(nuxt.options.rootDir, wizardPlan.installLibrary, logger);
if (!wizardPlan.updateNuxtConfig)
return;
const updated = await updateNuxtConfig(nuxt.options.rootDir, { schema: schemaCode, shelve: shelveConfig, logger });
if (updated) {
logger.success("Updated nuxt.config.ts");
const parts = [];
if (schemaCode)
parts.push("schema validation");
if (shelveConfig)
parts.push("Shelve integration");
logger.info(`Added: ${parts.join(" + ")}`);
}
}
const logger = useLogger("safe-runtime-config");
function pushUnique(list, item) {
if (!list.includes(item))
list.push(item);
}
function getShelveRuntimePluginContents(shelveClientPath, transformPath, runtimeConfigImport) {
return `import process from 'node:process'
import { shelveRuntimeConfig } from '#safe-runtime-config/shelve'
import { consola } from 'consola'
import defu from 'defu'
import { createShelveClient } from ${JSON.stringify(shelveClientPath)}
import { transformEnvVars } from ${JSON.stringify(transformPath)}
import { useRuntimeConfig } from ${JSON.stringify(runtimeConfigImport)}
const logger = consola.withTag('safe-runtime-config')
export default async () => {
const cfg = shelveRuntimeConfig
const token = process.env.SHELVE_TOKEN
if (!token) {
logger.warn('SHELVE_TOKEN not set, skipping runtime secrets fetch')
return
}
const client = createShelveClient({ token, url: cfg.url })
try {
const [project, environment] = await Promise.all([
client.getProjectByName(cfg.slug, cfg.project),
client.getEnvironment(cfg.slug, cfg.environment),
])
const variables = await client.getVariables(cfg.slug, project.id, environment.id)
if (variables.length === 0) {
logger.info('No Shelve variables found')
return
}
const secrets = transformEnvVars(variables)
const config = useRuntimeConfig()
Object.assign(config, defu(secrets, config))
logger.success(\`Loaded \${variables.length} secrets from Shelve (runtime)\`)
}
catch (error) {
const msg = error instanceof Error ? error.message : String(error)
logger.error(\`Failed to fetch Shelve secrets: \${msg}\`)
}
}
`;
}
const module$1 = defineNuxtModule({
meta: {
name: "nuxt-safe-runtime-config",
version,
configKey: "safeRuntimeConfig",
compatibility: { nuxt: ">=3.0.0" }
},
defaults: {
$schema: void 0,
validateAtBuild: true,
validateAtRuntime: false,
jsonSchemaTarget: "draft-2020-12",
onError: "throw",
shelve: void 0
},
// onInstall: Nuxt 4.1+, ignored on older versions
async onInstall(nuxt) {
if (isCI || isTest || !process.stdin.isTTY || !process.stdout.isTTY)
return;
await runShelveWizard(nuxt);
},
async setup(options, nuxt) {
const resolver = createResolver(import.meta.url);
const onError = options.onError;
const nuxtMajorVersion = Number.parseInt(getNuxtVersion(nuxt), 10);
const rootRequire = createRequire(join(nuxt.options.rootDir, "package.json"));
const runtimeConfigImport = resolveRuntimeConfigImport(
nuxtMajorVersion >= 5 ? 3 : 2,
rootRequire.resolve("nuxt/package.json")
);
const shelveOpts = resolveShelveOptions(options.shelve);
if (shelveOpts) {
const fetchAtBuild = shelveOpts.fetchAtBuild !== false;
let shelveConfig = null;
try {
shelveConfig = resolveShelveConfig(shelveOpts, nuxt.options.rootDir, nuxt.options.dev);
} catch (error) {
logger.warn(`Shelve config resolution failed: ${errorMessage(error)}`);
}
if (fetchAtBuild && shelveConfig) {
try {
const secrets = await fetchShelveSecrets(shelveConfig);
nuxt.options.runtimeConfig = defu(secrets, nuxt.options.runtimeConfig);
nuxt.options.nitro.runtimeConfig = defu(secrets, nuxt.options.nitro.runtimeConfig);
logger.success(`Loaded ${Object.keys(secrets).length} secrets from Shelve (${shelveConfig.environment})`);
} catch (error) {
logger.error(errorMessage(error));
if (onError === "throw")
throw error;
}
}
if (shelveOpts.fetchAtRuntime && shelveConfig) {
const tpl = addTemplate({
filename: "safe-runtime-config/shelve.mjs",
write: true,
getContents: () => `export const shelveRuntimeConfig = ${JSON.stringify({
url: shelveConfig.url,
slug: shelveConfig.slug,
project: shelveConfig.project,
environment: shelveConfig.environment
})}
`
});
addTemplate({
filename: "safe-runtime-config/shelve.d.ts",
write: true,
getContents: () => `export declare const shelveRuntimeConfig: { url: string, slug: string, project: string, environment: string }
`
});
nuxt.options.alias["#safe-runtime-config/shelve"] = tpl.dst;
nuxt.hook("nitro:config", (nitroConfig) => {
nitroConfig.alias ||= {};
nitroConfig.alias["#safe-runtime-config/shelve"] = tpl.dst;
});
const shelvePlugin = addTemplate({
filename: "safe-runtime-config/shelve-plugin.mjs",
write: true,
getContents: () => getShelveRuntimePluginContents(
resolver.resolve("./runtime/shelve-client"),
resolver.resolve("./runtime/utils/transform"),
runtimeConfigImport
)
});
addServerPlugin(shelvePlugin.dst);
}
}
if (!options.$schema)
return;
const validationOptions = await resolveValidationOptions(options, nuxt.options.rootDir);
const artifacts = await createRuntimeValidationArtifacts(validationOptions, (msg) => logger.warn(msg));
if (!artifacts)
return;
addTypeTemplate({
filename: "types/safe-runtime-config.d.ts",
getContents: () => artifacts.typeDeclaration
});
if (validationOptions.schemaPath && validationOptions.validateAtRuntime)
addPlugin({ src: resolver.resolve("./runtime/plugins/validated-public.server"), mode: "server" });
nuxt.hook("nitro:config", (nitroConfig) => {
nitroConfig.safeRuntimeConfig = validationOptions;
nitroConfig.alias ||= {};
nitroConfig.alias["#safe-runtime-config/nitro-runtime-config"] = runtimeConfigImport;
nitroConfig.modules ||= [];
pushUnique(nitroConfig.modules, safeRuntimeConfigNitroModule);
nitroConfig.typescript ||= {};
nitroConfig.typescript.tsConfig ||= {};
nitroConfig.typescript.tsConfig.include ||= [];
pushUnique(nitroConfig.typescript.tsConfig.include, "./types/safe-runtime-config.d.ts");
});
const composable = {
name: "useSafeRuntimeConfig",
from: resolver.resolve("./runtime/composables/useSafeRuntimeConfig")
};
addImports(composable);
addServerImports(composable);
nuxt.hook("eslint:config:addons", (addons) => {
addons.push({
name: "nuxt-safe-runtime-config",
getConfigs: () => ({
imports: [{ from: "nuxt-safe-runtime-config/eslint", name: "default", as: "safeRuntimeConfig" }],
configs: [`safeRuntimeConfig.configs.recommended`]
})
});
});
}
});
export { module$1 as default };