From c1daef39f2958c18d14d336a7bead3c8375fa064 Mon Sep 17 00:00:00 2001 From: Anton Gunnarsson Date: Mon, 30 Jun 2025 13:54:24 +0000 Subject: [PATCH] Merged in chore/env-scripts (pull request #2481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chore: Add script to show missing envs * base * Better output * Add missing flag * Add script to check env Approved-by: Joakim Jäderberg --- package.json | 4 +- scripts/show-env.mjs | 216 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 scripts/show-env.mjs diff --git a/package.json b/package.json index 77a00294e..28e0a1cb1 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "test": "turbo run test", "postinstall": "husky", "icons:update": "node scripts/material-symbols-update.mjs", - "check-types": "turbo run check-types" + "check-types": "turbo run check-types", + "env:web": "node scripts/show-env.mjs scandic-web --missing", + "env:sas": "node scripts/show-env.mjs partner-sas --missing" }, "workspaces": [ "apps/*", diff --git a/scripts/show-env.mjs b/scripts/show-env.mjs new file mode 100644 index 000000000..636c54196 --- /dev/null +++ b/scripts/show-env.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node +import fs from 'fs/promises' +import path from 'path' + +// --- Helpers --- +async function fileExists(filePath) { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +function getAppDir(appName) { + // Try apps/appName, then root + const candidates = [path.join(process.cwd(), 'apps', appName), path.join(process.cwd(), appName)] + return candidates.find((dir) => + fs + .stat(dir) + .then((s) => s.isDirectory()) + .catch(() => false) + ) +} + +async function getMonorepoPackages() { + const pkgsDir = path.join(process.cwd(), 'packages') + const entries = await fs.readdir(pkgsDir) + return entries.filter((e) => !e.startsWith('.')) +} + +async function getAppDependencies(appDir) { + const pkgJsonPath = path.join(appDir, 'package.json') + const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8')) + const allDeps = { ...pkgJson.dependencies, ...pkgJson.devDependencies } + return Object.keys(allDeps || {}).filter((dep) => dep.startsWith('@scandic-hotels/')) +} + +async function findEnvFiles(baseDir) { + const envDir = path.join(baseDir, 'env') + const files = [] + for (const f of ['server.ts', 'client.ts']) { + const full = path.join(envDir, f) + if (await fileExists(full)) files.push(full) + } + return files +} + +async function extractEnvVarsFromFile(filePath) { + const content = await fs.readFile(filePath, 'utf8') + const regex = /process\.env\.([A-Z0-9_]+)/g + const vars = new Set() + let match + while ((match = regex.exec(content))) { + vars.add(match[1]) + } + return Array.from(vars) +} + +async function readEnvLocalVars(envPath) { + try { + const content = await fs.readFile(envPath, 'utf8') + const lines = content.split(/\r?\n/) + const vars = new Set() + for (const line of lines) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=/) + if (m) vars.add(m[1]) + } + return vars + } catch { + return new Set() + } +} + +// --- New: Extract zod schema example values --- +function extractZodExamplesFromContent(content) { + // Heuristic: find zod schema definitions in the file + // Only supports simple z.string(), z.enum([...]), z.coerce.number(), .default(), .refine() + const result = {} + // Match server: { ... } or client: { ... } + const serverMatch = content.match(/(server|client):\s*{([\s\S]*?)}\s*,/) + if (!serverMatch) return result + const block = serverMatch[2] + // Match lines like VAR: z.string().default("foo"), + const lineRegex = + /([A-Z0-9_]+):\s*z\.(string|enum|coerce\.number|coerce\.boolean|number|boolean)\(([^)]*)\)([^,]*)/g + let match + while ((match = lineRegex.exec(block))) { + const [_, varName, zodType, zodArgs, zodChain] = match + let example = 'example' + if (zodType === 'string') { + if (/refine\(.*s === \"true\" \|\| s === \"false\"/.test(zodChain)) { + example = '"true/false"' + } else if (/default\(["']([^"']+)["']\)/.test(zodChain)) { + example = '"' + zodChain.match(/default\(["']([^"']+)["']\)/)[1] + '"' + } else { + example = '"example"' + } + } else if (zodType === 'enum') { + // z.enum(["a", "b"]) => "a|b" + const opts = zodArgs + .replace(/\[|\]|\s|"|'/g, '') + .split(',') + .filter(Boolean) + example = '"' + opts.join('|') + '"' + } else if (zodType.includes('number')) { + if (/default\((\d+(\.\d+)?)\)/.test(zodChain)) { + example = '"' + zodChain.match(/default\((\d+(\.\d+)?)\)/)[1] + '"' + } else { + example = '"123"' + } + } else if (zodType.includes('boolean')) { + example = '"true/false"' + } + result[varName] = example + } + return result +} + +async function extractEnvExamplesFromFile(filePath) { + const content = await fs.readFile(filePath, 'utf8') + return extractZodExamplesFromContent(content) +} + +async function main() { + const args = process.argv.slice(2) + const appName = args[0] + const group = args.includes('--group') + const missing = args.includes('--missing') + if (!appName) { + console.error('Usage: node show-env.mjs [--group] [--missing]') + process.exit(1) + } + + const appDir = getAppDir(appName) + if (!appDir) { + console.error(`App directory not found for: ${appName}`) + process.exit(1) + } + + const deps = await getAppDependencies(appDir) + const monorepoPkgs = await getMonorepoPackages() + const usedPkgs = deps + .map((dep) => dep.replace('@scandic-hotels/', '')) + .filter((pkg) => monorepoPkgs.includes(pkg)) + + // Collect env files + const sources = [{ name: appName, dir: appDir }] + for (const pkg of usedPkgs) { + sources.push({ name: pkg, dir: path.join(process.cwd(), 'packages', pkg) }) + } + + const envVarsBySource = {} + const envExamplesBySource = {} + for (const src of sources) { + const files = await findEnvFiles(src.dir) + const vars = new Set() + const examples = {} + for (const file of files) { + for (const v of await extractEnvVarsFromFile(file)) vars.add(v) + const ex = await extractEnvExamplesFromFile(file) + Object.assign(examples, ex) + } + envVarsBySource[src.name] = Array.from(vars).sort() + envExamplesBySource[src.name] = examples + } + + function getExample(varName, source) { + return envExamplesBySource[source]?.[varName] || '"example"' + } + + // If --missing, read .env.local from appDir + let definedVars = new Set() + if (missing) { + const envPath = path.join(appDir, '.env.local') + definedVars = await readEnvLocalVars(envPath) + } + + function filterMissing(vars) { + if (!missing) return vars + return vars.filter((v) => !definedVars.has(v)) + } + + if (group) { + // Print grouped by source + for (const [src, vars] of Object.entries(envVarsBySource)) { + const missingVars = filterMissing(vars) + if (missingVars.length === 0) continue + console.log(`\n[${src}]`) + for (const v of missingVars) console.log(`${v}=${getExample(v, src)}`) + } + } else { + // Print all unique env vars, sorted, with example + const allVars = new Set() + for (const vars of Object.values(envVarsBySource)) { + for (const v of vars) allVars.add(v) + } + const sortedVars = Array.from(allVars).sort() + const missingVars = filterMissing(sortedVars) + for (const v of missingVars) { + let example = '"example"' + for (const src of Object.keys(envExamplesBySource)) { + if (envExamplesBySource[src][v]) { + example = envExamplesBySource[src][v] + break + } + } + console.log(`${v}=${example}`) + } + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +})