@lunora/cli 0.0.0 → 1.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE.md +105 -0
  2. package/README.md +109 -9
  3. package/__assets__/package-og.svg +14 -0
  4. package/dist/bin.mjs +11 -0
  5. package/dist/index.d.mts +852 -0
  6. package/dist/index.d.ts +852 -0
  7. package/dist/index.mjs +19 -0
  8. package/dist/packem_chunks/handler.mjs +76 -0
  9. package/dist/packem_chunks/handler10.mjs +22 -0
  10. package/dist/packem_chunks/handler11.mjs +192 -0
  11. package/dist/packem_chunks/handler12.mjs +131 -0
  12. package/dist/packem_chunks/handler13.mjs +65 -0
  13. package/dist/packem_chunks/handler14.mjs +58 -0
  14. package/dist/packem_chunks/handler15.mjs +79 -0
  15. package/dist/packem_chunks/handler16.mjs +41 -0
  16. package/dist/packem_chunks/handler17.mjs +105 -0
  17. package/dist/packem_chunks/handler18.mjs +172 -0
  18. package/dist/packem_chunks/handler19.mjs +89 -0
  19. package/dist/packem_chunks/handler2.mjs +114 -0
  20. package/dist/packem_chunks/handler20.mjs +94 -0
  21. package/dist/packem_chunks/handler21.mjs +311 -0
  22. package/dist/packem_chunks/handler3.mjs +204 -0
  23. package/dist/packem_chunks/handler4.mjs +33 -0
  24. package/dist/packem_chunks/handler5.mjs +49 -0
  25. package/dist/packem_chunks/handler6.mjs +91 -0
  26. package/dist/packem_chunks/handler7.mjs +42 -0
  27. package/dist/packem_chunks/handler8.mjs +174 -0
  28. package/dist/packem_chunks/handler9.mjs +16 -0
  29. package/dist/packem_chunks/planDevCommand.mjs +543 -0
  30. package/dist/packem_chunks/runCodegenCommand.mjs +52 -0
  31. package/dist/packem_chunks/runDeployCommand.mjs +504 -0
  32. package/dist/packem_chunks/runInitCommand.mjs +652 -0
  33. package/dist/packem_chunks/runMigrateGenerateCommand.mjs +397 -0
  34. package/dist/packem_chunks/runResetCommand.mjs +41 -0
  35. package/dist/packem_chunks/runRpcCommand.mjs +68 -0
  36. package/dist/packem_shared/COMMANDS-1V_KEx35.mjs +905 -0
  37. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +244 -0
  38. package/dist/packem_shared/admin-url-4UzT-CI4.mjs +19 -0
  39. package/dist/packem_shared/api-spec-CtA6ilu4.mjs +13 -0
  40. package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +38 -0
  41. package/dist/packem_shared/command-BDXcJCCJ.mjs +14 -0
  42. package/dist/packem_shared/createLogger-CHPNjFw2.mjs +73 -0
  43. package/dist/packem_shared/defaultSpawner-DxI3mebw.mjs +43 -0
  44. package/dist/packem_shared/diffSnapshots-RR2ZE8Ya.mjs +161 -0
  45. package/dist/packem_shared/docker-hMQ97KSQ.mjs +21 -0
  46. package/dist/packem_shared/features-ocSSpZtS.mjs +24 -0
  47. package/dist/packem_shared/insertSchemaExtension-BuzF6-t2.mjs +59 -0
  48. package/dist/packem_shared/open-url-Dfq6fAyT.mjs +41 -0
  49. package/dist/packem_shared/output-format-7gyGR3h8.mjs +17 -0
  50. package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +56 -0
  51. package/dist/packem_shared/parseManifest--vZf2FY1.mjs +94 -0
  52. package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +16 -0
  53. package/dist/packem_shared/runAddCommand-BZGkRnBs.mjs +693 -0
  54. package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +79 -0
  55. package/dist/packem_shared/schemaIrToSnapshot-aBTo7TM5.mjs +43 -0
  56. package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +12 -0
  57. package/package.json +61 -18
  58. package/skills/README.md +29 -0
  59. package/skills/lunora/SKILL.md +83 -0
  60. package/skills/lunora-create-package/SKILL.md +129 -0
  61. package/skills/lunora-deploy/SKILL.md +150 -0
  62. package/skills/lunora-functions/SKILL.md +182 -0
  63. package/skills/lunora-migration-helper/SKILL.md +194 -0
  64. package/skills/lunora-performance-audit/SKILL.md +143 -0
  65. package/skills/lunora-quickstart/SKILL.md +240 -0
  66. package/skills/lunora-realtime/SKILL.md +177 -0
  67. package/skills/lunora-setup-auth/SKILL.md +170 -0
  68. package/skills/lunora-setup-hyperdrive/SKILL.md +154 -0
  69. package/skills/lunora-setup-hyperdrive-global/SKILL.md +171 -0
  70. package/skills/lunora-setup-mail/SKILL.md +151 -0
  71. package/skills/lunora-setup-scheduler/SKILL.md +157 -0
  72. package/skills/lunora-setup-storage/SKILL.md +154 -0
@@ -0,0 +1,693 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
2
+ import { join, dirname } from '@visulima/path';
3
+ import { DEV_VARS_FILE, parseDevVariableEntries, promptYesNo } from '@lunora/config';
4
+ import { modify, applyEdits, parse } from 'jsonc-parser';
5
+ import { collectCatalog, buildRegistryIndex } from './buildRegistryIndex-BcYe607_.mjs';
6
+ import { insertSchemaExtension } from './insertSchemaExtension-BuzF6-t2.mjs';
7
+ import { createHash } from 'node:crypto';
8
+ import { tmpdir } from 'node:os';
9
+ import { downloadTemplate } from 'giget';
10
+ import parseManifest from './parseManifest--vZf2FY1.mjs';
11
+
12
+ const applyDeps = (deps, projectRoot, logger, section = "dependencies") => {
13
+ const entries = Object.entries(deps);
14
+ if (entries.length === 0) {
15
+ return [];
16
+ }
17
+ const packageJsonPath = join(projectRoot, "package.json");
18
+ if (!existsSync(packageJsonPath)) {
19
+ logger.warn(`package.json not found at ${packageJsonPath} — skipping dependency updates`);
20
+ return [];
21
+ }
22
+ let text = readFileSync(packageJsonPath, "utf8");
23
+ const parsed = JSON.parse(text);
24
+ const added = [];
25
+ for (const [name, range] of entries) {
26
+ if (parsed.dependencies?.[name] !== void 0 || parsed.devDependencies?.[name] !== void 0) {
27
+ logger.info(`dep already present: ${name}`);
28
+ continue;
29
+ }
30
+ const edits = modify(text, [section, name], range, {
31
+ formattingOptions: { insertSpaces: true, tabSize: 4 }
32
+ });
33
+ text = applyEdits(text, edits);
34
+ added.push(name);
35
+ }
36
+ if (added.length > 0) {
37
+ writeFileSync(packageJsonPath, text, "utf8");
38
+ logger.success(
39
+ `added ${String(added.length)} ${section === "devDependencies" ? "devDependency(ies)" : "dependency(ies)"} to package.json: ${added.join(", ")}`
40
+ );
41
+ }
42
+ return added;
43
+ };
44
+ const applyEnvVariables = (envVariables, projectRoot, logger) => {
45
+ if (envVariables.length === 0) {
46
+ return [];
47
+ }
48
+ const devVariablesPath = join(projectRoot, DEV_VARS_FILE);
49
+ const existing = existsSync(devVariablesPath) ? readFileSync(devVariablesPath, "utf8") : "";
50
+ const present = new Set(parseDevVariableEntries(existing).map((entry) => entry.key));
51
+ const appended = [];
52
+ const secretsToSet = [];
53
+ const lines = [];
54
+ for (const variable of envVariables) {
55
+ if (variable.secret) {
56
+ secretsToSet.push(variable.name);
57
+ }
58
+ if (present.has(variable.name)) {
59
+ continue;
60
+ }
61
+ if (variable.description) {
62
+ lines.push(`# ${variable.description}`);
63
+ }
64
+ lines.push(`${variable.name}=${variable.secret ? "" : variable.value ?? ""}`);
65
+ appended.push(variable.name);
66
+ }
67
+ if (appended.length > 0) {
68
+ const prefix = existing === "" || existing.endsWith("\n") ? existing : `${existing}
69
+ `;
70
+ writeFileSync(devVariablesPath, `${prefix}${lines.join("\n")}
71
+ `, "utf8");
72
+ logger.success(`scaffolded ${String(appended.length)} env var(s) into .dev.vars: ${appended.join(", ")}`);
73
+ }
74
+ if (secretsToSet.length > 0) {
75
+ logger.info(`set secret value(s) locally in .dev.vars, then for production: ${secretsToSet.map((name) => `wrangler secret put ${name}`).join("; ")}`);
76
+ }
77
+ return appended;
78
+ };
79
+ const ALLOWED_BINDING_ROOTS = /* @__PURE__ */ new Set([
80
+ "ai",
81
+ "analytics_engine_datasets",
82
+ "browser",
83
+ "d1_databases",
84
+ "durable_objects",
85
+ "hyperdrive",
86
+ "kv_namespaces",
87
+ "mtls_certificates",
88
+ "queues",
89
+ "r2_buckets",
90
+ "send_email",
91
+ "services",
92
+ "vars",
93
+ "vectorize",
94
+ "version_metadata",
95
+ "workflows"
96
+ ]);
97
+ const applyBindings = (bindings, projectRoot, logger) => {
98
+ if (bindings.length === 0) {
99
+ return [];
100
+ }
101
+ const candidates = ["wrangler.jsonc", "wrangler.json"];
102
+ const wranglerPath = candidates.map((candidate) => join(projectRoot, candidate)).find((candidate) => existsSync(candidate));
103
+ if (!wranglerPath) {
104
+ logger.warn("wrangler.jsonc not found — skipping binding updates");
105
+ return [];
106
+ }
107
+ let text = readFileSync(wranglerPath, "utf8");
108
+ const applied = [];
109
+ const isUnknownArray = (value) => Array.isArray(value);
110
+ const readAt = (path) => {
111
+ let node = parse(text);
112
+ for (const segment of path) {
113
+ if (typeof node !== "object" || node === null) {
114
+ return void 0;
115
+ }
116
+ node = node[segment];
117
+ }
118
+ return node;
119
+ };
120
+ for (const binding of bindings) {
121
+ const root = binding.path[0];
122
+ if (root === void 0 || !ALLOWED_BINDING_ROOTS.has(root)) {
123
+ logger.warn(
124
+ `skipping binding "${binding.path.join(".")}" — only resource bindings (${[...ALLOWED_BINDING_ROOTS].join(", ")}) may be written, not exec/entrypoint keys`
125
+ );
126
+ continue;
127
+ }
128
+ let { value } = binding;
129
+ if (isUnknownArray(value)) {
130
+ const existing = readAt(binding.path);
131
+ if (isUnknownArray(existing)) {
132
+ const seen = new Set(existing.map((entry) => JSON.stringify(entry)));
133
+ value = [...existing, ...value.filter((entry) => !seen.has(JSON.stringify(entry)))];
134
+ }
135
+ }
136
+ const edits = modify(text, [...binding.path], value, {
137
+ formattingOptions: { insertSpaces: true, tabSize: 4 }
138
+ });
139
+ if (edits.length === 0) {
140
+ continue;
141
+ }
142
+ text = applyEdits(text, edits);
143
+ applied.push(binding.path.join("."));
144
+ }
145
+ if (applied.length > 0) {
146
+ writeFileSync(wranglerPath, text, "utf8");
147
+ logger.success(`applied ${String(applied.length)} binding(s) to ${wranglerPath}: ${applied.join(", ")}`);
148
+ }
149
+ return applied;
150
+ };
151
+ const applyItemResources = (manifest, cwd, logger) => {
152
+ const deps = [];
153
+ const bindings = [];
154
+ if (manifest.deps) {
155
+ deps.push(...applyDeps(manifest.deps, cwd, logger));
156
+ }
157
+ if (manifest.devDependencies) {
158
+ deps.push(...applyDeps(manifest.devDependencies, cwd, logger, "devDependencies"));
159
+ }
160
+ if (manifest.bindings) {
161
+ bindings.push(...applyBindings(manifest.bindings, cwd, logger));
162
+ }
163
+ if (manifest.envVars) {
164
+ applyEnvVariables(manifest.envVars, cwd, logger);
165
+ }
166
+ return { bindings, deps };
167
+ };
168
+ const confirmDepMutation = async (items, options) => {
169
+ const hasDeps = items.some(({ manifest }) => Object.keys(manifest.deps ?? {}).length > 0 || Object.keys(manifest.devDependencies ?? {}).length > 0);
170
+ const hasBindings = items.some(({ manifest }) => (manifest.bindings ?? []).length > 0);
171
+ const nonDefaultSource = options.source !== void 0 && options.source.length > 0;
172
+ if (!hasDeps && !hasBindings && !nonDefaultSource || options.yes) {
173
+ return true;
174
+ }
175
+ const reasons = [];
176
+ if (hasDeps) {
177
+ reasons.push("add dependencies to package.json");
178
+ }
179
+ if (hasBindings) {
180
+ reasons.push("write wrangler.jsonc bindings");
181
+ }
182
+ if (nonDefaultSource) {
183
+ reasons.push(`come from a non-default source (${String(options.source)})`);
184
+ }
185
+ const reasonText = reasons.join(", ");
186
+ if (!process.stdin.isTTY && options.confirm === void 0) {
187
+ options.logger.error(`add: stdin is not a TTY and the requested items ${reasonText} — re-run with --yes to confirm`);
188
+ return false;
189
+ }
190
+ const confirmer = options.confirm ?? promptYesNo;
191
+ const confirmed = await confirmer(`The requested items ${reasonText}. Continue? [y/N] `);
192
+ if (!confirmed) {
193
+ options.logger.info("add: aborted");
194
+ }
195
+ return confirmed;
196
+ };
197
+
198
+ const LOCK_VERSION = 1;
199
+ const LOCK_FILE = ".lunora-registry.json";
200
+ const lockPath = (projectRoot) => join(projectRoot, "lunora", LOCK_FILE);
201
+ const isLockShape = (value) => {
202
+ if (typeof value !== "object" || value === null || !("items" in value)) {
203
+ return false;
204
+ }
205
+ return typeof value.items === "object" && value.items !== null;
206
+ };
207
+ const hashContent = (content) => createHash("sha256").update(content).digest("hex");
208
+ const readLock = (projectRoot) => {
209
+ const path = lockPath(projectRoot);
210
+ if (!existsSync(path)) {
211
+ return { items: {}, version: LOCK_VERSION };
212
+ }
213
+ try {
214
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
215
+ if (isLockShape(parsed)) {
216
+ return { items: parsed.items, version: LOCK_VERSION };
217
+ }
218
+ } catch {
219
+ }
220
+ return { items: {}, version: LOCK_VERSION };
221
+ };
222
+ const writeLock = (projectRoot, lock) => {
223
+ writeFileSync(lockPath(projectRoot), `${JSON.stringify(lock, void 0, 2)}
224
+ `, "utf8");
225
+ };
226
+ const recordFile = (lock, itemKey, destinationRelative, content) => {
227
+ const existing = lock.items[itemKey];
228
+ const item = existing ?? { files: {} };
229
+ if (existing === void 0) {
230
+ lock.items[itemKey] = item;
231
+ }
232
+ item.files[destinationRelative] = hashContent(content);
233
+ };
234
+ const recordedHash = (lock, itemKey, destinationRelative) => lock.items[itemKey]?.files[destinationRelative];
235
+
236
+ const CONTEXT = 3;
237
+ const splitLines = (text) => text === "" ? [] : text.split("\n");
238
+ const renderDiff = (oldText, newText) => {
239
+ const a = splitLines(oldText);
240
+ const b = splitLines(newText);
241
+ let start = 0;
242
+ while (start < a.length && start < b.length && a[start] === b[start]) {
243
+ start += 1;
244
+ }
245
+ let endA = a.length;
246
+ let endB = b.length;
247
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
248
+ endA -= 1;
249
+ endB -= 1;
250
+ }
251
+ if (start === endA && start === endB) {
252
+ return [];
253
+ }
254
+ const out = [];
255
+ for (let k = Math.max(0, start - CONTEXT); k < start; k += 1) {
256
+ out.push(` ${a[k] ?? ""}`);
257
+ }
258
+ for (let k = start; k < endA; k += 1) {
259
+ out.push(`- ${a[k] ?? ""}`);
260
+ }
261
+ for (let k = start; k < endB; k += 1) {
262
+ out.push(`+ ${b[k] ?? ""}`);
263
+ }
264
+ for (let k = endB; k < Math.min(b.length, endB + CONTEXT); k += 1) {
265
+ out.push(` ${b[k] ?? ""}`);
266
+ }
267
+ return out;
268
+ };
269
+
270
+ const reconcileSchemaExtension = (file, itemKey, itemDirectory, projectRoot, logger, diff) => {
271
+ const schemaPath = join(projectRoot, "lunora", "schema.ts");
272
+ if (diff) {
273
+ logger.info(`~ would merge .extend(${itemKey}.extension) into lunora/schema.ts (and create ${file.to} if absent)`);
274
+ return { kind: "skipped", path: schemaPath };
275
+ }
276
+ const destinationPath = join(projectRoot, file.to);
277
+ if (!existsSync(destinationPath)) {
278
+ mkdirSync(dirname(destinationPath), { recursive: true });
279
+ writeFileSync(destinationPath, readFileSync(join(itemDirectory, file.from), "utf8"), "utf8");
280
+ }
281
+ const existingSchema = existsSync(schemaPath) ? readFileSync(schemaPath, "utf8") : 'import { defineSchema } from "@lunora/server";\n\nexport const schema = defineSchema({});\n';
282
+ const result = insertSchemaExtension(existingSchema, itemKey);
283
+ if (result.ok) {
284
+ mkdirSync(dirname(schemaPath), { recursive: true });
285
+ writeFileSync(schemaPath, result.text, "utf8");
286
+ logger.success(`merged .extend(${itemKey}.extension) into lunora/schema.ts`);
287
+ return { kind: "written", path: schemaPath };
288
+ }
289
+ if (result.reason === "already-applied") {
290
+ logger.warn(`lunora/schema.ts already extends "${itemKey}" — skipping`);
291
+ return { kind: "skipped", path: schemaPath };
292
+ }
293
+ if (result.reason === "invalid-identifier") {
294
+ throw new Error(
295
+ `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 "-").`
296
+ );
297
+ }
298
+ throw new Error(`schema-extension merge failed for "${itemKey}": ${result.reason}`);
299
+ };
300
+ const previewWholeFile = (file, current, incoming, exists, logger) => {
301
+ const lines = renderDiff(current, incoming);
302
+ if (lines.length === 0) {
303
+ logger.info(`= ${file.to} (unchanged)`);
304
+ return;
305
+ }
306
+ logger.info(`${exists ? "~" : "+"} ${file.to}`);
307
+ for (const line of lines) {
308
+ logger.info(` ${line}`);
309
+ }
310
+ };
311
+ const reconcileWholeFile = (file, itemKey, itemDirectory, projectRoot, logger, lock, reconcileOptions) => {
312
+ const destinationPath = join(projectRoot, file.to);
313
+ const incoming = readFileSync(join(itemDirectory, file.from), "utf8");
314
+ const exists = existsSync(destinationPath);
315
+ const current = exists ? readFileSync(destinationPath, "utf8") : "";
316
+ const write = (message) => {
317
+ mkdirSync(dirname(destinationPath), { recursive: true });
318
+ writeFileSync(destinationPath, incoming, "utf8");
319
+ recordFile(lock, itemKey, file.to, incoming);
320
+ logger.success(`${message}: ${file.to}`);
321
+ return { kind: "written", path: destinationPath };
322
+ };
323
+ if (reconcileOptions.diff) {
324
+ previewWholeFile(file, current, incoming, exists, logger);
325
+ return { kind: "skipped", path: destinationPath };
326
+ }
327
+ if (!exists) {
328
+ return write("write");
329
+ }
330
+ const currentHash = hashContent(current);
331
+ if (currentHash === hashContent(incoming)) {
332
+ recordFile(lock, itemKey, file.to, incoming);
333
+ logger.warn(`skip (exists): ${file.to}`);
334
+ return { kind: "skipped", path: destinationPath };
335
+ }
336
+ if (reconcileOptions.overwrite) {
337
+ return write("overwrite");
338
+ }
339
+ const base = recordedHash(lock, itemKey, file.to);
340
+ if (base === void 0) {
341
+ logger.warn(`skip (exists, untracked): ${file.to} — refusing to overwrite a file lunora didn't add (use --overwrite to force)`);
342
+ return { kind: "skipped", path: destinationPath };
343
+ }
344
+ if (base === currentHash) {
345
+ return write("update");
346
+ }
347
+ writeFileSync(`${destinationPath}.new`, incoming, "utf8");
348
+ logger.warn(`conflict: ${file.to} has local edits and an upstream update — wrote ${file.to}.new (use --overwrite to take theirs)`);
349
+ return { kind: "skipped", path: destinationPath };
350
+ };
351
+ const reconcileFile = (file, itemKey, itemDirectory, projectRoot, logger, lock, reconcileOptions = {}) => {
352
+ if (file.merge === "schema-extension") {
353
+ return reconcileSchemaExtension(file, itemKey, itemDirectory, projectRoot, logger, reconcileOptions.diff === true);
354
+ }
355
+ return reconcileWholeFile(file, itemKey, itemDirectory, projectRoot, logger, lock, reconcileOptions);
356
+ };
357
+ const reconcileItems = (items, cwd, logger, reconcileOptions = {}) => {
358
+ const written = [];
359
+ const skipped = [];
360
+ const depsAdded = [];
361
+ const bindingsApplied = [];
362
+ const lock = readLock(cwd);
363
+ for (const { directory, manifest } of items) {
364
+ for (const file of manifest.files) {
365
+ const outcome = reconcileFile(file, manifest.name, directory, cwd, logger, lock, reconcileOptions);
366
+ (outcome.kind === "written" ? written : skipped).push(outcome.path);
367
+ }
368
+ if (reconcileOptions.diff) {
369
+ continue;
370
+ }
371
+ const applied = applyItemResources(manifest, cwd, logger);
372
+ depsAdded.push(...applied.deps);
373
+ bindingsApplied.push(...applied.bindings);
374
+ }
375
+ if (!reconcileOptions.diff && Object.keys(lock.items).length > 0) {
376
+ writeLock(cwd, lock);
377
+ }
378
+ return { bindings: bindingsApplied, deps: depsAdded, skipped, written };
379
+ };
380
+
381
+ const DEFAULT_SOURCE_BASE = "gh:anolilab/lunora/registry";
382
+ const DEFAULT_SOURCE_REF = "alpha";
383
+ const VALID_ITEM_NAME = /^[A-Za-z0-9][\w-]*$/u;
384
+ const assertSafeItemName = (name) => {
385
+ if (!VALID_ITEM_NAME.test(name)) {
386
+ throw new Error(
387
+ `invalid registry item name "${name}" — names must match ${VALID_ITEM_NAME.source} (letters, digits, "-", "_"; no path separators or "..")`
388
+ );
389
+ }
390
+ };
391
+ const isSafeSource = (source) => {
392
+ if (source.includes("..")) {
393
+ return false;
394
+ }
395
+ return source.startsWith("gh:") || source.startsWith("github:") || source.startsWith("https://");
396
+ };
397
+ const isBlockedRemoteSource = (options) => options.from === void 0 && options.source !== void 0 && options.source.length > 0 && !options.allowUnsafeSource && !isSafeSource(options.source);
398
+ 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;
399
+ const fetchToStaging = async (remote, label, logger) => {
400
+ const stagingRoot = mkdtempSync(join(tmpdir(), `lunora-${label}-fetch-`));
401
+ const stagingDirectory = join(stagingRoot, label);
402
+ logger.info(`fetching ${remote}`);
403
+ try {
404
+ const downloaded = await downloadTemplate(remote, {
405
+ cwd: stagingRoot,
406
+ dir: stagingDirectory,
407
+ force: true,
408
+ install: false,
409
+ silent: true
410
+ });
411
+ logger.info(downloaded.commit ? `resolved ${downloaded.source} @ ${downloaded.commit}` : `resolved ${downloaded.source}`);
412
+ return {
413
+ cleanup: () => {
414
+ rmSync(stagingRoot, { force: true, recursive: true });
415
+ },
416
+ directory: stagingDirectory
417
+ };
418
+ } catch (error) {
419
+ rmSync(stagingRoot, { force: true, recursive: true });
420
+ throw error;
421
+ }
422
+ };
423
+ const resolveItemDirectory = async (name, options) => {
424
+ assertSafeItemName(name);
425
+ if (options.from !== void 0) {
426
+ const directory = join(options.from, name);
427
+ if (!existsSync(directory)) {
428
+ throw new Error(`registry item not found in local source: ${directory}`);
429
+ }
430
+ return { cleanup: () => {
431
+ }, directory };
432
+ }
433
+ const base = options.source ?? DEFAULT_SOURCE_BASE;
434
+ return fetchToStaging(`${base}/${name}#${DEFAULT_SOURCE_REF}`, "item", options.logger);
435
+ };
436
+ const resolveRegistryRoot = async (options) => {
437
+ if (options.from !== void 0) {
438
+ if (!existsSync(options.from)) {
439
+ throw new Error(`registry root not found: ${options.from}`);
440
+ }
441
+ return { cleanup: () => {
442
+ }, root: options.from };
443
+ }
444
+ const base = options.source ?? DEFAULT_SOURCE_BASE;
445
+ const { cleanup, directory } = await fetchToStaging(`${base}#${DEFAULT_SOURCE_REF}`, "registry", options.logger);
446
+ return { cleanup, root: directory };
447
+ };
448
+ const readManifest = (itemDirectory, name) => {
449
+ const raw = JSON.parse(readFileSync(join(itemDirectory, "registry.json"), "utf8"));
450
+ return parseManifest(raw, name);
451
+ };
452
+ const resolvePlan = async (names, options) => {
453
+ const items = [];
454
+ const cleanups = [];
455
+ const seen = /* @__PURE__ */ new Set();
456
+ const inProgress = /* @__PURE__ */ new Set();
457
+ const visit = async (name) => {
458
+ if (seen.has(name)) {
459
+ return;
460
+ }
461
+ if (inProgress.has(name)) {
462
+ throw new Error(`cyclic registry dependency detected at "${name}"`);
463
+ }
464
+ inProgress.add(name);
465
+ const { cleanup, directory } = await resolveItemDirectory(name, options);
466
+ cleanups.push(cleanup);
467
+ const manifest = readManifest(directory, name);
468
+ for (const requirement of manifest.requires ?? []) {
469
+ await visit(requirement);
470
+ }
471
+ inProgress.delete(name);
472
+ seen.add(name);
473
+ items.push({ directory, manifest });
474
+ };
475
+ try {
476
+ for (const name of names) {
477
+ await visit(name);
478
+ }
479
+ } catch (error) {
480
+ for (const cleanup of cleanups) {
481
+ cleanup();
482
+ }
483
+ throw error;
484
+ }
485
+ return { cleanups, items };
486
+ };
487
+
488
+ const emptyResult = () => {
489
+ return { bindings: [], code: 0, deps: [], skipped: [], written: [] };
490
+ };
491
+
492
+ const printPlan = (logger, manifest) => {
493
+ const label = manifest.title ?? manifest.description;
494
+ logger.info(`plan: ${manifest.name}${label ? ` — ${label}` : ""}`);
495
+ for (const file of manifest.files) {
496
+ logger.info(` file ${file.to} (${file.merge})`);
497
+ }
498
+ for (const [dep, range] of Object.entries(manifest.deps ?? {})) {
499
+ logger.info(` dep ${dep}@${range}`);
500
+ }
501
+ for (const [dep, range] of Object.entries(manifest.devDependencies ?? {})) {
502
+ logger.info(` dev ${dep}@${range}`);
503
+ }
504
+ for (const binding of manifest.bindings ?? []) {
505
+ logger.info(` bind ${binding.path.join(".")} = ${JSON.stringify(binding.value)}`);
506
+ }
507
+ for (const variable of manifest.envVars ?? []) {
508
+ const valueSuffix = variable.secret ? " (secret)" : ` = ${JSON.stringify(variable.value ?? "")}`;
509
+ logger.info(` env ${variable.name}${valueSuffix}`);
510
+ }
511
+ };
512
+ const printJsonPlan = (items) => {
513
+ const planSnapshot = items.map(({ manifest }) => {
514
+ return {
515
+ // Include the concrete value so a JSON-plan consumer can audit the
516
+ // mutation (not just the key path) before it is applied.
517
+ bindings: (manifest.bindings ?? []).map((binding) => {
518
+ return { path: binding.path.join("."), value: binding.value };
519
+ }),
520
+ deps: Object.keys(manifest.deps ?? {}),
521
+ devDependencies: Object.keys(manifest.devDependencies ?? {}),
522
+ envVars: (manifest.envVars ?? []).map((variable) => {
523
+ return { name: variable.name, ...variable.secret ? { secret: true } : { value: variable.value ?? "" } };
524
+ }),
525
+ files: manifest.files.map((file) => {
526
+ return { merge: file.merge, to: file.to };
527
+ }),
528
+ name: manifest.name,
529
+ requires: manifest.requires ?? [],
530
+ title: manifest.title
531
+ };
532
+ });
533
+ process.stdout.write(`${JSON.stringify({ items: planSnapshot }, void 0, 2)}
534
+ `);
535
+ };
536
+ const reportAddResult = (items, deps, written, skipped, logger) => {
537
+ logger.success(`add complete: ${String(written)} written, ${String(skipped)} skipped`);
538
+ logger.info("next steps:");
539
+ logger.info(" lunora codegen # regenerate _generated/ so the new tables/functions appear");
540
+ if (deps.length > 0) {
541
+ logger.info(" pnpm install # install newly-added dependencies");
542
+ }
543
+ for (const { manifest } of items) {
544
+ if (manifest.docs) {
545
+ logger.info(`${manifest.name}: ${manifest.docs}`);
546
+ }
547
+ }
548
+ };
549
+ const runListCommand = async (options) => {
550
+ const empty = emptyResult();
551
+ const gate = sourceGateError("list", options);
552
+ if (gate) {
553
+ options.logger.error(gate);
554
+ return { ...empty, code: 1 };
555
+ }
556
+ let cleanup = () => {
557
+ };
558
+ try {
559
+ const resolved = await resolveRegistryRoot(options);
560
+ cleanup = resolved.cleanup;
561
+ const items = collectCatalog(resolved.root);
562
+ if (options.json) {
563
+ process.stdout.write(`${JSON.stringify(items, void 0, 2)}
564
+ `);
565
+ return empty;
566
+ }
567
+ options.logger.info(`available registry items (${String(items.length)}):`);
568
+ for (const item of items) {
569
+ options.logger.info(` ${item.name}${item.description ? ` — ${item.description}` : ""}`);
570
+ }
571
+ return empty;
572
+ } catch (error) {
573
+ options.logger.error(`list failed: ${error instanceof Error ? error.message : String(error)}`);
574
+ return { ...empty, code: 1 };
575
+ } finally {
576
+ cleanup();
577
+ }
578
+ };
579
+ const runAddCommand = async (options) => {
580
+ const cwd = options.cwd ?? process.cwd();
581
+ const empty = emptyResult();
582
+ if (options.list) {
583
+ return runListCommand(options);
584
+ }
585
+ if (options.names.length === 0) {
586
+ options.logger.error("add requires at least one item name. Usage: lunora registry add <name> [...names]");
587
+ return { ...empty, code: 1 };
588
+ }
589
+ const gate = sourceGateError("add", options);
590
+ if (gate) {
591
+ options.logger.error(gate);
592
+ return { ...empty, code: 1 };
593
+ }
594
+ let cleanups = [];
595
+ try {
596
+ const { cleanups: planCleanups, items } = await resolvePlan(options.names, options);
597
+ cleanups = planCleanups;
598
+ for (const { manifest } of items) {
599
+ printPlan(options.logger, manifest);
600
+ }
601
+ if (options.json) {
602
+ printJsonPlan(items);
603
+ }
604
+ if (options.dryRun) {
605
+ options.logger.info("dry-run: stopping before any files are written");
606
+ return empty;
607
+ }
608
+ if (options.diff) {
609
+ reconcileItems(items, cwd, options.logger, { diff: true });
610
+ options.logger.info("diff: preview only — re-run without --diff to apply");
611
+ return empty;
612
+ }
613
+ if (!await confirmDepMutation(items, options)) {
614
+ return { ...empty, code: 1 };
615
+ }
616
+ const { bindings, deps, skipped, written } = reconcileItems(items, cwd, options.logger, { overwrite: options.overwrite });
617
+ reportAddResult(items, deps, written.length, skipped.length, options.logger);
618
+ return { bindings, code: 0, deps, skipped, written };
619
+ } catch (error) {
620
+ options.logger.error(`add failed: ${error instanceof Error ? error.message : String(error)}`);
621
+ return { ...empty, code: 1 };
622
+ } finally {
623
+ for (const cleanup of cleanups) {
624
+ cleanup();
625
+ }
626
+ }
627
+ };
628
+ const runRegistryViewCommand = async (options) => {
629
+ const empty = emptyResult();
630
+ if (options.names.length === 0) {
631
+ options.logger.error("view requires an item name. Usage: lunora registry view <name>");
632
+ return { ...empty, code: 1 };
633
+ }
634
+ const gate = sourceGateError("view", options);
635
+ if (gate) {
636
+ options.logger.error(gate);
637
+ return { ...empty, code: 1 };
638
+ }
639
+ const cleanups = [];
640
+ try {
641
+ for (const name of options.names) {
642
+ const { cleanup, directory } = await resolveItemDirectory(name, options);
643
+ cleanups.push(cleanup);
644
+ const manifest = readManifest(directory, name);
645
+ printPlan(options.logger, manifest);
646
+ for (const file of manifest.files) {
647
+ options.logger.info(`--- ${file.to} (${file.merge}) ---`);
648
+ const content = readFileSync(join(directory, file.from), "utf8");
649
+ for (const line of content.split("\n")) {
650
+ options.logger.info(line);
651
+ }
652
+ }
653
+ }
654
+ return empty;
655
+ } catch (error) {
656
+ options.logger.error(`view failed: ${error instanceof Error ? error.message : String(error)}`);
657
+ return { ...empty, code: 1 };
658
+ } finally {
659
+ for (const cleanup of cleanups) {
660
+ cleanup();
661
+ }
662
+ }
663
+ };
664
+ const runBuildIndexCommand = async (options) => {
665
+ const empty = emptyResult();
666
+ const root = options.from;
667
+ if (root === void 0) {
668
+ options.logger.error("registry build requires --from <registry root>");
669
+ return { ...empty, code: 1 };
670
+ }
671
+ if (!existsSync(root)) {
672
+ options.logger.error(`registry root not found: ${root}`);
673
+ return { ...empty, code: 1 };
674
+ }
675
+ const index = buildRegistryIndex(root);
676
+ const outputPath = options.out ?? join(root, "index.json");
677
+ if (options.check) {
678
+ const current = existsSync(outputPath) ? JSON.parse(readFileSync(outputPath, "utf8")) : { items: [] };
679
+ const drift = JSON.stringify(current.items ?? []) !== JSON.stringify(index.items);
680
+ if (drift) {
681
+ options.logger.error(`registry: ${outputPath} is stale — run \`lunora registry build\` to regenerate it`);
682
+ return { ...empty, code: 1 };
683
+ }
684
+ options.logger.success(`registry: ${outputPath} is up to date (${String(index.items.length)} items)`);
685
+ return empty;
686
+ }
687
+ writeFileSync(outputPath, `${JSON.stringify({ $schema: "./schema/registry.schema.json", ...index }, void 0, 4)}
688
+ `, "utf8");
689
+ options.logger.success(`registry: wrote ${outputPath} (${String(index.items.length)} items)`);
690
+ return empty;
691
+ };
692
+
693
+ export { runAddCommand, runBuildIndexCommand, runListCommand, runRegistryViewCommand };