@ollie-shop/cli 1.4.1 → 1.5.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @ollie-shop/cli@1.4.1 build /home/runner/work/ollie-shop/ollie-shop/packages/cli
2
+ > @ollie-shop/cli@1.5.0 build /home/runner/work/ollie-shop/ollie-shop/packages/cli
3
3
  > tsup
4
4
 
5
5
  CLI Building entry: src/index.tsx
@@ -9,5 +9,5 @@
9
9
  CLI Target: node22
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
- ESM dist/index.js 95.89 KB
13
- ESM ⚡️ Build success in 248ms
12
+ ESM dist/index.js 105.76 KB
13
+ ESM ⚡️ Build success in 353ms
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @ollie-shop/cli
2
2
 
3
+ ## 1.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 54cca83: Make `ollie.json` the source of truth for Studio components via a `components` map (`{ id: { path, slot } }`), replacing per-folder `meta.json` while staying backward-compatible: existing `meta.json` projects keep working, and folders not referenced anywhere are still discovered as unlinked. Add a `setup` command that migrates legacy `meta.json` / `meta.{stage}.json` files into the config map and removes the old metas (`--dry-run` supported). The dev server exposes `/stages` and `/stage` endpoints for runtime stage switching, and the manifest now carries `otherStage` (component owned by another stage's config) and `unlinked` (no component id) flags. All additive — older Studio clients ignore the new endpoints and fields.
8
+
3
9
  ## 1.4.1
4
10
 
5
11
  ### Patch Changes
package/dist/index.js CHANGED
@@ -70,6 +70,10 @@ function HelpCommand() {
70
70
  /* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "green", children: "init" }) }),
71
71
  /* @__PURE__ */ jsx(Text, { children: "Write store/version IDs to ollie.json" })
72
72
  ] }),
73
+ /* @__PURE__ */ jsxs(Box, { children: [
74
+ /* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "green", children: "setup" }) }),
75
+ /* @__PURE__ */ jsx(Text, { children: "Migrate legacy meta.json into the config components map" })
76
+ ] }),
73
77
  /* @__PURE__ */ jsxs(Box, { children: [
74
78
  /* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "green", children: "help" }) }),
75
79
  /* @__PURE__ */ jsx(Text, { children: "Show this help message" })
@@ -386,9 +390,14 @@ import { useCallback, useEffect as useEffect2, useRef, useState as useState2 } f
386
390
  import fs2 from "fs/promises";
387
391
  import path2 from "path";
388
392
  import { z } from "zod";
393
+ var ComponentEntrySchema = z.object({
394
+ path: z.string(),
395
+ slot: z.string().optional()
396
+ });
389
397
  var OllieConfigSchema = z.object({
390
398
  storeId: z.string().uuid(),
391
- versionId: z.string().uuid().optional()
399
+ versionId: z.string().uuid().optional(),
400
+ components: z.record(z.string(), ComponentEntrySchema).optional()
392
401
  }).passthrough();
393
402
  var CONFIG_FILE = "ollie.json";
394
403
  function getConfigFileName(stage) {
@@ -443,6 +452,51 @@ async function saveConfig(config, options = {}) {
443
452
  };
444
453
  await fs2.writeFile(configPath, JSON.stringify(merged, null, 2));
445
454
  }
455
+ async function listStages(cwd = process.cwd()) {
456
+ let files;
457
+ try {
458
+ files = await fs2.readdir(cwd);
459
+ } catch {
460
+ return [];
461
+ }
462
+ const seen = /* @__PURE__ */ new Set();
463
+ const stages = [];
464
+ for (const file of files.sort()) {
465
+ const match = file.match(/^ollie(?:\.([^.]+))?\.json$/);
466
+ if (!match) continue;
467
+ const stage = match[1] ?? "prod";
468
+ if (seen.has(stage)) continue;
469
+ seen.add(stage);
470
+ let versionId;
471
+ try {
472
+ const config = await loadConfig({ cwd, stage: match[1] });
473
+ versionId = config?.versionId;
474
+ } catch {
475
+ }
476
+ stages.push({ stage, versionId });
477
+ }
478
+ return stages;
479
+ }
480
+ async function upsertComponentEntry(id, values, options = {}) {
481
+ const { cwd = process.cwd(), stage } = options;
482
+ const configPath = path2.join(cwd, getConfigFileName(stage));
483
+ const existing = await loadConfigFile(configPath) ?? {};
484
+ const components = {
485
+ ...existing.components ?? {}
486
+ };
487
+ const prev = components[id];
488
+ const slot = values.slot ?? prev?.slot;
489
+ const entry = {
490
+ path: prev?.path ?? values.defaultPath,
491
+ ...slot !== void 0 ? { slot } : {}
492
+ };
493
+ components[id] = entry;
494
+ await fs2.writeFile(
495
+ configPath,
496
+ JSON.stringify({ ...existing, components }, null, 2)
497
+ );
498
+ return entry;
499
+ }
446
500
  function resolveStage(cliStage) {
447
501
  return cliStage || process.env.OLLIE_STAGE || void 0;
448
502
  }
@@ -579,19 +633,84 @@ async function readComponentMeta(componentDir, stage) {
579
633
  return null;
580
634
  }
581
635
  }
636
+ async function componentsByDir(cwd, stageLabel) {
637
+ const byDir = /* @__PURE__ */ new Map();
638
+ let config = null;
639
+ try {
640
+ config = await loadConfig({ cwd, stage: stageLabel });
641
+ } catch {
642
+ return byDir;
643
+ }
644
+ for (const [id, entry] of Object.entries(config?.components ?? {})) {
645
+ const dir = path4.resolve(cwd, entry.path);
646
+ if (!byDir.has(dir)) byDir.set(dir, { id, slot: entry.slot });
647
+ }
648
+ return byDir;
649
+ }
582
650
  async function discoverComponents(options = {}) {
583
651
  const { cwd = process.cwd(), stage } = options;
652
+ const activeStage = stage ?? "prod";
653
+ const buildDir = path4.join(cwd, "node_modules/.ollie", "build");
654
+ const components = [];
655
+ const mappedDirs = /* @__PURE__ */ new Set();
656
+ const activeByDir = await componentsByDir(cwd, activeStage);
657
+ const otherStageByDir = /* @__PURE__ */ new Map();
658
+ try {
659
+ for (const { stage: label } of await listStages(cwd)) {
660
+ if (label === activeStage) continue;
661
+ for (const [dir, entry] of await componentsByDir(cwd, label)) {
662
+ if (!activeByDir.has(dir) && !otherStageByDir.has(dir)) {
663
+ otherStageByDir.set(dir, entry);
664
+ }
665
+ }
666
+ }
667
+ } catch {
668
+ }
669
+ for (const [componentDir, entry] of activeByDir) {
670
+ const entryPoint = path4.join(componentDir, "index.tsx");
671
+ try {
672
+ await fs4.access(entryPoint);
673
+ } catch {
674
+ console.warn(
675
+ `${entry.id}: ${componentDir}/index.tsx not found - skipping`
676
+ );
677
+ continue;
678
+ }
679
+ const name = path4.basename(componentDir);
680
+ mappedDirs.add(componentDir);
681
+ components.push({
682
+ id: entry.id,
683
+ name,
684
+ slot: entry.slot,
685
+ entryPoint,
686
+ outfile: path4.join(buildDir, name, "index.js")
687
+ });
688
+ }
584
689
  const componentsDir = path4.join(cwd, "components");
585
690
  try {
586
691
  await fs4.access(componentsDir);
587
692
  } catch {
588
- return [];
693
+ return components;
589
694
  }
590
695
  const entries = await glob("*/index.tsx", { cwd: componentsDir });
591
- const components = [];
592
696
  for (const entry of entries) {
593
697
  const name = path4.dirname(entry);
594
- const componentDir = path4.join(componentsDir, name);
698
+ const componentDir = path4.resolve(componentsDir, name);
699
+ if (mappedDirs.has(componentDir)) continue;
700
+ const entryPoint = path4.join(componentsDir, entry);
701
+ const outfile = path4.join(buildDir, name, "index.js");
702
+ const other = otherStageByDir.get(componentDir);
703
+ if (other) {
704
+ components.push({
705
+ id: other.id,
706
+ name,
707
+ slot: other.slot,
708
+ otherStage: true,
709
+ entryPoint,
710
+ outfile
711
+ });
712
+ continue;
713
+ }
595
714
  const meta = await readComponentMeta(componentDir, stage);
596
715
  const id = meta?.id ?? `studio-${name}`;
597
716
  const isUnlinked = !meta?.id;
@@ -604,8 +723,9 @@ async function discoverComponents(options = {}) {
604
723
  id,
605
724
  name,
606
725
  slot: meta?.slot,
607
- entryPoint: path4.join(componentsDir, entry),
608
- outfile: path4.join(cwd, "node_modules/.ollie", "build", name, "index.js")
726
+ ...isUnlinked ? { unlinked: true } : {},
727
+ entryPoint,
728
+ outfile
609
729
  });
610
730
  }
611
731
  return components;
@@ -685,24 +805,29 @@ async function startDevServer(options = {}) {
685
805
  const servedir = path4.join(cwd, "node_modules/.ollie", "build");
686
806
  const componentsDir = path4.join(cwd, "components");
687
807
  const internalPort = port + 1;
808
+ let activeStage = stage ?? "prod";
688
809
  let ctx = null;
689
810
  let entryNames = /* @__PURE__ */ new Set();
690
811
  async function buildAndServe(components) {
691
812
  entryNames = new Set(components.map((c) => c.name));
692
- ctx = await createBuildContext(components, { cwd, stage, onBuildEnd });
813
+ ctx = await createBuildContext(components, {
814
+ cwd,
815
+ stage: activeStage,
816
+ onBuildEnd
817
+ });
693
818
  await ctx.rebuild();
694
819
  await ctx.watch();
695
820
  await ctx.serve({ port: internalPort, host, servedir, onRequest });
696
821
  }
697
822
  async function recreate() {
698
- const components = await discoverComponents({ cwd, stage });
823
+ const components = await discoverComponents({ cwd, stage: activeStage });
699
824
  const oldCtx = ctx;
700
825
  ctx = null;
701
826
  if (oldCtx) await oldCtx.dispose();
702
827
  await buildAndServe(components);
703
828
  notifyComponentsChanged(components);
704
829
  }
705
- await buildAndServe(await discoverComponents({ cwd, stage }));
830
+ await buildAndServe(await discoverComponents({ cwd, stage: activeStage }));
706
831
  async function currentComponentNames() {
707
832
  try {
708
833
  const entries = await glob("*/index.tsx", { cwd: componentsDir });
@@ -859,6 +984,55 @@ data: ${payload}
859
984
  }
860
985
  return;
861
986
  }
987
+ if (url.pathname === "/stages" && req.method === "GET") {
988
+ res.setHeader("Access-Control-Allow-Origin", "*");
989
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
990
+ res.setHeader("Content-Type", "application/json");
991
+ const stages = await listStages(cwd);
992
+ res.end(JSON.stringify({ active: activeStage, stages }));
993
+ return;
994
+ }
995
+ if (url.pathname === "/stage" && req.method === "POST") {
996
+ res.setHeader("Access-Control-Allow-Origin", "*");
997
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
998
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
999
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
1000
+ let body = "";
1001
+ req.on("data", (chunk) => {
1002
+ body += chunk.toString();
1003
+ });
1004
+ req.on("end", async () => {
1005
+ try {
1006
+ const { stage: next } = JSON.parse(body);
1007
+ if (typeof next !== "string" || !next) {
1008
+ res.statusCode = 400;
1009
+ res.setHeader("Content-Type", "application/json");
1010
+ res.end(JSON.stringify({ error: "Missing 'stage' in body" }));
1011
+ return;
1012
+ }
1013
+ activeStage = next;
1014
+ await recreate();
1015
+ let versionId;
1016
+ try {
1017
+ versionId = (await loadConfig({ cwd, stage: activeStage }))?.versionId;
1018
+ } catch {
1019
+ }
1020
+ res.setHeader("Content-Type", "application/json");
1021
+ res.end(
1022
+ JSON.stringify({ success: true, stage: activeStage, versionId })
1023
+ );
1024
+ } catch (err) {
1025
+ res.statusCode = 400;
1026
+ res.setHeader("Content-Type", "application/json");
1027
+ res.end(
1028
+ JSON.stringify({
1029
+ error: err instanceof Error ? err.message : "Invalid JSON body"
1030
+ })
1031
+ );
1032
+ }
1033
+ });
1034
+ return;
1035
+ }
862
1036
  if (url.pathname === "/meta" && req.method === "POST") {
863
1037
  const componentName = url.searchParams.get("component");
864
1038
  if (!componentName) {
@@ -880,6 +1054,32 @@ data: ${payload}
880
1054
  req.on("end", async () => {
881
1055
  try {
882
1056
  const updates = JSON.parse(body);
1057
+ let cfg = null;
1058
+ try {
1059
+ cfg = await loadConfig({ cwd, stage: activeStage });
1060
+ } catch {
1061
+ }
1062
+ if (cfg?.components !== void 0) {
1063
+ const id = typeof updates.id === "string" ? updates.id : void 0;
1064
+ if (!id) {
1065
+ res.statusCode = 400;
1066
+ res.setHeader("Content-Type", "application/json");
1067
+ res.end(JSON.stringify({ error: "Missing 'id' in body" }));
1068
+ return;
1069
+ }
1070
+ const slot = typeof updates.slot === "string" ? updates.slot : void 0;
1071
+ const entry = await upsertComponentEntry(
1072
+ id,
1073
+ { defaultPath: `components/${componentName}`, slot },
1074
+ { cwd, stage: activeStage }
1075
+ );
1076
+ await ctx?.rebuild();
1077
+ res.setHeader("Content-Type", "application/json");
1078
+ res.end(
1079
+ JSON.stringify({ success: true, component: { id, ...entry } })
1080
+ );
1081
+ return;
1082
+ }
883
1083
  const metaPath = path4.join(
884
1084
  cwd,
885
1085
  "components",
@@ -986,7 +1186,9 @@ async function writeManifest(components, buildResult, cwd = process.cwd()) {
986
1186
  id: c.id,
987
1187
  name: c.name,
988
1188
  js: `/${c.name}/index.js`,
989
- slot: c.slot
1189
+ slot: c.slot,
1190
+ ...c.otherStage ? { otherStage: true } : {},
1191
+ ...c.unlinked ? { unlinked: true } : {}
990
1192
  };
991
1193
  const cssPath = path4.join(outdir, c.name, "index.css");
992
1194
  try {
@@ -1340,7 +1542,7 @@ function App({ command, args }) {
1340
1542
  }
1341
1543
  }
1342
1544
  function VersionCommand() {
1343
- const version = "1.4.1" ? "1.4.1" : "unknown";
1545
+ const version = "1.5.0" ? "1.5.0" : "unknown";
1344
1546
  return /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { children: [
1345
1547
  "ollieshop v",
1346
1548
  version
@@ -2493,6 +2695,115 @@ async function schemaCommand(parsed2) {
2493
2695
  `);
2494
2696
  }
2495
2697
 
2698
+ // src/commands/setup-cmd.ts
2699
+ import fs5 from "fs/promises";
2700
+ import path5 from "path";
2701
+ import { glob as glob2 } from "glob";
2702
+ var META_FILE_RE = /^meta(?:\.([^.]+))?\.json$/;
2703
+ function configFileForStage(stage) {
2704
+ return !stage || stage === "prod" ? "ollie.json" : `ollie.${stage}.json`;
2705
+ }
2706
+ async function setupCommand(parsed2) {
2707
+ const format = detectOutputFormat(parsed2.global.output);
2708
+ const cwd = process.cwd();
2709
+ const dryRun = parsed2.global.dryRun;
2710
+ try {
2711
+ const componentsDir = path5.join(cwd, "components");
2712
+ let folders = [];
2713
+ try {
2714
+ await fs5.access(componentsDir);
2715
+ const entries = await glob2("*/index.tsx", { cwd: componentsDir });
2716
+ folders = entries.map((entry) => path5.dirname(entry));
2717
+ } catch {
2718
+ }
2719
+ const migrated = [];
2720
+ const skipped = [];
2721
+ for (const name of folders) {
2722
+ const folderDir = path5.join(componentsDir, name);
2723
+ const metaFiles = await glob2("meta*.json", { cwd: folderDir });
2724
+ for (const metaFile of metaFiles) {
2725
+ const match = metaFile.match(META_FILE_RE);
2726
+ if (!match) continue;
2727
+ const stage = match[1];
2728
+ let meta;
2729
+ try {
2730
+ meta = JSON.parse(
2731
+ await fs5.readFile(path5.join(folderDir, metaFile), "utf-8")
2732
+ );
2733
+ } catch {
2734
+ skipped.push({
2735
+ component: name,
2736
+ metaFile,
2737
+ reason: "unreadable meta"
2738
+ });
2739
+ continue;
2740
+ }
2741
+ const id = typeof meta.id === "string" ? meta.id : void 0;
2742
+ if (!id) {
2743
+ skipped.push({
2744
+ component: name,
2745
+ metaFile,
2746
+ reason: "no id (unlinked) - left as-is"
2747
+ });
2748
+ continue;
2749
+ }
2750
+ const slot = typeof meta.slot === "string" ? meta.slot : void 0;
2751
+ if (!dryRun) {
2752
+ await upsertComponentEntry(
2753
+ id,
2754
+ { defaultPath: `components/${name}`, slot },
2755
+ { cwd, stage }
2756
+ );
2757
+ await fs5.rm(path5.join(folderDir, metaFile));
2758
+ }
2759
+ migrated.push({
2760
+ component: name,
2761
+ stage: stage ?? "prod",
2762
+ configFile: configFileForStage(stage),
2763
+ metaFile,
2764
+ id,
2765
+ slot
2766
+ });
2767
+ }
2768
+ }
2769
+ if (format === "json") {
2770
+ outputResult(
2771
+ { data: { dryRun, migrated, skipped } },
2772
+ format,
2773
+ parsed2.global.fields
2774
+ );
2775
+ return;
2776
+ }
2777
+ const prefix = dryRun ? "\x1B[33m[dry-run]\x1B[0m " : "";
2778
+ if (migrated.length === 0) {
2779
+ console.log(`${prefix}No legacy meta files to migrate.`);
2780
+ } else {
2781
+ console.log(
2782
+ `${prefix}\x1B[1mMigrated ${migrated.length} meta file(s) to config:\x1B[0m`
2783
+ );
2784
+ for (const entry of migrated) {
2785
+ const slotLabel = entry.slot ? `, slot ${entry.slot}` : "";
2786
+ const removed = dryRun ? "" : ` [removed ${entry.metaFile}]`;
2787
+ console.log(
2788
+ ` ${entry.configFile.padEnd(16)} ${entry.component} (id ${entry.id}${slotLabel})${removed}`
2789
+ );
2790
+ }
2791
+ }
2792
+ if (skipped.length > 0) {
2793
+ console.log("\n\x1B[2mSkipped:\x1B[0m");
2794
+ for (const entry of skipped) {
2795
+ console.log(` ${entry.component}/${entry.metaFile}: ${entry.reason}`);
2796
+ }
2797
+ }
2798
+ } catch (err) {
2799
+ outputResult(
2800
+ { error: { message: err instanceof Error ? err.message : String(err) } },
2801
+ format
2802
+ );
2803
+ process.exit(1);
2804
+ }
2805
+ }
2806
+
2496
2807
  // src/commands/status-cmd.ts
2497
2808
  async function statusCommand(parsed2) {
2498
2809
  const format = detectOutputFormat(parsed2.global.output);
@@ -2876,7 +3187,8 @@ var AGENT_COMMANDS = {
2876
3187
  schema: schemaCommand,
2877
3188
  init: initCommand,
2878
3189
  deploy: deployCommand,
2879
- status: statusCommand
3190
+ status: statusCommand,
3191
+ setup: setupCommand
2880
3192
  };
2881
3193
  var parsed = parseArgs(process.argv);
2882
3194
  if (parsed.command in AGENT_COMMANDS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ollie-shop/cli",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Ollie Shop CLI - Development tools for custom checkouts",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -91,6 +91,12 @@ export function HelpCommand() {
91
91
  </Box>
92
92
  <Text>Write store/version IDs to ollie.json</Text>
93
93
  </Box>
94
+ <Box>
95
+ <Box width={24}>
96
+ <Text color="green">setup</Text>
97
+ </Box>
98
+ <Text>Migrate legacy meta.json into the config components map</Text>
99
+ </Box>
94
100
  <Box>
95
101
  <Box width={24}>
96
102
  <Text color="green">help</Text>
@@ -0,0 +1,142 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { glob } from "glob";
4
+ import { upsertComponentEntry } from "../utils/config.js";
5
+ import { detectOutputFormat, outputResult } from "../utils/output.js";
6
+ import type { ParsedArgs } from "../utils/parse-args.js";
7
+
8
+ const META_FILE_RE = /^meta(?:\.([^.]+))?\.json$/;
9
+
10
+ function configFileForStage(stage?: string): string {
11
+ return !stage || stage === "prod" ? "ollie.json" : `ollie.${stage}.json`;
12
+ }
13
+
14
+ interface MigratedMeta {
15
+ component: string;
16
+ stage: string;
17
+ configFile: string;
18
+ metaFile: string;
19
+ id: string;
20
+ slot?: string;
21
+ }
22
+
23
+ /**
24
+ * Migrates legacy per-folder meta.json / meta.{stage}.json into the config
25
+ * `components` map (meta.json -> ollie.json, meta.{stage}.json -> ollie.{stage}.json)
26
+ * and removes the migrated meta files. Local-only metas (no id) are left in place.
27
+ */
28
+ export async function setupCommand(parsed: ParsedArgs): Promise<void> {
29
+ const format = detectOutputFormat(parsed.global.output);
30
+ const cwd = process.cwd();
31
+ const dryRun = parsed.global.dryRun;
32
+
33
+ try {
34
+ const componentsDir = path.join(cwd, "components");
35
+ let folders: string[] = [];
36
+ try {
37
+ await fs.access(componentsDir);
38
+ const entries = await glob("*/index.tsx", { cwd: componentsDir });
39
+ folders = entries.map((entry) => path.dirname(entry));
40
+ } catch {
41
+ // No components directory
42
+ }
43
+
44
+ const migrated: MigratedMeta[] = [];
45
+ const skipped: Array<{
46
+ component: string;
47
+ metaFile: string;
48
+ reason: string;
49
+ }> = [];
50
+
51
+ for (const name of folders) {
52
+ const folderDir = path.join(componentsDir, name);
53
+ const metaFiles = await glob("meta*.json", { cwd: folderDir });
54
+
55
+ for (const metaFile of metaFiles) {
56
+ const match = metaFile.match(META_FILE_RE);
57
+ if (!match) continue;
58
+ const stage = match[1];
59
+
60
+ let meta: { id?: unknown; slot?: unknown };
61
+ try {
62
+ meta = JSON.parse(
63
+ await fs.readFile(path.join(folderDir, metaFile), "utf-8"),
64
+ );
65
+ } catch {
66
+ skipped.push({
67
+ component: name,
68
+ metaFile,
69
+ reason: "unreadable meta",
70
+ });
71
+ continue;
72
+ }
73
+
74
+ const id = typeof meta.id === "string" ? meta.id : undefined;
75
+ if (!id) {
76
+ skipped.push({
77
+ component: name,
78
+ metaFile,
79
+ reason: "no id (unlinked) - left as-is",
80
+ });
81
+ continue;
82
+ }
83
+ const slot = typeof meta.slot === "string" ? meta.slot : undefined;
84
+
85
+ if (!dryRun) {
86
+ await upsertComponentEntry(
87
+ id,
88
+ { defaultPath: `components/${name}`, slot },
89
+ { cwd, stage },
90
+ );
91
+ await fs.rm(path.join(folderDir, metaFile));
92
+ }
93
+
94
+ migrated.push({
95
+ component: name,
96
+ stage: stage ?? "prod",
97
+ configFile: configFileForStage(stage),
98
+ metaFile,
99
+ id,
100
+ slot,
101
+ });
102
+ }
103
+ }
104
+
105
+ if (format === "json") {
106
+ outputResult(
107
+ { data: { dryRun, migrated, skipped } },
108
+ format,
109
+ parsed.global.fields,
110
+ );
111
+ return;
112
+ }
113
+
114
+ const prefix = dryRun ? "\x1b[33m[dry-run]\x1b[0m " : "";
115
+ if (migrated.length === 0) {
116
+ console.log(`${prefix}No legacy meta files to migrate.`);
117
+ } else {
118
+ console.log(
119
+ `${prefix}\x1b[1mMigrated ${migrated.length} meta file(s) to config:\x1b[0m`,
120
+ );
121
+ for (const entry of migrated) {
122
+ const slotLabel = entry.slot ? `, slot ${entry.slot}` : "";
123
+ const removed = dryRun ? "" : ` [removed ${entry.metaFile}]`;
124
+ console.log(
125
+ ` ${entry.configFile.padEnd(16)} ${entry.component} (id ${entry.id}${slotLabel})${removed}`,
126
+ );
127
+ }
128
+ }
129
+ if (skipped.length > 0) {
130
+ console.log("\n\x1b[2mSkipped:\x1b[0m");
131
+ for (const entry of skipped) {
132
+ console.log(` ${entry.component}/${entry.metaFile}: ${entry.reason}`);
133
+ }
134
+ }
135
+ } catch (err) {
136
+ outputResult(
137
+ { error: { message: err instanceof Error ? err.message : String(err) } },
138
+ format,
139
+ );
140
+ process.exit(1);
141
+ }
142
+ }
package/src/index.tsx CHANGED
@@ -6,6 +6,7 @@ import { deployCommand } from "./commands/deploy-cmd.js";
6
6
  import { functionCommand } from "./commands/function-cmd.js";
7
7
  import { initCommand } from "./commands/init-cmd.js";
8
8
  import { schemaCommand } from "./commands/schema-cmd.js";
9
+ import { setupCommand } from "./commands/setup-cmd.js";
9
10
  import { statusCommand } from "./commands/status-cmd.js";
10
11
  import { storeCommand } from "./commands/store-cmd.js";
11
12
  import { versionCommand } from "./commands/version-cmd.js";
@@ -27,6 +28,7 @@ const AGENT_COMMANDS: Record<
27
28
  init: initCommand,
28
29
  deploy: deployCommand,
29
30
  status: statusCommand,
31
+ setup: setupCommand,
30
32
  };
31
33
 
32
34
  const parsed = parseArgs(process.argv);
@@ -2,10 +2,18 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
4
 
5
+ const ComponentEntrySchema = z.object({
6
+ path: z.string(),
7
+ slot: z.string().optional(),
8
+ });
9
+
10
+ export type ComponentEntry = z.infer<typeof ComponentEntrySchema>;
11
+
5
12
  const OllieConfigSchema = z
6
13
  .object({
7
14
  storeId: z.string().uuid(),
8
15
  versionId: z.string().uuid().optional(),
16
+ components: z.record(z.string(), ComponentEntrySchema).optional(),
9
17
  })
10
18
  .passthrough(); // Allow any other fields without validation
11
19
 
@@ -114,6 +122,74 @@ export async function saveConfig(
114
122
  await fs.writeFile(configPath, JSON.stringify(merged, null, 2));
115
123
  }
116
124
 
125
+ /**
126
+ * Lists the stages available in a project by scanning its ollie config files.
127
+ * ollie.json is the "prod" stage; ollie.{stage}.json is the "{stage}" stage.
128
+ */
129
+ export async function listStages(
130
+ cwd: string = process.cwd(),
131
+ ): Promise<Array<{ stage: string; versionId?: string }>> {
132
+ let files: string[];
133
+ try {
134
+ files = await fs.readdir(cwd);
135
+ } catch {
136
+ return [];
137
+ }
138
+
139
+ const seen = new Set<string>();
140
+ const stages: Array<{ stage: string; versionId?: string }> = [];
141
+ for (const file of files.sort()) {
142
+ const match = file.match(/^ollie(?:\.([^.]+))?\.json$/);
143
+ if (!match) continue;
144
+ const stage = match[1] ?? "prod";
145
+ if (seen.has(stage)) continue;
146
+ seen.add(stage);
147
+
148
+ let versionId: string | undefined;
149
+ try {
150
+ const config = await loadConfig({ cwd, stage: match[1] });
151
+ versionId = config?.versionId;
152
+ } catch {
153
+ // List the stage even if its config is invalid/incomplete
154
+ }
155
+ stages.push({ stage, versionId });
156
+ }
157
+
158
+ return stages;
159
+ }
160
+
161
+ /**
162
+ * Upserts a component entry into the stage's config file (ollie.json or
163
+ * ollie.{stage}.json). Preserves the existing path/slot when not provided.
164
+ */
165
+ export async function upsertComponentEntry(
166
+ id: string,
167
+ values: { defaultPath: string; slot?: string },
168
+ options: SaveConfigOptions = {},
169
+ ): Promise<ComponentEntry> {
170
+ const { cwd = process.cwd(), stage } = options;
171
+ const configPath = path.join(cwd, getConfigFileName(stage));
172
+
173
+ const existing = (await loadConfigFile(configPath)) ?? {};
174
+ const components = {
175
+ ...((existing.components as Record<string, ComponentEntry>) ?? {}),
176
+ };
177
+ const prev = components[id];
178
+ const slot = values.slot ?? prev?.slot;
179
+ const entry: ComponentEntry = {
180
+ path: prev?.path ?? values.defaultPath,
181
+ ...(slot !== undefined ? { slot } : {}),
182
+ };
183
+ components[id] = entry;
184
+
185
+ await fs.writeFile(
186
+ configPath,
187
+ JSON.stringify({ ...existing, components }, null, 2),
188
+ );
189
+
190
+ return entry;
191
+ }
192
+
117
193
  /**
118
194
  * Returns the resolved stage from CLI args or environment.
119
195
  * Priority: CLI arg > OLLIE_STAGE env > undefined (defaults to prod behavior)
@@ -5,14 +5,24 @@ import path from "node:path";
5
5
  import * as esbuild from "esbuild";
6
6
  import { glob } from "glob";
7
7
  import { createComponentBundle } from "./bundle.js";
8
+ import {
9
+ type OllieConfig,
10
+ listStages,
11
+ loadConfig,
12
+ upsertComponentEntry,
13
+ } from "./config.js";
8
14
 
9
15
  export interface ComponentInfo {
10
- /** Component ID from meta.json (UUID) or generated temporary ID (studio-*) */
16
+ /** Component ID from the ollie config (UUID) or generated temporary ID (studio-*) */
11
17
  id: string;
12
18
  /** Folder name */
13
19
  name: string;
14
- /** Target slot for new components (from meta.json) */
20
+ /** Target slot (from the ollie config) */
15
21
  slot?: string;
22
+ /** True when the folder belongs to another stage's config, not the active one */
23
+ otherStage?: boolean;
24
+ /** True when the folder isn't linked to a component id (synthetic studio-* id) */
25
+ unlinked?: boolean;
16
26
  entryPoint: string;
17
27
  outfile: string;
18
28
  }
@@ -33,7 +43,7 @@ interface ComponentMeta {
33
43
  }
34
44
 
35
45
  /**
36
- * Reads the meta.json file for a component.
46
+ * Reads the meta.json file for a component (legacy, per-folder source).
37
47
  * Supports stage-specific meta files: meta.{stage}.json
38
48
  */
39
49
  async function readComponentMeta(
@@ -66,35 +76,118 @@ export interface DiscoverComponentsOptions {
66
76
  stage?: string;
67
77
  }
68
78
 
79
+ /** Maps resolved component dirs -> {id, slot} for a stage's config map. */
80
+ async function componentsByDir(
81
+ cwd: string,
82
+ stageLabel: string,
83
+ ): Promise<Map<string, { id: string; slot?: string }>> {
84
+ const byDir = new Map<string, { id: string; slot?: string }>();
85
+ let config: OllieConfig | null = null;
86
+ try {
87
+ config = await loadConfig({ cwd, stage: stageLabel });
88
+ } catch {
89
+ return byDir;
90
+ }
91
+ for (const [id, entry] of Object.entries(config?.components ?? {})) {
92
+ const dir = path.resolve(cwd, entry.path);
93
+ if (!byDir.has(dir)) byDir.set(dir, { id, slot: entry.slot });
94
+ }
95
+ return byDir;
96
+ }
97
+
69
98
  /**
70
- * Discovers components in the components directory.
71
- * Each component should have an index.tsx file and a meta.json with its ID.
99
+ * Discovers components, config-first:
100
+ * 1. The active stage's `components` map the source of truth; entries may
101
+ * point inside or outside components/.
102
+ * 2. Folders under components/ that belong to ANOTHER stage's config are
103
+ * surfaced with their real id and flagged `otherStage` (hidden by default
104
+ * in Studio, shown via "show all").
105
+ * 3. Remaining folders fall back to legacy per-folder meta.json; folders with
106
+ * neither get a temporary studio-* id (unlinked).
72
107
  */
73
108
  export async function discoverComponents(
74
109
  options: DiscoverComponentsOptions = {},
75
110
  ): Promise<ComponentInfo[]> {
76
111
  const { cwd = process.cwd(), stage } = options;
77
- const componentsDir = path.join(cwd, "components");
112
+ const activeStage = stage ?? "prod";
113
+ const buildDir = path.join(cwd, "node_modules/.ollie", "build");
114
+ const components: ComponentInfo[] = [];
115
+ const mappedDirs = new Set<string>();
116
+
117
+ const activeByDir = await componentsByDir(cwd, activeStage);
78
118
 
119
+ // Component dirs claimed by other stages (so we don't mistake them for new)
120
+ const otherStageByDir = new Map<string, { id: string; slot?: string }>();
121
+ try {
122
+ for (const { stage: label } of await listStages(cwd)) {
123
+ if (label === activeStage) continue;
124
+ for (const [dir, entry] of await componentsByDir(cwd, label)) {
125
+ if (!activeByDir.has(dir) && !otherStageByDir.has(dir)) {
126
+ otherStageByDir.set(dir, entry);
127
+ }
128
+ }
129
+ }
130
+ } catch {
131
+ // No other stages
132
+ }
133
+
134
+ // 1. Active stage's config map (source of truth)
135
+ for (const [componentDir, entry] of activeByDir) {
136
+ const entryPoint = path.join(componentDir, "index.tsx");
137
+ try {
138
+ await fs.access(entryPoint);
139
+ } catch {
140
+ console.warn(
141
+ `${entry.id}: ${componentDir}/index.tsx not found - skipping`,
142
+ );
143
+ continue;
144
+ }
145
+ const name = path.basename(componentDir);
146
+ mappedDirs.add(componentDir);
147
+ components.push({
148
+ id: entry.id,
149
+ name,
150
+ slot: entry.slot,
151
+ entryPoint,
152
+ outfile: path.join(buildDir, name, "index.js"),
153
+ });
154
+ }
155
+
156
+ // 2. Folders under components/ not in the active map
157
+ const componentsDir = path.join(cwd, "components");
79
158
  try {
80
159
  await fs.access(componentsDir);
81
160
  } catch {
82
- return [];
161
+ return components;
83
162
  }
84
163
 
85
164
  const entries = await glob("*/index.tsx", { cwd: componentsDir });
86
- const components: ComponentInfo[] = [];
87
-
88
165
  for (const entry of entries) {
89
166
  const name = path.dirname(entry);
90
- const componentDir = path.join(componentsDir, name);
91
- const meta = await readComponentMeta(componentDir, stage);
167
+ const componentDir = path.resolve(componentsDir, name);
168
+ if (mappedDirs.has(componentDir)) continue;
169
+
170
+ const entryPoint = path.join(componentsDir, entry);
171
+ const outfile = path.join(buildDir, name, "index.js");
172
+
173
+ // Belongs to another stage's config: surface with its real id, flagged
174
+ const other = otherStageByDir.get(componentDir);
175
+ if (other) {
176
+ components.push({
177
+ id: other.id,
178
+ name,
179
+ slot: other.slot,
180
+ otherStage: true,
181
+ entryPoint,
182
+ outfile,
183
+ });
184
+ continue;
185
+ }
92
186
 
93
- // Components without id (or without meta.json) get a temporary studio-* ID
187
+ // Legacy per-folder meta.json fallback
188
+ const meta = await readComponentMeta(componentDir, stage);
94
189
  const id = meta?.id ?? `studio-${name}`;
95
190
  const isUnlinked = !meta?.id;
96
-
97
- // Unlinked components without a slot can still be built, just not placed in checkout
98
191
  if (isUnlinked && !meta?.slot) {
99
192
  console.warn(
100
193
  `${name}: no slot defined - component will be built but not placed in checkout`,
@@ -105,8 +198,9 @@ export async function discoverComponents(
105
198
  id,
106
199
  name,
107
200
  slot: meta?.slot,
108
- entryPoint: path.join(componentsDir, entry),
109
- outfile: path.join(cwd, "node_modules/.ollie", "build", name, "index.js"),
201
+ ...(isUnlinked ? { unlinked: true } : {}),
202
+ entryPoint,
203
+ outfile,
110
204
  });
111
205
  }
112
206
 
@@ -250,19 +344,26 @@ export async function startDevServer(
250
344
  const componentsDir = path.join(cwd, "components");
251
345
  const internalPort = port + 1;
252
346
 
347
+ // The active stage is mutable: the admin can switch it at runtime via POST /stage.
348
+ let activeStage = stage ?? "prod";
349
+
253
350
  let ctx: esbuild.BuildContext | null = null;
254
351
  let entryNames = new Set<string>();
255
352
 
256
353
  async function buildAndServe(components: ComponentInfo[]): Promise<void> {
257
354
  entryNames = new Set(components.map((c) => c.name));
258
- ctx = await createBuildContext(components, { cwd, stage, onBuildEnd });
355
+ ctx = await createBuildContext(components, {
356
+ cwd,
357
+ stage: activeStage,
358
+ onBuildEnd,
359
+ });
259
360
  await ctx.rebuild();
260
361
  await ctx.watch();
261
362
  await ctx.serve({ port: internalPort, host, servedir, onRequest });
262
363
  }
263
364
 
264
365
  async function recreate(): Promise<void> {
265
- const components = await discoverComponents({ cwd, stage });
366
+ const components = await discoverComponents({ cwd, stage: activeStage });
266
367
  const oldCtx = ctx;
267
368
  ctx = null;
268
369
  if (oldCtx) await oldCtx.dispose();
@@ -270,7 +371,7 @@ export async function startDevServer(
270
371
  notifyComponentsChanged(components);
271
372
  }
272
373
 
273
- await buildAndServe(await discoverComponents({ cwd, stage }));
374
+ await buildAndServe(await discoverComponents({ cwd, stage: activeStage }));
274
375
 
275
376
  async function currentComponentNames(): Promise<Set<string>> {
276
377
  try {
@@ -459,6 +560,62 @@ export async function startDevServer(
459
560
  return;
460
561
  }
461
562
 
563
+ // List available stages (from local ollie*.json) and the active one
564
+ if (url.pathname === "/stages" && req.method === "GET") {
565
+ res.setHeader("Access-Control-Allow-Origin", "*");
566
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
567
+ res.setHeader("Content-Type", "application/json");
568
+ const stages = await listStages(cwd);
569
+ res.end(JSON.stringify({ active: activeStage, stages }));
570
+ return;
571
+ }
572
+
573
+ // Switch the active stage at runtime: re-discover + rebuild for it
574
+ if (url.pathname === "/stage" && req.method === "POST") {
575
+ res.setHeader("Access-Control-Allow-Origin", "*");
576
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
577
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
578
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
579
+
580
+ let body = "";
581
+ req.on("data", (chunk) => {
582
+ body += chunk.toString();
583
+ });
584
+ req.on("end", async () => {
585
+ try {
586
+ const { stage: next } = JSON.parse(body) as { stage?: unknown };
587
+ if (typeof next !== "string" || !next) {
588
+ res.statusCode = 400;
589
+ res.setHeader("Content-Type", "application/json");
590
+ res.end(JSON.stringify({ error: "Missing 'stage' in body" }));
591
+ return;
592
+ }
593
+ activeStage = next;
594
+ await recreate();
595
+ let versionId: string | undefined;
596
+ try {
597
+ versionId = (await loadConfig({ cwd, stage: activeStage }))
598
+ ?.versionId;
599
+ } catch {
600
+ // Stage config may be missing/invalid
601
+ }
602
+ res.setHeader("Content-Type", "application/json");
603
+ res.end(
604
+ JSON.stringify({ success: true, stage: activeStage, versionId }),
605
+ );
606
+ } catch (err) {
607
+ res.statusCode = 400;
608
+ res.setHeader("Content-Type", "application/json");
609
+ res.end(
610
+ JSON.stringify({
611
+ error: err instanceof Error ? err.message : "Invalid JSON body",
612
+ }),
613
+ );
614
+ }
615
+ });
616
+ return;
617
+ }
618
+
462
619
  // Handle POST /meta?component=ComponentName to update meta.json
463
620
  if (url.pathname === "/meta" && req.method === "POST") {
464
621
  const componentName = url.searchParams.get("component");
@@ -487,6 +644,40 @@ export async function startDevServer(
487
644
  req.on("end", async () => {
488
645
  try {
489
646
  const updates = JSON.parse(body) as Record<string, unknown>;
647
+
648
+ // A project opts into the config model by having a `components` map.
649
+ // Otherwise we keep writing the legacy per-folder meta.json.
650
+ let cfg: OllieConfig | null = null;
651
+ try {
652
+ cfg = await loadConfig({ cwd, stage: activeStage });
653
+ } catch {
654
+ // Treat as legacy if the config can't be loaded
655
+ }
656
+
657
+ if (cfg?.components !== undefined) {
658
+ const id = typeof updates.id === "string" ? updates.id : undefined;
659
+ if (!id) {
660
+ res.statusCode = 400;
661
+ res.setHeader("Content-Type", "application/json");
662
+ res.end(JSON.stringify({ error: "Missing 'id' in body" }));
663
+ return;
664
+ }
665
+ const slot =
666
+ typeof updates.slot === "string" ? updates.slot : undefined;
667
+ const entry = await upsertComponentEntry(
668
+ id,
669
+ { defaultPath: `components/${componentName}`, slot },
670
+ { cwd, stage: activeStage },
671
+ );
672
+ // Rebuild so the manifest reflects the new mapping/slot
673
+ await ctx?.rebuild();
674
+ res.setHeader("Content-Type", "application/json");
675
+ res.end(
676
+ JSON.stringify({ success: true, component: { id, ...entry } }),
677
+ );
678
+ return;
679
+ }
680
+
490
681
  const metaPath = path.join(
491
682
  cwd,
492
683
  "components",
@@ -641,6 +832,10 @@ export interface ManifestEntry {
641
832
  css?: string;
642
833
  /** Target slot for components */
643
834
  slot?: string;
835
+ /** True when the component belongs to another stage's config */
836
+ otherStage?: boolean;
837
+ /** True when the component isn't linked to a component id (studio-*) */
838
+ unlinked?: boolean;
644
839
  }
645
840
 
646
841
  /**
@@ -690,6 +885,8 @@ export async function writeManifest(
690
885
  name: c.name,
691
886
  js: `/${c.name}/index.js`,
692
887
  slot: c.slot,
888
+ ...(c.otherStage ? { otherStage: true } : {}),
889
+ ...(c.unlinked ? { unlinked: true } : {}),
693
890
  };
694
891
 
695
892
  // Check if CSS exists