@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.
- package/dist/api/deploy.d.ts +80 -2
- package/dist/api/deploy.d.ts.map +1 -1
- package/dist/api/deploy.js +82 -38
- package/dist/api/deploy.js.map +1 -1
- package/dist/cli/commands/deploy.d.ts +3 -0
- package/dist/cli/commands/deploy.d.ts.map +1 -1
- package/dist/cli/commands/deploy.js +1 -0
- package/dist/cli/commands/deploy.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +24 -2
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/preview.d.ts +65 -0
- package/dist/cli/commands/preview.d.ts.map +1 -0
- package/dist/cli/commands/preview.js +234 -0
- package/dist/cli/commands/preview.js.map +1 -0
- package/dist/cli/commands/preview.test.d.ts +2 -0
- package/dist/cli/commands/preview.test.d.ts.map +1 -0
- package/dist/cli/commands/preview.test.js +36 -0
- package/dist/cli/commands/preview.test.js.map +1 -0
- package/dist/cli/index.js +135 -36
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/output.d.ts +45 -0
- package/dist/cli/output.d.ts.map +1 -1
- package/dist/cli/output.js +73 -1
- package/dist/cli/output.js.map +1 -1
- package/dist/cli/output.test.js +98 -2
- package/dist/cli/output.test.js.map +1 -1
- package/dist/client/preview.d.ts +36 -0
- package/dist/client/preview.d.ts.map +1 -0
- package/dist/client/preview.js +161 -0
- package/dist/client/preview.js.map +1 -0
- package/dist/client/preview.test.d.ts +2 -0
- package/dist/client/preview.test.d.ts.map +1 -0
- package/dist/client/preview.test.js +137 -0
- package/dist/client/preview.test.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/schema/project.d.ts.map +1 -1
- package/dist/schema/project.js +7 -3
- package/dist/schema/project.js.map +1 -1
- package/package.json +1 -1
- package/src/api/deploy.ts +170 -10
- package/src/cli/commands/deploy.ts +4 -1
- package/src/cli/commands/init.ts +24 -2
- package/src/cli/commands/preview.test.ts +42 -0
- package/src/cli/commands/preview.ts +313 -0
- package/src/cli/index.ts +147 -37
- package/src/cli/output.test.ts +116 -1
- package/src/cli/output.ts +96 -1
- package/src/client/preview.test.ts +168 -0
- package/src/client/preview.ts +210 -0
- package/src/index.ts +8 -0
- package/src/schema/project.ts +9 -3
package/src/cli/output.test.ts
CHANGED
|
@@ -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).
|
|
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
|
-
|
|
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";
|
package/src/schema/project.ts
CHANGED
|
@@ -222,11 +222,17 @@ function buildProjectClient<
|
|
|
222
222
|
|
|
223
223
|
const getClient = async (): Promise<TinybirdClient> => {
|
|
224
224
|
if (!_client) {
|
|
225
|
-
// Dynamic
|
|
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
|
|
229
|
-
token
|
|
234
|
+
baseUrl,
|
|
235
|
+
token,
|
|
230
236
|
devMode: process.env.NODE_ENV === "development",
|
|
231
237
|
});
|
|
232
238
|
}
|