@tinybirdco/sdk 0.0.36 → 0.0.38

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 (59) hide show
  1. package/README.md +50 -1
  2. package/dist/api/api.d.ts +66 -1
  3. package/dist/api/api.d.ts.map +1 -1
  4. package/dist/api/api.js +114 -0
  5. package/dist/api/api.js.map +1 -1
  6. package/dist/api/api.test.js +192 -0
  7. package/dist/api/api.test.js.map +1 -1
  8. package/dist/api/tokens.d.ts +79 -0
  9. package/dist/api/tokens.d.ts.map +1 -0
  10. package/dist/api/tokens.js +80 -0
  11. package/dist/api/tokens.js.map +1 -0
  12. package/dist/api/tokens.test.d.ts +2 -0
  13. package/dist/api/tokens.test.d.ts.map +1 -0
  14. package/dist/api/tokens.test.js +209 -0
  15. package/dist/api/tokens.test.js.map +1 -0
  16. package/dist/cli/commands/init.d.ts +2 -0
  17. package/dist/cli/commands/init.d.ts.map +1 -1
  18. package/dist/cli/commands/init.js +2 -3
  19. package/dist/cli/commands/init.js.map +1 -1
  20. package/dist/client/base.d.ts +29 -1
  21. package/dist/client/base.d.ts.map +1 -1
  22. package/dist/client/base.js +44 -0
  23. package/dist/client/base.js.map +1 -1
  24. package/dist/client/base.test.js +25 -0
  25. package/dist/client/base.test.js.map +1 -1
  26. package/dist/client/tokens.d.ts +42 -0
  27. package/dist/client/tokens.d.ts.map +1 -0
  28. package/dist/client/tokens.js +67 -0
  29. package/dist/client/tokens.js.map +1 -0
  30. package/dist/client/tokens.test.d.ts +2 -0
  31. package/dist/client/tokens.test.d.ts.map +1 -0
  32. package/dist/client/tokens.test.js +79 -0
  33. package/dist/client/tokens.test.js.map +1 -0
  34. package/dist/client/types.d.ts +49 -0
  35. package/dist/client/types.d.ts.map +1 -1
  36. package/dist/index.d.ts +4 -2
  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 +40 -12
  41. package/dist/schema/project.d.ts.map +1 -1
  42. package/dist/schema/project.js +67 -13
  43. package/dist/schema/project.js.map +1 -1
  44. package/dist/schema/project.test.js +63 -10
  45. package/dist/schema/project.test.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/api/api.test.ts +265 -0
  48. package/src/api/api.ts +196 -0
  49. package/src/api/tokens.test.ts +253 -0
  50. package/src/api/tokens.ts +169 -0
  51. package/src/cli/commands/init.ts +5 -3
  52. package/src/client/base.test.ts +32 -0
  53. package/src/client/base.ts +60 -0
  54. package/src/client/tokens.test.ts +103 -0
  55. package/src/client/tokens.ts +69 -0
  56. package/src/client/types.ts +54 -0
  57. package/src/index.ts +20 -0
  58. package/src/schema/project.test.ts +75 -10
  59. package/src/schema/project.ts +134 -27
package/src/api/api.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { createTinybirdFetcher, type TinybirdFetch } from "./fetcher.js";
2
2
  import type {
3
+ AppendOptions,
4
+ AppendResult,
3
5
  IngestOptions,
4
6
  IngestResult,
5
7
  QueryOptions,
@@ -41,6 +43,59 @@ export interface TinybirdApiIngestOptions extends IngestOptions {
41
43
  token?: string;
42
44
  }
43
45
 
46
+ export interface TinybirdApiAppendOptions extends Omit<AppendOptions, 'url' | 'file'> {
47
+ /** Optional token override for this request */
48
+ token?: string;
49
+ }
50
+
51
+ /**
52
+ * Scope definition for token creation APIs
53
+ */
54
+ export interface TinybirdApiTokenScope {
55
+ type: string;
56
+ resource?: string;
57
+ fixed_params?: Record<string, string | number | boolean>;
58
+ filter?: string;
59
+ }
60
+
61
+ /**
62
+ * Request body for creating Tinybird tokens.
63
+ * Supports JWT-style scopes and static-token scope strings.
64
+ */
65
+ export interface TinybirdApiCreateTokenRequest {
66
+ /** Token name/identifier */
67
+ name: string;
68
+ /** JWT-style scopes */
69
+ scopes?: TinybirdApiTokenScope[];
70
+ /** Static-token scope strings */
71
+ scope?: string | string[];
72
+ /** Optional rate-limiting config */
73
+ limits?: {
74
+ rps?: number;
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Options for token creation requests
80
+ */
81
+ export interface TinybirdApiCreateTokenOptions {
82
+ /** Optional expiration time for JWT tokens */
83
+ expirationTime?: number;
84
+ /** Optional token override for this request */
85
+ token?: string;
86
+ /** Request timeout in milliseconds */
87
+ timeout?: number;
88
+ /** AbortController signal for cancellation */
89
+ signal?: AbortSignal;
90
+ }
91
+
92
+ /**
93
+ * Result of token creation
94
+ */
95
+ export interface TinybirdApiCreateTokenResult {
96
+ token: string;
97
+ }
98
+
44
99
  /**
45
100
  * Error thrown by TinybirdApi when a response is not OK
46
101
  */
@@ -242,6 +297,147 @@ export class TinybirdApi {
242
297
  return (await response.json()) as QueryResult<T>;
243
298
  }
244
299
 
300
+ /**
301
+ * Append data to a datasource from a URL or local file
302
+ */
303
+ async appendDatasource(
304
+ datasourceName: string,
305
+ options: AppendOptions,
306
+ apiOptions: TinybirdApiAppendOptions = {}
307
+ ): Promise<AppendResult> {
308
+ const { url: sourceUrl, file: filePath } = options;
309
+
310
+ if (!sourceUrl && !filePath) {
311
+ throw new Error("Either 'url' or 'file' must be provided in options");
312
+ }
313
+
314
+ if (sourceUrl && filePath) {
315
+ throw new Error("Only one of 'url' or 'file' can be provided, not both");
316
+ }
317
+
318
+ const url = new URL("/v0/datasources", `${this.baseUrl}/`);
319
+ url.searchParams.set("name", datasourceName);
320
+ url.searchParams.set("mode", "append");
321
+
322
+ // Auto-detect format from file/url extension
323
+ const format = this.detectFormat(sourceUrl ?? filePath!);
324
+ if (format) {
325
+ url.searchParams.set("format", format);
326
+ }
327
+
328
+ // Add CSV dialect options if applicable
329
+ if (options.csvDialect) {
330
+ if (options.csvDialect.delimiter) {
331
+ url.searchParams.set("dialect_delimiter", options.csvDialect.delimiter);
332
+ }
333
+ if (options.csvDialect.newLine) {
334
+ url.searchParams.set("dialect_new_line", options.csvDialect.newLine);
335
+ }
336
+ if (options.csvDialect.escapeChar) {
337
+ url.searchParams.set("dialect_escapechar", options.csvDialect.escapeChar);
338
+ }
339
+ }
340
+
341
+ let response: Response;
342
+
343
+ if (sourceUrl) {
344
+ // Remote URL: send as form-urlencoded with url parameter
345
+ response = await this.request(url.toString(), {
346
+ method: "POST",
347
+ token: apiOptions.token,
348
+ headers: {
349
+ "Content-Type": "application/x-www-form-urlencoded",
350
+ },
351
+ body: `url=${encodeURIComponent(sourceUrl)}`,
352
+ signal: this.createAbortSignal(options.timeout ?? apiOptions.timeout, options.signal ?? apiOptions.signal),
353
+ });
354
+ } else {
355
+ // Local file: send as multipart form data
356
+ const formData = await this.createFileFormData(filePath!);
357
+ response = await this.request(url.toString(), {
358
+ method: "POST",
359
+ token: apiOptions.token,
360
+ body: formData,
361
+ signal: this.createAbortSignal(options.timeout ?? apiOptions.timeout, options.signal ?? apiOptions.signal),
362
+ });
363
+ }
364
+
365
+ if (!response.ok) {
366
+ await this.handleErrorResponse(response);
367
+ }
368
+
369
+ return (await response.json()) as AppendResult;
370
+ }
371
+
372
+ /**
373
+ * Create a token using Tinybird Token API.
374
+ * Supports both static and JWT token payloads.
375
+ */
376
+ async createToken(
377
+ body: TinybirdApiCreateTokenRequest,
378
+ options: TinybirdApiCreateTokenOptions = {}
379
+ ): Promise<TinybirdApiCreateTokenResult> {
380
+ const url = new URL("/v0/tokens/", `${this.baseUrl}/`);
381
+
382
+ if (options.expirationTime !== undefined) {
383
+ url.searchParams.set("expiration_time", String(options.expirationTime));
384
+ }
385
+
386
+ const response = await this.request(url.toString(), {
387
+ method: "POST",
388
+ token: options.token,
389
+ headers: {
390
+ "Content-Type": "application/json",
391
+ },
392
+ body: JSON.stringify(body),
393
+ signal: this.createAbortSignal(options.timeout, options.signal),
394
+ });
395
+
396
+ if (!response.ok) {
397
+ await this.handleErrorResponse(response);
398
+ }
399
+
400
+ return (await response.json()) as TinybirdApiCreateTokenResult;
401
+ }
402
+
403
+ /**
404
+ * Detect format from file path or URL extension
405
+ */
406
+ private detectFormat(source: string): "csv" | "ndjson" | "parquet" | undefined {
407
+ // Remove query string if present
408
+ const pathOnly = source.split("?")[0];
409
+ const extension = pathOnly.split(".").pop()?.toLowerCase();
410
+
411
+ switch (extension) {
412
+ case "csv":
413
+ return "csv";
414
+ case "ndjson":
415
+ case "jsonl":
416
+ return "ndjson";
417
+ case "parquet":
418
+ return "parquet";
419
+ default:
420
+ return undefined;
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Create FormData for file upload
426
+ */
427
+ private async createFileFormData(filePath: string): Promise<FormData> {
428
+ // Dynamic import for Node.js fs module (browser-safe)
429
+ const fs = await import("node:fs");
430
+ const path = await import("node:path");
431
+
432
+ const fileContent = await fs.promises.readFile(filePath);
433
+ const fileName = path.basename(filePath);
434
+
435
+ const formData = new FormData();
436
+ formData.append("csv", new Blob([fileContent]), fileName);
437
+
438
+ return formData;
439
+ }
440
+
245
441
  private createAbortSignal(
246
442
  timeout?: number,
247
443
  existingSignal?: AbortSignal
@@ -0,0 +1,253 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import {
3
+ createJWT,
4
+ TokenApiError,
5
+ type TokenApiConfig,
6
+ } from "./tokens.js";
7
+
8
+ // Mock fetch globally
9
+ const mockFetch = vi.fn();
10
+ global.fetch = mockFetch;
11
+
12
+ function expectFromParam(url: string) {
13
+ const parsed = new URL(url);
14
+ expect(parsed.searchParams.get("from")).toBe("ts-sdk");
15
+ return parsed;
16
+ }
17
+
18
+ describe("Token API client", () => {
19
+ const config: TokenApiConfig = {
20
+ baseUrl: "https://api.tinybird.co",
21
+ token: "p.admin-token",
22
+ };
23
+
24
+ beforeEach(() => {
25
+ mockFetch.mockReset();
26
+ });
27
+
28
+ describe("createJWT", () => {
29
+ it("creates a JWT token with scopes", async () => {
30
+ const mockResponse = { token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test" };
31
+
32
+ mockFetch.mockResolvedValueOnce({
33
+ ok: true,
34
+ json: () => Promise.resolve(mockResponse),
35
+ });
36
+
37
+ const result = await createJWT(config, {
38
+ name: "user_token",
39
+ expiresAt: 1700000000,
40
+ scopes: [
41
+ {
42
+ type: "PIPES:READ",
43
+ resource: "analytics_pipe",
44
+ fixed_params: { user_id: 123 },
45
+ },
46
+ ],
47
+ });
48
+
49
+ expect(result.token).toBe(mockResponse.token);
50
+
51
+ const [url, init] = mockFetch.mock.calls[0];
52
+ const parsed = expectFromParam(url);
53
+ expect(parsed.pathname).toBe("/v0/tokens/");
54
+ expect(parsed.searchParams.get("expiration_time")).toBe("1700000000");
55
+ expect(init.method).toBe("POST");
56
+ const headers = new Headers(init.headers);
57
+ expect(headers.get("Authorization")).toBe("Bearer p.admin-token");
58
+ expect(headers.get("Content-Type")).toBe("application/json");
59
+
60
+ const body = JSON.parse(init.body);
61
+ expect(body.name).toBe("user_token");
62
+ expect(body.scopes).toHaveLength(1);
63
+ expect(body.scopes[0].type).toBe("PIPES:READ");
64
+ expect(body.scopes[0].resource).toBe("analytics_pipe");
65
+ expect(body.scopes[0].fixed_params).toEqual({ user_id: 123 });
66
+ });
67
+
68
+ it("converts Date to Unix timestamp", async () => {
69
+ mockFetch.mockResolvedValueOnce({
70
+ ok: true,
71
+ json: () => Promise.resolve({ token: "jwt-token" }),
72
+ });
73
+
74
+ const expirationDate = new Date("2024-01-01T00:00:00Z");
75
+ await createJWT(config, {
76
+ name: "test",
77
+ expiresAt: expirationDate,
78
+ scopes: [{ type: "PIPES:READ", resource: "pipe" }],
79
+ });
80
+
81
+ const [url] = mockFetch.mock.calls[0];
82
+ const parsed = new URL(url);
83
+ expect(parsed.searchParams.get("expiration_time")).toBe("1704067200");
84
+ });
85
+
86
+ it("converts ISO string to Unix timestamp", async () => {
87
+ mockFetch.mockResolvedValueOnce({
88
+ ok: true,
89
+ json: () => Promise.resolve({ token: "jwt-token" }),
90
+ });
91
+
92
+ await createJWT(config, {
93
+ name: "test",
94
+ expiresAt: "2024-01-01T00:00:00Z",
95
+ scopes: [{ type: "PIPES:READ", resource: "pipe" }],
96
+ });
97
+
98
+ const [url] = mockFetch.mock.calls[0];
99
+ const parsed = new URL(url);
100
+ expect(parsed.searchParams.get("expiration_time")).toBe("1704067200");
101
+ });
102
+
103
+ it("includes limits when provided", async () => {
104
+ mockFetch.mockResolvedValueOnce({
105
+ ok: true,
106
+ json: () => Promise.resolve({ token: "jwt-token" }),
107
+ });
108
+
109
+ await createJWT(config, {
110
+ name: "rate_limited_token",
111
+ expiresAt: 1700000000,
112
+ scopes: [{ type: "PIPES:READ", resource: "pipe" }],
113
+ limits: { rps: 100 },
114
+ });
115
+
116
+ const [, init] = mockFetch.mock.calls[0];
117
+ const body = JSON.parse(init.body);
118
+ expect(body.limits).toEqual({ rps: 100 });
119
+ });
120
+
121
+ it("uses custom fetch implementation when provided", async () => {
122
+ const customFetch = vi.fn().mockResolvedValueOnce({
123
+ ok: true,
124
+ json: () => Promise.resolve({ token: "jwt-token" }),
125
+ });
126
+
127
+ await createJWT(
128
+ {
129
+ ...config,
130
+ fetch: customFetch as typeof fetch,
131
+ },
132
+ {
133
+ name: "custom_fetch_token",
134
+ expiresAt: 1700000000,
135
+ scopes: [{ type: "PIPES:READ", resource: "pipe" }],
136
+ }
137
+ );
138
+
139
+ expect(customFetch).toHaveBeenCalledTimes(1);
140
+ expect(mockFetch).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it("supports datasource scope with filter", async () => {
144
+ mockFetch.mockResolvedValueOnce({
145
+ ok: true,
146
+ json: () => Promise.resolve({ token: "jwt-token" }),
147
+ });
148
+
149
+ await createJWT(config, {
150
+ name: "filtered_token",
151
+ expiresAt: 1700000000,
152
+ scopes: [
153
+ {
154
+ type: "DATASOURCES:READ",
155
+ resource: "events",
156
+ filter: "org_id = 'acme'",
157
+ },
158
+ ],
159
+ });
160
+
161
+ const [, init] = mockFetch.mock.calls[0];
162
+ const body = JSON.parse(init.body);
163
+ expect(body.scopes[0].filter).toBe("org_id = 'acme'");
164
+ });
165
+
166
+ it("throws TokenApiError on 403 with helpful message", async () => {
167
+ mockFetch.mockResolvedValueOnce({
168
+ ok: false,
169
+ status: 403,
170
+ statusText: "Forbidden",
171
+ text: () => Promise.resolve("Insufficient permissions"),
172
+ });
173
+
174
+ await expect(
175
+ createJWT(config, {
176
+ name: "test",
177
+ expiresAt: 1700000000,
178
+ scopes: [],
179
+ })
180
+ ).rejects.toThrow(TokenApiError);
181
+
182
+ mockFetch.mockResolvedValueOnce({
183
+ ok: false,
184
+ status: 403,
185
+ statusText: "Forbidden",
186
+ text: () => Promise.resolve("Insufficient permissions"),
187
+ });
188
+
189
+ try {
190
+ await createJWT(config, {
191
+ name: "test",
192
+ expiresAt: 1700000000,
193
+ scopes: [],
194
+ });
195
+ } catch (error) {
196
+ expect((error as TokenApiError).status).toBe(403);
197
+ expect((error as TokenApiError).message).toContain("TOKENS or ADMIN scope");
198
+ }
199
+ });
200
+
201
+ it("throws TokenApiError on 400 with validation error", async () => {
202
+ mockFetch.mockResolvedValueOnce({
203
+ ok: false,
204
+ status: 400,
205
+ statusText: "Bad Request",
206
+ text: () => Promise.resolve('{"error": "Invalid scope type"}'),
207
+ });
208
+
209
+ await expect(
210
+ createJWT(config, {
211
+ name: "test",
212
+ expiresAt: 1700000000,
213
+ scopes: [],
214
+ })
215
+ ).rejects.toThrow(TokenApiError);
216
+
217
+ mockFetch.mockResolvedValueOnce({
218
+ ok: false,
219
+ status: 400,
220
+ statusText: "Bad Request",
221
+ text: () => Promise.resolve('{"error": "Invalid scope type"}'),
222
+ });
223
+
224
+ try {
225
+ await createJWT(config, {
226
+ name: "test",
227
+ expiresAt: 1700000000,
228
+ scopes: [],
229
+ });
230
+ } catch (error) {
231
+ expect((error as TokenApiError).status).toBe(400);
232
+ expect((error as TokenApiError).message).toContain("Invalid scope type");
233
+ }
234
+ });
235
+
236
+ it("does not include limits if not provided", async () => {
237
+ mockFetch.mockResolvedValueOnce({
238
+ ok: true,
239
+ json: () => Promise.resolve({ token: "jwt-token" }),
240
+ });
241
+
242
+ await createJWT(config, {
243
+ name: "simple_token",
244
+ expiresAt: 1700000000,
245
+ scopes: [{ type: "PIPES:READ", resource: "pipe" }],
246
+ });
247
+
248
+ const [, init] = mockFetch.mock.calls[0];
249
+ const body = JSON.parse(init.body);
250
+ expect(body.limits).toBeUndefined();
251
+ });
252
+ });
253
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Tinybird Token API client
3
+ */
4
+
5
+ import {
6
+ createTinybirdApi,
7
+ TinybirdApiError,
8
+ } from "./api.js";
9
+
10
+ /**
11
+ * API configuration for token operations.
12
+ * Requires a token with TOKENS or ADMIN scope.
13
+ */
14
+ export interface TokenApiConfig {
15
+ /** Tinybird API base URL */
16
+ baseUrl: string;
17
+ /** Workspace token with TOKENS or ADMIN scope */
18
+ token: string;
19
+ /** Custom fetch implementation (optional, defaults to global fetch) */
20
+ fetch?: typeof fetch;
21
+ /** Default timeout in milliseconds (optional) */
22
+ timeout?: number;
23
+ }
24
+
25
+ /**
26
+ * Error thrown by token API operations
27
+ */
28
+ export class TokenApiError extends Error {
29
+ constructor(
30
+ message: string,
31
+ public readonly status: number,
32
+ public readonly body?: unknown
33
+ ) {
34
+ super(message);
35
+ this.name = "TokenApiError";
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Scope type for JWT tokens
41
+ */
42
+ export type JWTScopeType =
43
+ | "PIPES:READ"
44
+ | "DATASOURCES:READ"
45
+ | "DATASOURCES:APPEND";
46
+
47
+ /**
48
+ * A scope definition for JWT tokens
49
+ */
50
+ export interface JWTScope {
51
+ /** The type of access being granted */
52
+ type: JWTScopeType;
53
+ /** The resource name (pipe or datasource) */
54
+ resource: string;
55
+ /** Fixed parameters embedded in the JWT (for pipes) */
56
+ fixed_params?: Record<string, string | number | boolean>;
57
+ /** SQL filter expression (for datasources) */
58
+ filter?: string;
59
+ }
60
+
61
+ /**
62
+ * Rate limiting configuration for JWT tokens
63
+ */
64
+ export interface JWTLimits {
65
+ /** Requests per second limit */
66
+ rps?: number;
67
+ }
68
+
69
+ /**
70
+ * Options for creating a JWT token
71
+ */
72
+ export interface CreateJWTOptions {
73
+ /** Token name/identifier */
74
+ name: string;
75
+ /** Expiration time as Date, Unix timestamp (number), or ISO string */
76
+ expiresAt: Date | number | string;
77
+ /** Array of scopes defining access permissions */
78
+ scopes: JWTScope[];
79
+ /** Optional rate limiting configuration */
80
+ limits?: JWTLimits;
81
+ }
82
+
83
+ /**
84
+ * Result of creating a JWT token
85
+ */
86
+ export interface CreateJWTResult {
87
+ /** The generated JWT token string */
88
+ token: string;
89
+ }
90
+
91
+ /**
92
+ * Request body for creating a JWT token
93
+ */
94
+ interface CreateJWTRequestBody {
95
+ name: string;
96
+ scopes: JWTScope[];
97
+ limits?: JWTLimits;
98
+ }
99
+
100
+ /**
101
+ * Convert expiration input to Unix timestamp
102
+ */
103
+ function toUnixTimestamp(expiresAt: Date | number | string): number {
104
+ if (typeof expiresAt === "number") {
105
+ return expiresAt;
106
+ }
107
+ if (expiresAt instanceof Date) {
108
+ return Math.floor(expiresAt.getTime() / 1000);
109
+ }
110
+ return Math.floor(new Date(expiresAt).getTime() / 1000);
111
+ }
112
+
113
+ /**
114
+ * Create a JWT token
115
+ * POST /v0/tokens/?expiration_time={unix_timestamp}
116
+ *
117
+ * @param config - API configuration (requires TOKENS or ADMIN scope)
118
+ * @param options - JWT creation options
119
+ * @returns The created JWT token
120
+ */
121
+ export async function createJWT(
122
+ config: TokenApiConfig,
123
+ options: CreateJWTOptions
124
+ ): Promise<CreateJWTResult> {
125
+ const expirationTime = toUnixTimestamp(options.expiresAt);
126
+
127
+ const body: CreateJWTRequestBody = {
128
+ name: options.name,
129
+ scopes: options.scopes,
130
+ };
131
+
132
+ if (options.limits) {
133
+ body.limits = options.limits;
134
+ }
135
+
136
+ const api = createTinybirdApi({
137
+ baseUrl: config.baseUrl,
138
+ token: config.token,
139
+ fetch: config.fetch,
140
+ timeout: config.timeout,
141
+ });
142
+
143
+ try {
144
+ const result = await api.createToken(body, {
145
+ expirationTime,
146
+ });
147
+ return { token: result.token };
148
+ } catch (error) {
149
+ if (!(error instanceof TinybirdApiError)) {
150
+ throw error;
151
+ }
152
+
153
+ const responseBody = error.responseBody ?? error.message;
154
+ let message: string;
155
+
156
+ if (error.statusCode === 403) {
157
+ message =
158
+ `Permission denied creating JWT token. ` +
159
+ `Make sure the token has TOKENS or ADMIN scope. ` +
160
+ `API response: ${responseBody}`;
161
+ } else if (error.statusCode === 400) {
162
+ message = `Invalid JWT token request: ${responseBody}`;
163
+ } else {
164
+ message = `Failed to create JWT token: ${error.statusCode}. API response: ${responseBody}`;
165
+ }
166
+
167
+ throw new TokenApiError(message, error.statusCode, responseBody);
168
+ }
169
+ }
@@ -22,7 +22,6 @@ import { getGitRoot } from "../git.js";
22
22
  import { fetchAllResources } from "../../api/resources.js";
23
23
  import { generateCombinedFile } from "../../codegen/index.js";
24
24
  import { execSync } from "child_process";
25
- import { setTimeout as sleep } from "node:timers/promises";
26
25
  import {
27
26
  detectPackageManager,
28
27
  getPackageManagerAddCmd,
@@ -313,6 +312,8 @@ export interface InitOptions {
313
312
  includeCdWorkflow?: boolean;
314
313
  /** Git provider for workflow templates */
315
314
  workflowProvider?: "github" | "gitlab";
315
+ /** Skip auto-installing @tinybirdco/sdk dependency */
316
+ skipDependencyInstall?: boolean;
316
317
  }
317
318
 
318
319
  /**
@@ -415,6 +416,8 @@ export async function runInit(options: InitOptions = {}): Promise<InitResult> {
415
416
  const cwd = options.cwd ?? process.cwd();
416
417
  const force = options.force ?? false;
417
418
  const skipLogin = options.skipLogin ?? false;
419
+ const skipDependencyInstall =
420
+ options.skipDependencyInstall ?? Boolean(process.env.VITEST);
418
421
 
419
422
  const created: string[] = [];
420
423
  const skipped: string[] = [];
@@ -739,14 +742,13 @@ export async function runInit(options: InitOptions = {}): Promise<InitResult> {
739
742
  }
740
743
 
741
744
  // Install @tinybirdco/sdk if not already installed
742
- if (!hasTinybirdSdkDependency(cwd)) {
745
+ if (!skipDependencyInstall && !hasTinybirdSdkDependency(cwd)) {
743
746
  const s = p.spinner();
744
747
  s.start("Installing dependencies");
745
748
  const packageManager = detectPackageManager(cwd);
746
749
  const addCmd = getPackageManagerAddCmd(packageManager);
747
750
  try {
748
751
  execSync(`${addCmd} @tinybirdco/sdk`, { cwd, stdio: "pipe" });
749
- await sleep(1000);
750
752
  s.stop("Installed dependencies");
751
753
  created.push("@tinybirdco/sdk");
752
754
  } catch (error) {