UNPKG

nuxt-safe-runtime-config

Version:

Validate Nuxt runtime config with Standard Schema at build time

604 lines (591 loc) 23.1 kB
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 };