@lunora/cli
Version:
The Lunora CLI: init, dev, deploy, codegen, run, reset, and migrate commands
335 lines (332 loc) • 13.4 kB
JavaScript
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 };