@tinybirdco/sdk 0.0.13 → 0.0.14

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 (55) hide show
  1. package/dist/api/deploy.d.ts +80 -2
  2. package/dist/api/deploy.d.ts.map +1 -1
  3. package/dist/api/deploy.js +82 -38
  4. package/dist/api/deploy.js.map +1 -1
  5. package/dist/cli/commands/deploy.d.ts +3 -0
  6. package/dist/cli/commands/deploy.d.ts.map +1 -1
  7. package/dist/cli/commands/deploy.js +1 -0
  8. package/dist/cli/commands/deploy.js.map +1 -1
  9. package/dist/cli/commands/init.d.ts.map +1 -1
  10. package/dist/cli/commands/init.js +24 -2
  11. package/dist/cli/commands/init.js.map +1 -1
  12. package/dist/cli/commands/preview.d.ts +65 -0
  13. package/dist/cli/commands/preview.d.ts.map +1 -0
  14. package/dist/cli/commands/preview.js +234 -0
  15. package/dist/cli/commands/preview.js.map +1 -0
  16. package/dist/cli/commands/preview.test.d.ts +2 -0
  17. package/dist/cli/commands/preview.test.d.ts.map +1 -0
  18. package/dist/cli/commands/preview.test.js +36 -0
  19. package/dist/cli/commands/preview.test.js.map +1 -0
  20. package/dist/cli/index.js +135 -36
  21. package/dist/cli/index.js.map +1 -1
  22. package/dist/cli/output.d.ts +45 -0
  23. package/dist/cli/output.d.ts.map +1 -1
  24. package/dist/cli/output.js +73 -1
  25. package/dist/cli/output.js.map +1 -1
  26. package/dist/cli/output.test.js +98 -2
  27. package/dist/cli/output.test.js.map +1 -1
  28. package/dist/client/preview.d.ts +36 -0
  29. package/dist/client/preview.d.ts.map +1 -0
  30. package/dist/client/preview.js +161 -0
  31. package/dist/client/preview.js.map +1 -0
  32. package/dist/client/preview.test.d.ts +2 -0
  33. package/dist/client/preview.test.d.ts.map +1 -0
  34. package/dist/client/preview.test.js +137 -0
  35. package/dist/client/preview.test.js.map +1 -0
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/schema/project.d.ts.map +1 -1
  41. package/dist/schema/project.js +7 -3
  42. package/dist/schema/project.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/api/deploy.ts +170 -10
  45. package/src/cli/commands/deploy.ts +4 -1
  46. package/src/cli/commands/init.ts +24 -2
  47. package/src/cli/commands/preview.test.ts +42 -0
  48. package/src/cli/commands/preview.ts +313 -0
  49. package/src/cli/index.ts +147 -37
  50. package/src/cli/output.test.ts +116 -1
  51. package/src/cli/output.ts +96 -1
  52. package/src/client/preview.test.ts +168 -0
  53. package/src/client/preview.ts +210 -0
  54. package/src/index.ts +8 -0
  55. package/src/schema/project.ts +9 -3
@@ -6,10 +6,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
6
6
  import {
7
7
  formatDuration,
8
8
  showResourceChange,
9
+ showChangesTable,
9
10
  showBuildErrors,
10
11
  showBuildSuccess,
11
12
  showBuildFailure,
12
13
  showNoChanges,
14
+ showWaitingForDeployment,
15
+ showDeploymentReady,
16
+ showDeploymentLive,
17
+ showValidatingDeployment,
18
+ showDeploySuccess,
19
+ showDeployFailure,
13
20
  } from "./output.js";
14
21
 
15
22
  describe("output utilities", () => {
@@ -58,6 +65,50 @@ describe("output utilities", () => {
58
65
  });
59
66
  });
60
67
 
68
+ describe("showChangesTable", () => {
69
+ it("shows no changes message when empty", () => {
70
+ showChangesTable([]);
71
+ expect(consoleLogSpy).toHaveBeenCalled();
72
+ const call = consoleLogSpy.mock.calls[0][0];
73
+ expect(call).toContain("No changes to be deployed");
74
+ });
75
+
76
+ it("shows table with changes", () => {
77
+ showChangesTable([
78
+ { status: "new", name: "events", type: "datasource" },
79
+ { status: "modified", name: "top_pages", type: "pipe" },
80
+ { status: "deleted", name: "old_data", type: "datasource" },
81
+ ]);
82
+
83
+ // Check that table header and data were logged
84
+ const allCalls = consoleLogSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n");
85
+ expect(allCalls).toContain("Changes to be deployed");
86
+ expect(allCalls).toContain("status");
87
+ expect(allCalls).toContain("name");
88
+ expect(allCalls).toContain("type");
89
+ expect(allCalls).toContain("new");
90
+ expect(allCalls).toContain("events");
91
+ expect(allCalls).toContain("datasource");
92
+ expect(allCalls).toContain("modified");
93
+ expect(allCalls).toContain("top_pages");
94
+ expect(allCalls).toContain("pipe");
95
+ expect(allCalls).toContain("deleted");
96
+ expect(allCalls).toContain("old_data");
97
+ });
98
+
99
+ it("shows table borders", () => {
100
+ showChangesTable([{ status: "new", name: "test", type: "pipe" }]);
101
+
102
+ const allCalls = consoleLogSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n");
103
+ expect(allCalls).toContain("┌");
104
+ expect(allCalls).toContain("┐");
105
+ expect(allCalls).toContain("├");
106
+ expect(allCalls).toContain("┤");
107
+ expect(allCalls).toContain("└");
108
+ expect(allCalls).toContain("┘");
109
+ });
110
+ });
111
+
61
112
  describe("showBuildErrors", () => {
62
113
  it("shows errors with filename", () => {
63
114
  showBuildErrors([
@@ -138,7 +189,71 @@ describe("output utilities", () => {
138
189
  describe("showNoChanges", () => {
139
190
  it("shows no changes message", () => {
140
191
  showNoChanges();
141
- expect(consoleLogSpy).toHaveBeenCalledWith("No changes. Build skipped.");
192
+ expect(consoleLogSpy).toHaveBeenCalled();
193
+ const call = consoleLogSpy.mock.calls[0][0];
194
+ expect(call).toContain("△");
195
+ expect(call).toContain("Not deploying. No changes.");
196
+ });
197
+ });
198
+
199
+ describe("showWaitingForDeployment", () => {
200
+ it("shows waiting for deployment message", () => {
201
+ showWaitingForDeployment();
202
+ expect(consoleLogSpy).toHaveBeenCalledWith("» Waiting for deployment to be ready...");
203
+ });
204
+ });
205
+
206
+ describe("showDeploymentReady", () => {
207
+ it("shows deployment ready message", () => {
208
+ showDeploymentReady();
209
+ expect(consoleLogSpy).toHaveBeenCalled();
210
+ const call = consoleLogSpy.mock.calls[0][0];
211
+ expect(call).toContain("✓");
212
+ expect(call).toContain("Deployment is ready");
213
+ });
214
+ });
215
+
216
+ describe("showDeploymentLive", () => {
217
+ it("shows deployment live message with ID", () => {
218
+ showDeploymentLive("abc123");
219
+ expect(consoleLogSpy).toHaveBeenCalled();
220
+ const call = consoleLogSpy.mock.calls[0][0];
221
+ expect(call).toContain("✓");
222
+ expect(call).toContain("Deployment #abc123 is live!");
223
+ });
224
+ });
225
+
226
+ describe("showValidatingDeployment", () => {
227
+ it("shows validating deployment message", () => {
228
+ showValidatingDeployment();
229
+ expect(consoleLogSpy).toHaveBeenCalledWith("» Validating deployment...");
230
+ });
231
+ });
232
+
233
+ describe("showDeploySuccess", () => {
234
+ it("shows deploy success with duration in ms", () => {
235
+ showDeploySuccess(500);
236
+ expect(consoleLogSpy).toHaveBeenCalled();
237
+ const call = consoleLogSpy.mock.calls[0][0];
238
+ expect(call).toContain("✓");
239
+ expect(call).toContain("Deploy completed in 500ms");
240
+ });
241
+
242
+ it("shows deploy success with duration in seconds", () => {
243
+ showDeploySuccess(2500);
244
+ expect(consoleLogSpy).toHaveBeenCalled();
245
+ const call = consoleLogSpy.mock.calls[0][0];
246
+ expect(call).toContain("Deploy completed in 2.5s");
247
+ });
248
+ });
249
+
250
+ describe("showDeployFailure", () => {
251
+ it("shows deploy failure", () => {
252
+ showDeployFailure();
253
+ expect(consoleErrorSpy).toHaveBeenCalled();
254
+ const call = consoleErrorSpy.mock.calls[0][0];
255
+ expect(call).toContain("✗");
256
+ expect(call).toContain("Deploy failed");
142
257
  });
143
258
  });
144
259
  });
package/src/cli/output.ts CHANGED
@@ -90,6 +90,7 @@ export function formatDuration(ms: number): string {
90
90
 
91
91
  /**
92
92
  * Show a resource change (checkmark + path + status)
93
+ * @deprecated Use showChangesTable instead for table format
93
94
  */
94
95
  export function showResourceChange(
95
96
  path: string,
@@ -98,6 +99,51 @@ export function showResourceChange(
98
99
  console.log(`✓ ${path} ${status}`);
99
100
  }
100
101
 
102
+ /**
103
+ * Resource change entry for table display
104
+ */
105
+ export interface ResourceChange {
106
+ status: "new" | "modified" | "deleted";
107
+ name: string;
108
+ type: "datasource" | "pipe" | "connection";
109
+ }
110
+
111
+ /**
112
+ * Show changes table similar to Python CLI
113
+ * Displays a formatted table of resource changes
114
+ */
115
+ export function showChangesTable(changes: ResourceChange[]): void {
116
+ if (changes.length === 0) {
117
+ gray("* No changes to be deployed");
118
+ return;
119
+ }
120
+
121
+ info("\n* Changes to be deployed:");
122
+
123
+ // Calculate column widths
124
+ const statusWidth = Math.max(6, ...changes.map((c) => c.status.length));
125
+ const nameWidth = Math.max(4, ...changes.map((c) => c.name.length));
126
+ const typeWidth = Math.max(4, ...changes.map((c) => c.type.length));
127
+
128
+ // Build table
129
+ const separator = `├${"─".repeat(statusWidth + 2)}┼${"─".repeat(nameWidth + 2)}┼${"─".repeat(typeWidth + 2)}┤`;
130
+ const topBorder = `┌${"─".repeat(statusWidth + 2)}┬${"─".repeat(nameWidth + 2)}┬${"─".repeat(typeWidth + 2)}┐`;
131
+ const bottomBorder = `└${"─".repeat(statusWidth + 2)}┴${"─".repeat(nameWidth + 2)}┴${"─".repeat(typeWidth + 2)}┘`;
132
+
133
+ const padRight = (str: string, width: number) => str + " ".repeat(width - str.length);
134
+
135
+ // Print table
136
+ console.log(topBorder);
137
+ console.log(`│ ${padRight("status", statusWidth)} │ ${padRight("name", nameWidth)} │ ${padRight("type", typeWidth)} │`);
138
+ console.log(separator);
139
+
140
+ for (const change of changes) {
141
+ console.log(`│ ${padRight(change.status, statusWidth)} │ ${padRight(change.name, nameWidth)} │ ${padRight(change.type, typeWidth)} │`);
142
+ }
143
+
144
+ console.log(bottomBorder);
145
+ }
146
+
101
147
  /**
102
148
  * Show a warning for a resource
103
149
  */
@@ -148,7 +194,49 @@ export function showBuildFailure(isRebuild = false): void {
148
194
  * Show no changes message
149
195
  */
150
196
  export function showNoChanges(): void {
151
- info("No changes. Build skipped.");
197
+ warning(" Not deploying. No changes.");
198
+ }
199
+
200
+ /**
201
+ * Show waiting for deployment message
202
+ */
203
+ export function showWaitingForDeployment(): void {
204
+ info("» Waiting for deployment to be ready...");
205
+ }
206
+
207
+ /**
208
+ * Show deployment ready message
209
+ */
210
+ export function showDeploymentReady(): void {
211
+ success("✓ Deployment is ready");
212
+ }
213
+
214
+ /**
215
+ * Show deployment live message
216
+ */
217
+ export function showDeploymentLive(deploymentId: string): void {
218
+ success(`✓ Deployment #${deploymentId} is live!`);
219
+ }
220
+
221
+ /**
222
+ * Show validating deployment message
223
+ */
224
+ export function showValidatingDeployment(): void {
225
+ info("» Validating deployment...");
226
+ }
227
+
228
+ /**
229
+ * Show final deploy success message
230
+ */
231
+ export function showDeploySuccess(durationMs: number): void {
232
+ success(`\n✓ Deploy completed in ${formatDuration(durationMs)}`);
233
+ }
234
+
235
+ /**
236
+ * Show final deploy failure message
237
+ */
238
+ export function showDeployFailure(): void {
239
+ error(`\n✗ Deploy failed`);
152
240
  }
153
241
 
154
242
  /**
@@ -165,9 +253,16 @@ export const output = {
165
253
  formatTime,
166
254
  formatDuration,
167
255
  showResourceChange,
256
+ showChangesTable,
168
257
  showResourceWarning,
169
258
  showBuildErrors,
170
259
  showBuildSuccess,
171
260
  showBuildFailure,
172
261
  showNoChanges,
262
+ showWaitingForDeployment,
263
+ showDeploymentReady,
264
+ showDeploymentLive,
265
+ showValidatingDeployment,
266
+ showDeploySuccess,
267
+ showDeployFailure,
173
268
  };
@@ -0,0 +1,168 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ isPreviewEnvironment,
4
+ getPreviewBranchName,
5
+ resolveToken,
6
+ clearTokenCache,
7
+ } from "./preview.js";
8
+
9
+ describe("Preview environment detection", () => {
10
+ const originalEnv = { ...process.env };
11
+
12
+ beforeEach(() => {
13
+ // Clear all relevant env vars before each test
14
+ delete process.env.VERCEL_ENV;
15
+ delete process.env.GITHUB_HEAD_REF;
16
+ delete process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME;
17
+ delete process.env.CI;
18
+ delete process.env.TINYBIRD_PREVIEW_MODE;
19
+ delete process.env.VERCEL_GIT_COMMIT_REF;
20
+ delete process.env.GITHUB_REF_NAME;
21
+ delete process.env.CI_COMMIT_BRANCH;
22
+ delete process.env.CIRCLE_BRANCH;
23
+ delete process.env.BUILD_SOURCEBRANCHNAME;
24
+ delete process.env.BITBUCKET_BRANCH;
25
+ delete process.env.TINYBIRD_BRANCH_NAME;
26
+ delete process.env.TINYBIRD_BRANCH_TOKEN;
27
+ delete process.env.TINYBIRD_TOKEN;
28
+ delete process.env.TINYBIRD_URL;
29
+ clearTokenCache();
30
+ });
31
+
32
+ afterEach(() => {
33
+ process.env = { ...originalEnv };
34
+ clearTokenCache();
35
+ vi.restoreAllMocks();
36
+ });
37
+
38
+ describe("isPreviewEnvironment", () => {
39
+ it("returns false in non-preview environment", () => {
40
+ expect(isPreviewEnvironment()).toBe(false);
41
+ });
42
+
43
+ it("returns true for Vercel preview deployments", () => {
44
+ process.env.VERCEL_ENV = "preview";
45
+ expect(isPreviewEnvironment()).toBe(true);
46
+ });
47
+
48
+ it("returns false for Vercel production deployments", () => {
49
+ process.env.VERCEL_ENV = "production";
50
+ expect(isPreviewEnvironment()).toBe(false);
51
+ });
52
+
53
+ it("returns true for GitHub Actions PRs", () => {
54
+ process.env.GITHUB_HEAD_REF = "feature-branch";
55
+ expect(isPreviewEnvironment()).toBe(true);
56
+ });
57
+
58
+ it("returns true for GitLab merge requests", () => {
59
+ process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME = "feature-branch";
60
+ expect(isPreviewEnvironment()).toBe(true);
61
+ });
62
+
63
+ it("returns true for generic CI with preview mode", () => {
64
+ process.env.CI = "true";
65
+ process.env.TINYBIRD_PREVIEW_MODE = "true";
66
+ expect(isPreviewEnvironment()).toBe(true);
67
+ });
68
+
69
+ it("returns false for generic CI without preview mode", () => {
70
+ process.env.CI = "true";
71
+ expect(isPreviewEnvironment()).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe("getPreviewBranchName", () => {
76
+ it("returns null when no env vars are set", () => {
77
+ expect(getPreviewBranchName()).toBeNull();
78
+ });
79
+
80
+ it("prefers explicit TINYBIRD_BRANCH_NAME override", () => {
81
+ process.env.TINYBIRD_BRANCH_NAME = "override-branch";
82
+ process.env.VERCEL_GIT_COMMIT_REF = "vercel-branch";
83
+ expect(getPreviewBranchName()).toBe("override-branch");
84
+ });
85
+
86
+ it("uses VERCEL_GIT_COMMIT_REF for Vercel", () => {
87
+ process.env.VERCEL_GIT_COMMIT_REF = "vercel-branch";
88
+ expect(getPreviewBranchName()).toBe("vercel-branch");
89
+ });
90
+
91
+ it("uses GITHUB_HEAD_REF for GitHub Actions PRs", () => {
92
+ process.env.GITHUB_HEAD_REF = "pr-branch";
93
+ expect(getPreviewBranchName()).toBe("pr-branch");
94
+ });
95
+
96
+ it("uses GITHUB_REF_NAME for GitHub Actions pushes", () => {
97
+ process.env.GITHUB_REF_NAME = "main";
98
+ expect(getPreviewBranchName()).toBe("main");
99
+ });
100
+
101
+ it("prefers GITHUB_HEAD_REF over GITHUB_REF_NAME", () => {
102
+ process.env.GITHUB_HEAD_REF = "pr-branch";
103
+ process.env.GITHUB_REF_NAME = "main";
104
+ expect(getPreviewBranchName()).toBe("pr-branch");
105
+ });
106
+
107
+ it("uses CI_MERGE_REQUEST_SOURCE_BRANCH_NAME for GitLab MRs", () => {
108
+ process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME = "mr-branch";
109
+ expect(getPreviewBranchName()).toBe("mr-branch");
110
+ });
111
+
112
+ it("uses CI_COMMIT_BRANCH for GitLab CI branches", () => {
113
+ process.env.CI_COMMIT_BRANCH = "gitlab-branch";
114
+ expect(getPreviewBranchName()).toBe("gitlab-branch");
115
+ });
116
+
117
+ it("uses CIRCLE_BRANCH for CircleCI", () => {
118
+ process.env.CIRCLE_BRANCH = "circle-branch";
119
+ expect(getPreviewBranchName()).toBe("circle-branch");
120
+ });
121
+
122
+ it("uses BUILD_SOURCEBRANCHNAME for Azure Pipelines", () => {
123
+ process.env.BUILD_SOURCEBRANCHNAME = "azure-branch";
124
+ expect(getPreviewBranchName()).toBe("azure-branch");
125
+ });
126
+
127
+ it("uses BITBUCKET_BRANCH for Bitbucket Pipelines", () => {
128
+ process.env.BITBUCKET_BRANCH = "bitbucket-branch";
129
+ expect(getPreviewBranchName()).toBe("bitbucket-branch");
130
+ });
131
+ });
132
+
133
+ describe("resolveToken", () => {
134
+ it("returns TINYBIRD_BRANCH_TOKEN if set", async () => {
135
+ process.env.TINYBIRD_BRANCH_TOKEN = "branch-token";
136
+ process.env.TINYBIRD_TOKEN = "workspace-token";
137
+ const token = await resolveToken();
138
+ expect(token).toBe("branch-token");
139
+ });
140
+
141
+ it("throws if no token is configured", async () => {
142
+ await expect(resolveToken()).rejects.toThrow("TINYBIRD_TOKEN is not configured");
143
+ });
144
+
145
+ it("returns configured token from options", async () => {
146
+ const token = await resolveToken({ token: "option-token" });
147
+ expect(token).toBe("option-token");
148
+ });
149
+
150
+ it("returns TINYBIRD_TOKEN when not in preview environment", async () => {
151
+ process.env.TINYBIRD_TOKEN = "workspace-token";
152
+ const token = await resolveToken();
153
+ expect(token).toBe("workspace-token");
154
+ });
155
+
156
+ it("passes token from options", async () => {
157
+ const token = await resolveToken({ token: "my-token" });
158
+ expect(token).toBe("my-token");
159
+ });
160
+ });
161
+
162
+ describe("clearTokenCache", () => {
163
+ it("clears the cached token", async () => {
164
+ // Just verify it doesn't throw
165
+ expect(() => clearTokenCache()).not.toThrow();
166
+ });
167
+ });
168
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Preview environment detection and branch token resolution
3
+ *
4
+ * Automatically detects preview/CI environments and resolves the appropriate
5
+ * Tinybird branch token for the current git branch.
6
+ */
7
+
8
+ import { tinybirdFetch } from "../api/fetcher.js";
9
+
10
+ /**
11
+ * Branch information with token
12
+ */
13
+ interface BranchWithToken {
14
+ id: string;
15
+ name: string;
16
+ token: string;
17
+ created_at: string;
18
+ }
19
+
20
+ /**
21
+ * Cached branch token to avoid repeated API calls
22
+ */
23
+ let cachedBranchToken: string | null = null;
24
+ let cachedBranchName: string | null = null;
25
+
26
+ /**
27
+ * Detect if we're running in a preview/CI environment
28
+ */
29
+ export function isPreviewEnvironment(): boolean {
30
+ return !!(
31
+ // Vercel preview deployments
32
+ process.env.VERCEL_ENV === "preview" ||
33
+ // GitHub Actions (PRs)
34
+ process.env.GITHUB_HEAD_REF ||
35
+ // GitLab CI (merge requests)
36
+ process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ||
37
+ // Generic CI with preview indicator
38
+ (process.env.CI && process.env.TINYBIRD_PREVIEW_MODE === "true")
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Get the current git branch name from environment variables
44
+ * Supports various CI platforms
45
+ */
46
+ export function getPreviewBranchName(): string | null {
47
+ // Explicit override
48
+ if (process.env.TINYBIRD_BRANCH_NAME) {
49
+ return process.env.TINYBIRD_BRANCH_NAME;
50
+ }
51
+
52
+ // Vercel
53
+ if (process.env.VERCEL_GIT_COMMIT_REF) {
54
+ return process.env.VERCEL_GIT_COMMIT_REF;
55
+ }
56
+
57
+ // GitHub Actions (PR)
58
+ if (process.env.GITHUB_HEAD_REF) {
59
+ return process.env.GITHUB_HEAD_REF;
60
+ }
61
+
62
+ // GitHub Actions (push)
63
+ if (process.env.GITHUB_REF_NAME) {
64
+ return process.env.GITHUB_REF_NAME;
65
+ }
66
+
67
+ // GitLab CI (merge request)
68
+ if (process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME) {
69
+ return process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME;
70
+ }
71
+
72
+ // GitLab CI (branch)
73
+ if (process.env.CI_COMMIT_BRANCH) {
74
+ return process.env.CI_COMMIT_BRANCH;
75
+ }
76
+
77
+ // CircleCI
78
+ if (process.env.CIRCLE_BRANCH) {
79
+ return process.env.CIRCLE_BRANCH;
80
+ }
81
+
82
+ // Azure Pipelines
83
+ if (process.env.BUILD_SOURCEBRANCHNAME) {
84
+ return process.env.BUILD_SOURCEBRANCHNAME;
85
+ }
86
+
87
+ // Bitbucket Pipelines
88
+ if (process.env.BITBUCKET_BRANCH) {
89
+ return process.env.BITBUCKET_BRANCH;
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Sanitize a git branch name for use as a Tinybird branch name
97
+ * Tinybird only accepts alphanumeric characters and underscores
98
+ */
99
+ function sanitizeBranchName(branchName: string): string {
100
+ return branchName
101
+ .replace(/[^a-zA-Z0-9_]/g, "_")
102
+ .replace(/_+/g, "_")
103
+ .replace(/^_|_$/g, "");
104
+ }
105
+
106
+ /**
107
+ * Fetch branch token from Tinybird API
108
+ */
109
+ async function fetchBranchToken(
110
+ baseUrl: string,
111
+ workspaceToken: string,
112
+ branchName: string
113
+ ): Promise<string | null> {
114
+ const sanitizedName = sanitizeBranchName(branchName);
115
+ const url = new URL(`/v0/environments/${encodeURIComponent(sanitizedName)}`, baseUrl);
116
+ url.searchParams.set("with_token", "true");
117
+
118
+ try {
119
+ const response = await tinybirdFetch(url.toString(), {
120
+ method: "GET",
121
+ headers: {
122
+ Authorization: `Bearer ${workspaceToken}`,
123
+ },
124
+ });
125
+
126
+ if (!response.ok) {
127
+ // Branch doesn't exist or access denied
128
+ return null;
129
+ }
130
+
131
+ const branch = (await response.json()) as BranchWithToken;
132
+ return branch.token ?? null;
133
+ } catch {
134
+ // Network error or other issue
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Resolve the token to use for API calls
141
+ *
142
+ * Priority:
143
+ * 1. Explicit TINYBIRD_BRANCH_TOKEN env var
144
+ * 2. In preview environment: fetch branch token using workspace token
145
+ * 3. Fall back to TINYBIRD_TOKEN
146
+ *
147
+ * @param options - Optional configuration overrides
148
+ * @returns The resolved token to use
149
+ */
150
+ export async function resolveToken(options?: {
151
+ baseUrl?: string;
152
+ token?: string;
153
+ }): Promise<string> {
154
+ // 1. Check for explicit branch token override
155
+ if (process.env.TINYBIRD_BRANCH_TOKEN) {
156
+ return process.env.TINYBIRD_BRANCH_TOKEN;
157
+ }
158
+
159
+ // Get the configured token (workspace token)
160
+ const configuredToken = options?.token ?? process.env.TINYBIRD_TOKEN;
161
+
162
+ if (!configuredToken) {
163
+ throw new Error(
164
+ "TINYBIRD_TOKEN is not configured. Set it in your environment or pass it to createTinybirdClient()."
165
+ );
166
+ }
167
+
168
+ // 2. Check if we're in a preview environment
169
+ if (isPreviewEnvironment()) {
170
+ const branchName = getPreviewBranchName();
171
+
172
+ if (branchName) {
173
+ // Check cache first
174
+ if (cachedBranchToken && cachedBranchName === branchName) {
175
+ return cachedBranchToken;
176
+ }
177
+
178
+ const baseUrl = options?.baseUrl ?? process.env.TINYBIRD_URL ?? "https://api.tinybird.co";
179
+
180
+ // Fetch branch token
181
+ const branchToken = await fetchBranchToken(baseUrl, configuredToken, branchName);
182
+
183
+ if (branchToken) {
184
+ // Cache for subsequent calls
185
+ cachedBranchToken = branchToken;
186
+ cachedBranchName = branchName;
187
+ return branchToken;
188
+ }
189
+
190
+ // Branch doesn't exist - fall back to workspace token
191
+ // This allows the app to still work, just using main workspace
192
+ console.warn(
193
+ `[tinybird] Preview branch "${branchName}" not found. ` +
194
+ `Run "tinybird preview" to create it. Falling back to workspace token.`
195
+ );
196
+ }
197
+ }
198
+
199
+ // 3. Fall back to configured token
200
+ return configuredToken;
201
+ }
202
+
203
+ /**
204
+ * Clear the cached branch token
205
+ * Useful for testing or when switching branches
206
+ */
207
+ export function clearTokenCache(): void {
208
+ cachedBranchToken = null;
209
+ cachedBranchName = null;
210
+ }
package/src/index.ts CHANGED
@@ -213,3 +213,11 @@ export type {
213
213
  TypedPipeEndpoint,
214
214
  TypedDatasourceIngest,
215
215
  } from "./client/types.js";
216
+
217
+ // ============ Preview Environment ============
218
+ export {
219
+ isPreviewEnvironment,
220
+ getPreviewBranchName,
221
+ resolveToken,
222
+ clearTokenCache,
223
+ } from "./client/preview.js";
@@ -222,11 +222,17 @@ function buildProjectClient<
222
222
 
223
223
  const getClient = async (): Promise<TinybirdClient> => {
224
224
  if (!_client) {
225
- // Dynamic import to avoid circular dependencies
225
+ // Dynamic imports to avoid circular dependencies
226
226
  const { createClient } = await import("../client/base.js");
227
+ const { resolveToken } = await import("../client/preview.js");
228
+
229
+ // Resolve the token (handles preview environment detection)
230
+ const baseUrl = options?.baseUrl ?? process.env.TINYBIRD_URL ?? "https://api.tinybird.co";
231
+ const token = await resolveToken({ baseUrl, token: options?.token });
232
+
227
233
  _client = createClient({
228
- baseUrl: options?.baseUrl ?? process.env.TINYBIRD_URL ?? "https://api.tinybird.co",
229
- token: options?.token ?? process.env.TINYBIRD_TOKEN!,
234
+ baseUrl,
235
+ token,
230
236
  devMode: process.env.NODE_ENV === "development",
231
237
  });
232
238
  }