@ollie-shop/cli 1.4.0 → 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.0 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 93.69 KB
13
- ESM ⚡️ Build success in 295ms
12
+ ESM dist/index.js 105.76 KB
13
+ ESM ⚡️ Build success in 353ms
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
9
+ ## 1.4.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 630ae3e: Keep Studio live reload working after a component is added or removed. The dev server proxy now owns the `/esbuild` connection and re-subscribes across context recreates, so the browser's EventSource no longer drops when the esbuild context is disposed.
14
+
3
15
  ## 1.4.0
4
16
 
5
17
  ### Minor 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,23 +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);
828
+ notifyComponentsChanged(components);
703
829
  }
704
- await buildAndServe(await discoverComponents({ cwd, stage }));
830
+ await buildAndServe(await discoverComponents({ cwd, stage: activeStage }));
705
831
  async function currentComponentNames() {
706
832
  try {
707
833
  const entries = await glob("*/index.tsx", { cwd: componentsDir });
@@ -737,8 +863,80 @@ async function startDevServer(options = {}) {
737
863
  if (watchTimer) clearTimeout(watchTimer);
738
864
  watchTimer = setTimeout(maybeRecreate, 150);
739
865
  });
866
+ const sseClients = /* @__PURE__ */ new Set();
867
+ let upstreamReq = null;
868
+ let upstreamRetry = null;
869
+ function broadcast(chunk) {
870
+ for (const client of sseClients) {
871
+ if (!client.writableEnded) client.write(chunk);
872
+ }
873
+ }
874
+ function connectUpstream() {
875
+ if (upstreamReq || sseClients.size === 0) return;
876
+ const req = http.request(
877
+ {
878
+ hostname: host,
879
+ port: internalPort,
880
+ path: "/esbuild",
881
+ method: "GET",
882
+ headers: { accept: "text/event-stream" }
883
+ },
884
+ (upstream) => {
885
+ upstream.on("data", broadcast);
886
+ upstream.on("close", onUpstreamLost);
887
+ upstream.on("error", onUpstreamLost);
888
+ }
889
+ );
890
+ req.on("error", onUpstreamLost);
891
+ req.end();
892
+ upstreamReq = req;
893
+ }
894
+ function onUpstreamLost() {
895
+ if (!upstreamReq) return;
896
+ upstreamReq = null;
897
+ if (upstreamRetry) clearTimeout(upstreamRetry);
898
+ if (sseClients.size === 0) return;
899
+ upstreamRetry = setTimeout(connectUpstream, 200);
900
+ }
901
+ function teardownUpstream() {
902
+ if (upstreamRetry) {
903
+ clearTimeout(upstreamRetry);
904
+ upstreamRetry = null;
905
+ }
906
+ const req = upstreamReq;
907
+ upstreamReq = null;
908
+ req?.destroy();
909
+ }
910
+ function notifyComponentsChanged(components) {
911
+ if (sseClients.size === 0) return;
912
+ const updated = components.map((c) => `/${c.name}/index.js`);
913
+ const payload = JSON.stringify({ added: [], removed: [], updated });
914
+ broadcast(`event: change
915
+ data: ${payload}
916
+
917
+ `);
918
+ }
740
919
  const proxyServer = http.createServer(async (req, res) => {
741
920
  const url = new URL(req.url || "/", `http://${host}:${port}`);
921
+ if (url.pathname === "/esbuild" && req.method === "GET") {
922
+ res.writeHead(200, {
923
+ "Content-Type": "text/event-stream",
924
+ "Cache-Control": "no-cache",
925
+ Connection: "keep-alive",
926
+ "Access-Control-Allow-Origin": "*",
927
+ "Access-Control-Allow-Private-Network": "true"
928
+ });
929
+ res.write("retry: 500\n\n");
930
+ sseClients.add(res);
931
+ connectUpstream();
932
+ const dropClient = () => {
933
+ sseClients.delete(res);
934
+ if (sseClients.size === 0) teardownUpstream();
935
+ };
936
+ req.on("close", dropClient);
937
+ res.on("error", dropClient);
938
+ return;
939
+ }
742
940
  if (url.pathname === "/bundle" && req.method === "GET") {
743
941
  const componentPath = url.searchParams.get("path");
744
942
  if (!componentPath) {
@@ -786,6 +984,55 @@ async function startDevServer(options = {}) {
786
984
  }
787
985
  return;
788
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
+ }
789
1036
  if (url.pathname === "/meta" && req.method === "POST") {
790
1037
  const componentName = url.searchParams.get("component");
791
1038
  if (!componentName) {
@@ -807,6 +1054,32 @@ async function startDevServer(options = {}) {
807
1054
  req.on("end", async () => {
808
1055
  try {
809
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
+ }
810
1083
  const metaPath = path4.join(
811
1084
  cwd,
812
1085
  "components",
@@ -896,6 +1169,9 @@ async function startDevServer(options = {}) {
896
1169
  stop: async () => {
897
1170
  if (watchTimer) clearTimeout(watchTimer);
898
1171
  componentsWatcher.close();
1172
+ teardownUpstream();
1173
+ for (const client of sseClients) client.end();
1174
+ sseClients.clear();
899
1175
  proxyServer.close();
900
1176
  await ctx?.dispose();
901
1177
  }
@@ -910,7 +1186,9 @@ async function writeManifest(components, buildResult, cwd = process.cwd()) {
910
1186
  id: c.id,
911
1187
  name: c.name,
912
1188
  js: `/${c.name}/index.js`,
913
- slot: c.slot
1189
+ slot: c.slot,
1190
+ ...c.otherStage ? { otherStage: true } : {},
1191
+ ...c.unlinked ? { unlinked: true } : {}
914
1192
  };
915
1193
  const cssPath = path4.join(outdir, c.name, "index.css");
916
1194
  try {
@@ -1264,7 +1542,7 @@ function App({ command, args }) {
1264
1542
  }
1265
1543
  }
1266
1544
  function VersionCommand() {
1267
- const version = "1.4.0" ? "1.4.0" : "unknown";
1545
+ const version = "1.5.0" ? "1.5.0" : "unknown";
1268
1546
  return /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { children: [
1269
1547
  "ollieshop v",
1270
1548
  version
@@ -2417,6 +2695,115 @@ async function schemaCommand(parsed2) {
2417
2695
  `);
2418
2696
  }
2419
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
+
2420
2807
  // src/commands/status-cmd.ts
2421
2808
  async function statusCommand(parsed2) {
2422
2809
  const format = detectOutputFormat(parsed2.global.output);
@@ -2800,7 +3187,8 @@ var AGENT_COMMANDS = {
2800
3187
  schema: schemaCommand,
2801
3188
  init: initCommand,
2802
3189
  deploy: deployCommand,
2803
- status: statusCommand
3190
+ status: statusCommand,
3191
+ setup: setupCommand
2804
3192
  };
2805
3193
  var parsed = parseArgs(process.argv);
2806
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.0",
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);
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
+ }
78
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,26 +344,34 @@ 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();
269
370
  await buildAndServe(components);
371
+ notifyComponentsChanged(components);
270
372
  }
271
373
 
272
- await buildAndServe(await discoverComponents({ cwd, stage }));
374
+ await buildAndServe(await discoverComponents({ cwd, stage: activeStage }));
273
375
 
274
376
  async function currentComponentNames(): Promise<Set<string>> {
275
377
  try {
@@ -313,10 +415,92 @@ export async function startDevServer(
313
415
  watchTimer = setTimeout(maybeRecreate, 150);
314
416
  });
315
417
 
418
+ // esbuild's /esbuild live-reload stream lives on the context, so disposing it
419
+ // during a recreate drops the browser's EventSource — and Studio closes that
420
+ // connection for good on the first error. The proxy owns the downstream
421
+ // connection instead and re-subscribes to esbuild's stream across recreates,
422
+ // so live reload survives a component being added or removed.
423
+ const sseClients = new Set<http.ServerResponse>();
424
+ let upstreamReq: http.ClientRequest | null = null;
425
+ let upstreamRetry: ReturnType<typeof setTimeout> | null = null;
426
+
427
+ function broadcast(chunk: string | Buffer): void {
428
+ for (const client of sseClients) {
429
+ if (!client.writableEnded) client.write(chunk);
430
+ }
431
+ }
432
+
433
+ function connectUpstream(): void {
434
+ if (upstreamReq || sseClients.size === 0) return;
435
+ const req = http.request(
436
+ {
437
+ hostname: host,
438
+ port: internalPort,
439
+ path: "/esbuild",
440
+ method: "GET",
441
+ headers: { accept: "text/event-stream" },
442
+ },
443
+ (upstream) => {
444
+ upstream.on("data", broadcast);
445
+ upstream.on("close", onUpstreamLost);
446
+ upstream.on("error", onUpstreamLost);
447
+ },
448
+ );
449
+ req.on("error", onUpstreamLost);
450
+ req.end();
451
+ upstreamReq = req;
452
+ }
453
+
454
+ function onUpstreamLost(): void {
455
+ if (!upstreamReq) return;
456
+ upstreamReq = null;
457
+ if (upstreamRetry) clearTimeout(upstreamRetry);
458
+ if (sseClients.size === 0) return;
459
+ upstreamRetry = setTimeout(connectUpstream, 200);
460
+ }
461
+
462
+ function teardownUpstream(): void {
463
+ if (upstreamRetry) {
464
+ clearTimeout(upstreamRetry);
465
+ upstreamRetry = null;
466
+ }
467
+ const req = upstreamReq;
468
+ upstreamReq = null;
469
+ req?.destroy();
470
+ }
471
+
472
+ function notifyComponentsChanged(components: ComponentInfo[]): void {
473
+ if (sseClients.size === 0) return;
474
+ const updated = components.map((c) => `/${c.name}/index.js`);
475
+ const payload = JSON.stringify({ added: [], removed: [], updated });
476
+ broadcast(`event: change\ndata: ${payload}\n\n`);
477
+ }
478
+
316
479
  // Create proxy server that handles /bundle/* and forwards to esbuild
317
480
  const proxyServer = http.createServer(async (req, res) => {
318
481
  const url = new URL(req.url || "/", `http://${host}:${port}`);
319
482
 
483
+ // Hold the Studio live-reload stream open across esbuild recreates
484
+ if (url.pathname === "/esbuild" && req.method === "GET") {
485
+ res.writeHead(200, {
486
+ "Content-Type": "text/event-stream",
487
+ "Cache-Control": "no-cache",
488
+ Connection: "keep-alive",
489
+ "Access-Control-Allow-Origin": "*",
490
+ "Access-Control-Allow-Private-Network": "true",
491
+ });
492
+ res.write("retry: 500\n\n");
493
+ sseClients.add(res);
494
+ connectUpstream();
495
+ const dropClient = () => {
496
+ sseClients.delete(res);
497
+ if (sseClients.size === 0) teardownUpstream();
498
+ };
499
+ req.on("close", dropClient);
500
+ res.on("error", dropClient);
501
+ return;
502
+ }
503
+
320
504
  // Handle /bundle?path=/ComponentName/index.js
321
505
  if (url.pathname === "/bundle" && req.method === "GET") {
322
506
  const componentPath = url.searchParams.get("path");
@@ -376,6 +560,62 @@ export async function startDevServer(
376
560
  return;
377
561
  }
378
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
+
379
619
  // Handle POST /meta?component=ComponentName to update meta.json
380
620
  if (url.pathname === "/meta" && req.method === "POST") {
381
621
  const componentName = url.searchParams.get("component");
@@ -404,6 +644,40 @@ export async function startDevServer(
404
644
  req.on("end", async () => {
405
645
  try {
406
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
+
407
681
  const metaPath = path.join(
408
682
  cwd,
409
683
  "components",
@@ -520,6 +794,9 @@ export async function startDevServer(
520
794
  stop: async () => {
521
795
  if (watchTimer) clearTimeout(watchTimer);
522
796
  componentsWatcher.close();
797
+ teardownUpstream();
798
+ for (const client of sseClients) client.end();
799
+ sseClients.clear();
523
800
  proxyServer.close();
524
801
  await ctx?.dispose();
525
802
  },
@@ -555,6 +832,10 @@ export interface ManifestEntry {
555
832
  css?: string;
556
833
  /** Target slot for components */
557
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;
558
839
  }
559
840
 
560
841
  /**
@@ -604,6 +885,8 @@ export async function writeManifest(
604
885
  name: c.name,
605
886
  js: `/${c.name}/index.js`,
606
887
  slot: c.slot,
888
+ ...(c.otherStage ? { otherStage: true } : {}),
889
+ ...(c.unlinked ? { unlinked: true } : {}),
607
890
  };
608
891
 
609
892
  // Check if CSS exists