@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ollie-shop/cli",
3
- "version": "1.4.1",
3
+ "version": "1.6.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>
@@ -145,6 +151,14 @@ export function HelpCommand() {
145
151
  </Box>
146
152
  <Text>start: don't auto-open Studio (also honored via CI env)</Text>
147
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>
148
162
  </Box>
149
163
 
150
164
  <Box marginTop={1}>
@@ -154,6 +168,7 @@ export function HelpCommand() {
154
168
  <Text dimColor>$ ollieshop login</Text>
155
169
  <Text dimColor>$ ollieshop start --stage dev</Text>
156
170
  <Text dimColor>$ ollieshop start --no-open</Text>
171
+ <Text dimColor>$ ollieshop start --browser-logs</Text>
157
172
  <Text dimColor>$ ollieshop whoami -o json</Text>
158
173
  <Text dimColor>$ ollieshop schema store.create</Text>
159
174
  <Text dimColor>
@@ -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
+ }
@@ -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 (
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)