@ollie-shop/cli 1.5.0 → 1.7.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.5.0 build /home/runner/work/ollie-shop/ollie-shop/packages/cli
2
+ > @ollie-shop/cli@1.7.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 105.76 KB
13
- ESM ⚡️ Build success in 353ms
12
+ ESM dist/index.js 115.94 KB
13
+ ESM ⚡️ Build success in 278ms
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @ollie-shop/cli
2
2
 
3
+ ## 1.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3d5d259: Add a `component update` command so custom component props (and name, slot, active, version) can be changed after creation directly from the CLI. Previously the CLI could only `create` and `list` components, forcing prop edits through the Studio UI. Supports partial updates via flags (`--id`, `--name`, `--slot`, `--active`, `--props`, `--version-id`) or a full `--data` JSON payload, mirroring the existing `business-rule update` command.
8
+
9
+ ### Patch Changes
10
+
11
+ - 616e0ea: Fix `deploy --function-id` looking for the function source under `components/`. The deploy bundler was hardcoded to `components/<name>` and always emitted a component-style `index.tsx` (`export { default }`), so function deploys failed with `Component "<name>" not found in components/` — or bundled the wrong entry point. It now resolves function sources from `functions/<name>` and emits a function-style `index.ts` (`export { handler }`) that the builder's `build_function` step expects.
12
+
13
+ ## 1.6.0
14
+
15
+ ### Minor Changes
16
+
17
+ - df230be: Add a `--browser-logs` flag to `ollieshop start` that streams custom components' browser console logs and uncaught errors to the CLI terminal, attributed per component. Off by default — no console patching or overhead unless the flag is passed, and deploy bundles are unaffected.
18
+
3
19
  ## 1.5.0
4
20
 
5
21
  ### Minor Changes
package/dist/index.js CHANGED
@@ -47,8 +47,8 @@ function HelpCommand() {
47
47
  /* @__PURE__ */ jsx(Text, { children: "Create or list versions" })
48
48
  ] }),
49
49
  /* @__PURE__ */ jsxs(Box, { children: [
50
- /* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "green", children: "component create|list" }) }),
51
- /* @__PURE__ */ jsx(Text, { children: "Create or list components" })
50
+ /* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "green", children: "component create|update|list" }) }),
51
+ /* @__PURE__ */ jsx(Text, { children: "Create, update or list components" })
52
52
  ] }),
53
53
  /* @__PURE__ */ jsxs(Box, { children: [
54
54
  /* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "green", children: "function create|list" }) }),
@@ -115,6 +115,10 @@ function HelpCommand() {
115
115
  /* @__PURE__ */ jsxs(Box, { children: [
116
116
  /* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "yellow", children: "--no-open" }) }),
117
117
  /* @__PURE__ */ jsx(Text, { children: "start: don't auto-open Studio (also honored via CI env)" })
118
+ ] }),
119
+ /* @__PURE__ */ jsxs(Box, { children: [
120
+ /* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "yellow", children: "--browser-logs" }) }),
121
+ /* @__PURE__ */ jsx(Text, { children: "start: stream custom components' browser console to terminal" })
118
122
  ] })
119
123
  ] }),
120
124
  /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { bold: true, children: "Examples:" }) }),
@@ -122,6 +126,7 @@ function HelpCommand() {
122
126
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop login" }),
123
127
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop start --stage dev" }),
124
128
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop start --no-open" }),
129
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop start --browser-logs" }),
125
130
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop whoami -o json" }),
126
131
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop schema store.create" }),
127
132
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: '$ ollieshop store create --name "My Store" --platform vtex --platform-store-id mystore' }),
@@ -515,12 +520,19 @@ import path3 from "path";
515
520
  import { PassThrough } from "stream";
516
521
  import archiver from "archiver";
517
522
  async function createComponentBundle(options) {
518
- const { cwd = process.cwd(), componentName } = options;
519
- const componentDir = path3.join(cwd, "components", componentName);
523
+ const {
524
+ cwd = process.cwd(),
525
+ componentName,
526
+ resourceType = "component"
527
+ } = options;
528
+ const sourceDir = resourceType === "function" ? "functions" : "components";
529
+ const componentDir = path3.join(cwd, sourceDir, componentName);
520
530
  try {
521
531
  await fs3.access(componentDir);
522
532
  } catch {
523
- throw new Error(`Component "${componentName}" not found in components/`);
533
+ throw new Error(
534
+ `${resourceType === "function" ? "Function" : "Component"} "${componentName}" not found in ${sourceDir}/`
535
+ );
524
536
  }
525
537
  const packageJsonPath = path3.join(cwd, "package.json");
526
538
  try {
@@ -536,9 +548,15 @@ async function createComponentBundle(options) {
536
548
  archive.on("error", (err) => {
537
549
  output.destroy(err);
538
550
  });
539
- const entryPoint = `export { default } from './components/${componentName}';
551
+ if (resourceType === "function") {
552
+ const entryPoint = `export { handler } from './functions/${componentName}';
553
+ `;
554
+ archive.append(entryPoint, { name: "index.ts" });
555
+ } else {
556
+ const entryPoint = `export { default } from './components/${componentName}';
540
557
  `;
541
- archive.append(entryPoint, { name: "index.tsx" });
558
+ archive.append(entryPoint, { name: "index.tsx" });
559
+ }
542
560
  archive.file(packageJsonPath, { name: "package.json" });
543
561
  const entries = await fs3.readdir(cwd, { withFileTypes: true });
544
562
  for (const entry of entries) {
@@ -730,8 +748,106 @@ async function discoverComponents(options = {}) {
730
748
  }
731
749
  return components;
732
750
  }
751
+ function browserLogBridge(endpoint) {
752
+ const target = JSON.stringify(endpoint);
753
+ return `(() => {
754
+ if (typeof window === "undefined" || window.__ollieLogBridge) return;
755
+ window.__ollieLogBridge = true;
756
+
757
+ const endpoint = ${target};
758
+ const SLOT_FRAME = /ollie-slot\\/([^.\\s:]+)\\.js/;
759
+ const LEVELS = ["log", "info", "warn", "error", "debug"];
760
+ const MAX_MESSAGE = 2000;
761
+ const MAX_BATCH = 20;
762
+ const FLUSH_MS = 200;
763
+
764
+ let queue = [];
765
+ let flushTimer = null;
766
+
767
+ function format(value) {
768
+ if (typeof value === "string") return value;
769
+ try {
770
+ return JSON.stringify(value);
771
+ } catch (_) {
772
+ return String(value);
773
+ }
774
+ }
775
+
776
+ function slotName(stack) {
777
+ const match = (stack || "").match(SLOT_FRAME);
778
+ return match ? match[1] : null;
779
+ }
780
+
781
+ function flush() {
782
+ if (flushTimer) {
783
+ clearTimeout(flushTimer);
784
+ flushTimer = null;
785
+ }
786
+ if (queue.length === 0) return;
787
+ const batch = queue;
788
+ queue = [];
789
+ try {
790
+ fetch(endpoint, {
791
+ method: "POST",
792
+ mode: "cors",
793
+ keepalive: true,
794
+ headers: { "Content-Type": "text/plain" },
795
+ body: JSON.stringify({ batch: batch }),
796
+ }).catch(function () {});
797
+ } catch (_) {}
798
+ }
799
+
800
+ function forward(level, component, args) {
801
+ try {
802
+ let message = args.map(format).join(" ");
803
+ if (message.length > MAX_MESSAGE) {
804
+ message = message.slice(0, MAX_MESSAGE) + " \u2026(truncated)";
805
+ }
806
+ queue.push({ level: level, component: component, message: message });
807
+ if (queue.length >= MAX_BATCH) {
808
+ flush();
809
+ } else if (!flushTimer) {
810
+ flushTimer = setTimeout(flush, FLUSH_MS);
811
+ }
812
+ } catch (_) {}
813
+ }
814
+
815
+ for (const level of LEVELS) {
816
+ const original = console[level]
817
+ ? console[level].bind(console)
818
+ : function () {};
819
+ console[level] = function () {
820
+ const args = Array.prototype.slice.call(arguments);
821
+ original.apply(null, args);
822
+ const component = slotName(new Error().stack);
823
+ if (component) forward(level, component, args);
824
+ };
825
+ }
826
+
827
+ window.addEventListener("error", function (event) {
828
+ const component = slotName(event.error && event.error.stack);
829
+ if (component) forward("error", component, [event.message]);
830
+ });
831
+
832
+ window.addEventListener("unhandledrejection", function (event) {
833
+ const reason = event.reason;
834
+ const component = slotName(reason && reason.stack);
835
+ if (component) {
836
+ const message = reason && reason.message ? reason.message : String(reason);
837
+ forward("error", component, [message]);
838
+ }
839
+ });
840
+
841
+ window.addEventListener("pagehide", flush);
842
+ })();`;
843
+ }
733
844
  async function createBuildContext(components, options = {}) {
734
- const { cwd = process.cwd(), stage, onBuildEnd } = options;
845
+ const {
846
+ cwd = process.cwd(),
847
+ stage,
848
+ onBuildEnd,
849
+ browserLogsEndpoint
850
+ } = options;
735
851
  const outdir = path4.join(cwd, "node_modules/.ollie", "build");
736
852
  await fs4.mkdir(outdir, { recursive: true });
737
853
  const entryPoints = {};
@@ -789,7 +905,8 @@ async function createBuildContext(components, options = {}) {
789
905
  logLevel: "silent",
790
906
  // We handle logging ourselves
791
907
  jsx: "automatic",
792
- plugins: [manifestPlugin]
908
+ plugins: [manifestPlugin],
909
+ ...browserLogsEndpoint ? { banner: { js: browserLogBridge(browserLogsEndpoint) } } : {}
793
910
  });
794
911
  return ctx;
795
912
  }
@@ -800,11 +917,13 @@ async function startDevServer(options = {}) {
800
917
  cwd = process.cwd(),
801
918
  stage,
802
919
  onRequest,
803
- onBuildEnd
920
+ onBuildEnd,
921
+ onBrowserLogs
804
922
  } = options;
805
923
  const servedir = path4.join(cwd, "node_modules/.ollie", "build");
806
924
  const componentsDir = path4.join(cwd, "components");
807
925
  const internalPort = port + 1;
926
+ const browserLogsEndpoint = onBrowserLogs ? `http://${host}:${port}/__log` : void 0;
808
927
  let activeStage = stage ?? "prod";
809
928
  let ctx = null;
810
929
  let entryNames = /* @__PURE__ */ new Set();
@@ -813,7 +932,8 @@ async function startDevServer(options = {}) {
813
932
  ctx = await createBuildContext(components, {
814
933
  cwd,
815
934
  stage: activeStage,
816
- onBuildEnd
935
+ onBuildEnd,
936
+ browserLogsEndpoint
817
937
  });
818
938
  await ctx.rebuild();
819
939
  await ctx.watch();
@@ -1118,6 +1238,45 @@ data: ${payload}
1118
1238
  });
1119
1239
  return;
1120
1240
  }
1241
+ if (url.pathname === "/__log" && req.method === "POST") {
1242
+ if (!onBrowserLogs) {
1243
+ res.statusCode = 404;
1244
+ res.end();
1245
+ return;
1246
+ }
1247
+ res.setHeader("Access-Control-Allow-Origin", "*");
1248
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
1249
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
1250
+ const MAX_LOG_BODY = 64 * 1024;
1251
+ let body = "";
1252
+ let aborted = false;
1253
+ req.on("data", (chunk) => {
1254
+ if (aborted) return;
1255
+ body += chunk.toString();
1256
+ if (body.length > MAX_LOG_BODY) {
1257
+ aborted = true;
1258
+ res.statusCode = 413;
1259
+ res.end();
1260
+ req.destroy();
1261
+ }
1262
+ });
1263
+ req.on("end", () => {
1264
+ if (aborted) return;
1265
+ try {
1266
+ const parsed2 = JSON.parse(body);
1267
+ const batch = parsed2.batch;
1268
+ const candidates = Array.isArray(batch) ? batch : [parsed2];
1269
+ const entries = candidates.filter(
1270
+ (entry) => typeof entry?.message === "string"
1271
+ );
1272
+ if (entries.length > 0) onBrowserLogs(entries);
1273
+ } catch {
1274
+ }
1275
+ res.statusCode = 204;
1276
+ res.end();
1277
+ });
1278
+ return;
1279
+ }
1121
1280
  if (req.method === "OPTIONS") {
1122
1281
  res.setHeader("Access-Control-Allow-Origin", "*");
1123
1282
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
@@ -1222,13 +1381,16 @@ function StartCommand({ args }) {
1222
1381
  const [state, setState] = useState2({ status: "initializing" });
1223
1382
  const [components, setComponents] = useState2([]);
1224
1383
  const [logs, setLogs] = useState2([]);
1384
+ const [browserLogs, setBrowserLogs] = useState2([]);
1225
1385
  const [buildCount, setBuildCount] = useState2(0);
1226
1386
  const [lastBuildTime, setLastBuildTime] = useState2(null);
1227
1387
  const logIdRef = useRef(0);
1388
+ const browserLogIdRef = useRef(0);
1228
1389
  const rebuildRef = useRef(null);
1229
1390
  const stopRef = useRef(null);
1230
1391
  const stage = resolveStage(parseArg(args, "--stage", "-s"));
1231
1392
  const noOpen = args.includes("--no-open") || Boolean(process.env.CI);
1393
+ const browserLogsEnabled = args.includes("--browser-logs");
1232
1394
  const isInteractive = Boolean(process.stdin.isTTY);
1233
1395
  const addLog = useCallback((log) => {
1234
1396
  setLogs((prev) => {
@@ -1251,6 +1413,18 @@ function StartCommand({ args }) {
1251
1413
  },
1252
1414
  [addLog]
1253
1415
  );
1416
+ const addBrowserLogs = useCallback((entries) => {
1417
+ if (entries.length === 0) return;
1418
+ setBrowserLogs((prev) => {
1419
+ const mapped = entries.map((entry) => ({
1420
+ ...entry,
1421
+ message: sanitizeLogMessage(entry.message),
1422
+ id: ++browserLogIdRef.current,
1423
+ timestamp: /* @__PURE__ */ new Date()
1424
+ }));
1425
+ return [...prev, ...mapped].slice(-MAX_LOGS);
1426
+ });
1427
+ }, []);
1254
1428
  useEffect2(() => {
1255
1429
  let mounted = true;
1256
1430
  async function init() {
@@ -1281,6 +1455,7 @@ function StartCommand({ args }) {
1281
1455
  port: PORT,
1282
1456
  stage,
1283
1457
  onRequest: handleRequest,
1458
+ onBrowserLogs: browserLogsEnabled ? addBrowserLogs : void 0,
1284
1459
  onBuildEnd: (updatedComponents) => {
1285
1460
  setComponents(updatedComponents);
1286
1461
  setBuildCount((c) => c + 1);
@@ -1321,7 +1496,7 @@ function StartCommand({ args }) {
1321
1496
  mounted = false;
1322
1497
  stopRef.current?.();
1323
1498
  };
1324
- }, [stage, handleRequest, noOpen]);
1499
+ }, [stage, handleRequest, addBrowserLogs, browserLogsEnabled, noOpen]);
1325
1500
  useInput(
1326
1501
  (input, key) => {
1327
1502
  if (input === "q" || input === "c" && key.ctrl) {
@@ -1378,6 +1553,7 @@ function StartCommand({ args }) {
1378
1553
  /* @__PURE__ */ jsx3(ComponentList, { components }),
1379
1554
  /* @__PURE__ */ jsx3(BuildInfo, { buildCount, lastBuildTime }),
1380
1555
  /* @__PURE__ */ jsx3(RequestLogs, { logs }),
1556
+ browserLogsEnabled && /* @__PURE__ */ jsx3(BrowserLogs, { logs: browserLogs }),
1381
1557
  /* @__PURE__ */ jsx3(Footer, { interactive: isInteractive })
1382
1558
  ] })
1383
1559
  ] });
@@ -1506,6 +1682,38 @@ function RequestLogs({ logs }) {
1506
1682
  ] }, log.id)) })
1507
1683
  ] });
1508
1684
  }
1685
+ function sanitizeLogMessage(message) {
1686
+ let out = "";
1687
+ for (const ch of message) {
1688
+ const code = ch.codePointAt(0) ?? 0;
1689
+ out += code < 32 || code >= 127 && code <= 159 ? " " : ch;
1690
+ if (out.length >= 2e3) break;
1691
+ }
1692
+ return out;
1693
+ }
1694
+ function browserLogColor(level) {
1695
+ if (level === "error") return "red";
1696
+ if (level === "warn") return "yellow";
1697
+ if (level === "info") return "cyan";
1698
+ return "gray";
1699
+ }
1700
+ function BrowserLogs({ logs }) {
1701
+ return /* @__PURE__ */ jsxs3(Box3, { marginTop: 1, flexDirection: "column", children: [
1702
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Browser Logs:" }),
1703
+ /* @__PURE__ */ jsx3(Box3, { marginLeft: 2, flexDirection: "column", children: logs.length === 0 ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "No browser logs yet..." }) : logs.map((log) => /* @__PURE__ */ jsxs3(Box3, { children: [
1704
+ /* @__PURE__ */ jsxs3(Text3, { color: browserLogColor(log.level), children: [
1705
+ log.level.toUpperCase(),
1706
+ " "
1707
+ ] }),
1708
+ log.component && /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1709
+ "[",
1710
+ log.component,
1711
+ "] "
1712
+ ] }),
1713
+ /* @__PURE__ */ jsx3(Text3, { children: log.message })
1714
+ ] }, log.id)) })
1715
+ ] });
1716
+ }
1509
1717
  function Footer({ interactive = true }) {
1510
1718
  if (!interactive) {
1511
1719
  return /* @__PURE__ */ jsx3(Box3, { marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Headless (no TTY) \u2014 Ctrl+C to stop" }) });
@@ -1542,7 +1750,7 @@ function App({ command, args }) {
1542
1750
  }
1543
1751
  }
1544
1752
  function VersionCommand() {
1545
- const version = "1.5.0" ? "1.5.0" : "unknown";
1753
+ const version = "1.7.0" ? "1.7.0" : "unknown";
1546
1754
  return /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { children: [
1547
1755
  "ollieshop v",
1548
1756
  version
@@ -2068,6 +2276,14 @@ var componentCreateSchema = z2.object({
2068
2276
  active: z2.boolean().default(true).describe("Whether component is active"),
2069
2277
  props: z2.record(z2.unknown()).nullable().default(null).describe("Default component props as JSON object")
2070
2278
  });
2279
+ var componentUpdateSchema = z2.object({
2280
+ id: z2.string().uuid().describe("Component UUID to update"),
2281
+ versionId: z2.string().uuid().optional().describe("Move component to a different version UUID"),
2282
+ name: z2.string().min(1).optional().describe("Component name"),
2283
+ slot: z2.string().min(1).optional().describe("Target slot"),
2284
+ active: z2.boolean().optional().describe("Whether component is active"),
2285
+ props: z2.record(z2.unknown()).nullable().optional().describe("Component props as JSON object (null clears them)")
2286
+ });
2071
2287
  var componentListSchema = z2.object({
2072
2288
  storeId: z2.string().uuid().describe("Store UUID"),
2073
2289
  versionId: z2.string().uuid().optional().describe("Optional version UUID to filter by")
@@ -2099,6 +2315,7 @@ var schemas = {
2099
2315
  },
2100
2316
  component: {
2101
2317
  create: componentCreateSchema,
2318
+ update: componentUpdateSchema,
2102
2319
  list: componentListSchema
2103
2320
  },
2104
2321
  function: {
@@ -2155,6 +2372,32 @@ async function createComponent(client, input) {
2155
2372
  }
2156
2373
  return { data: { id: data.id } };
2157
2374
  }
2375
+ async function updateComponent(client, id, input) {
2376
+ const parsed2 = componentUpdateSchema.safeParse({ id, ...input });
2377
+ if (!parsed2.success) {
2378
+ return {
2379
+ error: { message: parsed2.error.issues.map((i) => i.message).join("; ") }
2380
+ };
2381
+ }
2382
+ const updatePayload = {};
2383
+ if (input.name !== void 0) updatePayload.name = input.name;
2384
+ if (input.slot !== void 0) updatePayload.slot = input.slot;
2385
+ if (input.active !== void 0) updatePayload.active = input.active;
2386
+ if (input.versionId !== void 0) updatePayload.version_id = input.versionId;
2387
+ if (input.props !== void 0) updatePayload.props = input.props;
2388
+ if (Object.keys(updatePayload).length === 0) {
2389
+ return {
2390
+ error: {
2391
+ message: "No fields to update. Provide at least one of: --name, --slot, --active, --props, --version-id."
2392
+ }
2393
+ };
2394
+ }
2395
+ const { data, error } = await client.from("components").update(updatePayload).eq("id", id).select("id").single();
2396
+ if (error) {
2397
+ return { error: { message: error.message } };
2398
+ }
2399
+ return { data: { id: data.id } };
2400
+ }
2158
2401
  async function listComponents(client, storeId, versionId) {
2159
2402
  let query = client.from("components").select(
2160
2403
  "id, name, slot, active, version_id, props, created_at, versions!inner(id, name)"
@@ -2173,9 +2416,10 @@ async function listComponents(client, storeId, versionId) {
2173
2416
  async function componentCommand(parsed2) {
2174
2417
  const sub = parsed2.subcommand;
2175
2418
  if (sub === "create") return componentCreateCommand(parsed2);
2419
+ if (sub === "update") return componentUpdateCommand(parsed2);
2176
2420
  if (sub === "list" || sub === "ls") return componentListCommand(parsed2);
2177
2421
  console.error(
2178
- `Unknown component subcommand: ${sub}. Use: component create | component list`
2422
+ `Unknown component subcommand: ${sub}. Use: component create | component update | component list`
2179
2423
  );
2180
2424
  process.exit(1);
2181
2425
  }
@@ -2226,6 +2470,66 @@ async function componentCreateCommand(parsed2) {
2226
2470
  process.exit(1);
2227
2471
  }
2228
2472
  }
2473
+ async function componentUpdateCommand(parsed2) {
2474
+ const format = detectOutputFormat(parsed2.global.output);
2475
+ try {
2476
+ let id;
2477
+ let input;
2478
+ if (parsed2.global.data) {
2479
+ const raw = JSON.parse(parsed2.global.data);
2480
+ id = validateUuid(validateRequired(raw.id, "id"), "id");
2481
+ input = {
2482
+ versionId: raw.versionId !== void 0 ? validateUuid(raw.versionId, "versionId") : void 0,
2483
+ name: raw.name,
2484
+ slot: raw.slot,
2485
+ active: typeof raw.active === "boolean" ? raw.active : void 0,
2486
+ props: raw.props
2487
+ };
2488
+ } else {
2489
+ id = validateUuid(
2490
+ validateRequired(getFlag(parsed2.flags, "id"), "id"),
2491
+ "id"
2492
+ );
2493
+ const versionId = getFlag(parsed2.flags, "version-id");
2494
+ if (versionId) validateUuid(versionId, "version-id");
2495
+ const activeRaw = parsed2.flags.active;
2496
+ let active;
2497
+ if (activeRaw === "true" || activeRaw === true) {
2498
+ active = true;
2499
+ } else if (activeRaw === "false") {
2500
+ active = false;
2501
+ }
2502
+ const propsRaw = getFlag(parsed2.flags, "props");
2503
+ input = {
2504
+ versionId,
2505
+ name: getFlag(parsed2.flags, "name", "n"),
2506
+ slot: getFlag(parsed2.flags, "slot", "s"),
2507
+ active,
2508
+ props: propsRaw ? JSON.parse(propsRaw) : void 0
2509
+ };
2510
+ }
2511
+ if (parsed2.global.dryRun) {
2512
+ outputDryRun(
2513
+ "component.update",
2514
+ { id, ...input },
2515
+ format
2516
+ );
2517
+ return;
2518
+ }
2519
+ const client = await getAuthenticatedClient();
2520
+ const result = await updateComponent(client, id, input);
2521
+ outputResult(result, format, parsed2.global.fields);
2522
+ if (result.error) process.exit(1);
2523
+ } catch (err) {
2524
+ outputResult(
2525
+ {
2526
+ error: { message: err instanceof Error ? err.message : String(err) }
2527
+ },
2528
+ format
2529
+ );
2530
+ process.exit(1);
2531
+ }
2532
+ }
2229
2533
  async function componentListCommand(parsed2) {
2230
2534
  const format = detectOutputFormat(parsed2.global.output);
2231
2535
  try {
@@ -2393,7 +2697,8 @@ async function deployCommand(parsed2) {
2393
2697
  }
2394
2698
  const stream = await createComponentBundle({
2395
2699
  componentName,
2396
- cwd: process.cwd()
2700
+ cwd: process.cwd(),
2701
+ resourceType
2397
2702
  });
2398
2703
  const chunks = [];
2399
2704
  for await (const chunk of stream) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ollie-shop/cli",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Ollie Shop CLI - Development tools for custom checkouts",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,4 +1,8 @@
1
- import { createComponent, listComponents } from "../core/component.js";
1
+ import {
2
+ createComponent,
3
+ listComponents,
4
+ updateComponent,
5
+ } from "../core/component.js";
2
6
  import {
3
7
  detectOutputFormat,
4
8
  outputDryRun,
@@ -11,10 +15,11 @@ import { validateRequired, validateUuid } from "../utils/validate.js";
11
15
  export async function componentCommand(parsed: ParsedArgs): Promise<void> {
12
16
  const sub = parsed.subcommand;
13
17
  if (sub === "create") return componentCreateCommand(parsed);
18
+ if (sub === "update") return componentUpdateCommand(parsed);
14
19
  if (sub === "list" || sub === "ls") return componentListCommand(parsed);
15
20
 
16
21
  console.error(
17
- `Unknown component subcommand: ${sub}. Use: component create | component list`,
22
+ `Unknown component subcommand: ${sub}. Use: component create | component update | component list`,
18
23
  );
19
24
  process.exit(1);
20
25
  }
@@ -79,6 +84,87 @@ async function componentCreateCommand(parsed: ParsedArgs): Promise<void> {
79
84
  }
80
85
  }
81
86
 
87
+ async function componentUpdateCommand(parsed: ParsedArgs): Promise<void> {
88
+ const format = detectOutputFormat(parsed.global.output);
89
+
90
+ try {
91
+ let id: string;
92
+ let input: {
93
+ versionId?: string;
94
+ name?: string;
95
+ slot?: string;
96
+ active?: boolean;
97
+ props?: Record<string, unknown> | null;
98
+ };
99
+
100
+ if (parsed.global.data) {
101
+ const raw = JSON.parse(parsed.global.data);
102
+ id = validateUuid(validateRequired(raw.id, "id"), "id");
103
+ input = {
104
+ versionId:
105
+ raw.versionId !== undefined
106
+ ? validateUuid(raw.versionId, "versionId")
107
+ : undefined,
108
+ name: raw.name,
109
+ slot: raw.slot,
110
+ active: typeof raw.active === "boolean" ? raw.active : undefined,
111
+ props: raw.props,
112
+ };
113
+ } else {
114
+ id = validateUuid(
115
+ validateRequired(getFlag(parsed.flags, "id"), "id"),
116
+ "id",
117
+ );
118
+
119
+ const versionId = getFlag(parsed.flags, "version-id");
120
+ if (versionId) validateUuid(versionId, "version-id");
121
+
122
+ const activeRaw = parsed.flags.active;
123
+ let active: boolean | undefined;
124
+ if (activeRaw === "true" || activeRaw === true) {
125
+ active = true;
126
+ } else if (activeRaw === "false") {
127
+ active = false;
128
+ }
129
+
130
+ const propsRaw = getFlag(parsed.flags, "props");
131
+
132
+ input = {
133
+ versionId,
134
+ name: getFlag(parsed.flags, "name", "n"),
135
+ slot: getFlag(parsed.flags, "slot", "s"),
136
+ active,
137
+ props: propsRaw
138
+ ? (JSON.parse(propsRaw) as Record<string, unknown> | null)
139
+ : undefined,
140
+ };
141
+ }
142
+
143
+ if (parsed.global.dryRun) {
144
+ outputDryRun(
145
+ "component.update",
146
+ { id, ...input } as Record<string, unknown>,
147
+ format,
148
+ );
149
+ return;
150
+ }
151
+
152
+ const client = await getAuthenticatedClient();
153
+ const result = await updateComponent(client, id, input);
154
+
155
+ outputResult(result, format, parsed.global.fields);
156
+ if (result.error) process.exit(1);
157
+ } catch (err) {
158
+ outputResult(
159
+ {
160
+ error: { message: err instanceof Error ? err.message : String(err) },
161
+ },
162
+ format,
163
+ );
164
+ process.exit(1);
165
+ }
166
+ }
167
+
82
168
  async function componentListCommand(parsed: ParsedArgs): Promise<void> {
83
169
  const format = detectOutputFormat(parsed.global.output);
84
170
 
@@ -61,10 +61,11 @@ export async function deployCommand(parsed: ParsedArgs): Promise<void> {
61
61
  timeout = Number(getFlag(parsed.flags, "timeout") ?? "300");
62
62
  }
63
63
 
64
- // Bundle the component
64
+ // Bundle the resource
65
65
  const stream = await createComponentBundle({
66
66
  componentName,
67
67
  cwd: process.cwd(),
68
+ resourceType,
68
69
  });
69
70
 
70
71
  // Collect the zip buffer
@@ -57,9 +57,9 @@ export function HelpCommand() {
57
57
  </Box>
58
58
  <Box>
59
59
  <Box width={24}>
60
- <Text color="green">component create|list</Text>
60
+ <Text color="green">component create|update|list</Text>
61
61
  </Box>
62
- <Text>Create or list components</Text>
62
+ <Text>Create, update or list components</Text>
63
63
  </Box>
64
64
  <Box>
65
65
  <Box width={24}>
@@ -151,6 +151,14 @@ export function HelpCommand() {
151
151
  </Box>
152
152
  <Text>start: don't auto-open Studio (also honored via CI env)</Text>
153
153
  </Box>
154
+ <Box>
155
+ <Box width={24}>
156
+ <Text color="yellow">--browser-logs</Text>
157
+ </Box>
158
+ <Text>
159
+ start: stream custom components' browser console to terminal
160
+ </Text>
161
+ </Box>
154
162
  </Box>
155
163
 
156
164
  <Box marginTop={1}>
@@ -160,6 +168,7 @@ export function HelpCommand() {
160
168
  <Text dimColor>$ ollieshop login</Text>
161
169
  <Text dimColor>$ ollieshop start --stage dev</Text>
162
170
  <Text dimColor>$ ollieshop start --no-open</Text>
171
+ <Text dimColor>$ ollieshop start --browser-logs</Text>
163
172
  <Text dimColor>$ ollieshop whoami -o json</Text>
164
173
  <Text dimColor>$ ollieshop schema store.create</Text>
165
174
  <Text dimColor>
@@ -4,6 +4,7 @@ import open from "open";
4
4
  import { useCallback, useEffect, useRef, useState } from "react";
5
5
  import { loadConfig, resolveStage } from "../utils/config.js";
6
6
  import {
7
+ type BrowserLogEntry,
7
8
  type ComponentInfo,
8
9
  discoverComponents,
9
10
  startDevServer,
@@ -22,6 +23,14 @@ interface RequestLog {
22
23
  timestamp: Date;
23
24
  }
24
25
 
26
+ interface BrowserLog {
27
+ id: number;
28
+ level: string;
29
+ component?: string;
30
+ message: string;
31
+ timestamp: Date;
32
+ }
33
+
25
34
  type ServerState =
26
35
  | { status: "initializing" }
27
36
  | { status: "discovering" }
@@ -51,9 +60,11 @@ export function StartCommand({ args }: StartCommandProps) {
51
60
  const [state, setState] = useState<ServerState>({ status: "initializing" });
52
61
  const [components, setComponents] = useState<ComponentInfo[]>([]);
53
62
  const [logs, setLogs] = useState<RequestLog[]>([]);
63
+ const [browserLogs, setBrowserLogs] = useState<BrowserLog[]>([]);
54
64
  const [buildCount, setBuildCount] = useState(0);
55
65
  const [lastBuildTime, setLastBuildTime] = useState<Date | null>(null);
56
66
  const logIdRef = useRef(0);
67
+ const browserLogIdRef = useRef(0);
57
68
  const rebuildRef = useRef<(() => Promise<void>) | null>(null);
58
69
  const stopRef = useRef<(() => Promise<void>) | null>(null);
59
70
 
@@ -63,6 +74,7 @@ export function StartCommand({ args }: StartCommandProps) {
63
74
  // agent spawns the dev server and drives its own harness instead. Also honored
64
75
  // via the conventional CI env var.
65
76
  const noOpen = args.includes("--no-open") || Boolean(process.env.CI);
77
+ const browserLogsEnabled = args.includes("--browser-logs");
66
78
  // Keyboard shortcuts need raw mode, which Ink can only enable on a TTY stdin.
67
79
  // When spawned headless (agent/CI/backgrounded), stdin isn't a TTY — guard the
68
80
  // input handler so the server runs instead of crashing with "Raw mode is not
@@ -92,6 +104,19 @@ export function StartCommand({ args }: StartCommandProps) {
92
104
  [addLog],
93
105
  );
94
106
 
107
+ const addBrowserLogs = useCallback((entries: BrowserLogEntry[]) => {
108
+ if (entries.length === 0) return;
109
+ setBrowserLogs((prev) => {
110
+ const mapped = entries.map((entry) => ({
111
+ ...entry,
112
+ message: sanitizeLogMessage(entry.message),
113
+ id: ++browserLogIdRef.current,
114
+ timestamp: new Date(),
115
+ }));
116
+ return [...prev, ...mapped].slice(-MAX_LOGS);
117
+ });
118
+ }, []);
119
+
95
120
  // Initialize server
96
121
  useEffect(() => {
97
122
  let mounted = true;
@@ -134,6 +159,7 @@ export function StartCommand({ args }: StartCommandProps) {
134
159
  port: PORT,
135
160
  stage,
136
161
  onRequest: handleRequest,
162
+ onBrowserLogs: browserLogsEnabled ? addBrowserLogs : undefined,
137
163
  onBuildEnd: (updatedComponents) => {
138
164
  setComponents(updatedComponents);
139
165
  setBuildCount((c) => c + 1);
@@ -181,7 +207,7 @@ export function StartCommand({ args }: StartCommandProps) {
181
207
  mounted = false;
182
208
  stopRef.current?.();
183
209
  };
184
- }, [stage, handleRequest, noOpen]);
210
+ }, [stage, handleRequest, addBrowserLogs, browserLogsEnabled, noOpen]);
185
211
 
186
212
  // Handle keyboard input (only when attached to a TTY — see isInteractive above)
187
213
  useInput(
@@ -255,6 +281,7 @@ export function StartCommand({ args }: StartCommandProps) {
255
281
  <ComponentList components={components} />
256
282
  <BuildInfo buildCount={buildCount} lastBuildTime={lastBuildTime} />
257
283
  <RequestLogs logs={logs} />
284
+ {browserLogsEnabled && <BrowserLogs logs={browserLogs} />}
258
285
  <Footer interactive={isInteractive} />
259
286
  </>
260
287
  )}
@@ -404,6 +431,46 @@ function RequestLogs({ logs }: { logs: RequestLog[] }) {
404
431
  );
405
432
  }
406
433
 
434
+ function sanitizeLogMessage(message: string): string {
435
+ let out = "";
436
+ for (const ch of message) {
437
+ const code = ch.codePointAt(0) ?? 0;
438
+ out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? " " : ch;
439
+ if (out.length >= 2000) break;
440
+ }
441
+ return out;
442
+ }
443
+
444
+ function browserLogColor(level: string): string {
445
+ if (level === "error") return "red";
446
+ if (level === "warn") return "yellow";
447
+ if (level === "info") return "cyan";
448
+ return "gray";
449
+ }
450
+
451
+ function BrowserLogs({ logs }: { logs: BrowserLog[] }) {
452
+ return (
453
+ <Box marginTop={1} flexDirection="column">
454
+ <Text bold>Browser Logs:</Text>
455
+ <Box marginLeft={2} flexDirection="column">
456
+ {logs.length === 0 ? (
457
+ <Text dimColor>No browser logs yet...</Text>
458
+ ) : (
459
+ logs.map((log) => (
460
+ <Box key={log.id}>
461
+ <Text color={browserLogColor(log.level)}>
462
+ {log.level.toUpperCase()}{" "}
463
+ </Text>
464
+ {log.component && <Text dimColor>[{log.component}] </Text>}
465
+ <Text>{log.message}</Text>
466
+ </Box>
467
+ ))
468
+ )}
469
+ </Box>
470
+ </Box>
471
+ );
472
+ }
473
+
407
474
  function Footer({ interactive = true }: { interactive?: boolean }) {
408
475
  if (!interactive) {
409
476
  return (
@@ -1,5 +1,5 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
- import { componentCreateSchema } from "./schema.js";
2
+ import { componentCreateSchema, componentUpdateSchema } from "./schema.js";
3
3
 
4
4
  export interface CreateComponentInput {
5
5
  versionId: string;
@@ -9,6 +9,14 @@ export interface CreateComponentInput {
9
9
  props?: Record<string, unknown> | null;
10
10
  }
11
11
 
12
+ export interface UpdateComponentInput {
13
+ versionId?: string;
14
+ name?: string;
15
+ slot?: string;
16
+ active?: boolean;
17
+ props?: Record<string, unknown> | null;
18
+ }
19
+
12
20
  export interface ComponentRecord {
13
21
  id: string;
14
22
  name: string;
@@ -50,6 +58,48 @@ export async function createComponent(
50
58
  return { data: { id: data.id } };
51
59
  }
52
60
 
61
+ export async function updateComponent(
62
+ client: SupabaseClient,
63
+ id: string,
64
+ input: UpdateComponentInput,
65
+ ): Promise<{ data?: { id: string }; error?: { message: string } }> {
66
+ const parsed = componentUpdateSchema.safeParse({ id, ...input });
67
+ if (!parsed.success) {
68
+ return {
69
+ error: { message: parsed.error.issues.map((i) => i.message).join("; ") },
70
+ };
71
+ }
72
+
73
+ const updatePayload: Record<string, unknown> = {};
74
+ if (input.name !== undefined) updatePayload.name = input.name;
75
+ if (input.slot !== undefined) updatePayload.slot = input.slot;
76
+ if (input.active !== undefined) updatePayload.active = input.active;
77
+ if (input.versionId !== undefined) updatePayload.version_id = input.versionId;
78
+ if (input.props !== undefined) updatePayload.props = input.props;
79
+
80
+ if (Object.keys(updatePayload).length === 0) {
81
+ return {
82
+ error: {
83
+ message:
84
+ "No fields to update. Provide at least one of: --name, --slot, --active, --props, --version-id.",
85
+ },
86
+ };
87
+ }
88
+
89
+ const { data, error } = await client
90
+ .from("components")
91
+ .update(updatePayload)
92
+ .eq("id", id)
93
+ .select("id")
94
+ .single();
95
+
96
+ if (error) {
97
+ return { error: { message: error.message } };
98
+ }
99
+
100
+ return { data: { id: data.id } };
101
+ }
102
+
53
103
  export async function listComponents(
54
104
  client: SupabaseClient,
55
105
  storeId: string,
@@ -52,6 +52,23 @@ export const componentCreateSchema = z.object({
52
52
  .describe("Default component props as JSON object"),
53
53
  });
54
54
 
55
+ export const componentUpdateSchema = z.object({
56
+ id: z.string().uuid().describe("Component UUID to update"),
57
+ versionId: z
58
+ .string()
59
+ .uuid()
60
+ .optional()
61
+ .describe("Move component to a different version UUID"),
62
+ name: z.string().min(1).optional().describe("Component name"),
63
+ slot: z.string().min(1).optional().describe("Target slot"),
64
+ active: z.boolean().optional().describe("Whether component is active"),
65
+ props: z
66
+ .record(z.unknown())
67
+ .nullable()
68
+ .optional()
69
+ .describe("Component props as JSON object (null clears them)"),
70
+ });
71
+
55
72
  export const componentListSchema = z.object({
56
73
  storeId: z.string().uuid().describe("Store UUID"),
57
74
  versionId: z
@@ -109,6 +126,7 @@ const schemas: Record<string, SchemaMap> = {
109
126
  },
110
127
  component: {
111
128
  create: componentCreateSchema,
129
+ update: componentUpdateSchema,
112
130
  list: componentListSchema,
113
131
  },
114
132
  function: {
@@ -5,11 +5,15 @@ import archiver from "archiver";
5
5
 
6
6
  // TODO: Implement .ollieignore file support for custom exclusions
7
7
 
8
+ export type BundleResourceType = "component" | "function";
9
+
8
10
  export interface BundleOptions {
9
11
  /** Project root directory */
10
12
  cwd?: string;
11
13
  /** Component name (folder name in components/) */
12
14
  componentName: string;
15
+ /** Resource type — decides the source folder and entry point shape */
16
+ resourceType?: BundleResourceType;
13
17
  }
14
18
 
15
19
  export interface BundleResult {
@@ -33,14 +37,21 @@ export interface BundleResult {
33
37
  export async function createComponentBundle(
34
38
  options: BundleOptions,
35
39
  ): Promise<PassThrough> {
36
- const { cwd = process.cwd(), componentName } = options;
40
+ const {
41
+ cwd = process.cwd(),
42
+ componentName,
43
+ resourceType = "component",
44
+ } = options;
45
+
46
+ const sourceDir = resourceType === "function" ? "functions" : "components";
37
47
 
38
- // Verify component exists
39
- const componentDir = path.join(cwd, "components", componentName);
48
+ const componentDir = path.join(cwd, sourceDir, componentName);
40
49
  try {
41
50
  await fs.access(componentDir);
42
51
  } catch {
43
- throw new Error(`Component "${componentName}" not found in components/`);
52
+ throw new Error(
53
+ `${resourceType === "function" ? "Function" : "Component"} "${componentName}" not found in ${sourceDir}/`,
54
+ );
44
55
  }
45
56
 
46
57
  // Verify package.json exists
@@ -67,8 +78,13 @@ export async function createComponentBundle(
67
78
  });
68
79
 
69
80
  // Add synthetic entry point
70
- const entryPoint = `export { default } from './components/${componentName}';\n`;
71
- archive.append(entryPoint, { name: "index.tsx" });
81
+ if (resourceType === "function") {
82
+ const entryPoint = `export { handler } from './functions/${componentName}';\n`;
83
+ archive.append(entryPoint, { name: "index.ts" });
84
+ } else {
85
+ const entryPoint = `export { default } from './components/${componentName}';\n`;
86
+ archive.append(entryPoint, { name: "index.tsx" });
87
+ }
72
88
 
73
89
  // Add package.json
74
90
  archive.file(packageJsonPath, { name: "package.json" });
@@ -213,10 +213,111 @@ export interface BuildResult {
213
213
  buildTime: number;
214
214
  }
215
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
+
216
316
  export interface CreateBuildContextOptions {
217
317
  cwd?: string;
218
318
  stage?: string;
219
319
  onBuildEnd?: (components: ComponentInfo[], result: BuildResult) => void;
320
+ browserLogsEndpoint?: string;
220
321
  }
221
322
 
222
323
  /**
@@ -227,7 +328,12 @@ export async function createBuildContext(
227
328
  components: ComponentInfo[],
228
329
  options: CreateBuildContextOptions = {},
229
330
  ): Promise<esbuild.BuildContext> {
230
- const { cwd = process.cwd(), stage, onBuildEnd } = options;
331
+ const {
332
+ cwd = process.cwd(),
333
+ stage,
334
+ onBuildEnd,
335
+ browserLogsEndpoint,
336
+ } = options;
231
337
  const outdir = path.join(cwd, "node_modules/.ollie", "build");
232
338
 
233
339
  // Ensure output directory exists
@@ -303,6 +409,9 @@ export async function createBuildContext(
303
409
  logLevel: "silent", // We handle logging ourselves
304
410
  jsx: "automatic",
305
411
  plugins: [manifestPlugin],
412
+ ...(browserLogsEndpoint
413
+ ? { banner: { js: browserLogBridge(browserLogsEndpoint) } }
414
+ : {}),
306
415
  });
307
416
 
308
417
  return ctx;
@@ -327,6 +436,7 @@ export async function startDevServer(
327
436
  stage?: string;
328
437
  onRequest?: (args: esbuild.ServeOnRequestArgs) => void;
329
438
  onBuildEnd?: (components: ComponentInfo[], result: BuildResult) => void;
439
+ onBrowserLogs?: (entries: BrowserLogEntry[]) => void;
330
440
  } = {},
331
441
  ): Promise<
332
442
  ServeResult & { rebuild: () => Promise<void>; stop: () => Promise<void> }
@@ -338,11 +448,15 @@ export async function startDevServer(
338
448
  stage,
339
449
  onRequest,
340
450
  onBuildEnd,
451
+ onBrowserLogs,
341
452
  } = options;
342
453
 
343
454
  const servedir = path.join(cwd, "node_modules/.ollie", "build");
344
455
  const componentsDir = path.join(cwd, "components");
345
456
  const internalPort = port + 1;
457
+ const browserLogsEndpoint = onBrowserLogs
458
+ ? `http://${host}:${port}/__log`
459
+ : undefined;
346
460
 
347
461
  // The active stage is mutable: the admin can switch it at runtime via POST /stage.
348
462
  let activeStage = stage ?? "prod";
@@ -356,6 +470,7 @@ export async function startDevServer(
356
470
  cwd,
357
471
  stage: activeStage,
358
472
  onBuildEnd,
473
+ browserLogsEndpoint,
359
474
  });
360
475
  await ctx.rebuild();
361
476
  await ctx.watch();
@@ -731,6 +846,50 @@ export async function startDevServer(
731
846
  return;
732
847
  }
733
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
+
734
893
  // Handle CORS preflight
735
894
  if (req.method === "OPTIONS") {
736
895
  res.setHeader("Access-Control-Allow-Origin", "*");