UNPKG

@lunora/cli

Version:

The Lunora CLI: init, dev, deploy, codegen, run, reset, and migrate commands

335 lines (332 loc) 13.4 kB
import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { DEV_VARS_FILE, DEV_VARS_KEY_PATTERN, generateSecretValue, DEV_VARS_EXAMPLE_FILE, parseDevVariableEntries, isPlaceholderValue, packageNamesFromBindings, inferLunoraBindings, requiredSecrets, isMintableSecretKey } from '@lunora/config'; import { d as defineHandler } from '../packem_shared/command-lYnl4QyF.mjs'; import { d as detectPackageManager, e as execArgsFor } from '../packem_shared/detect-package-manager-v4hHpQd0.mjs'; import { defaultSpawner } from '../packem_shared/createRecordingSpawner-WuSn20kb.mjs'; import { l as listRemoteSecrets } from '../packem_shared/wrangler-secrets-Coni-mER.mjs'; const NEWLINE_PRESENT = /[\r\n]/u; const UNREPRESENTABLE_PRESENT = /["\\]/u; const parseDevVariables = (content) => { const map = /* @__PURE__ */ new Map(); for (const entry of parseDevVariableEntries(content)) { map.set(entry.key, entry); } return map; }; const escapeRegExp = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); const devVariableLinePattern = (key, global) => new RegExp(String.raw`^[ \t]*${escapeRegExp(key)}[ \t]*=.*(\r?\n|$)`, global ? "gmu" : "mu"); const upsertDevVariableLine = (content, key, value) => { const rendered = `${key}="${value}"`; if (!devVariableLinePattern(key, false).test(content)) { if (content === "") { return `${rendered} `; } return content.endsWith("\n") ? `${content}${rendered} ` : `${content} ${rendered} `; } let replacedFirst = false; return content.replace(devVariableLinePattern(key, true), (_match, newline) => { if (replacedFirst) { return ""; } replacedFirst = true; return `${rendered}${newline}`; }); }; const removeDevVariableLine = (content, key) => content.replaceAll(new RegExp(String.raw`^[ \t]*${key}[ \t]*=.*(?:\r?\n|$)`, "gmu"), ""); const redact = (value) => { if (value.length <= 4) { return "****"; } return `${value.slice(0, 4)}${"*".repeat(Math.min(8, value.length - 4))}`; }; const loadDevVariables = (devVariablesPath) => { if (!existsSync(devVariablesPath)) { return /* @__PURE__ */ new Map(); } return parseDevVariables(readFileSync(devVariablesPath, "utf8")); }; const runEnvList = (context) => { const map = loadDevVariables(context.devVariablesPath); if (map.size === 0) { context.logger.info(`${DEV_VARS_FILE}: (empty)`); return { code: 0, descriptors: [] }; } for (const entry of map.values()) { context.logger.info(`${entry.key}=${redact(entry.value)}`); } return { code: 0, descriptors: [] }; }; const runEnvGet = (context) => { const { devVariablesPath, logger, options } = context; if (!options.key) { logger.error("env get requires a key. Usage: lunora env get <KEY>"); return { code: 1, descriptors: [] }; } const entry = loadDevVariables(devVariablesPath).get(options.key); if (!entry) { logger.error(`env: ${options.key} is not set in ${DEV_VARS_FILE}`); return { code: 1, descriptors: [] }; } process.stdout.write(`${entry.value} `); return { code: 0, descriptors: [] }; }; const runEnvSet = (context) => { const { devVariablesPath, logger, options } = context; if (!options.key) { logger.error("env set requires a key. Usage: lunora env set <KEY> <VALUE>"); return { code: 1, descriptors: [] }; } if (!DEV_VARS_KEY_PATTERN.test(options.key)) { logger.error(`env: invalid key "${options.key}" — must match [A-Za-z_][A-Za-z0-9_]*`); return { code: 1, descriptors: [] }; } if (options.value === void 0) { logger.error("env set requires a value. Usage: lunora env set <KEY> <VALUE>"); return { code: 1, descriptors: [] }; } if (NEWLINE_PRESENT.test(options.value)) { logger.error(`env: value for "${options.key}" contains a newline, which .dev.vars cannot represent`); return { code: 1, descriptors: [] }; } if (UNREPRESENTABLE_PRESENT.test(options.value)) { logger.error(`env: value for "${options.key}" contains a double-quote or backslash, which .dev.vars cannot round-trip`); return { code: 1, descriptors: [] }; } const raw = existsSync(devVariablesPath) ? readFileSync(devVariablesPath, "utf8") : ""; writeFileSync(devVariablesPath, upsertDevVariableLine(raw, options.key, options.value), "utf8"); logger.success(`env: set ${options.key} (${redact(options.value)}) in ${DEV_VARS_FILE}`); return { code: 0, descriptors: [] }; }; const runEnvUnset = (context) => { const { devVariablesPath, logger, options } = context; if (!options.key) { logger.error("env unset requires a key. Usage: lunora env unset <KEY>"); return { code: 1, descriptors: [] }; } const raw = existsSync(devVariablesPath) ? readFileSync(devVariablesPath, "utf8") : ""; if (!parseDevVariables(raw).has(options.key)) { logger.warn(`env: ${options.key} was not set in ${DEV_VARS_FILE}`); return { code: 0, descriptors: [] }; } writeFileSync(devVariablesPath, removeDevVariableLine(raw, options.key), "utf8"); logger.success(`env: unset ${options.key} in ${DEV_VARS_FILE}`); return { code: 0, descriptors: [] }; }; const runEnvPush = async (context) => { const { devVariablesPath, logger, options } = context; if (!options.yes) { logger.error("env push uploads secrets to Cloudflare. Re-run with --yes to confirm."); return { code: 1, descriptors: [] }; } const map = loadDevVariables(devVariablesPath); if (map.size === 0) { logger.warn(`${DEV_VARS_FILE}: nothing to push (empty)`); return { code: 0, descriptors: [] }; } const spawner = options.spawner ?? defaultSpawner; const cwd = options.cwd ?? process.cwd(); const manager = detectPackageManager(cwd); const descriptors = []; for (const entry of map.values()) { const args = ["secret", "put", entry.key]; if (options.prod) { args.push("--env", "production"); } if (options.temporary) { args.push("--temporary"); } const exec = execArgsFor(manager, "wrangler", args); const descriptor = { args: exec.args, command: exec.command, cwd, // `wrangler secret put <name>` reads the value from stdin. We // pipe it through the spawner's `input` channel so the secret // never lands on the command line, in env, or in shell history. input: entry.value }; descriptors.push(descriptor); logger.info(`pushing ${entry.key} -> wrangler secret${options.prod ? " (production)" : ""}`); const result = await spawner(descriptor); if (result.code !== 0) { logger.error(`env push: failed at ${entry.key} (exit ${String(result.code)})`); return { code: result.code, descriptors }; } } logger.success(`env: pushed ${String(map.size)} secret(s)`); return { code: 0, descriptors }; }; const fetchRemoteSecretNames = (context) => { const lister = context.options.secretLister ?? listRemoteSecrets; return lister({ cwd: context.cwd, env: context.options.prod ? "production" : void 0, temporary: context.options.temporary }); }; const runEnvDiff = async (context) => { const { devVariablesPath, logger } = context; const remote = await fetchRemoteSecretNames(context); if (!remote.ok) { logger.error(`env diff: ${remote.error ?? "failed to list remote secrets"}`); return { code: 1, descriptors: [] }; } const localKeys = new Set(loadDevVariables(devVariablesPath).keys()); const remoteKeys = new Set(remote.names); const localOnly = [...localKeys].filter((key) => !remoteKeys.has(key)).toSorted((a, b) => a.localeCompare(b)); const remoteOnly = remote.names.filter((name) => !localKeys.has(name)); const both = [...localKeys].filter((key) => remoteKeys.has(key)).toSorted((a, b) => a.localeCompare(b)); for (const key of localOnly) { logger.info(`local only (run \`lunora env push\`): ${key}`); } for (const key of remoteOnly) { logger.info(`remote only (in Cloudflare, not ${DEV_VARS_FILE}): ${key}`); } logger.info(`in both: ${String(both.length)} secret(s)`); if (localOnly.length === 0 && remoteOnly.length === 0) { logger.success("env diff: local and remote secret names match"); } return { code: 0, descriptors: [] }; }; const runEnvDoctor = (context) => { const { cwd, devVariablesPath, logger } = context; const examplePath = join(cwd, DEV_VARS_EXAMPLE_FILE); if (!existsSync(examplePath)) { logger.info(`env doctor: no ${DEV_VARS_EXAMPLE_FILE} to check against — nothing to validate.`); return { code: 0, descriptors: [] }; } const exampleKeys = parseDevVariableEntries(readFileSync(examplePath, "utf8")).map((entry) => entry.key); const current = loadDevVariables(devVariablesPath); if (!existsSync(devVariablesPath)) { logger.error(`env doctor: ${DEV_VARS_FILE} is missing. Run \`lunora dev\` to scaffold it, or \`lunora env set <KEY> <VALUE>\`.`); logger.info(`expected (from ${DEV_VARS_EXAMPLE_FILE}): ${exampleKeys.join(", ")}`); return { code: 1, descriptors: [] }; } const missing = exampleKeys.filter((key) => !current.has(key)); const placeholders = [...current.values()].filter((entry) => isPlaceholderValue(entry.value)).map((entry) => entry.key); const exampleKeySet = new Set(exampleKeys); const extra = [...current.keys()].filter((key) => !exampleKeySet.has(key)); for (const key of missing) { logger.error(`missing: ${key} is in ${DEV_VARS_EXAMPLE_FILE} but not ${DEV_VARS_FILE}`); } for (const key of placeholders) { logger.error(`unset: ${key} still has a placeholder value`); } for (const key of extra) { logger.info(`extra: ${key} is set locally but not listed in ${DEV_VARS_EXAMPLE_FILE}`); } if (missing.length === 0 && placeholders.length === 0) { logger.success(`env doctor: ${DEV_VARS_FILE} looks good (${String(current.size)} var(s)).`); return { code: 0, descriptors: [] }; } return { code: 1, descriptors: [] }; }; const resolveMintableKeys = async (context) => { let packages = []; try { packages = packageNamesFromBindings(await inferLunoraBindings({ projectRoot: context.cwd })); } catch { } const fromPackages = requiredSecrets(packages).map((entry) => entry.key).filter((key) => isMintableSecretKey(key)); const fromLocal = [...loadDevVariables(context.devVariablesPath).keys()].filter((key) => isMintableSecretKey(key)); return [.../* @__PURE__ */ new Set([...fromPackages, ...fromLocal])]; }; const runEnvGenerate = async (context) => { const { devVariablesPath, logger, options } = context; let keys; if (options.key === void 0) { keys = await resolveMintableKeys(context); if (keys.length === 0) { logger.info("env generate: no locally-generatable secrets for this project. Name one explicitly: lunora env generate <KEY>"); return { code: 0, descriptors: [] }; } } else { if (!DEV_VARS_KEY_PATTERN.test(options.key)) { logger.error(`env: invalid key "${options.key}" — must match [A-Za-z_][A-Za-z0-9_]*`); return { code: 1, descriptors: [] }; } keys = [options.key]; } const generated = keys.map((key) => { return { key, value: generateSecretValue() }; }); if (options.set === true) { let raw = existsSync(devVariablesPath) ? readFileSync(devVariablesPath, "utf8") : ""; for (const entry of generated) { raw = upsertDevVariableLine(raw, entry.key, entry.value); } writeFileSync(devVariablesPath, raw, "utf8"); logger.success(`env: generated ${String(generated.length)} secret(s) into ${DEV_VARS_FILE}: ${generated.map((entry) => entry.key).join(", ")}`); return { code: 0, descriptors: [] }; } for (const entry of generated) { process.stdout.write(`${entry.key}=${entry.value} `); } return { code: 0, descriptors: [] }; }; const runEnvCommand = async (options) => { const cwd = options.cwd ?? process.cwd(); const context = { cwd, devVariablesPath: join(cwd, DEV_VARS_FILE), logger: options.logger, options }; switch (options.subcommand) { case "diff": { return runEnvDiff(context); } case "doctor": { return runEnvDoctor(context); } case "generate": { return runEnvGenerate(context); } case "get": { return runEnvGet(context); } case "list": { return runEnvList(context); } case "push": { return runEnvPush(context); } case "set": { return runEnvSet(context); } case "unset": { return runEnvUnset(context); } default: { options.logger.error(`env: unknown subcommand "${options.subcommand}"`); return { code: 1, descriptors: [] }; } } }; const isEnvSubcommand = (value) => value === "list" || value === "get" || value === "set" || value === "unset" || value === "push" || value === "diff" || value === "doctor" || value === "generate"; const execute = defineHandler(({ argument, cwd, logger, options }) => { const sub = argument[0]; if (!isEnvSubcommand(sub)) { logger.error(`env: unknown subcommand "${sub ?? ""}" — expected list | get | set | unset | push | diff | doctor | generate`); return { code: 1 }; } return runEnvCommand({ cwd, key: argument[1], logger, prod: options.prod === true, set: options.set === true, subcommand: sub, temporary: options.temporary === true, value: argument[2], yes: options.yes === true }); }); export { execute, runEnvCommand };