@ollie-shop/cli 1.4.1 → 1.6.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.
@@ -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
 
@@ -119,10 +213,111 @@ export interface BuildResult {
119
213
  buildTime: number;
120
214
  }
121
215
 
216
+ export interface BrowserLogEntry {
217
+ level: string;
218
+ component?: string;
219
+ message: string;
220
+ }
221
+
222
+ function browserLogBridge(endpoint: string): string {
223
+ const target = JSON.stringify(endpoint);
224
+ return `(() => {
225
+ if (typeof window === "undefined" || window.__ollieLogBridge) return;
226
+ window.__ollieLogBridge = true;
227
+
228
+ const endpoint = ${target};
229
+ const SLOT_FRAME = /ollie-slot\\/([^.\\s:]+)\\.js/;
230
+ const LEVELS = ["log", "info", "warn", "error", "debug"];
231
+ const MAX_MESSAGE = 2000;
232
+ const MAX_BATCH = 20;
233
+ const FLUSH_MS = 200;
234
+
235
+ let queue = [];
236
+ let flushTimer = null;
237
+
238
+ function format(value) {
239
+ if (typeof value === "string") return value;
240
+ try {
241
+ return JSON.stringify(value);
242
+ } catch (_) {
243
+ return String(value);
244
+ }
245
+ }
246
+
247
+ function slotName(stack) {
248
+ const match = (stack || "").match(SLOT_FRAME);
249
+ return match ? match[1] : null;
250
+ }
251
+
252
+ function flush() {
253
+ if (flushTimer) {
254
+ clearTimeout(flushTimer);
255
+ flushTimer = null;
256
+ }
257
+ if (queue.length === 0) return;
258
+ const batch = queue;
259
+ queue = [];
260
+ try {
261
+ fetch(endpoint, {
262
+ method: "POST",
263
+ mode: "cors",
264
+ keepalive: true,
265
+ headers: { "Content-Type": "text/plain" },
266
+ body: JSON.stringify({ batch: batch }),
267
+ }).catch(function () {});
268
+ } catch (_) {}
269
+ }
270
+
271
+ function forward(level, component, args) {
272
+ try {
273
+ let message = args.map(format).join(" ");
274
+ if (message.length > MAX_MESSAGE) {
275
+ message = message.slice(0, MAX_MESSAGE) + " …(truncated)";
276
+ }
277
+ queue.push({ level: level, component: component, message: message });
278
+ if (queue.length >= MAX_BATCH) {
279
+ flush();
280
+ } else if (!flushTimer) {
281
+ flushTimer = setTimeout(flush, FLUSH_MS);
282
+ }
283
+ } catch (_) {}
284
+ }
285
+
286
+ for (const level of LEVELS) {
287
+ const original = console[level]
288
+ ? console[level].bind(console)
289
+ : function () {};
290
+ console[level] = function () {
291
+ const args = Array.prototype.slice.call(arguments);
292
+ original.apply(null, args);
293
+ const component = slotName(new Error().stack);
294
+ if (component) forward(level, component, args);
295
+ };
296
+ }
297
+
298
+ window.addEventListener("error", function (event) {
299
+ const component = slotName(event.error && event.error.stack);
300
+ if (component) forward("error", component, [event.message]);
301
+ });
302
+
303
+ window.addEventListener("unhandledrejection", function (event) {
304
+ const reason = event.reason;
305
+ const component = slotName(reason && reason.stack);
306
+ if (component) {
307
+ const message = reason && reason.message ? reason.message : String(reason);
308
+ forward("error", component, [message]);
309
+ }
310
+ });
311
+
312
+ window.addEventListener("pagehide", flush);
313
+ })();`;
314
+ }
315
+
122
316
  export interface CreateBuildContextOptions {
123
317
  cwd?: string;
124
318
  stage?: string;
125
319
  onBuildEnd?: (components: ComponentInfo[], result: BuildResult) => void;
320
+ browserLogsEndpoint?: string;
126
321
  }
127
322
 
128
323
  /**
@@ -133,7 +328,12 @@ export async function createBuildContext(
133
328
  components: ComponentInfo[],
134
329
  options: CreateBuildContextOptions = {},
135
330
  ): Promise<esbuild.BuildContext> {
136
- const { cwd = process.cwd(), stage, onBuildEnd } = options;
331
+ const {
332
+ cwd = process.cwd(),
333
+ stage,
334
+ onBuildEnd,
335
+ browserLogsEndpoint,
336
+ } = options;
137
337
  const outdir = path.join(cwd, "node_modules/.ollie", "build");
138
338
 
139
339
  // Ensure output directory exists
@@ -209,6 +409,9 @@ export async function createBuildContext(
209
409
  logLevel: "silent", // We handle logging ourselves
210
410
  jsx: "automatic",
211
411
  plugins: [manifestPlugin],
412
+ ...(browserLogsEndpoint
413
+ ? { banner: { js: browserLogBridge(browserLogsEndpoint) } }
414
+ : {}),
212
415
  });
213
416
 
214
417
  return ctx;
@@ -233,6 +436,7 @@ export async function startDevServer(
233
436
  stage?: string;
234
437
  onRequest?: (args: esbuild.ServeOnRequestArgs) => void;
235
438
  onBuildEnd?: (components: ComponentInfo[], result: BuildResult) => void;
439
+ onBrowserLogs?: (entries: BrowserLogEntry[]) => void;
236
440
  } = {},
237
441
  ): Promise<
238
442
  ServeResult & { rebuild: () => Promise<void>; stop: () => Promise<void> }
@@ -244,25 +448,37 @@ export async function startDevServer(
244
448
  stage,
245
449
  onRequest,
246
450
  onBuildEnd,
451
+ onBrowserLogs,
247
452
  } = options;
248
453
 
249
454
  const servedir = path.join(cwd, "node_modules/.ollie", "build");
250
455
  const componentsDir = path.join(cwd, "components");
251
456
  const internalPort = port + 1;
457
+ const browserLogsEndpoint = onBrowserLogs
458
+ ? `http://${host}:${port}/__log`
459
+ : undefined;
460
+
461
+ // The active stage is mutable: the admin can switch it at runtime via POST /stage.
462
+ let activeStage = stage ?? "prod";
252
463
 
253
464
  let ctx: esbuild.BuildContext | null = null;
254
465
  let entryNames = new Set<string>();
255
466
 
256
467
  async function buildAndServe(components: ComponentInfo[]): Promise<void> {
257
468
  entryNames = new Set(components.map((c) => c.name));
258
- ctx = await createBuildContext(components, { cwd, stage, onBuildEnd });
469
+ ctx = await createBuildContext(components, {
470
+ cwd,
471
+ stage: activeStage,
472
+ onBuildEnd,
473
+ browserLogsEndpoint,
474
+ });
259
475
  await ctx.rebuild();
260
476
  await ctx.watch();
261
477
  await ctx.serve({ port: internalPort, host, servedir, onRequest });
262
478
  }
263
479
 
264
480
  async function recreate(): Promise<void> {
265
- const components = await discoverComponents({ cwd, stage });
481
+ const components = await discoverComponents({ cwd, stage: activeStage });
266
482
  const oldCtx = ctx;
267
483
  ctx = null;
268
484
  if (oldCtx) await oldCtx.dispose();
@@ -270,7 +486,7 @@ export async function startDevServer(
270
486
  notifyComponentsChanged(components);
271
487
  }
272
488
 
273
- await buildAndServe(await discoverComponents({ cwd, stage }));
489
+ await buildAndServe(await discoverComponents({ cwd, stage: activeStage }));
274
490
 
275
491
  async function currentComponentNames(): Promise<Set<string>> {
276
492
  try {
@@ -459,6 +675,62 @@ export async function startDevServer(
459
675
  return;
460
676
  }
461
677
 
678
+ // List available stages (from local ollie*.json) and the active one
679
+ if (url.pathname === "/stages" && req.method === "GET") {
680
+ res.setHeader("Access-Control-Allow-Origin", "*");
681
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
682
+ res.setHeader("Content-Type", "application/json");
683
+ const stages = await listStages(cwd);
684
+ res.end(JSON.stringify({ active: activeStage, stages }));
685
+ return;
686
+ }
687
+
688
+ // Switch the active stage at runtime: re-discover + rebuild for it
689
+ if (url.pathname === "/stage" && req.method === "POST") {
690
+ res.setHeader("Access-Control-Allow-Origin", "*");
691
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
692
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
693
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
694
+
695
+ let body = "";
696
+ req.on("data", (chunk) => {
697
+ body += chunk.toString();
698
+ });
699
+ req.on("end", async () => {
700
+ try {
701
+ const { stage: next } = JSON.parse(body) as { stage?: unknown };
702
+ if (typeof next !== "string" || !next) {
703
+ res.statusCode = 400;
704
+ res.setHeader("Content-Type", "application/json");
705
+ res.end(JSON.stringify({ error: "Missing 'stage' in body" }));
706
+ return;
707
+ }
708
+ activeStage = next;
709
+ await recreate();
710
+ let versionId: string | undefined;
711
+ try {
712
+ versionId = (await loadConfig({ cwd, stage: activeStage }))
713
+ ?.versionId;
714
+ } catch {
715
+ // Stage config may be missing/invalid
716
+ }
717
+ res.setHeader("Content-Type", "application/json");
718
+ res.end(
719
+ JSON.stringify({ success: true, stage: activeStage, versionId }),
720
+ );
721
+ } catch (err) {
722
+ res.statusCode = 400;
723
+ res.setHeader("Content-Type", "application/json");
724
+ res.end(
725
+ JSON.stringify({
726
+ error: err instanceof Error ? err.message : "Invalid JSON body",
727
+ }),
728
+ );
729
+ }
730
+ });
731
+ return;
732
+ }
733
+
462
734
  // Handle POST /meta?component=ComponentName to update meta.json
463
735
  if (url.pathname === "/meta" && req.method === "POST") {
464
736
  const componentName = url.searchParams.get("component");
@@ -487,6 +759,40 @@ export async function startDevServer(
487
759
  req.on("end", async () => {
488
760
  try {
489
761
  const updates = JSON.parse(body) as Record<string, unknown>;
762
+
763
+ // A project opts into the config model by having a `components` map.
764
+ // Otherwise we keep writing the legacy per-folder meta.json.
765
+ let cfg: OllieConfig | null = null;
766
+ try {
767
+ cfg = await loadConfig({ cwd, stage: activeStage });
768
+ } catch {
769
+ // Treat as legacy if the config can't be loaded
770
+ }
771
+
772
+ if (cfg?.components !== undefined) {
773
+ const id = typeof updates.id === "string" ? updates.id : undefined;
774
+ if (!id) {
775
+ res.statusCode = 400;
776
+ res.setHeader("Content-Type", "application/json");
777
+ res.end(JSON.stringify({ error: "Missing 'id' in body" }));
778
+ return;
779
+ }
780
+ const slot =
781
+ typeof updates.slot === "string" ? updates.slot : undefined;
782
+ const entry = await upsertComponentEntry(
783
+ id,
784
+ { defaultPath: `components/${componentName}`, slot },
785
+ { cwd, stage: activeStage },
786
+ );
787
+ // Rebuild so the manifest reflects the new mapping/slot
788
+ await ctx?.rebuild();
789
+ res.setHeader("Content-Type", "application/json");
790
+ res.end(
791
+ JSON.stringify({ success: true, component: { id, ...entry } }),
792
+ );
793
+ return;
794
+ }
795
+
490
796
  const metaPath = path.join(
491
797
  cwd,
492
798
  "components",
@@ -540,6 +846,50 @@ export async function startDevServer(
540
846
  return;
541
847
  }
542
848
 
849
+ if (url.pathname === "/__log" && req.method === "POST") {
850
+ if (!onBrowserLogs) {
851
+ res.statusCode = 404;
852
+ res.end();
853
+ return;
854
+ }
855
+
856
+ res.setHeader("Access-Control-Allow-Origin", "*");
857
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
858
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
859
+
860
+ const MAX_LOG_BODY = 64 * 1024;
861
+ let body = "";
862
+ let aborted = false;
863
+ req.on("data", (chunk) => {
864
+ if (aborted) return;
865
+ body += chunk.toString();
866
+ if (body.length > MAX_LOG_BODY) {
867
+ aborted = true;
868
+ res.statusCode = 413;
869
+ res.end();
870
+ req.destroy();
871
+ }
872
+ });
873
+ req.on("end", () => {
874
+ if (aborted) return;
875
+ try {
876
+ const parsed: unknown = JSON.parse(body);
877
+ const batch = (parsed as { batch?: unknown }).batch;
878
+ const candidates: unknown[] = Array.isArray(batch) ? batch : [parsed];
879
+ const entries = candidates.filter(
880
+ (entry): entry is BrowserLogEntry =>
881
+ typeof (entry as BrowserLogEntry)?.message === "string",
882
+ );
883
+ if (entries.length > 0) onBrowserLogs(entries);
884
+ } catch {
885
+ // Ignore malformed log payloads
886
+ }
887
+ res.statusCode = 204;
888
+ res.end();
889
+ });
890
+ return;
891
+ }
892
+
543
893
  // Handle CORS preflight
544
894
  if (req.method === "OPTIONS") {
545
895
  res.setHeader("Access-Control-Allow-Origin", "*");
@@ -641,6 +991,10 @@ export interface ManifestEntry {
641
991
  css?: string;
642
992
  /** Target slot for components */
643
993
  slot?: string;
994
+ /** True when the component belongs to another stage's config */
995
+ otherStage?: boolean;
996
+ /** True when the component isn't linked to a component id (studio-*) */
997
+ unlinked?: boolean;
644
998
  }
645
999
 
646
1000
  /**
@@ -690,6 +1044,8 @@ export async function writeManifest(
690
1044
  name: c.name,
691
1045
  js: `/${c.name}/index.js`,
692
1046
  slot: c.slot,
1047
+ ...(c.otherStage ? { otherStage: true } : {}),
1048
+ ...(c.unlinked ? { unlinked: true } : {}),
693
1049
  };
694
1050
 
695
1051
  // Check if CSS exists