@ollie-shop/cli 1.2.1 → 1.3.3

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/src/utils/auth.ts CHANGED
@@ -231,6 +231,10 @@ export async function getCurrentUser(): Promise<{ email: string } | null> {
231
231
 
232
232
  try {
233
233
  const decoded = jwtDecode<JwtPayload>(credentials.accessToken);
234
+ // Reject expired sessions (local `exp` check; misses server-side revocation).
235
+ if (decoded.exp && decoded.exp * 1000 <= Date.now()) {
236
+ return null;
237
+ }
234
238
  return decoded.email ? { email: decoded.email } : null;
235
239
  } catch {
236
240
  return null;
@@ -1,3 +1,4 @@
1
+ import { watch } from "node:fs";
1
2
  import fs from "node:fs/promises";
2
3
  import http from "node:http";
3
4
  import path from "node:path";
@@ -221,32 +222,95 @@ export interface ServeResult {
221
222
  /**
222
223
  * Starts the esbuild serve + watch mode with a proxy server.
223
224
  * The proxy handles /bundle/:componentName requests and forwards others to esbuild.
224
- * Returns the server info and a way to stop it.
225
+ * Owns the esbuild context lifecycle and recreates it when a component folder is
226
+ * added or removed. Returns the server info, a manual rebuild, and a way to stop it.
225
227
  */
226
228
  export async function startDevServer(
227
- ctx: esbuild.BuildContext,
228
229
  options: {
229
230
  port?: number;
230
231
  host?: string;
231
232
  cwd?: string;
233
+ stage?: string;
232
234
  onRequest?: (args: esbuild.ServeOnRequestArgs) => void;
233
- onRebuild?: (result: esbuild.BuildResult) => void;
235
+ onBuildEnd?: (components: ComponentInfo[], result: BuildResult) => void;
234
236
  } = {},
235
- ): Promise<ServeResult & { stop: () => Promise<void> }> {
236
- const { port = 4000, host = "localhost", cwd = process.cwd() } = options;
237
+ ): Promise<
238
+ ServeResult & { rebuild: () => Promise<void>; stop: () => Promise<void> }
239
+ > {
240
+ const {
241
+ port = 4000,
242
+ host = "localhost",
243
+ cwd = process.cwd(),
244
+ stage,
245
+ onRequest,
246
+ onBuildEnd,
247
+ } = options;
237
248
 
238
249
  const servedir = path.join(cwd, "node_modules/.ollie", "build");
250
+ const componentsDir = path.join(cwd, "components");
251
+ const internalPort = port + 1;
239
252
 
240
- // Start watching for changes
241
- await ctx.watch();
253
+ let ctx: esbuild.BuildContext | null = null;
254
+ let entryNames = new Set<string>();
242
255
 
243
- // Start esbuild server on internal port (proxy will forward to it)
244
- const internalPort = port + 1;
245
- await ctx.serve({
246
- port: internalPort,
247
- host,
248
- servedir,
249
- onRequest: options.onRequest,
256
+ async function buildAndServe(components: ComponentInfo[]): Promise<void> {
257
+ entryNames = new Set(components.map((c) => c.name));
258
+ ctx = await createBuildContext(components, { cwd, stage, onBuildEnd });
259
+ await ctx.rebuild();
260
+ await ctx.watch();
261
+ await ctx.serve({ port: internalPort, host, servedir, onRequest });
262
+ }
263
+
264
+ async function recreate(): Promise<void> {
265
+ const components = await discoverComponents({ cwd, stage });
266
+ const oldCtx = ctx;
267
+ ctx = null;
268
+ if (oldCtx) await oldCtx.dispose();
269
+ await buildAndServe(components);
270
+ }
271
+
272
+ await buildAndServe(await discoverComponents({ cwd, stage }));
273
+
274
+ async function currentComponentNames(): Promise<Set<string>> {
275
+ try {
276
+ const entries = await glob("*/index.tsx", { cwd: componentsDir });
277
+ return new Set(entries.map((e) => path.dirname(e)));
278
+ } catch {
279
+ return new Set();
280
+ }
281
+ }
282
+
283
+ // Recreate the context when the set of component folders changes
284
+ let watchTimer: ReturnType<typeof setTimeout> | null = null;
285
+ let recreating = false;
286
+ let pending = false;
287
+
288
+ async function maybeRecreate(): Promise<void> {
289
+ if (recreating) {
290
+ pending = true;
291
+ return;
292
+ }
293
+ recreating = true;
294
+ try {
295
+ do {
296
+ pending = false;
297
+ const names = await currentComponentNames();
298
+ const changed =
299
+ names.size !== entryNames.size ||
300
+ [...names].some((n) => !entryNames.has(n));
301
+ if (!changed) break;
302
+ await recreate();
303
+ } while (pending);
304
+ } catch (err) {
305
+ console.error("[DevServer] Failed to reload components:", err);
306
+ } finally {
307
+ recreating = false;
308
+ }
309
+ }
310
+
311
+ const componentsWatcher = watch(componentsDir, { recursive: true }, () => {
312
+ if (watchTimer) clearTimeout(watchTimer);
313
+ watchTimer = setTimeout(maybeRecreate, 150);
250
314
  });
251
315
 
252
316
  // Create proxy server that handles /bundle/* and forwards to esbuild
@@ -356,6 +420,21 @@ export async function startDevServer(
356
420
  // File doesn't exist, start fresh
357
421
  }
358
422
 
423
+ if (
424
+ typeof updates.id === "string" &&
425
+ typeof existingMeta.id === "string" &&
426
+ existingMeta.id !== updates.id
427
+ ) {
428
+ res.statusCode = 409;
429
+ res.setHeader("Content-Type", "application/json");
430
+ res.end(
431
+ JSON.stringify({
432
+ error: `id mismatch: ${componentName} is linked to ${existingMeta.id}`,
433
+ }),
434
+ );
435
+ return;
436
+ }
437
+
359
438
  // Merge updates
360
439
  const newMeta = { ...existingMeta, ...updates };
361
440
 
@@ -435,9 +514,14 @@ export async function startDevServer(
435
514
  return {
436
515
  host,
437
516
  port,
517
+ rebuild: async () => {
518
+ await ctx?.rebuild();
519
+ },
438
520
  stop: async () => {
521
+ if (watchTimer) clearTimeout(watchTimer);
522
+ componentsWatcher.close();
439
523
  proxyServer.close();
440
- await ctx.dispose();
524
+ await ctx?.dispose();
441
525
  },
442
526
  };
443
527
  }
@@ -3,6 +3,7 @@ export interface GlobalFlags {
3
3
  dryRun: boolean;
4
4
  fields?: string[];
5
5
  data?: string;
6
+ stage?: string;
6
7
  }
7
8
 
8
9
  export interface ParsedArgs {
@@ -63,6 +64,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
63
64
  .map((f) => f.trim())
64
65
  : undefined,
65
66
  data: (flags.data as string) || (flags.d as string) || undefined,
67
+ stage: (flags.stage as string) || (flags.s as string) || undefined,
66
68
  };
67
69
 
68
70
  return { command, subcommand, flags, global, positional };
@@ -6,15 +6,49 @@ declare const __OLLIE_SUPABASE_URL__: string;
6
6
  declare const __OLLIE_SUPABASE_ANON_KEY__: string;
7
7
  declare const __OLLIE_BUILDER_URL__: string;
8
8
 
9
+ // Resolution order: runtime env var > CI build-time inline > prod default.
10
+ // Baked-in defaults ensure `ollieshop` works zero-config when CI inject is
11
+ // missing or stale. All three values are public (anon Supabase key, public
12
+ // API URLs) so inlining them carries no secret-leak risk.
13
+ const PROD_DEFAULTS = {
14
+ supabaseUrl: "https://aazahtmqrhjqsyqoqdkm.supabase.co",
15
+ supabaseAnonKey:
16
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFhemFodG1xcmhqcXN5cW9xZGttIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDg2MzEwOTcsImV4cCI6MjA2NDIwNzA5N30.VuAbAyjDe0HcL09SZtQ-UmP1o7Z6qwGuOtvfFhnyAcM",
17
+ builderUrl: "https://api.ollie.shop/builder/v1",
18
+ };
19
+
20
+ function envOrDefault(
21
+ envValue: string | undefined,
22
+ buildTimeValue: string,
23
+ prodDefault: string,
24
+ ): string {
25
+ return envValue || buildTimeValue || prodDefault;
26
+ }
27
+
28
+ // Collapses a Supabase to-one embed (object, array, or null) to a single record.
29
+ export function unwrapToOne<T = Record<string, unknown>>(
30
+ embed: unknown,
31
+ ): T | null {
32
+ const record = Array.isArray(embed) ? embed[0] : embed;
33
+ return record && typeof record === "object" ? (record as T) : null;
34
+ }
35
+
9
36
  export async function getAuthenticatedClient(): Promise<SupabaseClient> {
10
37
  const credentials = await getCredentials();
11
38
  if (!credentials) {
12
39
  throw new Error("Not authenticated. Run `ollieshop login` first.");
13
40
  }
14
41
 
15
- const supabaseUrl = process.env.OLLIE_SUPABASE_URL || __OLLIE_SUPABASE_URL__;
16
- const supabaseAnonKey =
17
- process.env.OLLIE_SUPABASE_ANON_KEY || __OLLIE_SUPABASE_ANON_KEY__;
42
+ const supabaseUrl = envOrDefault(
43
+ process.env.OLLIE_SUPABASE_URL,
44
+ __OLLIE_SUPABASE_URL__,
45
+ PROD_DEFAULTS.supabaseUrl,
46
+ );
47
+ const supabaseAnonKey = envOrDefault(
48
+ process.env.OLLIE_SUPABASE_ANON_KEY,
49
+ __OLLIE_SUPABASE_ANON_KEY__,
50
+ PROD_DEFAULTS.supabaseAnonKey,
51
+ );
18
52
 
19
53
  const client = createClient(supabaseUrl, supabaseAnonKey, {
20
54
  auth: {
@@ -32,7 +66,11 @@ export async function getAuthenticatedClient(): Promise<SupabaseClient> {
32
66
  }
33
67
 
34
68
  export function getBuilderUrl(): string {
35
- return process.env.OLLIE_BUILDER_URL || __OLLIE_BUILDER_URL__;
69
+ return envOrDefault(
70
+ process.env.OLLIE_BUILDER_URL,
71
+ __OLLIE_BUILDER_URL__,
72
+ PROD_DEFAULTS.builderUrl,
73
+ );
36
74
  }
37
75
 
38
76
  export async function getAuthToken(): Promise<string> {
@@ -45,23 +83,40 @@ export async function getAuthToken(): Promise<string> {
45
83
 
46
84
  export async function getOrganizationId(
47
85
  client: SupabaseClient,
86
+ preferredOrgId?: string,
48
87
  ): Promise<string> {
49
- const { data: org, error } = await client
88
+ const { data: orgs, error } = await client
50
89
  .from("organizations")
51
- .select("id")
52
- .order("created_at", { ascending: true })
53
- .limit(1)
54
- .maybeSingle();
90
+ .select("id, name")
91
+ .order("created_at", { ascending: true });
55
92
 
56
93
  if (error) {
57
94
  throw new Error(`Failed to get organization: ${error.message}`);
58
95
  }
59
96
 
60
- if (!org) {
97
+ if (!orgs || orgs.length === 0) {
61
98
  throw new Error(
62
99
  "No organization found for your account. Create one at https://admin.ollie.shop",
63
100
  );
64
101
  }
65
102
 
66
- return org.id;
103
+ if (preferredOrgId) {
104
+ const match = orgs.find((o) => o.id === preferredOrgId);
105
+ if (!match) {
106
+ const list = orgs.map((o) => ` - ${o.id} ${o.name}`).join("\n");
107
+ throw new Error(
108
+ `Organization ${preferredOrgId} not found or you are not a member. Orgs you can access:\n${list}`,
109
+ );
110
+ }
111
+ return match.id;
112
+ }
113
+
114
+ if (orgs.length > 1) {
115
+ const list = orgs.map((o) => ` - ${o.id} ${o.name}`).join("\n");
116
+ throw new Error(
117
+ `You belong to multiple organizations. Pass --org <id> to select one:\n${list}`,
118
+ );
119
+ }
120
+
121
+ return orgs[0].id;
67
122
  }
@@ -56,3 +56,47 @@ export function validateRequired(
56
56
  }
57
57
  return rejectControlChars(value.trim(), name);
58
58
  }
59
+
60
+ export function validateInteger(
61
+ value: unknown,
62
+ name: string,
63
+ opts?: { min?: number },
64
+ ): number {
65
+ const n =
66
+ typeof value === "number"
67
+ ? value
68
+ : typeof value === "string" && value.trim() !== ""
69
+ ? Number(value)
70
+ : Number.NaN;
71
+ if (!Number.isFinite(n) || !Number.isInteger(n)) {
72
+ throw new Error(`Invalid ${name}: "${String(value)}". Must be an integer.`);
73
+ }
74
+ if (opts?.min !== undefined && n < opts.min) {
75
+ throw new Error(`Invalid ${name}: ${n}. Must be >= ${opts.min}.`);
76
+ }
77
+ return n;
78
+ }
79
+
80
+ export function validateTriggerUrl(value: string, name: string): string {
81
+ rejectControlChars(value, name);
82
+ const trimmed = value.trim();
83
+ if (trimmed === "") {
84
+ throw new Error(
85
+ `Invalid ${name}: must be a non-empty absolute http(s) URL or a relative path starting with "/".`,
86
+ );
87
+ }
88
+ if (trimmed.startsWith("/")) {
89
+ return trimmed;
90
+ }
91
+ try {
92
+ const parsed = new URL(trimmed);
93
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
94
+ throw new Error("unsupported protocol");
95
+ }
96
+ return trimmed;
97
+ } catch {
98
+ throw new Error(
99
+ `Invalid ${name}: "${value}". Must be an absolute http(s) URL or a relative path starting with "/".`,
100
+ );
101
+ }
102
+ }
package/tsup.config.ts CHANGED
@@ -1,9 +1,15 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { defineConfig } from "tsup";
2
3
 
3
4
  // CI injects SUPABASE_URL/SUPABASE_ANON_KEY/BUILDER_URL from GH Actions vars;
4
5
  // locally they're empty and the CLI falls back to runtime process.env.
5
6
  const env = (k: string) => JSON.stringify(process.env[k] ?? "");
6
7
 
8
+ // Inline the package version at build time so `-v`/`--version` report the real version.
9
+ const pkg = JSON.parse(
10
+ readFileSync(new URL("./package.json", import.meta.url), "utf8"),
11
+ );
12
+
7
13
  export default defineConfig({
8
14
  entry: ["src/index.tsx"],
9
15
  format: ["esm"],
@@ -16,5 +22,6 @@ export default defineConfig({
16
22
  __OLLIE_SUPABASE_URL__: env("SUPABASE_URL"),
17
23
  __OLLIE_SUPABASE_ANON_KEY__: env("SUPABASE_ANON_KEY"),
18
24
  __OLLIE_BUILDER_URL__: env("BUILDER_URL"),
25
+ __OLLIE_CLI_VERSION__: JSON.stringify(pkg.version),
19
26
  },
20
27
  });