@lunora/cli
Version:
The Lunora CLI: init, dev, deploy, codegen, run, reset, and migrate commands
992 lines (981 loc) • 38 kB
JavaScript
import { existsSync, readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { dirname, join, relative } from '@visulima/path';
import { DEV_VARS_FILE, parseDevVariableEntries, findWranglerFile, readWranglerJsonc } from '@lunora/config';
import { modify, applyEdits, parse } from 'jsonc-parser';
import { fileURLToPath } from 'node:url';
import { LunoraError } from '@lunora/errors';
import { b as tuiConfirm } from './tui-prompts-BjEN8XgP.mjs';
import { collectCatalog, buildRegistryIndex } from './buildRegistryIndex-BS5ig822.mjs';
import { insertSchemaExtension } from './insertSchemaExtension-DAqbfr9Z.mjs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { downloadTemplate } from 'giget';
import parseManifest from './parseManifest-Dbp-Q2q3.mjs';
const DEFAULT_SOURCE_REF_FALLBACK = "alpha";
const STABLE_BRANCH = "main";
const PRERELEASE_CHANNEL_BRANCHES = /* @__PURE__ */ new Set(["alpha", "beta", "next"]);
const SAFE_REF = /^[\w./@-]+$/;
const isSafeRef = (ref) => !ref.includes("..") && SAFE_REF.test(ref);
const resolveCliVersion = () => {
try {
let directory = dirname(fileURLToPath(import.meta.url));
for (let index = 0; index < 6; index += 1) {
const candidate = join(directory, "package.json");
if (existsSync(candidate)) {
const parsed = JSON.parse(readFileSync(candidate, "utf8"));
if (parsed.name === "@lunora/cli" && typeof parsed.version === "string") {
return parsed.version;
}
}
const parent = dirname(directory);
if (parent === directory) {
break;
}
directory = parent;
}
} catch {
}
return "0.0.0";
};
const resolveVersionRef = (version) => {
if (version === "0.0.0") {
return DEFAULT_SOURCE_REF_FALLBACK;
}
const core = version.split("+")[0] ?? version;
const dashIndex = core.indexOf("-");
if (dashIndex !== -1) {
const [channel] = core.slice(dashIndex + 1).split(".");
if (channel !== void 0 && PRERELEASE_CHANNEL_BRANCHES.has(channel)) {
return channel;
}
}
return STABLE_BRANCH;
};
const resolveSourceRef = (ref) => {
if (ref !== void 0 && ref.length > 0) {
if (!isSafeRef(ref)) {
throw new LunoraError("INTERNAL", `invalid --ref "${ref}" — a ref may contain letters, digits, ".", "_", "-", "/", "@" and must not contain "..".`);
}
return ref;
}
return resolveVersionRef(resolveCliVersion());
};
const SOURCE_REPO = "anolilab/lunora";
const COMMIT_SHA = /^[0-9a-f]{40}$/iu;
const SEMVER_BODY = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)+)?(?:\+[0-9A-Za-z.-]+)?`;
const LEADING_VERSION_TAG = new RegExp(String.raw`^v?${SEMVER_BODY}$`, "u");
const PACKAGE_VERSION_TAG = new RegExp(String.raw`^(?:@[\w.-]+\/)?[\w.-]+@${SEMVER_BODY}$`, "u");
const isImmutableRef = (ref) => COMMIT_SHA.test(ref) || LEADING_VERSION_TAG.test(ref) || PACKAGE_VERSION_TAG.test(ref);
const githubAuthHeaders = () => {
const token = process.env["GITHUB_TOKEN"] ?? process.env["GH_TOKEN"];
return token !== void 0 && token.length > 0 ? { authorization: `Bearer ${token}` } : {};
};
const fetchBranchSha = async (branch) => {
try {
const response = await fetch(`https://api.github.com/repos/${SOURCE_REPO}/commits/${encodeURIComponent(branch)}`, {
headers: { accept: "application/vnd.github+json", "user-agent": "lunora-cli", ...githubAuthHeaders() },
signal: AbortSignal.timeout(1e4)
});
if (!response.ok) {
return void 0;
}
const body = await response.json();
return typeof body.sha === "string" && COMMIT_SHA.test(body.sha) ? body.sha : void 0;
} catch {
return void 0;
}
};
const resolvePinnedSourceRef = async (ref, logger) => {
const resolved = resolveSourceRef(ref);
if (isImmutableRef(resolved)) {
return resolved;
}
const sha = await fetchBranchSha(resolved);
if (sha === void 0) {
logger.warn(`could not pin ${SOURCE_REPO}#${resolved} to a commit — fetching the UNPINNED branch (set GITHUB_TOKEN if rate-limited).`);
return resolved;
}
logger.info(`pinned ${SOURCE_REPO}#${resolved} → ${sha}`);
return sha;
};
const STABLE_DIST_TAG = "latest";
const resolveDistTag = (version = resolveCliVersion()) => {
const ref = resolveVersionRef(version);
return ref === STABLE_BRANCH ? STABLE_DIST_TAG : ref;
};
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
const registryBase = () => {
const configured = process.env["npm_config_registry"];
const base = configured !== void 0 && configured.length > 0 ? configured : DEFAULT_REGISTRY;
return base.endsWith("/") ? base.slice(0, -1) : base;
};
const resolveTagVersion = async (packageName, tag) => {
try {
const response = await fetch(`${registryBase()}/${packageName.replaceAll("/", "%2F")}`, {
headers: { accept: "application/vnd.npm.install-v1+json" },
signal: AbortSignal.timeout(1e4)
});
if (!response.ok) {
return void 0;
}
const packument = await response.json();
return packument["dist-tags"]?.[tag];
} catch {
return void 0;
}
};
const resolveTagVersions = async (names, tag) => {
const resolved = /* @__PURE__ */ new Map();
await Promise.all(
[...new Set(names)].map(async (name) => {
const version = await resolveTagVersion(name, tag);
if (version !== void 0) {
resolved.set(name, version);
}
})
);
return resolved;
};
const resolveDepRange = (range) => {
if (!range.startsWith("workspace:")) {
return range;
}
const rest = range.slice("workspace:".length);
if (rest === "" || rest === "*" || rest === "^" || rest === "~") {
return resolveDistTag();
}
return rest;
};
const UMBRELLA_REEXPORTED_DEPS = /* @__PURE__ */ new Set(["@lunora/client", "@lunora/do", "@lunora/ratelimit", "@lunora/runtime", "@lunora/server", "@lunora/values"]);
const UMBRELLA_IMPORT_RE = /(['"])@lunora\/(client|do|ratelimit|runtime|server|values)(\/[^'"]*)?\1/gu;
const projectUsesUmbrella = (projectRoot) => {
const packageJsonPath = join(projectRoot, "package.json");
if (!existsSync(packageJsonPath)) {
return false;
}
try {
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
return parsed.dependencies?.lunorash !== void 0 || parsed.devDependencies?.lunorash !== void 0;
} catch {
return false;
}
};
const rewriteUmbrellaImports = (source) => source.replaceAll(UMBRELLA_IMPORT_RE, (_match, quote, base, subpath) => `${quote}lunorash/${base}${subpath ?? ""}${quote}`);
const applyDeps = (deps, projectRoot, logger, section = "dependencies", useUmbrella = false) => {
const entries = Object.entries(deps);
if (entries.length === 0) {
return [];
}
const packageJsonPath = join(projectRoot, "package.json");
if (!existsSync(packageJsonPath)) {
logger.warn(`package.json not found at ${packageJsonPath} — skipping dependency updates`);
return [];
}
let text = readFileSync(packageJsonPath, "utf8");
const parsed = JSON.parse(text);
const added = [];
for (const [name, range] of entries) {
if (useUmbrella && UMBRELLA_REEXPORTED_DEPS.has(name)) {
logger.info(`dep provided by the lunorash umbrella, skipping: ${name}`);
continue;
}
if (parsed.dependencies?.[name] !== void 0 || parsed.devDependencies?.[name] !== void 0) {
logger.info(`dep already present: ${name}`);
continue;
}
const edits = modify(text, [section, name], resolveDepRange(range), {
formattingOptions: { insertSpaces: true, tabSize: 4 }
});
text = applyEdits(text, edits);
added.push(name);
}
if (added.length > 0) {
writeFileSync(packageJsonPath, text, "utf8");
logger.success(
`added ${String(added.length)} ${section === "devDependencies" ? "devDependency(ies)" : "dependency(ies)"} to package.json: ${added.join(", ")}`
);
}
return added;
};
const applyEnvVariables = (envVariables, projectRoot, logger) => {
if (envVariables.length === 0) {
return [];
}
const devVariablesPath = join(projectRoot, DEV_VARS_FILE);
const existing = existsSync(devVariablesPath) ? readFileSync(devVariablesPath, "utf8") : "";
const present = new Set(parseDevVariableEntries(existing).map((entry) => entry.key));
const appended = [];
const secretsToSet = [];
const lines = [];
for (const variable of envVariables) {
if (variable.secret) {
secretsToSet.push(variable.name);
}
if (present.has(variable.name)) {
continue;
}
if (variable.description) {
lines.push(`# ${variable.description}`);
}
lines.push(`${variable.name}=${variable.secret ? "" : variable.value ?? ""}`);
appended.push(variable.name);
}
if (appended.length > 0) {
const prefix = existing === "" || existing.endsWith("\n") ? existing : `${existing}
`;
writeFileSync(devVariablesPath, `${prefix}${lines.join("\n")}
`, "utf8");
logger.success(`scaffolded ${String(appended.length)} env var(s) into .dev.vars: ${appended.join(", ")}`);
}
if (secretsToSet.length > 0) {
logger.info(`set secret value(s) locally in .dev.vars, then for production: ${secretsToSet.map((name) => `wrangler secret put ${name}`).join("; ")}`);
}
return appended;
};
const ALLOWED_BINDING_ROOTS = /* @__PURE__ */ new Set([
"ai",
"analytics_engine_datasets",
"browser",
"d1_databases",
"durable_objects",
"hyperdrive",
"kv_namespaces",
"mtls_certificates",
"queues",
"r2_buckets",
"send_email",
"services",
"vars",
"vectorize",
"version_metadata",
"workflows"
]);
const applyBindings = (bindings, projectRoot, logger) => {
if (bindings.length === 0) {
return [];
}
const candidates = ["wrangler.jsonc", "wrangler.json"];
const wranglerPath = candidates.map((candidate) => join(projectRoot, candidate)).find((candidate) => existsSync(candidate));
if (!wranglerPath) {
logger.warn("wrangler.jsonc not found — skipping binding updates");
return [];
}
let text = readFileSync(wranglerPath, "utf8");
const applied = [];
const isUnknownArray = (value) => Array.isArray(value);
const readAt = (path) => {
let node = parse(text);
for (const segment of path) {
if (typeof node !== "object" || node === null) {
return void 0;
}
node = node[segment];
}
return node;
};
for (const binding of bindings) {
const root = binding.path[0];
if (root === void 0 || !ALLOWED_BINDING_ROOTS.has(root)) {
logger.warn(
`skipping binding "${binding.path.join(".")}" — only resource bindings (${[...ALLOWED_BINDING_ROOTS].join(", ")}) may be written, not exec/entrypoint keys`
);
continue;
}
let { value } = binding;
if (isUnknownArray(value)) {
const existing = readAt(binding.path);
if (isUnknownArray(existing)) {
const seen = new Set(existing.map((entry) => JSON.stringify(entry)));
value = [...existing, ...value.filter((entry) => !seen.has(JSON.stringify(entry)))];
}
}
const edits = modify(text, [...binding.path], value, {
formattingOptions: { insertSpaces: true, tabSize: 4 }
});
if (edits.length === 0) {
continue;
}
text = applyEdits(text, edits);
applied.push(binding.path.join("."));
}
if (applied.length > 0) {
writeFileSync(wranglerPath, text, "utf8");
logger.success(`applied ${String(applied.length)} binding(s) to ${wranglerPath}: ${applied.join(", ")}`);
}
return applied;
};
const applyItemResources = (manifest, cwd, logger, useUmbrella = false) => {
const deps = [];
const bindings = [];
if (manifest.deps) {
deps.push(...applyDeps(manifest.deps, cwd, logger, "dependencies", useUmbrella));
}
if (manifest.devDependencies) {
deps.push(...applyDeps(manifest.devDependencies, cwd, logger, "devDependencies", useUmbrella));
}
if (manifest.bindings) {
bindings.push(...applyBindings(manifest.bindings, cwd, logger));
}
if (manifest.envVars) {
applyEnvVariables(manifest.envVars, cwd, logger);
}
return { bindings, deps };
};
const confirmDepMutation = async (items, options) => {
const hasDeps = items.some(({ manifest }) => Object.keys(manifest.deps ?? {}).length > 0 || Object.keys(manifest.devDependencies ?? {}).length > 0);
const hasBindings = items.some(({ manifest }) => (manifest.bindings ?? []).length > 0);
const nonDefaultSource = options.source !== void 0 && options.source.length > 0;
if (!hasDeps && !hasBindings && !nonDefaultSource || options.yes) {
return true;
}
const reasons = [];
if (hasDeps) {
reasons.push("add dependencies to package.json");
}
if (hasBindings) {
reasons.push("write wrangler.jsonc bindings");
}
if (nonDefaultSource) {
reasons.push(`come from a non-default source (${String(options.source)})`);
}
const reasonText = reasons.join(", ");
if (!process.stdin.isTTY && options.confirm === void 0) {
options.logger.error(`add: stdin is not a TTY and the requested items ${reasonText} — re-run with --yes to confirm`);
return false;
}
const confirmer = options.confirm ?? tuiConfirm;
const confirmed = await confirmer(`The requested items ${reasonText}. Continue?`);
if (!confirmed) {
options.logger.info("add: aborted");
}
return confirmed;
};
const LOCK_VERSION = 1;
const LOCK_FILE = ".lunora-registry.json";
const lockPath = (projectRoot) => join(projectRoot, "lunora", LOCK_FILE);
const isLockShape = (value) => {
if (typeof value !== "object" || value === null || !("items" in value)) {
return false;
}
return typeof value.items === "object" && value.items !== null;
};
const hashContent = (content) => createHash("sha256").update(content).digest("hex");
const readLock = (projectRoot) => {
const path = lockPath(projectRoot);
if (!existsSync(path)) {
return { items: {}, version: LOCK_VERSION };
}
try {
const parsed = JSON.parse(readFileSync(path, "utf8"));
if (isLockShape(parsed)) {
return { items: parsed.items, version: LOCK_VERSION };
}
} catch {
}
return { items: {}, version: LOCK_VERSION };
};
const writeLock = (projectRoot, lock) => {
writeFileSync(lockPath(projectRoot), `${JSON.stringify(lock, void 0, 2)}
`, "utf8");
};
const recordFile = (lock, itemKey, destinationRelative, content) => {
const existing = lock.items[itemKey];
const item = existing ?? { files: {} };
if (existing === void 0) {
lock.items[itemKey] = item;
}
item.files[destinationRelative] = hashContent(content);
};
const recordedHash = (lock, itemKey, destinationRelative) => lock.items[itemKey]?.files[destinationRelative];
const CONTEXT = 3;
const splitLines = (text) => text === "" ? [] : text.split("\n");
const renderDiff = (oldText, newText) => {
const a = splitLines(oldText);
const b = splitLines(newText);
let start = 0;
while (start < a.length && start < b.length && a[start] === b[start]) {
start += 1;
}
let endA = a.length;
let endB = b.length;
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
endA -= 1;
endB -= 1;
}
if (start === endA && start === endB) {
return [];
}
const out = [];
for (let k = Math.max(0, start - CONTEXT); k < start; k += 1) {
out.push(` ${a[k] ?? ""}`);
}
for (let k = start; k < endA; k += 1) {
out.push(`- ${a[k] ?? ""}`);
}
for (let k = start; k < endB; k += 1) {
out.push(`+ ${b[k] ?? ""}`);
}
for (let k = endB; k < Math.min(b.length, endB + CONTEXT); k += 1) {
out.push(` ${b[k] ?? ""}`);
}
return out;
};
const CODE_FILE_RE = /\.[cm]?[jt]sx?$/u;
const readItemFile = (itemDirectory, file, useUmbrella) => {
const source = readFileSync(join(itemDirectory, file.from), "utf8");
return useUmbrella && CODE_FILE_RE.test(file.to) ? rewriteUmbrellaImports(source) : source;
};
const reconcileSchemaExtension = (file, itemKey, itemDirectory, projectRoot, logger, diff, useUmbrella) => {
const schemaPath = join(projectRoot, "lunora", "schema.ts");
if (diff) {
logger.info(`~ would merge .extend(${itemKey}.extension) into lunora/schema.ts (and create ${file.to} if absent)`);
return { kind: "skipped", path: schemaPath };
}
const destinationPath = join(projectRoot, file.to);
if (!existsSync(destinationPath)) {
mkdirSync(dirname(destinationPath), { recursive: true });
writeFileSync(destinationPath, readItemFile(itemDirectory, file, useUmbrella), "utf8");
}
const baseModule = useUmbrella ? "lunorash/server" : "@lunora/server";
const existingSchema = existsSync(schemaPath) ? readFileSync(schemaPath, "utf8") : `import { defineSchema } from "${baseModule}";
export const schema = defineSchema({});
`;
const result = insertSchemaExtension(existingSchema, itemKey);
if (result.ok) {
mkdirSync(dirname(schemaPath), { recursive: true });
writeFileSync(schemaPath, result.text, "utf8");
logger.success(`merged .extend(${itemKey}.extension) into lunora/schema.ts`);
return { kind: "written", path: schemaPath };
}
if (result.reason === "already-applied") {
logger.warn(`lunora/schema.ts already extends "${itemKey}" — skipping`);
return { kind: "skipped", path: schemaPath };
}
if (result.reason === "invalid-identifier") {
throw new LunoraError(
"INTERNAL",
`schema-extension item "${itemKey}" is not a valid JS identifier — it is spliced into lunora/schema.ts as \`import { ${itemKey} }\` / \`.extend(${itemKey}.extension)\`. Rename the item to a valid identifier (no leading digit, no "-").`
);
}
throw new LunoraError("INTERNAL", `schema-extension merge failed for "${itemKey}": ${result.reason}`);
};
const previewWholeFile = (file, current, incoming, exists, logger) => {
const lines = renderDiff(current, incoming);
if (lines.length === 0) {
logger.info(`= ${file.to} (unchanged)`);
return;
}
logger.info(`${exists ? "~" : "+"} ${file.to}`);
for (const line of lines) {
logger.info(` ${line}`);
}
};
const reconcileWholeFile = (file, itemKey, itemDirectory, projectRoot, logger, lock, reconcileOptions, useUmbrella) => {
const destinationPath = join(projectRoot, file.to);
const incoming = readItemFile(itemDirectory, file, useUmbrella);
const exists = existsSync(destinationPath);
const current = exists ? readFileSync(destinationPath, "utf8") : "";
const write = (message) => {
mkdirSync(dirname(destinationPath), { recursive: true });
writeFileSync(destinationPath, incoming, "utf8");
recordFile(lock, itemKey, file.to, incoming);
logger.success(`${message}: ${file.to}`);
return { kind: "written", path: destinationPath };
};
if (reconcileOptions.diff) {
previewWholeFile(file, current, incoming, exists, logger);
return { kind: "skipped", path: destinationPath };
}
if (!exists) {
return write("write");
}
const currentHash = hashContent(current);
if (currentHash === hashContent(incoming)) {
recordFile(lock, itemKey, file.to, incoming);
logger.warn(`skip (exists): ${file.to}`);
return { kind: "skipped", path: destinationPath };
}
if (reconcileOptions.overwrite) {
return write("overwrite");
}
const base = recordedHash(lock, itemKey, file.to);
if (base === void 0) {
logger.warn(`skip (exists, untracked): ${file.to} — refusing to overwrite a file lunora didn't add (use --overwrite to force)`);
return { kind: "skipped", path: destinationPath };
}
if (base === currentHash) {
return write("update");
}
writeFileSync(`${destinationPath}.new`, incoming, "utf8");
logger.warn(`conflict: ${file.to} has local edits and an upstream update — wrote ${file.to}.new (use --overwrite to take theirs)`);
return { kind: "skipped", path: destinationPath };
};
const reconcileFile = (file, itemKey, itemDirectory, projectRoot, logger, lock, reconcileOptions = {}, useUmbrella = false) => {
if (file.merge === "schema-extension") {
return reconcileSchemaExtension(file, itemKey, itemDirectory, projectRoot, logger, reconcileOptions.diff === true, useUmbrella);
}
return reconcileWholeFile(file, itemKey, itemDirectory, projectRoot, logger, lock, reconcileOptions, useUmbrella);
};
const WORKER_ENTRY_FALLBACKS = ["src/server.ts", "src/server/index.ts", "src/server/index.tsx", "src/index.ts", "src/worker.ts"];
const readWranglerMain = (projectRoot) => {
const wranglerPath = findWranglerFile(projectRoot);
if (wranglerPath === void 0) {
return void 0;
}
const { parsed } = readWranglerJsonc(wranglerPath);
return typeof parsed?.main === "string" ? parsed.main : void 0;
};
const findWorkerEntry = (projectRoot) => {
const main = readWranglerMain(projectRoot);
const candidates = main === void 0 ? WORKER_ENTRY_FALLBACKS : [main, ...WORKER_ENTRY_FALLBACKS];
for (const candidate of candidates) {
const absolute = join(projectRoot, candidate);
if (!existsSync(absolute)) {
continue;
}
const content = readFileSync(absolute, "utf8");
if (!content.includes("createShardDO(")) {
if (candidate === main) {
break;
}
continue;
}
return { entryPath: absolute, main: candidate, source: content };
}
return void 0;
};
const computeRelativeSpecifier = (entryPath, projectRoot, moduleName) => {
const importPath = relative(dirname(entryPath), join(projectRoot, "lunora", moduleName)).replaceAll("\\", "/");
return importPath.startsWith(".") ? importPath : `./${importPath}`;
};
const logClassAFallback = (entrypointReexports, logger) => {
for (const reexport of entrypointReexports) {
const specifier = `./lunora/${reexport.module}.js`;
const instruction = `Add \`export * from "${specifier}"\` to your worker entry`;
const suffix = reexport.comment ? ` (${reexport.comment})` : "";
logger.warn(`${instruction}${suffix}`);
}
return 0;
};
const buildReexportLines = (entrypointReexports, entryPath, projectRoot, source) => {
const lines = [];
for (const reexport of entrypointReexports) {
const specifier = computeRelativeSpecifier(entryPath, projectRoot, reexport.module);
const escapedSpecifier = specifier.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
const existingRe = new RegExp(String.raw`export\s+\*\s+from\s+["']${escapedSpecifier}\.js["']`, "u");
if (existingRe.test(source)) {
continue;
}
if (reexport.comment) {
lines.push(`
// ${reexport.comment}`);
}
lines.push(`export * from "${specifier}.js";`);
}
return lines;
};
const applyEntrypointReexports = (entrypointReexports, projectRoot, logger, diff) => {
if (entrypointReexports.length === 0) {
return 0;
}
const entry = findWorkerEntry(projectRoot);
if (entry === void 0) {
return logClassAFallback(entrypointReexports, logger);
}
const linesToAppend = buildReexportLines(entrypointReexports, entry.entryPath, projectRoot, entry.source);
if (linesToAppend.length === 0) {
return 0;
}
if (diff) {
for (const line of linesToAppend) {
if (line !== "") {
logger.info(`~ entrypoint: ${line}`);
}
}
return linesToAppend.length;
}
const separator = entry.source.endsWith("\n") ? "" : "\n";
writeFileSync(entry.entryPath, `${entry.source}${separator}${linesToAppend.join("\n")}
`, "utf8");
logger.success(`wrote ${String(linesToAppend.length)} entrypoint re-export(s) to ${relative(projectRoot, entry.entryPath)}`);
return linesToAppend.length;
};
const reconcileItems = (items, cwd, logger, reconcileOptions = {}) => {
const written = [];
const skipped = [];
const depsAdded = [];
const bindingsApplied = [];
const lock = readLock(cwd);
const useUmbrella = projectUsesUmbrella(cwd);
for (const { directory, manifest } of items) {
for (const file of manifest.files) {
const outcome = reconcileFile(file, manifest.name, directory, cwd, logger, lock, reconcileOptions, useUmbrella);
(outcome.kind === "written" ? written : skipped).push(outcome.path);
}
if (manifest.entrypointReexports !== void 0) {
applyEntrypointReexports(manifest.entrypointReexports, cwd, logger, reconcileOptions.diff === true);
}
if (reconcileOptions.diff) {
continue;
}
const applied = applyItemResources(manifest, cwd, logger, useUmbrella);
depsAdded.push(...applied.deps);
bindingsApplied.push(...applied.bindings);
}
if (!reconcileOptions.diff && Object.keys(lock.items).length > 0) {
writeLock(cwd, lock);
}
return { bindings: bindingsApplied, deps: depsAdded, skipped, written };
};
const DEFAULT_SOURCE_BASE = "gh:anolilab/lunora/registry";
const VALID_ITEM_NAME = /^[A-Za-z0-9][\w-]*$/u;
const assertSafeItemName = (name) => {
if (!VALID_ITEM_NAME.test(name)) {
throw new LunoraError(
"INTERNAL",
`invalid registry item name "${name}" — names must match ${VALID_ITEM_NAME.source} (letters, digits, "-", "_"; no path separators or "..")`
);
}
};
const isSafeSource = (source) => {
if (source.includes("..")) {
return false;
}
return source.startsWith("gh:") || source.startsWith("github:") || source.startsWith("https://");
};
const isBlockedRemoteSource = (options) => options.from === void 0 && options.source !== void 0 && options.source.length > 0 && !options.allowUnsafeSource && !isSafeSource(options.source);
const sourceGateError = (command, options) => isBlockedRemoteSource(options) ? `${command}: refusing --source ${String(options.source)} — only gh:, github:, or https:// sources are allowed (and may not contain ".."). Re-run with --allow-unsafe-source if you really want this.` : void 0;
const fetchToStaging = async (remote, label, logger) => {
const stagingRoot = mkdtempSync(join(tmpdir(), `lunora-${label}-fetch-`));
const stagingDirectory = join(stagingRoot, label);
logger.info(`fetching ${remote}`);
try {
const downloaded = await downloadTemplate(remote, {
cwd: stagingRoot,
dir: stagingDirectory,
force: true,
install: false,
silent: true
});
logger.info(downloaded.commit ? `resolved ${downloaded.source} @ ${downloaded.commit}` : `resolved ${downloaded.source}`);
return {
cleanup: () => {
rmSync(stagingRoot, { force: true, recursive: true });
},
directory: stagingDirectory
};
} catch (error) {
rmSync(stagingRoot, { force: true, recursive: true });
throw error;
}
};
const remoteRefCache = /* @__PURE__ */ new WeakMap();
const resolveRemoteRef = async (options) => {
const cached = remoteRefCache.get(options);
if (cached !== void 0) {
return cached;
}
const pending = options.source !== void 0 && options.source.length > 0 ? Promise.resolve(resolveSourceRef(options.ref)) : resolvePinnedSourceRef(options.ref, options.logger);
remoteRefCache.set(options, pending);
return pending;
};
const resolveItemDirectory = async (name, options) => {
assertSafeItemName(name);
if (options.from !== void 0) {
const directory = join(options.from, name);
if (!existsSync(directory)) {
throw new LunoraError("INTERNAL", `registry item not found in local source: ${directory}`);
}
return { cleanup: () => {
}, directory };
}
const base = options.source ?? DEFAULT_SOURCE_BASE;
return fetchToStaging(`${base}/${name}#${await resolveRemoteRef(options)}`, "item", options.logger);
};
const resolveRegistryRoot = async (options) => {
if (options.from !== void 0) {
if (!existsSync(options.from)) {
throw new LunoraError("INTERNAL", `registry root not found: ${options.from}`);
}
return { cleanup: () => {
}, root: options.from };
}
const base = options.source ?? DEFAULT_SOURCE_BASE;
const { cleanup, directory } = await fetchToStaging(`${base}#${await resolveRemoteRef(options)}`, "registry", options.logger);
return { cleanup, root: directory };
};
const readManifest = (itemDirectory, name) => {
const raw = JSON.parse(readFileSync(join(itemDirectory, "registry.json"), "utf8"));
return parseManifest(raw, name);
};
const resolvePlan = async (names, options) => {
const items = [];
const cleanups = [];
const seen = /* @__PURE__ */ new Set();
const inProgress = /* @__PURE__ */ new Set();
const visit = async (name) => {
if (seen.has(name)) {
return;
}
if (inProgress.has(name)) {
throw new LunoraError("INTERNAL", `cyclic registry dependency detected at "${name}"`);
}
inProgress.add(name);
const { cleanup, directory } = await resolveItemDirectory(name, options);
cleanups.push(cleanup);
const manifest = readManifest(directory, name);
for (const requirement of manifest.requires ?? []) {
await visit(requirement);
}
inProgress.delete(name);
seen.add(name);
items.push({ directory, manifest });
};
try {
for (const name of names) {
await visit(name);
}
} catch (error) {
for (const cleanup of cleanups) {
cleanup();
}
throw error;
}
return { cleanups, items };
};
const emptyResult = () => {
return { bindings: [], code: 0, deps: [], skipped: [], written: [] };
};
const setBindingField = (manifest, section, match, field, fieldValue) => {
if (!manifest.bindings) {
return manifest;
}
return {
...manifest,
bindings: manifest.bindings.map((binding) => {
if (binding.path[0] !== section || !Array.isArray(binding.value)) {
return binding;
}
const entries = binding.value;
return {
...binding,
value: entries.map(
(entry) => typeof entry === "object" && entry !== null && entry[match.key] === match.value ? { ...entry, [field]: fieldValue } : entry
)
};
})
};
};
const printPlan = (logger, manifest) => {
const label = manifest.title ?? manifest.description;
logger.info(`plan: ${manifest.name}${label ? ` — ${label}` : ""}`);
for (const file of manifest.files) {
logger.info(` file ${file.to} (${file.merge})`);
}
for (const [dep, range] of Object.entries(manifest.deps ?? {})) {
logger.info(` dep ${dep}@${range}`);
}
for (const [dep, range] of Object.entries(manifest.devDependencies ?? {})) {
logger.info(` dev ${dep}@${range}`);
}
for (const binding of manifest.bindings ?? []) {
logger.info(` bind ${binding.path.join(".")} = ${JSON.stringify(binding.value)}`);
}
for (const variable of manifest.envVars ?? []) {
const valueSuffix = variable.secret ? " (secret)" : ` = ${JSON.stringify(variable.value ?? "")}`;
logger.info(` env ${variable.name}${valueSuffix}`);
}
for (const reexport of manifest.entrypointReexports ?? []) {
const specifier = `./lunora/${reexport.module}`;
const suffix = reexport.comment ? ` // ${reexport.comment}` : "";
logger.info(` entry ${specifier}${suffix}`);
}
};
const printJsonPlan = (items) => {
const planSnapshot = items.map(({ manifest }) => {
return {
// Include the concrete value so a JSON-plan consumer can audit the
// mutation (not just the key path) before it is applied.
bindings: (manifest.bindings ?? []).map((binding) => {
return { path: binding.path.join("."), value: binding.value };
}),
deps: Object.keys(manifest.deps ?? {}),
devDependencies: Object.keys(manifest.devDependencies ?? {}),
entrypointReexports: (manifest.entrypointReexports ?? []).map((reexport) => {
return { module: reexport.module, ...reexport.comment ? { comment: reexport.comment } : {} };
}),
envVars: (manifest.envVars ?? []).map((variable) => {
return { name: variable.name, ...variable.secret ? { secret: true } : { value: variable.value ?? "" } };
}),
files: manifest.files.map((file) => {
return { merge: file.merge, to: file.to };
}),
name: manifest.name,
requires: manifest.requires ?? [],
title: manifest.title
};
});
process.stdout.write(`${JSON.stringify({ items: planSnapshot }, void 0, 2)}
`);
};
const reportAddResult = (items, deps, written, skipped, logger) => {
logger.success(`add complete: ${String(written)} written, ${String(skipped)} skipped`);
logger.info("next steps:");
logger.info(" lunora codegen # regenerate _generated/ so the new tables/functions appear");
if (deps.length > 0) {
logger.info(" pnpm install # install newly-added dependencies");
}
for (const { manifest } of items) {
if (manifest.docs) {
logger.info(`${manifest.name}: ${manifest.docs}`);
}
}
};
const runListCommand = async (options) => {
const empty = emptyResult();
const gate = sourceGateError("list", options);
if (gate) {
options.logger.error(gate);
return { ...empty, code: 1 };
}
let cleanup = () => {
};
try {
const resolved = await resolveRegistryRoot(options);
cleanup = resolved.cleanup;
const items = collectCatalog(resolved.root);
if (options.json) {
process.stdout.write(`${JSON.stringify(items, void 0, 2)}
`);
return empty;
}
options.logger.info(`available registry items (${String(items.length)}):`);
for (const item of items) {
options.logger.info(` ${item.name}${item.description ? ` — ${item.description}` : ""}`);
}
return empty;
} catch (error) {
options.logger.error(`list failed: ${error instanceof Error ? error.message : String(error)}`);
return { ...empty, code: 1 };
} finally {
cleanup();
}
};
const runAddCommand = async (options) => {
const cwd = options.cwd ?? process.cwd();
const empty = emptyResult();
if (options.list) {
return runListCommand(options);
}
if (options.names.length === 0) {
options.logger.error("add requires at least one item name. Usage: lunora registry add <name> [...names]");
return { ...empty, code: 1 };
}
const gate = sourceGateError("add", options);
if (gate) {
options.logger.error(gate);
return { ...empty, code: 1 };
}
let cleanups = [];
try {
const { cleanups: planCleanups, items: resolvedItems } = await resolvePlan(options.names, options);
cleanups = planCleanups;
const { transformManifest } = options;
const items = transformManifest ? resolvedItems.map((item) => {
return { ...item, manifest: transformManifest(item.manifest) };
}) : resolvedItems;
for (const { manifest } of items) {
printPlan(options.logger, manifest);
}
if (options.json) {
printJsonPlan(items);
}
if (options.dryRun) {
options.logger.info("dry-run: stopping before any files are written");
return empty;
}
if (options.diff) {
reconcileItems(items, cwd, options.logger, { diff: true });
options.logger.info("diff: preview only — re-run without --diff to apply");
return empty;
}
if (!await confirmDepMutation(items, options)) {
return { ...empty, code: 1 };
}
const { bindings, deps, skipped, written } = reconcileItems(items, cwd, options.logger, { overwrite: options.overwrite });
reportAddResult(items, deps, written.length, skipped.length, options.logger);
return { bindings, code: 0, deps, skipped, written };
} catch (error) {
options.logger.error(`add failed: ${error instanceof Error ? error.message : String(error)}`);
return { ...empty, code: 1 };
} finally {
for (const cleanup of cleanups) {
cleanup();
}
}
};
const runRegistryViewCommand = async (options) => {
const empty = emptyResult();
if (options.names.length === 0) {
options.logger.error("view requires an item name. Usage: lunora registry view <name>");
return { ...empty, code: 1 };
}
const gate = sourceGateError("view", options);
if (gate) {
options.logger.error(gate);
return { ...empty, code: 1 };
}
const cleanups = [];
try {
for (const name of options.names) {
const { cleanup, directory } = await resolveItemDirectory(name, options);
cleanups.push(cleanup);
const manifest = readManifest(directory, name);
printPlan(options.logger, manifest);
for (const file of manifest.files) {
options.logger.info(`--- ${file.to} (${file.merge}) ---`);
const content = readFileSync(join(directory, file.from), "utf8");
for (const line of content.split("\n")) {
options.logger.info(line);
}
}
}
return empty;
} catch (error) {
options.logger.error(`view failed: ${error instanceof Error ? error.message : String(error)}`);
return { ...empty, code: 1 };
} finally {
for (const cleanup of cleanups) {
cleanup();
}
}
};
const runBuildIndexCommand = async (options) => {
const empty = emptyResult();
const root = options.from;
if (root === void 0) {
options.logger.error("registry build requires --from <registry root>");
return { ...empty, code: 1 };
}
if (!existsSync(root)) {
options.logger.error(`registry root not found: ${root}`);
return { ...empty, code: 1 };
}
const index = buildRegistryIndex(root);
const outputPath = options.out ?? join(root, "index.json");
if (options.check) {
const current = existsSync(outputPath) ? JSON.parse(readFileSync(outputPath, "utf8")) : { items: [] };
const drift = JSON.stringify(current.items ?? []) !== JSON.stringify(index.items);
if (drift) {
options.logger.error(`registry: ${outputPath} is stale — run \`lunora registry build\` to regenerate it`);
return { ...empty, code: 1 };
}
options.logger.success(`registry: ${outputPath} is up to date (${String(index.items.length)} items)`);
return empty;
}
writeFileSync(outputPath, `${JSON.stringify({ $schema: "./schema/registry.schema.json", ...index }, void 0, 4)}
`, "utf8");
options.logger.success(`registry: wrote ${outputPath} (${String(index.items.length)} items)`);
return empty;
};
export { runBuildIndexCommand as a, runRegistryViewCommand as b, resolveTagVersions as c, resolveSourceRef as d, resolvePinnedSourceRef as e, resolveDistTag as f, runListCommand as g, runAddCommand as r, setBindingField as s };