@ollie-shop/cli 1.1.0 → 1.2.2

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.
@@ -0,0 +1,171 @@
1
+ import { getAuthToken, getBuilderUrl } from "../utils/supabase.js";
2
+
3
+ export type ResourceType = "component" | "function";
4
+
5
+ export interface BuildResult {
6
+ id: string;
7
+ startTime: string;
8
+ endTime?: string | null;
9
+ status: string;
10
+ resource: {
11
+ id: string;
12
+ type: ResourceType;
13
+ };
14
+ }
15
+
16
+ export interface UploadBuildInput {
17
+ resourceId: string;
18
+ type: ResourceType;
19
+ zipBuffer: Buffer;
20
+ }
21
+
22
+ interface BuilderResponse {
23
+ success: boolean;
24
+ data?: BuildResult;
25
+ error?: { message?: string; type?: string };
26
+ }
27
+
28
+ export async function uploadBuild(
29
+ input: UploadBuildInput,
30
+ ): Promise<
31
+ | { success: true; data: BuildResult }
32
+ | { success: false; error: { message: string } }
33
+ > {
34
+ const builderUrl = getBuilderUrl();
35
+ const token = await getAuthToken();
36
+
37
+ const formData = new FormData();
38
+ formData.append(
39
+ "code",
40
+ new Blob([input.zipBuffer], { type: "application/zip" }),
41
+ "code.zip",
42
+ );
43
+ formData.append("type", input.type);
44
+ formData.append("resource_id", input.resourceId);
45
+
46
+ let response: Response;
47
+ try {
48
+ response = await fetch(`${builderUrl}/`, {
49
+ method: "POST",
50
+ headers: {
51
+ Authorization: `Bearer ${token}`,
52
+ },
53
+ body: formData,
54
+ });
55
+ } catch (err) {
56
+ return {
57
+ success: false,
58
+ error: {
59
+ message: `Cannot reach builder at ${builderUrl}: ${err instanceof Error ? err.message : String(err)}`,
60
+ },
61
+ };
62
+ }
63
+
64
+ const body = (await response.json()) as BuilderResponse;
65
+
66
+ if (!response.ok || !body.success) {
67
+ const msg =
68
+ body.error?.message ||
69
+ body.error?.type ||
70
+ `Builder returned ${response.status}`;
71
+ return { success: false, error: { message: msg } };
72
+ }
73
+
74
+ return { success: true, data: body.data as BuildResult };
75
+ }
76
+
77
+ export async function fetchBuildStatus(
78
+ buildId: string,
79
+ ): Promise<
80
+ | { success: true; data: BuildResult }
81
+ | { success: false; error: { message: string } }
82
+ > {
83
+ const builderUrl = getBuilderUrl();
84
+ const token = await getAuthToken();
85
+
86
+ let response: Response;
87
+ try {
88
+ response = await fetch(`${builderUrl}/${encodeURIComponent(buildId)}`, {
89
+ method: "GET",
90
+ headers: {
91
+ Authorization: `Bearer ${token}`,
92
+ },
93
+ });
94
+ } catch (err) {
95
+ return {
96
+ success: false,
97
+ error: {
98
+ message: `Cannot reach builder at ${builderUrl}: ${err instanceof Error ? err.message : String(err)}`,
99
+ },
100
+ };
101
+ }
102
+
103
+ const body = (await response.json()) as BuilderResponse;
104
+
105
+ if (!response.ok || !body.success) {
106
+ const msg =
107
+ body.error?.message ||
108
+ body.error?.type ||
109
+ `Builder returned ${response.status}`;
110
+ return { success: false, error: { message: msg } };
111
+ }
112
+
113
+ return { success: true, data: body.data as BuildResult };
114
+ }
115
+
116
+ const TERMINAL_STATUSES = new Set([
117
+ "SUCCEEDED",
118
+ "FAILED",
119
+ "STOPPED",
120
+ "TIMED_OUT",
121
+ "FAULT",
122
+ ]);
123
+
124
+ export function isTerminalStatus(status: string): boolean {
125
+ return TERMINAL_STATUSES.has(status);
126
+ }
127
+
128
+ export interface PollOptions {
129
+ intervalMs?: number;
130
+ timeoutMs?: number;
131
+ onPoll?: (result: BuildResult) => void;
132
+ }
133
+
134
+ export async function pollBuildStatus(
135
+ buildId: string,
136
+ options: PollOptions = {},
137
+ ): Promise<
138
+ | { success: true; data: BuildResult }
139
+ | { success: false; error: { message: string } }
140
+ > {
141
+ const { intervalMs = 5000, timeoutMs = 300_000, onPoll } = options;
142
+ const deadline = Date.now() + timeoutMs;
143
+
144
+ while (Date.now() < deadline) {
145
+ const result = await fetchBuildStatus(buildId);
146
+
147
+ if (!result.success) {
148
+ return result;
149
+ }
150
+
151
+ if (onPoll) {
152
+ onPoll(result.data);
153
+ }
154
+
155
+ if (isTerminalStatus(result.data.status)) {
156
+ return result;
157
+ }
158
+
159
+ const remaining = deadline - Date.now();
160
+ if (remaining <= 0) break;
161
+
162
+ await new Promise((resolve) =>
163
+ setTimeout(resolve, Math.min(intervalMs, remaining)),
164
+ );
165
+ }
166
+
167
+ return {
168
+ success: false,
169
+ error: { message: `Build ${buildId} timed out after ${timeoutMs / 1000}s` },
170
+ };
171
+ }
@@ -0,0 +1,92 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import { functionCreateSchema } from "./schema.js";
3
+
4
+ export interface CreateFunctionInput {
5
+ versionId: string;
6
+ name: string;
7
+ trigger?: string;
8
+ active?: boolean;
9
+ onError?: "throw" | "skip";
10
+ priority?: number;
11
+ }
12
+
13
+ export interface FunctionRecord {
14
+ id: string;
15
+ name: string;
16
+ active: boolean;
17
+ urn: string | null;
18
+ on_error: string;
19
+ priority: number;
20
+ trigger: { url: string; expression: string } | null;
21
+ invocation: string | null;
22
+ version_id: string;
23
+ created_at: string;
24
+ versions?: { id: string; name: string };
25
+ }
26
+
27
+ export async function createFunction(
28
+ client: SupabaseClient,
29
+ input: CreateFunctionInput,
30
+ ): Promise<
31
+ | { success: true; data: { id: string } }
32
+ | { success: false; error: { message: string } }
33
+ > {
34
+ const parsed = functionCreateSchema.safeParse(input);
35
+ if (!parsed.success) {
36
+ return {
37
+ success: false,
38
+ error: {
39
+ message: parsed.error.issues
40
+ .map((i: { message: string }) => i.message)
41
+ .join("; "),
42
+ },
43
+ };
44
+ }
45
+
46
+ const { data, error } = await client
47
+ .from("functions")
48
+ .insert({
49
+ name: parsed.data.name,
50
+ active: parsed.data.active,
51
+ version_id: parsed.data.versionId,
52
+ trigger: parsed.data.trigger ?? null,
53
+ on_error: parsed.data.onError ?? "throw",
54
+ priority: parsed.data.priority ?? 0,
55
+ })
56
+ .select("id")
57
+ .single();
58
+
59
+ if (error) {
60
+ return { success: false, error: { message: error.message } };
61
+ }
62
+
63
+ return { success: true, data: { id: data.id } };
64
+ }
65
+
66
+ export async function listFunctions(
67
+ client: SupabaseClient,
68
+ storeId: string,
69
+ versionId?: string,
70
+ ): Promise<
71
+ | { success: true; data: FunctionRecord[] }
72
+ | { success: false; error: { message: string } }
73
+ > {
74
+ let query = client
75
+ .from("functions")
76
+ .select(
77
+ "id, name, active, urn, on_error, priority, trigger, invocation, version_id, created_at, versions!inner(id, name)",
78
+ )
79
+ .eq("versions.store_id", storeId);
80
+
81
+ if (versionId) {
82
+ query = query.eq("versions.id", versionId);
83
+ }
84
+
85
+ const { data, error } = await query;
86
+
87
+ if (error) {
88
+ return { success: false, error: { message: error.message } };
89
+ }
90
+
91
+ return { success: true, data: (data ?? []) as unknown as FunctionRecord[] };
92
+ }
@@ -67,8 +67,24 @@ export const functionCreateSchema = z.object({
67
67
  trigger: z
68
68
  .string()
69
69
  .min(1)
70
- .describe("Function trigger (e.g. beforePayment, afterShipping)"),
71
- active: z.boolean().default(true).describe("Whether function is active"),
70
+ .optional()
71
+ .describe(
72
+ "Function trigger URL — absolute http(s) URL or relative path starting with /",
73
+ ),
74
+ active: z
75
+ .boolean()
76
+ .default(false)
77
+ .describe("Whether function is active (default: false)"),
78
+ onError: z
79
+ .enum(["throw", "skip"])
80
+ .optional()
81
+ .describe("Error handling: throw (default) or skip"),
82
+ priority: z
83
+ .number()
84
+ .int()
85
+ .min(0)
86
+ .optional()
87
+ .describe("Execution order priority (default: 0)"),
72
88
  });
73
89
 
74
90
  export const functionListSchema = z.object({
package/src/index.tsx CHANGED
@@ -1,8 +1,12 @@
1
1
  import { render } from "ink";
2
2
  import { App } from "./cli.js";
3
+ import { businessRuleCommand } from "./commands/business-rule-cmd.js";
3
4
  import { componentCommand } from "./commands/component-cmd.js";
5
+ import { deployCommand } from "./commands/deploy-cmd.js";
6
+ import { functionCommand } from "./commands/function-cmd.js";
4
7
  import { initCommand } from "./commands/init-cmd.js";
5
8
  import { schemaCommand } from "./commands/schema-cmd.js";
9
+ import { statusCommand } from "./commands/status-cmd.js";
6
10
  import { storeCommand } from "./commands/store-cmd.js";
7
11
  import { versionCommand } from "./commands/version-cmd.js";
8
12
  import { whoamiCommand } from "./commands/whoami.js";
@@ -17,8 +21,12 @@ const AGENT_COMMANDS: Record<
17
21
  store: storeCommand,
18
22
  version: versionCommand,
19
23
  component: componentCommand,
24
+ "business-rule": businessRuleCommand,
25
+ function: functionCommand,
20
26
  schema: schemaCommand,
21
27
  init: initCommand,
28
+ deploy: deployCommand,
29
+ status: statusCommand,
22
30
  };
23
31
 
24
32
  const parsed = parseArgs(process.argv);
@@ -1,15 +1,10 @@
1
1
  import { type SupabaseClient, createClient } from "@supabase/supabase-js";
2
2
  import { getCredentials } from "./auth.js";
3
3
 
4
- function requireEnv(name: string): string {
5
- const value = process.env[name];
6
- if (!value) {
7
- throw new Error(
8
- `Missing required environment variable: ${name}. Set it in your .env file or shell.`,
9
- );
10
- }
11
- return value;
12
- }
4
+ // tsup inlines these from CI env vars at build time (see tsup.config.ts).
5
+ declare const __OLLIE_SUPABASE_URL__: string;
6
+ declare const __OLLIE_SUPABASE_ANON_KEY__: string;
7
+ declare const __OLLIE_BUILDER_URL__: string;
13
8
 
14
9
  export async function getAuthenticatedClient(): Promise<SupabaseClient> {
15
10
  const credentials = await getCredentials();
@@ -17,8 +12,9 @@ export async function getAuthenticatedClient(): Promise<SupabaseClient> {
17
12
  throw new Error("Not authenticated. Run `ollieshop login` first.");
18
13
  }
19
14
 
20
- const supabaseUrl = requireEnv("OLLIE_SUPABASE_URL");
21
- const supabaseAnonKey = requireEnv("OLLIE_SUPABASE_ANON_KEY");
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__;
22
18
 
23
19
  const client = createClient(supabaseUrl, supabaseAnonKey, {
24
20
  auth: {
@@ -35,6 +31,18 @@ export async function getAuthenticatedClient(): Promise<SupabaseClient> {
35
31
  return client;
36
32
  }
37
33
 
34
+ export function getBuilderUrl(): string {
35
+ return process.env.OLLIE_BUILDER_URL || __OLLIE_BUILDER_URL__;
36
+ }
37
+
38
+ export async function getAuthToken(): Promise<string> {
39
+ const credentials = await getCredentials();
40
+ if (!credentials) {
41
+ throw new Error("Not authenticated. Run `ollieshop login` first.");
42
+ }
43
+ return credentials.accessToken;
44
+ }
45
+
38
46
  export async function getOrganizationId(
39
47
  client: SupabaseClient,
40
48
  ): Promise<string> {
@@ -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,5 +1,9 @@
1
1
  import { defineConfig } from "tsup";
2
2
 
3
+ // CI injects SUPABASE_URL/SUPABASE_ANON_KEY/BUILDER_URL from GH Actions vars;
4
+ // locally they're empty and the CLI falls back to runtime process.env.
5
+ const env = (k: string) => JSON.stringify(process.env[k] ?? "");
6
+
3
7
  export default defineConfig({
4
8
  entry: ["src/index.tsx"],
5
9
  format: ["esm"],
@@ -7,7 +11,10 @@ export default defineConfig({
7
11
  dts: false,
8
12
  clean: true,
9
13
  sourcemap: false,
10
- banner: {
11
- js: "#!/usr/bin/env node",
14
+ banner: { js: "#!/usr/bin/env node" },
15
+ define: {
16
+ __OLLIE_SUPABASE_URL__: env("SUPABASE_URL"),
17
+ __OLLIE_SUPABASE_ANON_KEY__: env("SUPABASE_ANON_KEY"),
18
+ __OLLIE_BUILDER_URL__: env("BUILDER_URL"),
12
19
  },
13
20
  });