@botlr/cli 0.1.1 → 0.2.0

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.
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // src/cli.ts
4
4
  import { Command, InvalidArgumentError } from "commander";
5
5
  import { createRequire } from "module";
6
+ import path7 from "path";
6
7
 
7
8
  // src/config.ts
8
9
  import { access, readFile } from "fs/promises";
@@ -10,6 +11,7 @@ import path from "path";
10
11
  var CONFIG_FILE = "botlr.config.json";
11
12
  var DEFAULT_FRAMEWORK = "react-next";
12
13
  var DEFAULT_SECTIONS_DIR = "components/sections";
14
+ var DEFAULT_REGISTRY_FILE = "publisher-section-registry.ts";
13
15
  var ConfigError = class extends Error {
14
16
  constructor(message) {
15
17
  super(message);
@@ -67,6 +69,8 @@ async function loadConfig(cwd, overrides2 = {}, env = process.env) {
67
69
  appId,
68
70
  framework: overrides2.framework ?? env.BOTLR_FRAMEWORK ?? file.framework ?? DEFAULT_FRAMEWORK,
69
71
  sectionsDir,
72
+ registryFile: path.resolve(sectionsDir, file.registryFile ?? DEFAULT_REGISTRY_FILE),
73
+ types: file.types !== false,
70
74
  apiKey: env.BOTLR_API_KEY || void 0,
71
75
  personalToken: env.BOTLR_PAT || void 0
72
76
  };
@@ -104,6 +108,12 @@ import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
104
108
  import path2 from "path";
105
109
  var LOCK_DIR = ".botlr";
106
110
  var LOCK_FILE = "sections.lock.json";
111
+ function nextLockEntry(previous, fresh) {
112
+ if (previous && previous.scope === fresh.scope && previous.framework === fresh.framework && previous.version === fresh.version && previous.contentHash === fresh.contentHash) {
113
+ return { ...fresh, pulledAt: previous.pulledAt };
114
+ }
115
+ return fresh;
116
+ }
107
117
  function lockPath(root) {
108
118
  return path2.join(root, LOCK_DIR, LOCK_FILE);
109
119
  }
@@ -127,7 +137,7 @@ async function writeLock(root, lock) {
127
137
  }
128
138
  async function updateLock(root, name, entry) {
129
139
  const lock = await readLock(root);
130
- lock[name] = entry;
140
+ lock[name] = nextLockEntry(lock[name], entry);
131
141
  await writeLock(root, lock);
132
142
  return lock;
133
143
  }
@@ -198,13 +208,80 @@ function validateManifest(value, where = "manifest") {
198
208
  }
199
209
  return m;
200
210
  }
211
+ var MANIFEST_KEY_ORDER = [
212
+ "section",
213
+ "framework",
214
+ "entry",
215
+ "exportName",
216
+ "files",
217
+ "title",
218
+ "description",
219
+ "category",
220
+ "icon",
221
+ "dependencies",
222
+ "schema"
223
+ ];
224
+ var DEPENDENCIES_KEY_ORDER = ["npm", "internal"];
225
+ function orderKeys(value, first) {
226
+ const out = {};
227
+ for (const key of first) {
228
+ if (key in value && value[key] !== void 0) out[key] = value[key];
229
+ }
230
+ for (const key of Object.keys(value).sort()) {
231
+ if (first.includes(key) || value[key] === void 0) continue;
232
+ out[key] = value[key];
233
+ }
234
+ return out;
235
+ }
236
+ function normalizeNested(value) {
237
+ if (Array.isArray(value)) return value.map(normalizeNested);
238
+ if (value && typeof value === "object") {
239
+ const record = value;
240
+ const ordered = orderKeys(record, []);
241
+ for (const key of Object.keys(ordered)) ordered[key] = normalizeNested(ordered[key]);
242
+ return ordered;
243
+ }
244
+ return value;
245
+ }
246
+ function normalizeManifest(manifest) {
247
+ const stripped = stripManifestState(manifest);
248
+ const ordered = orderKeys(stripped, MANIFEST_KEY_ORDER);
249
+ for (const key of Object.keys(ordered)) {
250
+ if (key === "dependencies" && ordered[key] && typeof ordered[key] === "object" && !Array.isArray(ordered[key])) {
251
+ const deps = orderKeys(ordered[key], DEPENDENCIES_KEY_ORDER);
252
+ for (const depKey of Object.keys(deps)) deps[depKey] = normalizeNested(deps[depKey]);
253
+ ordered[key] = deps;
254
+ } else {
255
+ ordered[key] = normalizeNested(ordered[key]);
256
+ }
257
+ }
258
+ return ordered;
259
+ }
260
+ function formatManifest(manifest) {
261
+ return `${JSON.stringify(normalizeManifest(manifest), null, 2)}
262
+ `;
263
+ }
264
+ function manifestsEqual(a, b) {
265
+ return formatManifest(a) === formatManifest(b);
266
+ }
201
267
  async function writeManifest(sectionsDir, name, manifest) {
202
268
  const file = manifestPath(sectionsDir, name);
203
269
  await mkdir2(sectionsDir, { recursive: true });
204
- await writeFile2(file, `${JSON.stringify(stripManifestState(manifest), null, 2)}
205
- `, "utf8");
270
+ await writeFile2(file, formatManifest(manifest), "utf8");
206
271
  return file;
207
272
  }
273
+ async function writeManifestIfChanged(sectionsDir, name, manifest) {
274
+ const file = manifestPath(sectionsDir, name);
275
+ try {
276
+ const existing = JSON.parse(await readFile3(file, "utf8"));
277
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) {
278
+ if (manifestsEqual(existing, manifest)) return { file, changed: false };
279
+ }
280
+ } catch {
281
+ }
282
+ await writeManifest(sectionsDir, name, manifest);
283
+ return { file, changed: true };
284
+ }
208
285
  async function readSectionFiles(sectionsDir, manifest) {
209
286
  return Promise.all(
210
287
  manifest.files.map(async (relative) => {
@@ -240,6 +317,512 @@ function safeJoin(dir, relative) {
240
317
  return target;
241
318
  }
242
319
 
320
+ // src/registry.ts
321
+ import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
322
+ import path5 from "path";
323
+
324
+ // src/init.ts
325
+ import { readFile as readFile4, readdir as readdir2 } from "fs/promises";
326
+ import path4 from "path";
327
+ function parseRegistry(source) {
328
+ const importedFrom = /* @__PURE__ */ new Map();
329
+ const importRe = /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*["']([^"']+)["']/g;
330
+ for (const match of source.matchAll(importRe)) {
331
+ const names = match[1];
332
+ const spec = match[2];
333
+ for (const raw of names.split(",")) {
334
+ const name = raw.trim().split(/\s+as\s+/).pop()?.trim();
335
+ if (name) importedFrom.set(name, spec);
336
+ }
337
+ }
338
+ const bodyMatch = source.match(/=\s*\{([\s\S]*?)\n\}\s*;?/);
339
+ if (!bodyMatch) return [];
340
+ const entries = [];
341
+ const entryRe = /^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_$-]+))\s*:\s*([A-Za-z0-9_$]+)\s*,?\s*(?:\/\/.*)?$/gm;
342
+ for (const match of bodyMatch[1].matchAll(entryRe)) {
343
+ const slug = match[1] ?? match[2] ?? match[3];
344
+ const exportName = match[4];
345
+ const importPath = importedFrom.get(exportName);
346
+ if (!importPath || !importPath.startsWith(".")) continue;
347
+ entries.push({ slug, exportName, importPath });
348
+ }
349
+ return entries;
350
+ }
351
+ function parseDefinition(source) {
352
+ const match = source.match(/export\s+const\s+\w+Definition\s*=\s*\{([\s\S]*?)\n\}/);
353
+ if (!match) return null;
354
+ const body = match[1];
355
+ const field = (key) => {
356
+ const m = body.match(new RegExp(`(?:^|\\n)\\s*${key}\\s*:\\s*(?:"([^"]*)"|'([^']*)'|\`([^\`]*)\`)`));
357
+ return m ? m[1] ?? m[2] ?? m[3] : void 0;
358
+ };
359
+ const schemaName = body.match(/(?:^|\n)\s*schema\s*:\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*,?/)?.[1];
360
+ return {
361
+ name: field("name"),
362
+ heading: field("heading"),
363
+ category: field("category"),
364
+ description: field("description"),
365
+ icon: field("icon"),
366
+ ...schemaName ? { schemaName } : {}
367
+ };
368
+ }
369
+ function splitTopLevel(body) {
370
+ const parts = [];
371
+ let depth = 0;
372
+ let quote = null;
373
+ let current = "";
374
+ for (let i = 0; i < body.length; i++) {
375
+ const ch = body[i];
376
+ if (quote) {
377
+ current += ch;
378
+ if (ch === "\\") current += body[++i] ?? "";
379
+ else if (ch === quote) quote = null;
380
+ continue;
381
+ }
382
+ if (ch === "/" && body[i + 1] === "/") {
383
+ while (i < body.length && body[i] !== "\n") i++;
384
+ current += "\n";
385
+ continue;
386
+ }
387
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
388
+ else if ("([{".includes(ch)) depth++;
389
+ else if (")]}".includes(ch)) depth--;
390
+ if (ch === "," && depth === 0) {
391
+ parts.push(current);
392
+ current = "";
393
+ continue;
394
+ }
395
+ current += ch;
396
+ }
397
+ parts.push(current);
398
+ return parts.map((p) => p.trim()).filter(Boolean);
399
+ }
400
+ function enumValues(expr) {
401
+ const m = expr.match(/^z\.enum\(\s*\[([\s\S]*?)\]/);
402
+ if (!m) return null;
403
+ const values = [];
404
+ for (const raw of splitTopLevel(m[1])) {
405
+ const lit = raw.match(/^(?:"([^"]*)"|'([^']*)'|`([^`]*)`)$/);
406
+ if (!lit) return null;
407
+ values.push(lit[1] ?? lit[2] ?? lit[3]);
408
+ }
409
+ return values;
410
+ }
411
+ function zodFieldToSchemaField(expr) {
412
+ const source = expr.trim();
413
+ const optional = /\.(optional|nullable|nullish|default)\(/.test(source);
414
+ let field = null;
415
+ if (/^z\.string\(/.test(source)) field = { type: /\.(email)\(/.test(source) ? "email" : /\.(url)\(/.test(source) ? "url" : "text" };
416
+ else if (/^z\.number\(/.test(source)) field = { type: /\.int\(/.test(source) ? "integer" : "number" };
417
+ else if (/^z\.boolean\(/.test(source)) field = { type: "boolean" };
418
+ else if (/^z\.enum\(/.test(source)) {
419
+ const options = enumValues(source);
420
+ field = options ? { type: "select", options } : { type: "select" };
421
+ } else if (/^z\.array\(/.test(source)) field = { type: "array" };
422
+ else if (/^z\.(object|record)\(/.test(source)) field = { type: "json" };
423
+ else if (/^z\.union\(\s*\[\s*z\.string\(\)\s*,\s*z\.(any|object|record)\(/.test(source)) field = { type: "asset" };
424
+ if (!field) return null;
425
+ if (!optional) field.required = true;
426
+ const describe = source.match(/\.describe\(\s*(?:"([^"]*)"|'([^']*)'|`([^`]*)`)\s*\)/);
427
+ const label = describe ? describe[1] ?? describe[2] ?? describe[3] : void 0;
428
+ if (label) field.label = label;
429
+ return field;
430
+ }
431
+ function parseZodSchema(source, schemaName) {
432
+ const start = source.match(new RegExp(`(?:export\\s+)?const\\s+${schemaName}\\s*=\\s*z\\.object\\(\\s*\\{`));
433
+ if (!start || start.index === void 0) return null;
434
+ const open = start.index + start[0].length;
435
+ let depth = 1;
436
+ let end = -1;
437
+ for (let i = open; i < source.length; i++) {
438
+ const ch = source[i];
439
+ if (ch === "{" || ch === "(" || ch === "[") depth++;
440
+ else if (ch === "}" || ch === ")" || ch === "]") {
441
+ depth--;
442
+ if (depth === 0) {
443
+ end = i;
444
+ break;
445
+ }
446
+ }
447
+ }
448
+ if (end < 0) return null;
449
+ const schema = {};
450
+ for (const entry of splitTopLevel(source.slice(open, end))) {
451
+ const m = entry.match(/^(?:"([^"]+)"|'([^']+)'|([A-Za-z_$][A-Za-z0-9_$]*))\s*:\s*([\s\S]+)$/);
452
+ if (!m) continue;
453
+ const field = zodFieldToSchemaField(m[4]);
454
+ if (field) schema[m[1] ?? m[2] ?? m[3]] = field;
455
+ }
456
+ return Object.keys(schema).length ? schema : null;
457
+ }
458
+ function parseImports(source) {
459
+ const internal = /* @__PURE__ */ new Set();
460
+ const npm = /* @__PURE__ */ new Set();
461
+ const re = /(?:import|export)\s+(?:[^"';]*?\s+from\s+)?["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g;
462
+ for (const match of source.matchAll(re)) {
463
+ const spec = match[1] ?? match[2];
464
+ if (!spec) continue;
465
+ if (spec.startsWith(".") || spec.startsWith("@/") || spec.startsWith("~/")) internal.add(spec);
466
+ else npm.add(spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0]);
467
+ }
468
+ return { internal: [...internal].sort(), npm: [...npm].sort() };
469
+ }
470
+ var SOURCE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
471
+ async function resolveImport(sectionsDir, spec) {
472
+ const base = path4.resolve(sectionsDir, spec);
473
+ for (const ext of SOURCE_EXTENSIONS) {
474
+ try {
475
+ await readFile4(base + ext);
476
+ return base + ext;
477
+ } catch {
478
+ }
479
+ }
480
+ return null;
481
+ }
482
+ async function deriveManifests(sectionsDir, options) {
483
+ const registrySource = await readFile4(options.registryFile ?? path4.join(sectionsDir, DEFAULT_REGISTRY_FILE), "utf8");
484
+ const entries = parseRegistry(registrySource);
485
+ const out = [];
486
+ for (const entry of entries) {
487
+ const entryFile = await resolveImport(sectionsDir, entry.importPath);
488
+ if (!entryFile) continue;
489
+ const source = await readFile4(entryFile, "utf8");
490
+ const meta = parseDefinition(source);
491
+ const schema = meta?.schemaName ? parseZodSchema(source, meta.schemaName) : null;
492
+ const imports = parseImports(source);
493
+ const relative = path4.relative(sectionsDir, entryFile).split(path4.sep).join("/");
494
+ const npm = {};
495
+ for (const pkg of imports.npm) {
496
+ if (pkg === "react" || pkg === "next") continue;
497
+ npm[pkg] = options.npmVersions?.[pkg] ?? "*";
498
+ }
499
+ const manifest = {
500
+ section: entry.slug,
501
+ framework: options.framework,
502
+ entry: relative,
503
+ exportName: entry.exportName,
504
+ files: [relative],
505
+ ...meta?.heading ? { title: meta.heading } : {},
506
+ ...meta?.description ? { description: meta.description } : {},
507
+ ...meta?.category ? { category: meta.category } : {},
508
+ ...meta?.icon ? { icon: meta.icon } : {},
509
+ dependencies: {
510
+ ...Object.keys(npm).length ? { npm } : {},
511
+ ...imports.internal.length ? { internal: imports.internal } : {}
512
+ },
513
+ ...schema ? { schema } : {}
514
+ };
515
+ out.push({ name: entry.slug, manifest, entryFile });
516
+ }
517
+ return out;
518
+ }
519
+ async function readNpmVersions(dir) {
520
+ let current = path4.resolve(dir);
521
+ for (; ; ) {
522
+ try {
523
+ const raw = await readFile4(path4.join(current, "package.json"), "utf8");
524
+ const pkg = JSON.parse(raw);
525
+ return { ...pkg.devDependencies, ...pkg.dependencies };
526
+ } catch {
527
+ const parent = path4.dirname(current);
528
+ if (parent === current) return {};
529
+ current = parent;
530
+ }
531
+ }
532
+ }
533
+ async function hasRegistry(sectionsDir, registryFile) {
534
+ const file = registryFile ?? path4.join(sectionsDir, DEFAULT_REGISTRY_FILE);
535
+ try {
536
+ return (await readdir2(path4.dirname(file))).includes(path4.basename(file));
537
+ } catch {
538
+ return false;
539
+ }
540
+ }
541
+
542
+ // src/registry.ts
543
+ var SOURCE_EXTENSIONS2 = [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs"];
544
+ function importPathFor(registryFile, entryFile) {
545
+ const relative = path5.relative(path5.dirname(registryFile), entryFile).split(path5.sep).join("/");
546
+ const ext = path5.extname(relative);
547
+ const bare = SOURCE_EXTENSIONS2.includes(ext) ? relative.slice(0, -ext.length) : relative;
548
+ return bare.startsWith(".") ? bare : `./${bare}`;
549
+ }
550
+ var IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
551
+ function addRegistryEntry(source, addition) {
552
+ const { slug, exportName, importPath } = addition;
553
+ if (!IDENTIFIER.test(exportName)) throw new Error(`'${exportName}' is not a valid export name`);
554
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
555
+ let out = source;
556
+ let addedImport = false;
557
+ let addedEntry = false;
558
+ const importRe = /^[ \t]*import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*(["'])([^"']+)\2[ \t]*;?[ \t]*$/gm;
559
+ const imports = [...out.matchAll(importRe)];
560
+ const alreadyImported = imports.some(
561
+ (m) => m[1].split(",").some((raw) => raw.trim().split(/\s+as\s+/).pop()?.trim() === exportName)
562
+ );
563
+ if (!alreadyImported) {
564
+ const last = imports.at(-1);
565
+ const quote = last?.[2] ?? '"';
566
+ const semicolon = last ? last[0].trimEnd().endsWith(";") : true;
567
+ const line = `import { ${exportName} } from ${quote}${importPath}${quote}${semicolon ? ";" : ""}`;
568
+ if (last) {
569
+ const end = last.index + last[0].length;
570
+ out = `${out.slice(0, end)}${eol}${line}${out.slice(end)}`;
571
+ } else {
572
+ out = `${line}${eol}${eol}${out}`;
573
+ }
574
+ addedImport = true;
575
+ }
576
+ const alreadyMapped = parseRegistry(out).some((e) => e.slug === slug) || hasEntry(out, slug);
577
+ if (!alreadyMapped) {
578
+ const object = findRegistryObject(out);
579
+ if (!object) throw new Error("could not find the registry object (`export const ... = { ... }`)");
580
+ const body = out.slice(object.bodyStart, object.bodyEnd);
581
+ const entryLines = body.split(/\r?\n/).filter((l) => /^\s*(?:"[^"]+"|'[^']+'|[A-Za-z0-9_$-]+)\s*:/.test(l));
582
+ const lastEntry = entryLines.at(-1);
583
+ const indent = lastEntry?.match(/^\s*/)?.[0] ?? " ";
584
+ const key = IDENTIFIER.test(slug) ? slug : `"${slug}"`;
585
+ const entryLine = `${indent}${key}: ${exportName},`;
586
+ let newBody = body;
587
+ if (lastEntry !== void 0) {
588
+ const stripped = lastEntry.replace(/\/\/.*$/, "").trimEnd();
589
+ if (!stripped.endsWith(",")) {
590
+ const at = newBody.lastIndexOf(lastEntry);
591
+ const fixed = lastEntry.replace(/^(\s*(?:"[^"]+"|'[^']+'|[A-Za-z0-9_$-]+)\s*:\s*[A-Za-z0-9_$]+)/, "$1,");
592
+ newBody = `${newBody.slice(0, at)}${fixed}${newBody.slice(at + lastEntry.length)}`;
593
+ }
594
+ }
595
+ newBody = `${newBody.replace(/\s*$/, "")}${eol}${entryLine}`;
596
+ out = `${out.slice(0, object.bodyStart)}${newBody}${out.slice(object.bodyEnd)}`;
597
+ addedEntry = true;
598
+ }
599
+ return { source: out, changed: addedImport || addedEntry, addedImport, addedEntry };
600
+ }
601
+ function hasEntry(source, slug) {
602
+ const object = findRegistryObject(source);
603
+ if (!object) return false;
604
+ const body = source.slice(object.bodyStart, object.bodyEnd);
605
+ const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
606
+ return new RegExp(`^\\s*(?:"${escaped}"|'${escaped}'|${escaped})\\s*:`, "m").test(body);
607
+ }
608
+ function findRegistryObject(source) {
609
+ const open = source.match(/export\s+const\s+\w+\s*(?::[^=]*)?=\s*\{/) ?? source.match(/=\s*\{/);
610
+ if (!open || open.index === void 0) return null;
611
+ const bodyStart = open.index + open[0].length;
612
+ const close = /\r?\n\}/g;
613
+ close.lastIndex = bodyStart;
614
+ const closeMatch = close.exec(source);
615
+ if (!closeMatch) return null;
616
+ return { bodyStart, bodyEnd: closeMatch.index };
617
+ }
618
+ async function registerSection(sectionsDir, registryFile, manifest) {
619
+ const name = path5.basename(registryFile);
620
+ let source;
621
+ try {
622
+ source = await readFile5(registryFile, "utf8");
623
+ } catch (error) {
624
+ if (error.code === "ENOENT") {
625
+ return { file: registryFile, changed: false, notice: `no ${name} in ${path5.dirname(registryFile)}, register the section yourself` };
626
+ }
627
+ throw error;
628
+ }
629
+ if (!manifest.exportName) {
630
+ return { file: registryFile, changed: false, notice: `manifest has no exportName, ${name} not updated` };
631
+ }
632
+ const importPath = importPathFor(registryFile, path5.resolve(sectionsDir, manifest.entry));
633
+ let edit;
634
+ try {
635
+ edit = addRegistryEntry(source, { slug: manifest.section, exportName: manifest.exportName, importPath });
636
+ } catch (error) {
637
+ return { file: registryFile, changed: false, notice: `${name} not updated: ${error.message}` };
638
+ }
639
+ if (edit.changed) await writeFile3(registryFile, edit.source, "utf8");
640
+ return { file: registryFile, changed: edit.changed, addedImport: edit.addedImport, addedEntry: edit.addedEntry };
641
+ }
642
+
643
+ // src/typegen.ts
644
+ import { mkdir as mkdir3, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
645
+ import path6 from "path";
646
+ import {
647
+ isFieldSchema,
648
+ isFormBuilderSchema
649
+ } from "@botlr/sdk";
650
+ var PROPS_SUFFIX = ".props.ts";
651
+ var STRING_TYPES = /* @__PURE__ */ new Set([
652
+ "text",
653
+ "textarea",
654
+ "markdown",
655
+ "richtext",
656
+ "string",
657
+ "email",
658
+ "tel",
659
+ "url",
660
+ "icon",
661
+ "color",
662
+ "date",
663
+ "datetime",
664
+ "timestamp",
665
+ "timestamptz",
666
+ "uuid"
667
+ ]);
668
+ var NUMBER_TYPES = /* @__PURE__ */ new Set(["number", "integer", "currency"]);
669
+ var BOOLEAN_TYPES = /* @__PURE__ */ new Set(["boolean"]);
670
+ var STRING_LIST_TYPES = /* @__PURE__ */ new Set(["multiselect", "tags"]);
671
+ var LIST_TYPES = /* @__PURE__ */ new Set(["array", "list", "repeater"]);
672
+ var OBJECT_TYPES = /* @__PURE__ */ new Set(["json", "jsonb", "object"]);
673
+ var REFERENCE_TYPES = /* @__PURE__ */ new Set([
674
+ "asset",
675
+ "asset-picker-image",
676
+ "asset-picker-file",
677
+ "asset-picker-video",
678
+ "media",
679
+ "document",
680
+ "page",
681
+ "post",
682
+ "reference",
683
+ "relation",
684
+ "entity_reference",
685
+ "app_instance",
686
+ "forum_thread"
687
+ ]);
688
+ var REFERENCE_TYPE = "string | Record<string, unknown>";
689
+ var OBJECT_TYPE = "Record<string, unknown>";
690
+ var GROUP_NODE_TYPES = /* @__PURE__ */ new Set(["section", "fieldset", "composite", "group"]);
691
+ function literal(value) {
692
+ if (typeof value === "string") return JSON.stringify(value);
693
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
694
+ return null;
695
+ }
696
+ function optionUnion(options) {
697
+ if (!Array.isArray(options) || options.length === 0) return null;
698
+ const literals = [];
699
+ for (const option of options) {
700
+ const raw = option && typeof option === "object" ? option.value : option;
701
+ const lit = literal(raw);
702
+ if (lit === null) return null;
703
+ if (!literals.includes(lit)) literals.push(lit);
704
+ }
705
+ return literals.join(" | ");
706
+ }
707
+ function wrapArray(type) {
708
+ return /^[A-Za-z0-9_<>, ]+$/.test(type) ? `${type}[]` : `Array<${type}>`;
709
+ }
710
+ function tsTypeForField(field) {
711
+ const kind = typeof field.type === "string" ? field.type : "";
712
+ const multiple = field.multiple === true;
713
+ if (kind === "select") {
714
+ const union = optionUnion(field.options) ?? "string";
715
+ return multiple ? wrapArray(union) : union;
716
+ }
717
+ if (STRING_TYPES.has(kind)) return "string";
718
+ if (NUMBER_TYPES.has(kind)) return "number";
719
+ if (BOOLEAN_TYPES.has(kind)) return "boolean";
720
+ if (STRING_LIST_TYPES.has(kind)) return "string[]";
721
+ if (kind === "repeater" && Array.isArray(field.fields)) {
722
+ const items = collectFormBuilderFields(field.fields);
723
+ return items.length ? `Array<${inlineObject(items)}>` : "unknown[]";
724
+ }
725
+ if (LIST_TYPES.has(kind)) return "unknown[]";
726
+ if (OBJECT_TYPES.has(kind)) return OBJECT_TYPE;
727
+ if (REFERENCE_TYPES.has(kind)) return multiple ? `Array<${REFERENCE_TYPE}>` : REFERENCE_TYPE;
728
+ return "unknown";
729
+ }
730
+ function docFor(field) {
731
+ const doc = [];
732
+ for (const value of [field.label, field.helpText ?? field.description]) {
733
+ if (typeof value === "string" && value.trim()) doc.push(value.trim());
734
+ }
735
+ return doc;
736
+ }
737
+ function collectDslFields(schema) {
738
+ const out = [];
739
+ for (const [name, value] of Object.entries(schema)) {
740
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
741
+ const field = value;
742
+ out.push({ name, type: tsTypeForField(field), required: field.required === true, doc: docFor(field) });
743
+ }
744
+ return out;
745
+ }
746
+ function collectFormBuilderFields(nodes) {
747
+ const out = [];
748
+ for (const node of nodes) {
749
+ if (!node || typeof node !== "object") continue;
750
+ if (GROUP_NODE_TYPES.has(node.type) && Array.isArray(node.children)) {
751
+ out.push(...collectFormBuilderFields(node.children));
752
+ continue;
753
+ }
754
+ const name = typeof node.name === "string" && node.name ? node.name : typeof node.key === "string" ? node.key : "";
755
+ if (!name) continue;
756
+ const kind = node.type === "field" ? node.fieldType ?? "" : node.fieldType ?? node.type;
757
+ const type = tsTypeForField({ type: kind, options: node.options, multiple: node.multiple, fields: node.fields });
758
+ out.push({ name, type, required: node.required === true, doc: docFor(node) });
759
+ }
760
+ return out;
761
+ }
762
+ function collectFields(schema) {
763
+ if (isFormBuilderSchema(schema)) return collectFormBuilderFields(schema.nodes);
764
+ if (isFieldSchema(schema)) return collectDslFields(schema);
765
+ return [];
766
+ }
767
+ function schemaFieldNames(schema) {
768
+ return collectFields(schema).map((f) => f.name);
769
+ }
770
+ var IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
771
+ function propertyKey(name) {
772
+ return IDENTIFIER_RE.test(name) ? name : JSON.stringify(name);
773
+ }
774
+ function inlineObject(fields) {
775
+ return `{ ${fields.map((f) => `${propertyKey(f.name)}${f.required ? "" : "?"}: ${f.type}`).join("; ")} }`;
776
+ }
777
+ function pascalCase(slug) {
778
+ const words = slug.split(/[^A-Za-z0-9]+/).filter(Boolean);
779
+ const name = words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
780
+ return /^[0-9]/.test(name) ? `Section${name}` : name || "Section";
781
+ }
782
+ function propsInterfaceName(manifest) {
783
+ const base = manifest.exportName && IDENTIFIER_RE.test(manifest.exportName) ? manifest.exportName : pascalCase(manifest.section);
784
+ return `${base}Props`;
785
+ }
786
+ function generatePropsSource(schema, options) {
787
+ const fields = collectFields(schema);
788
+ const lines = [
789
+ `// Generated by @botlr/cli from the Botlr section definition "${options.section}".`,
790
+ "// Do not edit: the props schema lives in the dashboard, and `botlr pull` rewrites this file.",
791
+ ""
792
+ ];
793
+ if (fields.length === 0) {
794
+ lines.push("/** The definition has no props yet; define them in the dashboard and pull again. */");
795
+ lines.push(`export type ${options.interfaceName} = Record<string, unknown>;`);
796
+ return `${lines.join("\n")}
797
+ `;
798
+ }
799
+ lines.push(`export interface ${options.interfaceName} {`);
800
+ fields.forEach((field, index) => {
801
+ if (index > 0 && field.doc.length) lines.push("");
802
+ if (field.doc.length === 1) lines.push(` /** ${field.doc[0]} */`);
803
+ else if (field.doc.length > 1) lines.push(" /**", ...field.doc.map((d) => ` * ${d}`), " */");
804
+ lines.push(` ${propertyKey(field.name)}${field.required ? "" : "?"}: ${field.type};`);
805
+ });
806
+ lines.push("}");
807
+ return `${lines.join("\n")}
808
+ `;
809
+ }
810
+ function propsPath(sectionsDir, name) {
811
+ return path6.join(sectionsDir, `${name}${PROPS_SUFFIX}`);
812
+ }
813
+ async function writePropsTypes(sectionsDir, manifest, schema) {
814
+ const interfaceName = propsInterfaceName(manifest);
815
+ const file = propsPath(sectionsDir, manifest.section);
816
+ const text = generatePropsSource(schema, { section: manifest.section, interfaceName });
817
+ try {
818
+ if (await readFile6(file, "utf8") === text) return { file, interfaceName, changed: false };
819
+ } catch {
820
+ }
821
+ await mkdir3(sectionsDir, { recursive: true });
822
+ await writeFile4(file, text, "utf8");
823
+ return { file, interfaceName, changed: true };
824
+ }
825
+
243
826
  // src/commands/pull.ts
244
827
  async function localHash(sectionsDir, name) {
245
828
  try {
@@ -276,16 +859,35 @@ async function pullSections(config, options) {
276
859
  });
277
860
  const { source } = response;
278
861
  const files = await writeSectionFiles(config.sectionsDir, source.files);
279
- await writeManifest(config.sectionsDir, name, source.manifest);
280
- const newEntry = {
862
+ const manifest = await writeManifestIfChanged(config.sectionsDir, name, source.manifest);
863
+ const registry = options.noRegistry ? void 0 : await registerSection(config.sectionsDir, config.registryFile, source.manifest);
864
+ let types;
865
+ let typesNotice;
866
+ if (config.types && !options.noTypes) {
867
+ if (response.definition) {
868
+ types = await writePropsTypes(config.sectionsDir, source.manifest, response.definition.schema);
869
+ } else {
870
+ typesNotice = "no section definition resolves for this section, no prop types written";
871
+ }
872
+ }
873
+ const fresh = {
281
874
  scope: source.scope,
282
875
  framework: source.framework,
283
876
  version: source.version,
284
877
  contentHash: source.contentHash,
285
878
  pulledAt: (/* @__PURE__ */ new Date()).toISOString()
286
879
  };
287
- lock[name] = newEntry;
288
- results.push({ name, scope: source.scope, version: source.version, files });
880
+ lock[name] = nextLockEntry(entry, fresh);
881
+ results.push({
882
+ name,
883
+ scope: source.scope,
884
+ version: source.version,
885
+ files,
886
+ manifestChanged: manifest.changed,
887
+ registry,
888
+ types,
889
+ typesNotice
890
+ });
289
891
  }
290
892
  await writeLock(config.root, lock);
291
893
  return results;
@@ -296,6 +898,36 @@ import {
296
898
  BotlrConflictError,
297
899
  contentHash as contentHash2
298
900
  } from "@botlr/sdk";
901
+
902
+ // src/drift.ts
903
+ import { isFieldSchema as isFieldSchema2 } from "@botlr/sdk";
904
+ function manifestSchema(manifest) {
905
+ const schema = manifest.schema;
906
+ return isFieldSchema2(schema) ? schema : null;
907
+ }
908
+ function compareSchemas(manifest, definitionSchema) {
909
+ const code = manifestSchema(manifest);
910
+ if (!code) return null;
911
+ const codeKeys = new Set(Object.keys(code).filter((k) => code[k] && typeof code[k] === "object"));
912
+ const dashboardKeys = new Set(schemaFieldNames(definitionSchema));
913
+ const missingInDashboard = [...codeKeys].filter((k) => !dashboardKeys.has(k)).sort();
914
+ const missingInCode = [...dashboardKeys].filter((k) => !codeKeys.has(k)).sort();
915
+ if (missingInDashboard.length === 0 && missingInCode.length === 0) return null;
916
+ return { missingInDashboard, missingInCode };
917
+ }
918
+ function formatDriftWarning(name, drift) {
919
+ const lines = [`warning: props schema of '${name}' differs between the code and the dashboard`];
920
+ if (drift.missingInDashboard.length) {
921
+ lines.push(` the code expects props the dashboard does not define: ${drift.missingInDashboard.join(", ")}`);
922
+ }
923
+ if (drift.missingInCode.length) {
924
+ lines.push(` the dashboard defines props the code does not list: ${drift.missingInCode.join(", ")}`);
925
+ }
926
+ lines.push(` the dashboard owns the schema: edit it there, or update 'schema' in ${name}.botlr.json`);
927
+ return lines.join("\n");
928
+ }
929
+
930
+ // src/commands/push.ts
299
931
  var PushConflict = class extends Error {
300
932
  constructor(message, latest) {
301
933
  super(message);
@@ -358,12 +990,14 @@ async function pushSection(config, options) {
358
990
  contentHash: response.contentHash,
359
991
  pulledAt: (/* @__PURE__ */ new Date()).toISOString()
360
992
  });
993
+ const drift = compareSchemas(manifest, response.definition?.schema) ?? void 0;
361
994
  return {
362
995
  name,
363
996
  created: response.created,
364
997
  scope: response.scope,
365
998
  version: response.version,
366
- contentHash: response.contentHash
999
+ contentHash: response.contentHash,
1000
+ drift
367
1001
  };
368
1002
  }
369
1003
 
@@ -391,10 +1025,20 @@ async function sectionStatuses(config, options = {}) {
391
1025
  const names = options.names?.length ? options.names : await listManifests(config.sectionsDir);
392
1026
  const lock = await readLock(config.root);
393
1027
  let summaries = /* @__PURE__ */ new Map();
394
- if (!options.offline) {
395
- const listing = await clientFor(config).sections.list({ framework: config.framework });
1028
+ const client = options.offline ? null : clientFor(config);
1029
+ if (client) {
1030
+ const listing = await client.sections.list({ framework: config.framework });
396
1031
  summaries = new Map(listing.sections.map((s) => [s.name, s]));
397
1032
  }
1033
+ const driftFor = async (manifest) => {
1034
+ if (!client || !manifestSchema(manifest) || !summaries.has(manifest.section)) return void 0;
1035
+ try {
1036
+ const pulled = await client.sections.pull(manifest.section, { framework: config.framework });
1037
+ return compareSchemas(manifest, pulled.definition?.schema) ?? void 0;
1038
+ } catch {
1039
+ return void 0;
1040
+ }
1041
+ };
398
1042
  const rows = [];
399
1043
  for (const name of names) {
400
1044
  const entry = lock[name];
@@ -408,7 +1052,8 @@ async function sectionStatuses(config, options = {}) {
408
1052
  status: computeStatus({ localHash: localHash2, lock: entry, remote }),
409
1053
  local: entry?.version ?? null,
410
1054
  remote: remote?.version ?? null,
411
- scope: entry?.scope ?? summary?.effective ?? "-"
1055
+ scope: entry?.scope ?? summary?.effective ?? "-",
1056
+ drift: await driftFor(manifest)
412
1057
  });
413
1058
  } catch (error) {
414
1059
  rows.push({
@@ -424,139 +1069,18 @@ async function sectionStatuses(config, options = {}) {
424
1069
  return rows;
425
1070
  }
426
1071
 
427
- // src/init.ts
428
- import { readFile as readFile4, readdir as readdir2 } from "fs/promises";
429
- import path4 from "path";
430
- var REGISTRY_FILE = "publisher-section-registry.ts";
431
- function parseRegistry(source) {
432
- const importedFrom = /* @__PURE__ */ new Map();
433
- const importRe = /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*["']([^"']+)["']/g;
434
- for (const match of source.matchAll(importRe)) {
435
- const names = match[1];
436
- const spec = match[2];
437
- for (const raw of names.split(",")) {
438
- const name = raw.trim().split(/\s+as\s+/).pop()?.trim();
439
- if (name) importedFrom.set(name, spec);
440
- }
441
- }
442
- const bodyMatch = source.match(/=\s*\{([\s\S]*?)\n\}\s*;?/);
443
- if (!bodyMatch) return [];
444
- const entries = [];
445
- const entryRe = /^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_$-]+))\s*:\s*([A-Za-z0-9_$]+)\s*,?\s*(?:\/\/.*)?$/gm;
446
- for (const match of bodyMatch[1].matchAll(entryRe)) {
447
- const slug = match[1] ?? match[2] ?? match[3];
448
- const exportName = match[4];
449
- const importPath = importedFrom.get(exportName);
450
- if (!importPath || !importPath.startsWith(".")) continue;
451
- entries.push({ slug, exportName, importPath });
452
- }
453
- return entries;
454
- }
455
- function parseDefinition(source) {
456
- const match = source.match(/export\s+const\s+\w+Definition\s*=\s*\{([\s\S]*?)\n\}/);
457
- if (!match) return null;
458
- const body = match[1];
459
- const field = (key) => {
460
- const m = body.match(new RegExp(`(?:^|\\n)\\s*${key}\\s*:\\s*(?:"([^"]*)"|'([^']*)'|\`([^\`]*)\`)`));
461
- return m ? m[1] ?? m[2] ?? m[3] : void 0;
462
- };
463
- return {
464
- name: field("name"),
465
- heading: field("heading"),
466
- category: field("category"),
467
- description: field("description"),
468
- icon: field("icon")
469
- };
470
- }
471
- function parseImports(source) {
472
- const internal = /* @__PURE__ */ new Set();
473
- const npm = /* @__PURE__ */ new Set();
474
- const re = /(?:import|export)\s+(?:[^"';]*?\s+from\s+)?["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g;
475
- for (const match of source.matchAll(re)) {
476
- const spec = match[1] ?? match[2];
477
- if (!spec) continue;
478
- if (spec.startsWith(".") || spec.startsWith("@/") || spec.startsWith("~/")) internal.add(spec);
479
- else npm.add(spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0]);
480
- }
481
- return { internal: [...internal].sort(), npm: [...npm].sort() };
482
- }
483
- var SOURCE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
484
- async function resolveImport(sectionsDir, spec) {
485
- const base = path4.resolve(sectionsDir, spec);
486
- for (const ext of SOURCE_EXTENSIONS) {
487
- try {
488
- await readFile4(base + ext);
489
- return base + ext;
490
- } catch {
491
- }
492
- }
493
- return null;
494
- }
495
- async function deriveManifests(sectionsDir, options) {
496
- const registrySource = await readFile4(path4.join(sectionsDir, REGISTRY_FILE), "utf8");
497
- const entries = parseRegistry(registrySource);
498
- const out = [];
499
- for (const entry of entries) {
500
- const entryFile = await resolveImport(sectionsDir, entry.importPath);
501
- if (!entryFile) continue;
502
- const source = await readFile4(entryFile, "utf8");
503
- const meta = parseDefinition(source);
504
- const imports = parseImports(source);
505
- const relative = path4.relative(sectionsDir, entryFile).split(path4.sep).join("/");
506
- const npm = {};
507
- for (const pkg of imports.npm) {
508
- if (pkg === "react" || pkg === "next") continue;
509
- npm[pkg] = options.npmVersions?.[pkg] ?? "*";
510
- }
511
- const manifest = {
512
- section: entry.slug,
513
- framework: options.framework,
514
- entry: relative,
515
- exportName: entry.exportName,
516
- files: [relative],
517
- ...meta?.heading ? { title: meta.heading } : {},
518
- ...meta?.description ? { description: meta.description } : {},
519
- ...meta?.category ? { category: meta.category } : {},
520
- ...meta?.icon ? { icon: meta.icon } : {},
521
- dependencies: {
522
- ...Object.keys(npm).length ? { npm } : {},
523
- ...imports.internal.length ? { internal: imports.internal } : {}
524
- }
525
- };
526
- out.push({ name: entry.slug, manifest, entryFile });
527
- }
528
- return out;
529
- }
530
- async function readNpmVersions(dir) {
531
- let current = path4.resolve(dir);
532
- for (; ; ) {
533
- try {
534
- const raw = await readFile4(path4.join(current, "package.json"), "utf8");
535
- const pkg = JSON.parse(raw);
536
- return { ...pkg.devDependencies, ...pkg.dependencies };
537
- } catch {
538
- const parent = path4.dirname(current);
539
- if (parent === current) return {};
540
- current = parent;
541
- }
542
- }
543
- }
544
- async function hasRegistry(sectionsDir) {
545
- try {
546
- return (await readdir2(sectionsDir)).includes(REGISTRY_FILE);
547
- } catch {
548
- return false;
549
- }
550
- }
551
-
552
1072
  // src/commands/init.ts
553
1073
  async function initSections(config, options = {}) {
554
- if (!await hasRegistry(config.sectionsDir)) {
555
- throw new Error(`No ${REGISTRY_FILE} in ${config.sectionsDir}; sections init reads the registry map to find components`);
1074
+ if (!await hasRegistry(config.sectionsDir, config.registryFile)) {
1075
+ throw new Error(`No ${config.registryFile}; sections init reads the registry map to find components`);
556
1076
  }
557
1077
  const existing = new Set(await listManifests(config.sectionsDir));
558
1078
  const npmVersions = await readNpmVersions(config.sectionsDir);
559
- const derived = await deriveManifests(config.sectionsDir, { framework: config.framework, npmVersions });
1079
+ const derived = await deriveManifests(config.sectionsDir, {
1080
+ framework: config.framework,
1081
+ npmVersions,
1082
+ registryFile: config.registryFile
1083
+ });
560
1084
  const results = [];
561
1085
  for (const { name, manifest, entryFile } of derived) {
562
1086
  const skip = existing.has(name) && !options.force;
@@ -614,7 +1138,7 @@ async function run(action) {
614
1138
  }
615
1139
  var program = new Command("botlr").description("Pull and push section source between a Botlr site and this app").version(version).configureHelp({ sortSubcommands: true });
616
1140
  withCommon(
617
- program.command("pull [names...]").description("pull sections into the sections directory; writes <name>.botlr.json and the lock file").option("--all", "every section the server lists for the framework").option("--version <n>", "a specific version instead of the latest", parseVersion).option("--shared", "the shared row (per section definition) even when the app has its own").option("--force", "overwrite files that changed locally since the last pull")
1141
+ program.command("pull [names...]").description("pull sections into the sections directory; writes <name>.botlr.json and the lock file").option("--all", "every section the server lists for the framework").option("--version <n>", "a specific version instead of the latest", parseVersion).option("--shared", "the shared row (per section definition) even when the app has its own").option("--force", "overwrite files that changed locally since the last pull").option("--no-registry", "do not add the section to the registry file").option("--no-types", "do not write <name>.props.ts from the definition's props schema")
618
1142
  ).action(
619
1143
  (names, opts) => run(async () => {
620
1144
  if (names.length === 0 && !opts.all) fail("name a section or pass --all");
@@ -625,15 +1149,30 @@ withCommon(
625
1149
  all: opts.all,
626
1150
  version: opts.version,
627
1151
  shared: opts.shared,
628
- force: opts.force
1152
+ force: opts.force,
1153
+ noRegistry: opts.registry === false,
1154
+ noTypes: opts.types === false
629
1155
  });
630
1156
  if (results.length === 0) {
631
1157
  console.log("nothing to pull");
632
1158
  return;
633
1159
  }
634
1160
  for (const r of results) {
635
- if (r.skipped) console.log(`skip ${r.name}: ${r.skipped}`);
636
- else console.log(`pull ${r.name} ${r.scope} v${r.version} (${r.files.length} file${r.files.length === 1 ? "" : "s"})`);
1161
+ if (r.skipped) {
1162
+ console.log(`skip ${r.name}: ${r.skipped}`);
1163
+ continue;
1164
+ }
1165
+ console.log(`pull ${r.name} ${r.scope} v${r.version} (${r.files.length} file${r.files.length === 1 ? "" : "s"})`);
1166
+ if (r.types?.changed) console.log(` types: wrote ${r.types.interfaceName} to ${path7.relative(process.cwd(), r.types.file)}`);
1167
+ if (r.typesNotice) console.log(` types: ${r.typesNotice}`);
1168
+ const registry = r.registry;
1169
+ if (!registry) continue;
1170
+ const file = path7.relative(process.cwd(), registry.file);
1171
+ if (registry.notice) console.log(` registry: ${registry.notice}`);
1172
+ else if (registry.changed) {
1173
+ const what = [registry.addedImport ? "import" : "", registry.addedEntry ? `"${r.name}" entry` : ""].filter(Boolean).join(" and ");
1174
+ console.log(` registry: added ${what} to ${file}`);
1175
+ }
637
1176
  }
638
1177
  })
639
1178
  );
@@ -651,6 +1190,8 @@ withCommon(
651
1190
  });
652
1191
  if (result.created) console.log(`pushed ${result.name} ${result.scope} v${result.version}`);
653
1192
  else console.log(`${result.name} ${result.scope} v${result.version} already matches, nothing pushed`);
1193
+ if (result.drift) process.stderr.write(`${formatDriftWarning(result.name, result.drift)}
1194
+ `);
654
1195
  })
655
1196
  );
656
1197
  var sections = program.command("sections").description("list, inspect and bootstrap sections");
@@ -694,6 +1235,10 @@ withCommon(
694
1235
  ["name", "status", "local", "remote", "scope", ""]
695
1236
  )
696
1237
  );
1238
+ for (const r of rows) {
1239
+ if (r.drift) process.stderr.write(`${formatDriftWarning(r.name, r.drift)}
1240
+ `);
1241
+ }
697
1242
  if (rows.some((r) => r.status === "diverged" || r.status === "error")) process.exitCode = 1;
698
1243
  })
699
1244
  );