UNPKG

@lunora/cli

Version:

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

992 lines (981 loc) 38 kB
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 };