@tinybirdco/sdk 0.0.34 → 0.0.36

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.
Files changed (46) hide show
  1. package/dist/api/regions.d.ts +33 -0
  2. package/dist/api/regions.d.ts.map +1 -0
  3. package/dist/api/regions.js +52 -0
  4. package/dist/api/regions.js.map +1 -0
  5. package/dist/api/regions.test.d.ts +2 -0
  6. package/dist/api/regions.test.d.ts.map +1 -0
  7. package/dist/api/regions.test.js +69 -0
  8. package/dist/api/regions.test.js.map +1 -0
  9. package/dist/cli/commands/init.d.ts.map +1 -1
  10. package/dist/cli/commands/init.js +43 -8
  11. package/dist/cli/commands/init.js.map +1 -1
  12. package/dist/cli/commands/init.test.js +7 -0
  13. package/dist/cli/commands/init.test.js.map +1 -1
  14. package/dist/cli/commands/login.d.ts.map +1 -1
  15. package/dist/cli/commands/login.js +13 -3
  16. package/dist/cli/commands/login.js.map +1 -1
  17. package/dist/cli/commands/login.test.js +12 -1
  18. package/dist/cli/commands/login.test.js.map +1 -1
  19. package/dist/cli/index.js +9 -35
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/cli/region-selector.d.ts +39 -0
  22. package/dist/cli/region-selector.d.ts.map +1 -0
  23. package/dist/cli/region-selector.js +118 -0
  24. package/dist/cli/region-selector.js.map +1 -0
  25. package/dist/cli/region-selector.test.d.ts +2 -0
  26. package/dist/cli/region-selector.test.d.ts.map +1 -0
  27. package/dist/cli/region-selector.test.js +176 -0
  28. package/dist/cli/region-selector.test.js.map +1 -0
  29. package/dist/cli/utils/package-manager.d.ts +1 -0
  30. package/dist/cli/utils/package-manager.d.ts.map +1 -1
  31. package/dist/cli/utils/package-manager.js +13 -0
  32. package/dist/cli/utils/package-manager.js.map +1 -1
  33. package/dist/cli/utils/package-manager.test.js +15 -1
  34. package/dist/cli/utils/package-manager.test.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/api/regions.test.ts +89 -0
  37. package/src/api/regions.ts +75 -0
  38. package/src/cli/commands/init.test.ts +8 -0
  39. package/src/cli/commands/init.ts +78 -15
  40. package/src/cli/commands/login.test.ts +13 -1
  41. package/src/cli/commands/login.ts +14 -3
  42. package/src/cli/index.ts +25 -43
  43. package/src/cli/region-selector.test.ts +227 -0
  44. package/src/cli/region-selector.ts +151 -0
  45. package/src/cli/utils/package-manager.test.ts +19 -0
  46. package/src/cli/utils/package-manager.ts +14 -0
@@ -0,0 +1,227 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import * as os from "os";
5
+ import { selectRegion, getApiHostWithRegionSelection } from "./region-selector.js";
6
+
7
+ // Mock the regions API
8
+ vi.mock("../api/regions.js", () => ({
9
+ fetchRegions: vi.fn(),
10
+ }));
11
+
12
+ // Mock @clack/prompts
13
+ vi.mock("@clack/prompts", () => ({
14
+ select: vi.fn(),
15
+ isCancel: vi.fn(),
16
+ cancel: vi.fn(),
17
+ }));
18
+
19
+ import { fetchRegions } from "../api/regions.js";
20
+ import * as p from "@clack/prompts";
21
+
22
+ const mockedFetchRegions = vi.mocked(fetchRegions);
23
+ const mockedSelect = vi.mocked(p.select);
24
+ const mockedIsCancel = vi.mocked(p.isCancel);
25
+
26
+ describe("selectRegion", () => {
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ mockedIsCancel.mockReturnValue(false);
30
+ });
31
+
32
+ it("fetches regions and shows selection prompt with GCP first", async () => {
33
+ mockedFetchRegions.mockResolvedValue([
34
+ { name: "us-east-1", api_host: "https://api.us.tinybird.co", provider: "aws" },
35
+ { name: "europe-west3", api_host: "https://api.eu.tinybird.co", provider: "gcp" },
36
+ ]);
37
+ mockedSelect.mockResolvedValue("https://api.eu.tinybird.co");
38
+
39
+ const result = await selectRegion();
40
+
41
+ expect(result.success).toBe(true);
42
+ expect(result.apiHost).toBe("https://api.eu.tinybird.co");
43
+ expect(result.regionName).toBe("europe-west3");
44
+ // GCP regions should appear first, then AWS
45
+ expect(mockedSelect).toHaveBeenCalledWith({
46
+ message: "Select your Tinybird region",
47
+ options: [
48
+ { value: "https://api.eu.tinybird.co", label: "gcp | europe-west3", hint: "api.eu.tinybird.co" },
49
+ { value: "https://api.us.tinybird.co", label: "aws | us-east-1", hint: "api.us.tinybird.co" },
50
+ ],
51
+ initialValue: undefined,
52
+ });
53
+ });
54
+
55
+ it("uses fallback regions when API fails", async () => {
56
+ mockedFetchRegions.mockRejectedValue(new Error("Network error"));
57
+ mockedSelect.mockResolvedValue("https://api.europe-west2.gcp.tinybird.co");
58
+
59
+ const result = await selectRegion();
60
+
61
+ expect(result.success).toBe(true);
62
+ expect(result.apiHost).toBe("https://api.europe-west2.gcp.tinybird.co");
63
+ expect(mockedSelect).toHaveBeenCalled();
64
+ });
65
+
66
+ it("returns cancelled when user cancels", async () => {
67
+ mockedFetchRegions.mockResolvedValue([
68
+ { name: "EU", api_host: "https://api.eu.tinybird.co", provider: "gcp" },
69
+ ]);
70
+ mockedSelect.mockResolvedValue(Symbol("cancel"));
71
+ mockedIsCancel.mockReturnValue(true);
72
+
73
+ const result = await selectRegion();
74
+
75
+ expect(result.success).toBe(false);
76
+ expect(result.cancelled).toBe(true);
77
+ });
78
+
79
+ it("uses fallback regions when API returns empty array", async () => {
80
+ mockedFetchRegions.mockResolvedValue([]);
81
+ mockedSelect.mockResolvedValue("https://api.tinybird.co");
82
+
83
+ const result = await selectRegion();
84
+
85
+ expect(result.success).toBe(true);
86
+ // Should have used fallback regions
87
+ expect(mockedSelect).toHaveBeenCalled();
88
+ });
89
+
90
+ it("pre-selects region when defaultApiHost is provided", async () => {
91
+ mockedFetchRegions.mockResolvedValue([
92
+ { name: "EU", api_host: "https://api.eu.tinybird.co", provider: "gcp" },
93
+ { name: "US", api_host: "https://api.us.tinybird.co", provider: "aws" },
94
+ ]);
95
+ mockedSelect.mockResolvedValue("https://api.us.tinybird.co");
96
+
97
+ const result = await selectRegion("https://api.us.tinybird.co");
98
+
99
+ expect(result.success).toBe(true);
100
+ expect(mockedSelect).toHaveBeenCalledWith(
101
+ expect.objectContaining({
102
+ initialValue: "https://api.us.tinybird.co",
103
+ })
104
+ );
105
+ });
106
+
107
+ it("does not set initialValue when defaultApiHost does not match any region", async () => {
108
+ mockedFetchRegions.mockResolvedValue([
109
+ { name: "EU", api_host: "https://api.eu.tinybird.co", provider: "gcp" },
110
+ ]);
111
+ mockedSelect.mockResolvedValue("https://api.eu.tinybird.co");
112
+
113
+ await selectRegion("https://api.unknown.tinybird.co");
114
+
115
+ expect(mockedSelect).toHaveBeenCalledWith(
116
+ expect.objectContaining({
117
+ initialValue: undefined,
118
+ })
119
+ );
120
+ });
121
+ });
122
+
123
+ describe("getApiHostWithRegionSelection", () => {
124
+ let tempDir: string;
125
+
126
+ beforeEach(() => {
127
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "region-selector-test-"));
128
+ vi.clearAllMocks();
129
+ mockedIsCancel.mockReturnValue(false);
130
+ });
131
+
132
+ afterEach(() => {
133
+ try {
134
+ fs.rmSync(tempDir, { recursive: true });
135
+ } catch {
136
+ // Ignore cleanup errors
137
+ }
138
+ });
139
+
140
+ it("pre-selects baseUrl from config when present", async () => {
141
+ const configPath = path.join(tempDir, "tinybird.config.json");
142
+ fs.writeFileSync(
143
+ configPath,
144
+ JSON.stringify({ baseUrl: "https://api.us-east.tinybird.co" })
145
+ );
146
+
147
+ mockedFetchRegions.mockResolvedValue([
148
+ { name: "EU", api_host: "https://api.eu.tinybird.co", provider: "gcp" },
149
+ { name: "US East", api_host: "https://api.us-east.tinybird.co", provider: "aws" },
150
+ ]);
151
+ mockedSelect.mockResolvedValue("https://api.us-east.tinybird.co");
152
+
153
+ const result = await getApiHostWithRegionSelection(configPath);
154
+
155
+ expect(result).toEqual({
156
+ apiHost: "https://api.us-east.tinybird.co",
157
+ fromConfig: false,
158
+ });
159
+ // Should have called select with initialValue
160
+ expect(mockedSelect).toHaveBeenCalledWith(
161
+ expect.objectContaining({
162
+ initialValue: "https://api.us-east.tinybird.co",
163
+ })
164
+ );
165
+ });
166
+
167
+ it("prompts for region when config has no baseUrl", async () => {
168
+ const configPath = path.join(tempDir, "tinybird.config.json");
169
+ fs.writeFileSync(configPath, JSON.stringify({ token: "test" }));
170
+
171
+ mockedFetchRegions.mockResolvedValue([
172
+ { name: "EU", api_host: "https://api.eu.tinybird.co", provider: "gcp" },
173
+ ]);
174
+ mockedSelect.mockResolvedValue("https://api.eu.tinybird.co");
175
+
176
+ const result = await getApiHostWithRegionSelection(configPath);
177
+
178
+ expect(result).toEqual({
179
+ apiHost: "https://api.eu.tinybird.co",
180
+ fromConfig: false,
181
+ });
182
+ expect(mockedSelect).toHaveBeenCalled();
183
+ });
184
+
185
+ it("prompts for region when config file is null", async () => {
186
+ mockedFetchRegions.mockResolvedValue([
187
+ { name: "EU", api_host: "https://api.eu.tinybird.co", provider: "gcp" },
188
+ ]);
189
+ mockedSelect.mockResolvedValue("https://api.eu.tinybird.co");
190
+
191
+ const result = await getApiHostWithRegionSelection(null);
192
+
193
+ expect(result).toEqual({
194
+ apiHost: "https://api.eu.tinybird.co",
195
+ fromConfig: false,
196
+ });
197
+ });
198
+
199
+ it("prompts for region when config is not JSON", async () => {
200
+ mockedFetchRegions.mockResolvedValue([
201
+ { name: "EU", api_host: "https://api.eu.tinybird.co", provider: "gcp" },
202
+ ]);
203
+ mockedSelect.mockResolvedValue("https://api.eu.tinybird.co");
204
+
205
+ const result = await getApiHostWithRegionSelection("/path/to/config.mjs");
206
+
207
+ expect(result).toEqual({
208
+ apiHost: "https://api.eu.tinybird.co",
209
+ fromConfig: false,
210
+ });
211
+ });
212
+
213
+ it("returns null when user cancels region selection", async () => {
214
+ const configPath = path.join(tempDir, "tinybird.config.json");
215
+ fs.writeFileSync(configPath, JSON.stringify({ token: "test" }));
216
+
217
+ mockedFetchRegions.mockResolvedValue([
218
+ { name: "EU", api_host: "https://api.eu.tinybird.co", provider: "gcp" },
219
+ ]);
220
+ mockedSelect.mockResolvedValue(Symbol("cancel"));
221
+ mockedIsCancel.mockReturnValue(true);
222
+
223
+ const result = await getApiHostWithRegionSelection(configPath);
224
+
225
+ expect(result).toBeNull();
226
+ });
227
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Region selection utility for CLI commands
3
+ *
4
+ * Provides interactive region selection using @clack/prompts
5
+ */
6
+
7
+ import * as fs from "fs";
8
+ import * as p from "@clack/prompts";
9
+ import { fetchRegions, type TinybirdRegion } from "../api/regions.js";
10
+
11
+ /**
12
+ * Default fallback regions if API call fails
13
+ */
14
+ const FALLBACK_REGIONS: TinybirdRegion[] = [
15
+ {
16
+ name: "EU (GCP)",
17
+ api_host: "https://api.europe-west2.gcp.tinybird.co",
18
+ provider: "gcp",
19
+ },
20
+ {
21
+ name: "US East (AWS)",
22
+ api_host: "https://api.us-east.aws.tinybird.co",
23
+ provider: "aws",
24
+ },
25
+ {
26
+ name: "EU (Default)",
27
+ api_host: "https://api.tinybird.co",
28
+ provider: "gcp",
29
+ },
30
+ ];
31
+
32
+ /**
33
+ * Result of region selection
34
+ */
35
+ export interface RegionSelectionResult {
36
+ /** Whether selection was successful (not cancelled) */
37
+ success: boolean;
38
+ /** Selected API host URL */
39
+ apiHost?: string;
40
+ /** Selected region name (for display) */
41
+ regionName?: string;
42
+ /** Whether user cancelled */
43
+ cancelled?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Prompt user to select a Tinybird region
48
+ *
49
+ * Fetches available regions from the API and presents an interactive selection.
50
+ * Falls back to hardcoded regions if the API call fails.
51
+ *
52
+ * @param defaultApiHost - Optional API host to pre-select in the prompt
53
+ * @returns Selected region info or cancellation result
54
+ */
55
+ export async function selectRegion(
56
+ defaultApiHost?: string
57
+ ): Promise<RegionSelectionResult> {
58
+ let regions: TinybirdRegion[];
59
+
60
+ // Try to fetch regions from API
61
+ try {
62
+ regions = await fetchRegions();
63
+ } catch {
64
+ // Fall back to hardcoded regions
65
+ regions = FALLBACK_REGIONS;
66
+ }
67
+
68
+ // Ensure we have at least one region
69
+ if (regions.length === 0) {
70
+ regions = FALLBACK_REGIONS;
71
+ }
72
+
73
+ // Sort regions: GCP first, then AWS
74
+ regions.sort((a, b) => {
75
+ if (a.provider === b.provider) return 0;
76
+ return a.provider === "gcp" ? -1 : 1;
77
+ });
78
+
79
+ // Build options for p.select
80
+ const options = regions.map((region) => ({
81
+ value: region.api_host,
82
+ label: `${region.provider} | ${region.name}`,
83
+ hint: region.api_host.replace("https://", ""),
84
+ }));
85
+
86
+ // Find initial value if defaultApiHost is provided and matches a region
87
+ // Normalize URLs for comparison (remove trailing slashes, lowercase)
88
+ const normalizeUrl = (url: string) => url.toLowerCase().replace(/\/+$/, "");
89
+ const initialValue = defaultApiHost
90
+ ? regions.find(
91
+ (r) => normalizeUrl(r.api_host) === normalizeUrl(defaultApiHost)
92
+ )?.api_host
93
+ : undefined;
94
+
95
+ const selected = await p.select({
96
+ message: "Select your Tinybird region",
97
+ options,
98
+ initialValue,
99
+ });
100
+
101
+ if (p.isCancel(selected)) {
102
+ p.cancel("Operation cancelled");
103
+ return {
104
+ success: false,
105
+ cancelled: true,
106
+ };
107
+ }
108
+
109
+ const selectedRegion = regions.find((r) => r.api_host === selected);
110
+
111
+ return {
112
+ success: true,
113
+ apiHost: selected as string,
114
+ regionName: selectedRegion?.name,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Get API host from config file or prompt for region selection
120
+ *
121
+ * @param configPath - Path to config file (or null if no config)
122
+ * @returns API host URL and source, or null if cancelled
123
+ */
124
+ export async function getApiHostWithRegionSelection(
125
+ configPath: string | null
126
+ ): Promise<{ apiHost: string; fromConfig: boolean } | null> {
127
+ let existingBaseUrl: string | undefined;
128
+
129
+ // If we have a JSON config file, try to read baseUrl from it
130
+ if (configPath && configPath.endsWith(".json")) {
131
+ try {
132
+ const content = fs.readFileSync(configPath, "utf-8");
133
+ const config = JSON.parse(content);
134
+ existingBaseUrl = config.baseUrl;
135
+ } catch {
136
+ // Ignore errors reading config
137
+ }
138
+ }
139
+
140
+ // Prompt for region selection, pre-selecting existing baseUrl if available
141
+ const result = await selectRegion(existingBaseUrl);
142
+
143
+ if (!result.success || !result.apiHost) {
144
+ return null;
145
+ }
146
+
147
+ return {
148
+ apiHost: result.apiHost,
149
+ fromConfig: false,
150
+ };
151
+ }
@@ -6,6 +6,7 @@ import {
6
6
  detectPackageManager,
7
7
  detectPackageManagerInstallCmd,
8
8
  detectPackageManagerRunCmd,
9
+ getPackageManagerAddCmd,
9
10
  getPackageManagerInstallCmd,
10
11
  getPackageManagerRunCmd,
11
12
  hasTinybirdSdkDependency,
@@ -196,6 +197,24 @@ describe("getPackageManagerInstallCmd", () => {
196
197
  });
197
198
  });
198
199
 
200
+ describe("getPackageManagerAddCmd", () => {
201
+ it("maps npm to npm install", () => {
202
+ expect(getPackageManagerAddCmd("npm")).toBe("npm install");
203
+ });
204
+
205
+ it("maps pnpm to pnpm add", () => {
206
+ expect(getPackageManagerAddCmd("pnpm")).toBe("pnpm add");
207
+ });
208
+
209
+ it("maps yarn to yarn add", () => {
210
+ expect(getPackageManagerAddCmd("yarn")).toBe("yarn add");
211
+ });
212
+
213
+ it("maps bun to bun add", () => {
214
+ expect(getPackageManagerAddCmd("bun")).toBe("bun add");
215
+ });
216
+ });
217
+
199
218
  describe("detectPackageManagerInstallCmd", () => {
200
219
  it("detects install command based on lockfile", () => {
201
220
  fs.writeFileSync(path.join(tempDir, "yarn.lock"), "");
@@ -91,6 +91,20 @@ export function getPackageManagerInstallCmd(
91
91
  }
92
92
  }
93
93
 
94
+ export function getPackageManagerAddCmd(packageManager: PackageManager): string {
95
+ switch (packageManager) {
96
+ case "pnpm":
97
+ return "pnpm add";
98
+ case "yarn":
99
+ return "yarn add";
100
+ case "bun":
101
+ return "bun add";
102
+ case "npm":
103
+ default:
104
+ return "npm install";
105
+ }
106
+ }
107
+
94
108
  /**
95
109
  * Detect package manager (npm, pnpm, yarn, or bun)
96
110
  */