@vercel/sandbox 1.1.4 → 1.1.6

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 (86) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +14 -8
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +12 -0
  5. package/__mocks__/picocolors.ts +13 -0
  6. package/dist/api-client/api-client.d.ts +2 -2
  7. package/dist/api-client/api-client.js +3 -1
  8. package/dist/api-client/api-client.js.map +1 -1
  9. package/dist/api-client/api-error.d.ts +4 -1
  10. package/dist/api-client/api-error.js +3 -1
  11. package/dist/api-client/api-error.js.map +1 -1
  12. package/dist/api-client/base-client.js +13 -0
  13. package/dist/api-client/base-client.js.map +1 -1
  14. package/dist/api-client/validators.d.ts +10 -10
  15. package/dist/api-client/with-retry.js +1 -1
  16. package/dist/api-client/with-retry.js.map +1 -1
  17. package/dist/auth/api.d.ts +6 -0
  18. package/dist/auth/api.js +28 -0
  19. package/dist/auth/api.js.map +1 -0
  20. package/dist/auth/error.d.ts +11 -0
  21. package/dist/auth/error.js +12 -0
  22. package/dist/auth/error.js.map +1 -0
  23. package/dist/auth/file.d.ts +22 -0
  24. package/dist/auth/file.js +66 -0
  25. package/dist/auth/file.js.map +1 -0
  26. package/dist/auth/index.d.ts +6 -0
  27. package/dist/auth/index.js +27 -0
  28. package/dist/auth/index.js.map +1 -0
  29. package/dist/auth/linked-project.d.ts +10 -0
  30. package/dist/auth/linked-project.js +69 -0
  31. package/dist/auth/linked-project.js.map +1 -0
  32. package/dist/auth/oauth.d.ts +131 -0
  33. package/dist/auth/oauth.js +269 -0
  34. package/dist/auth/oauth.js.map +1 -0
  35. package/dist/auth/poll-for-token.d.ts +20 -0
  36. package/dist/auth/poll-for-token.js +66 -0
  37. package/dist/auth/poll-for-token.js.map +1 -0
  38. package/dist/auth/project.d.ts +40 -0
  39. package/dist/auth/project.js +80 -0
  40. package/dist/auth/project.js.map +1 -0
  41. package/dist/auth/zod.d.ts +5 -0
  42. package/dist/auth/zod.js +20 -0
  43. package/dist/auth/zod.js.map +1 -0
  44. package/dist/command.d.ts +7 -0
  45. package/dist/command.js +39 -7
  46. package/dist/command.js.map +1 -1
  47. package/dist/sandbox.js +1 -1
  48. package/dist/sandbox.js.map +1 -1
  49. package/dist/utils/dev-credentials.d.ts +37 -0
  50. package/dist/utils/dev-credentials.js +191 -0
  51. package/dist/utils/dev-credentials.js.map +1 -0
  52. package/dist/utils/get-credentials.d.ts +16 -0
  53. package/dist/utils/get-credentials.js +66 -7
  54. package/dist/utils/get-credentials.js.map +1 -1
  55. package/dist/utils/log.d.ts +2 -0
  56. package/dist/utils/log.js +24 -0
  57. package/dist/utils/log.js.map +1 -0
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/package.json +4 -1
  61. package/src/api-client/api-client.test.ts +176 -0
  62. package/src/api-client/api-client.ts +7 -1
  63. package/src/api-client/api-error.ts +6 -1
  64. package/src/api-client/base-client.ts +15 -0
  65. package/src/api-client/with-retry.ts +1 -1
  66. package/src/auth/api.ts +31 -0
  67. package/src/auth/error.ts +8 -0
  68. package/src/auth/file.ts +69 -0
  69. package/src/auth/index.ts +9 -0
  70. package/src/auth/infer-scope.test.ts +178 -0
  71. package/src/auth/linked-project.test.ts +86 -0
  72. package/src/auth/linked-project.ts +40 -0
  73. package/src/auth/oauth.ts +333 -0
  74. package/src/auth/poll-for-token.ts +89 -0
  75. package/src/auth/project.ts +92 -0
  76. package/src/auth/zod.ts +16 -0
  77. package/src/command.ts +50 -7
  78. package/src/sandbox.ts +1 -1
  79. package/src/utils/dev-credentials.test.ts +217 -0
  80. package/src/utils/dev-credentials.ts +189 -0
  81. package/src/utils/get-credentials.test.ts +20 -0
  82. package/src/utils/get-credentials.ts +72 -8
  83. package/src/utils/log.ts +20 -0
  84. package/src/version.ts +1 -1
  85. package/test-utils/mock-response.ts +12 -0
  86. package/vitest.config.ts +1 -0
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.write = write;
7
+ exports.code = code;
8
+ const picocolors_1 = __importDefault(require("picocolors"));
9
+ const colors = {
10
+ warn: picocolors_1.default.yellow,
11
+ error: picocolors_1.default.red,
12
+ success: picocolors_1.default.green,
13
+ info: picocolors_1.default.blue,
14
+ };
15
+ const logPrefix = picocolors_1.default.dim("[vercel/sandbox]");
16
+ function write(level, text) {
17
+ text = Array.isArray(text) ? text.join("\n") : text;
18
+ const prefixed = text.replace(/^/gm, `${logPrefix} `);
19
+ console.error(colors[level](prefixed));
20
+ }
21
+ function code(text) {
22
+ return picocolors_1.default.italic(picocolors_1.default.dim("`") + text + picocolors_1.default.dim("`"));
23
+ }
24
+ //# sourceMappingURL=log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log.js","sourceRoot":"","sources":["../../src/utils/log.ts"],"names":[],"mappings":";;;;;AAQA,sBAOC;AAED,oBAEC;AAnBD,4DAA8B;AAC9B,MAAM,MAAM,GAAG;IACb,IAAI,EAAE,oBAAI,CAAC,MAAM;IACjB,KAAK,EAAE,oBAAI,CAAC,GAAG;IACf,OAAO,EAAE,oBAAI,CAAC,KAAK;IACnB,IAAI,EAAE,oBAAI,CAAC,IAAI;CAChB,CAAC;AACF,MAAM,SAAS,GAAG,oBAAI,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;AAC/C,SAAgB,KAAK,CACnB,KAA4C,EAC5C,IAAuB;IAEvB,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACpD,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,SAAS,GAAG,CAAC,CAAC;IACtD,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;AACzC,CAAC;AAED,SAAgB,IAAI,CAAC,IAAY;IAC/B,OAAO,oBAAI,CAAC,MAAM,CAAC,oBAAI,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,GAAG,oBAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;AAC3D,CAAC"}
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "1.1.4";
1
+ export declare const VERSION = "1.1.6";
package/dist/version.js CHANGED
@@ -2,5 +2,5 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  // Autogenerated by inject-version.ts
5
- exports.VERSION = "1.1.4";
5
+ exports.VERSION = "1.1.6";
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/sandbox",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,8 +13,10 @@
13
13
  "async-retry": "1.3.3",
14
14
  "jsonlines": "0.1.1",
15
15
  "ms": "2.1.3",
16
+ "picocolors": "^1.1.1",
16
17
  "tar-stream": "3.1.7",
17
18
  "undici": "^7.16.0",
19
+ "xdg-app-paths": "5.1.0",
18
20
  "zod": "3.24.4"
19
21
  },
20
22
  "devDependencies": {
@@ -24,6 +26,7 @@
24
26
  "@types/node": "22.15.12",
25
27
  "@types/tar-stream": "3.1.4",
26
28
  "dotenv": "16.5.0",
29
+ "factoree": "^0.1.2",
27
30
  "typedoc": "0.28.5",
28
31
  "typescript": "5.8.3",
29
32
  "vitest": "3.2.1"
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { APIClient } from "./api-client";
3
+ import { APIError, StreamError } from "./api-error";
4
+ import { createNdjsonStream } from "../../test-utils/mock-response";
5
+
6
+ describe("APIClient", () => {
7
+ describe("getLogs", () => {
8
+ let client: APIClient;
9
+ let mockFetch: ReturnType<typeof vi.fn>;
10
+
11
+ beforeEach(() => {
12
+ mockFetch = vi.fn();
13
+ client = new APIClient({
14
+ teamId: "team_123",
15
+ token: "1234",
16
+ fetch: mockFetch,
17
+ });
18
+ });
19
+
20
+ it("yields stdout log lines", async () => {
21
+ const logLines = [
22
+ { stream: "stdout", data: "hello" },
23
+ { stream: "stdout", data: "world" },
24
+ ];
25
+
26
+ mockFetch.mockResolvedValue(
27
+ new Response(createNdjsonStream(logLines), {
28
+ headers: { "content-type": "application/x-ndjson" },
29
+ }),
30
+ );
31
+
32
+ const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" });
33
+ const results: Array<{ stream: string; data: string }> = [];
34
+
35
+ for await (const log of logs) {
36
+ results.push(log);
37
+ }
38
+
39
+ expect(results).toHaveLength(2);
40
+ expect(results[0]).toEqual({ stream: "stdout", data: "hello" });
41
+ expect(results[1]).toEqual({ stream: "stdout", data: "world" });
42
+ });
43
+
44
+ it("yields stderr log lines", async () => {
45
+ const logLines = [{ stream: "stderr", data: "Error" }];
46
+
47
+ mockFetch.mockResolvedValue(
48
+ new Response(createNdjsonStream(logLines), {
49
+ headers: { "content-type": "application/x-ndjson" },
50
+ }),
51
+ );
52
+
53
+ const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" });
54
+ const results: Array<{ stream: string; data: string }> = [];
55
+
56
+ for await (const log of logs) {
57
+ results.push(log);
58
+ }
59
+
60
+ expect(results).toHaveLength(1);
61
+ expect(results[0]).toEqual({
62
+ stream: "stderr",
63
+ data: "Error",
64
+ });
65
+ });
66
+
67
+ it("throws APIError when content-type is not application/x-ndjson", async () => {
68
+ mockFetch.mockResolvedValue(
69
+ new Response(null, {
70
+ headers: { "content-type": "application/json" },
71
+ }),
72
+ );
73
+
74
+ const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" });
75
+
76
+ await expect(async () => {
77
+ for await (const _ of logs) {
78
+ }
79
+ }).rejects.toThrow(APIError);
80
+ });
81
+
82
+ it("throws APIError when response body is null", async () => {
83
+ mockFetch.mockResolvedValue(
84
+ new Response(null, {
85
+ headers: { "content-type": "application/x-ndjson" },
86
+ }),
87
+ );
88
+
89
+ const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" });
90
+
91
+ await expect(async () => {
92
+ for await (const _ of logs) {
93
+ }
94
+ }).rejects.toThrow(APIError);
95
+ });
96
+
97
+ it("throws StreamError when error log line is received", async () => {
98
+ const logLines = [
99
+ { stream: "stdout", data: "some logs" },
100
+ {
101
+ stream: "error",
102
+ data: {
103
+ code: "sandbox_stream_closed",
104
+ message: "Sandbox stream was closed and is not accepting commands.",
105
+ },
106
+ },
107
+ ];
108
+
109
+ mockFetch.mockResolvedValue(
110
+ new Response(createNdjsonStream(logLines), {
111
+ headers: { "content-type": "application/x-ndjson" },
112
+ }),
113
+ );
114
+
115
+ const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" });
116
+ const results: Array<{ stream: string; data: string }> = [];
117
+
118
+ await expect(async () => {
119
+ for await (const log of logs) {
120
+ results.push(log);
121
+ }
122
+ }).rejects.toThrow(StreamError);
123
+
124
+ expect(results).toHaveLength(1);
125
+ expect(results[0]).toEqual({ stream: "stdout", data: "some logs" });
126
+ });
127
+
128
+ it("includes sandboxId in APIError", async () => {
129
+ mockFetch.mockResolvedValue(
130
+ new Response(null, {
131
+ headers: { "content-type": "application/json" },
132
+ }),
133
+ );
134
+
135
+ const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" });
136
+
137
+ try {
138
+ for await (const _ of logs) {
139
+ }
140
+ expect.fail("Expected APIError to be thrown");
141
+ } catch (err) {
142
+ expect(err).toBeInstanceOf(APIError);
143
+ expect((err as APIError<unknown>).sandboxId).toBe("sbx_123");
144
+ }
145
+ });
146
+
147
+ it("includes sandboxId in StreamError", async () => {
148
+ const logLines = [
149
+ {
150
+ stream: "error",
151
+ data: {
152
+ code: "sandbox_stopped",
153
+ message: "Sandbox has stopped",
154
+ },
155
+ },
156
+ ];
157
+
158
+ mockFetch.mockResolvedValue(
159
+ new Response(createNdjsonStream(logLines), {
160
+ headers: { "content-type": "application/x-ndjson" },
161
+ }),
162
+ );
163
+
164
+ const logs = client.getLogs({ sandboxId: "sbx_123", cmdId: "cmd_456" });
165
+
166
+ try {
167
+ for await (const _ of logs) {
168
+ }
169
+ expect.fail("Expected StreamError to be thrown");
170
+ } catch (err) {
171
+ expect(err).toBeInstanceOf(StreamError);
172
+ expect((err as StreamError).sandboxId).toBe("sbx_123");
173
+ }
174
+ });
175
+ });
176
+ });
@@ -381,12 +381,14 @@ export class APIClient extends BaseClient {
381
381
  if (response.headers.get("content-type") !== "application/x-ndjson") {
382
382
  throw new APIError(response, {
383
383
  message: "Expected a stream of logs",
384
+ sandboxId: params.sandboxId,
384
385
  });
385
386
  }
386
387
 
387
388
  if (response.body === null) {
388
389
  throw new APIError(response, {
389
390
  message: "No response body",
391
+ sandboxId: params.sandboxId,
390
392
  });
391
393
  }
392
394
 
@@ -398,7 +400,11 @@ export class APIClient extends BaseClient {
398
400
  for await (const chunk of jsonlinesStream) {
399
401
  const parsed = LogLine.parse(chunk);
400
402
  if (parsed.stream === "error") {
401
- throw new StreamError(parsed.data.code, parsed.data.message);
403
+ throw new StreamError(
404
+ parsed.data.code,
405
+ parsed.data.message,
406
+ params.sandboxId,
407
+ );
402
408
  }
403
409
  yield parsed;
404
410
  }
@@ -2,6 +2,7 @@ interface Options<ErrorData> {
2
2
  message?: string;
3
3
  json?: ErrorData;
4
4
  text?: string;
5
+ sandboxId?: string;
5
6
  }
6
7
 
7
8
  export class APIError<ErrorData> extends Error {
@@ -9,6 +10,7 @@ export class APIError<ErrorData> extends Error {
9
10
  public message: string;
10
11
  public json?: ErrorData;
11
12
  public text?: string;
13
+ public sandboxId?: string;
12
14
 
13
15
  constructor(response: Response, options?: Options<ErrorData>) {
14
16
  super(response.statusText);
@@ -20,6 +22,7 @@ export class APIError<ErrorData> extends Error {
20
22
  this.message = options?.message ?? "";
21
23
  this.json = options?.json;
22
24
  this.text = options?.text;
25
+ this.sandboxId = options?.sandboxId;
23
26
  }
24
27
  }
25
28
 
@@ -29,11 +32,13 @@ export class APIError<ErrorData> extends Error {
29
32
  */
30
33
  export class StreamError extends Error {
31
34
  public code: string;
35
+ public sandboxId: string;
32
36
 
33
- constructor(code: string, message: string) {
37
+ constructor(code: string, message: string, sandboxId: string) {
34
38
  super(message);
35
39
  this.name = "StreamError";
36
40
  this.code = code;
41
+ this.sandboxId = sandboxId;
37
42
  if (Error.captureStackTrace) {
38
43
  Error.captureStackTrace(this, StreamError);
39
44
  }
@@ -87,6 +87,15 @@ export interface Parsed<Data> {
87
87
  json: Data;
88
88
  }
89
89
 
90
+ /**
91
+ * Extract sandboxId from a sandbox API URL.
92
+ * URLs follow the pattern: /v1/sandboxes/{sandboxId}/...
93
+ */
94
+ function extractSandboxId(url: string): string | undefined {
95
+ const match = url.match(/\/v1\/sandboxes\/([^/?]+)/);
96
+ return match?.[1];
97
+ }
98
+
90
99
  /**
91
100
  * Allows to read the response text and parse it as JSON casting to the given
92
101
  * type. If the response is not ok or cannot be parsed it will return error.
@@ -98,9 +107,12 @@ export async function parse<Data, ErrorData>(
98
107
  validator: ZodType<Data>,
99
108
  response: Response,
100
109
  ): Promise<Parsed<Data> | APIError<ErrorData>> {
110
+ const sandboxId = extractSandboxId(response.url);
111
+
101
112
  const text = await response.text().catch((err) => {
102
113
  return new APIError<ErrorData>(response, {
103
114
  message: `Can't read response text: ${String(err)}`,
115
+ sandboxId,
104
116
  });
105
117
  });
106
118
 
@@ -116,6 +128,7 @@ export async function parse<Data, ErrorData>(
116
128
  return new APIError<ErrorData>(response, {
117
129
  message: `Can't parse JSON: ${String(error)}`,
118
130
  text,
131
+ sandboxId,
119
132
  });
120
133
  }
121
134
 
@@ -124,6 +137,7 @@ export async function parse<Data, ErrorData>(
124
137
  message: `Status code ${response.status} is not ok`,
125
138
  json: json as ErrorData,
126
139
  text,
140
+ sandboxId,
127
141
  });
128
142
  }
129
143
 
@@ -133,6 +147,7 @@ export async function parse<Data, ErrorData>(
133
147
  message: `Response JSON is not valid: ${validated.error}`,
134
148
  json: json as ErrorData,
135
149
  text,
150
+ sandboxId,
136
151
  });
137
152
  }
138
153
 
@@ -1,6 +1,6 @@
1
1
  import type { Options as RetryOptions } from "async-retry";
2
2
  import { APIError } from "./api-error";
3
- import { setTimeout } from "timers/promises";
3
+ import { setTimeout } from "node:timers/promises";
4
4
  import retry from "async-retry";
5
5
 
6
6
  export interface RequestOptions {
@@ -0,0 +1,31 @@
1
+ import { NotOk } from "./error";
2
+
3
+ export async function fetchApi(opts: {
4
+ token: string;
5
+ endpoint: string;
6
+ method?: string;
7
+ body?: string;
8
+ }): Promise<unknown> {
9
+ const x = await fetch(`https://api.vercel.com${opts.endpoint}`, {
10
+ method: opts.method,
11
+ body: opts.body,
12
+ headers: {
13
+ Authorization: `Bearer ${opts.token}`,
14
+ "Content-Type": "application/json",
15
+ },
16
+ });
17
+ if (!x.ok) {
18
+ let message = await x.text();
19
+
20
+ try {
21
+ const { error } = JSON.parse(message);
22
+ message = `${error.code.toUpperCase()}: ${error.message}`;
23
+ } catch {}
24
+
25
+ throw new NotOk({
26
+ responseText: message,
27
+ statusCode: x.status,
28
+ });
29
+ }
30
+ return (await x.json()) as unknown;
31
+ }
@@ -0,0 +1,8 @@
1
+ export class NotOk extends Error {
2
+ name = "NotOk";
3
+ response: { statusCode: number; responseText: string };
4
+ constructor(response: { statusCode: number; responseText: string }) {
5
+ super(`HTTP ${response.statusCode}: ${response.responseText}`);
6
+ this.response = response;
7
+ }
8
+ }
@@ -0,0 +1,69 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import XDGAppPaths from "xdg-app-paths";
5
+ import { z } from "zod";
6
+ import { json } from "./zod";
7
+
8
+ const ZodDate = z.number().transform((seconds) => new Date(seconds * 1000));
9
+
10
+ const AuthFile = z.object({
11
+ token: z.string().min(1).optional(),
12
+ refreshToken: z.string().min(1).optional(),
13
+ expiresAt: ZodDate.optional(),
14
+ });
15
+
16
+ const StoredAuthFile = json.pipe(AuthFile);
17
+
18
+ type AuthFile = z.infer<typeof AuthFile>;
19
+
20
+ // Returns whether a directory exists
21
+ const isDirectory = (path: string): boolean => {
22
+ try {
23
+ return fs.lstatSync(path).isDirectory();
24
+ } catch (_) {
25
+ // We don't care which kind of error occured, it isn't a directory anyway.
26
+ return false;
27
+ }
28
+ };
29
+
30
+ // Returns in which directory the config should be present
31
+ const getGlobalPathConfig = (): string => {
32
+ const vercelDirectories = XDGAppPaths("com.vercel.cli").dataDirs();
33
+
34
+ const possibleConfigPaths = [
35
+ ...vercelDirectories, // latest vercel directory
36
+ path.join(homedir(), ".now"), // legacy config in user's home directory
37
+ ...XDGAppPaths("now").dataDirs(), // legacy XDG directory
38
+ ];
39
+
40
+ // The customPath flag is the preferred location,
41
+ // followed by the vercel directory,
42
+ // followed by the now directory.
43
+ // If none of those exist, use the vercel directory.
44
+ return (
45
+ possibleConfigPaths.find((configPath) => isDirectory(configPath)) ||
46
+ vercelDirectories[0]
47
+ );
48
+ };
49
+
50
+ export const getAuth = () => {
51
+ try {
52
+ const pathname = path.join(getGlobalPathConfig(), "auth.json");
53
+ return StoredAuthFile.parse(fs.readFileSync(pathname, "utf8"));
54
+ } catch {
55
+ return null;
56
+ }
57
+ };
58
+
59
+ export function updateAuthConfig(config: AuthFile): void {
60
+ const pathname = path.join(getGlobalPathConfig(), "auth.json");
61
+ fs.mkdirSync(path.dirname(pathname), { recursive: true });
62
+ const content = {
63
+ token: config.token,
64
+ expiresAt:
65
+ config.expiresAt && Math.round(config.expiresAt.getTime() / 1000),
66
+ refreshToken: config.refreshToken,
67
+ } satisfies z.input<typeof AuthFile>;
68
+ fs.writeFileSync(pathname, JSON.stringify(content) + "\n");
69
+ }
@@ -0,0 +1,9 @@
1
+ // This file can also be imported as `@vercel/sandbox/dist/auth`, which is completely fine.
2
+ // The only valid importer of this would be the CLI as we share the same codebase.
3
+
4
+ export * from "./file";
5
+ export type * from "./file";
6
+ export * from "./oauth";
7
+ export type * from "./oauth";
8
+ export { pollForToken } from "./poll-for-token";
9
+ export { inferScope, selectTeam } from "./project";
@@ -0,0 +1,178 @@
1
+ import { inferScope, selectTeam } from "./project";
2
+ import {
3
+ beforeEach,
4
+ describe,
5
+ test,
6
+ vi,
7
+ Mock,
8
+ expect,
9
+ onTestFinished,
10
+ } from "vitest";
11
+ import { fetchApi } from "./api";
12
+ import { NotOk } from "./error";
13
+ import * as fs from "node:fs/promises";
14
+ import * as path from "node:path";
15
+ import * as os from "node:os";
16
+
17
+ const fetchApiMock = fetchApi as Mock<typeof fetchApi>;
18
+ vi.mock("./api");
19
+
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ });
23
+
24
+ async function getTempDir(): Promise<string> {
25
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "infer-scope-test-"));
26
+ onTestFinished(() => fs.rm(dir, { recursive: true }));
27
+ return dir;
28
+ }
29
+
30
+ describe("selectTeam", () => {
31
+ test("returns the first team", async () => {
32
+ fetchApiMock.mockResolvedValue({
33
+ teams: [{ slug: "one" }, { slug: "two" }],
34
+ });
35
+ const team = await selectTeam("token");
36
+ expect(fetchApiMock).toHaveBeenCalledWith({
37
+ endpoint: "/v2/teams?limit=1",
38
+ token: "token",
39
+ });
40
+ expect(team).toBe("one");
41
+ });
42
+ });
43
+
44
+ describe("inferScope", () => {
45
+ test("uses provided teamId", async () => {
46
+ fetchApiMock.mockResolvedValue({});
47
+ const scope = await inferScope({ teamId: "my-team", token: "token" });
48
+ expect(scope).toEqual({
49
+ created: false,
50
+ projectId: "vercel-sandbox-default-project",
51
+ teamId: "my-team",
52
+ });
53
+ });
54
+
55
+ describe("team creation", () => {
56
+ test("project 404 triggers project creation", async () => {
57
+ fetchApiMock.mockImplementation(async ({ method }) => {
58
+ if (!method || method === "GET") {
59
+ throw new NotOk({ statusCode: 404, responseText: "Not Found" });
60
+ }
61
+ return {};
62
+ });
63
+ const scope = await inferScope({ teamId: "my-team", token: "token" });
64
+ expect(scope).toEqual({
65
+ created: true,
66
+ projectId: "vercel-sandbox-default-project",
67
+ teamId: "my-team",
68
+ });
69
+ });
70
+
71
+ test("non-404 throws", async () => {
72
+ fetchApiMock.mockImplementation(async ({ method }) => {
73
+ if (!method || method === "GET") {
74
+ throw new NotOk({ statusCode: 403, responseText: "Forbidden" });
75
+ }
76
+ return {};
77
+ });
78
+ await expect(
79
+ inferScope({ teamId: "my-team", token: "token" }),
80
+ ).rejects.toThrowError(
81
+ new NotOk({ statusCode: 403, responseText: "Forbidden" }),
82
+ );
83
+ });
84
+
85
+ test("non-status errors are thrown", async () => {
86
+ fetchApiMock.mockImplementation(async ({ method }) => {
87
+ if (!method || method === "GET") {
88
+ throw new Error("Oops!");
89
+ }
90
+ return {};
91
+ });
92
+ await expect(inferScope({ token: "token" })).rejects.toThrowError(
93
+ "Oops!",
94
+ );
95
+ });
96
+ });
97
+
98
+ test("infers the team", async () => {
99
+ fetchApiMock.mockImplementation(async ({ endpoint }) => {
100
+ if (endpoint === "/v2/teams?limit=1") {
101
+ return { teams: [{ slug: "inferred-team" }] };
102
+ }
103
+ return {};
104
+ });
105
+ const scope = await inferScope({ token: "token" });
106
+ expect(scope).toEqual({
107
+ created: false,
108
+ projectId: "vercel-sandbox-default-project",
109
+ teamId: "inferred-team",
110
+ });
111
+ });
112
+
113
+ describe("linked project", () => {
114
+ test("uses linked project when .vercel/project.json exists", async () => {
115
+ const dir = await getTempDir();
116
+ await fs.mkdir(path.join(dir, ".vercel"));
117
+ await fs.writeFile(
118
+ path.join(dir, ".vercel", "project.json"),
119
+ JSON.stringify({
120
+ projectId: "prj_linked",
121
+ orgId: "team_linked",
122
+ }),
123
+ );
124
+
125
+ const scope = await inferScope({ token: "token", cwd: dir });
126
+
127
+ expect(scope).toEqual({
128
+ created: false,
129
+ projectId: "prj_linked",
130
+ teamId: "team_linked",
131
+ });
132
+ // Should not call API when using linked project
133
+ expect(fetchApiMock).not.toHaveBeenCalled();
134
+ });
135
+
136
+ test("falls back to default project when .vercel/project.json does not exist", async () => {
137
+ const dir = await getTempDir();
138
+ fetchApiMock.mockResolvedValue({});
139
+
140
+ const scope = await inferScope({
141
+ token: "token",
142
+ teamId: "my-team",
143
+ cwd: dir,
144
+ });
145
+
146
+ expect(scope).toEqual({
147
+ created: false,
148
+ projectId: "vercel-sandbox-default-project",
149
+ teamId: "my-team",
150
+ });
151
+ expect(fetchApiMock).toHaveBeenCalled();
152
+ });
153
+
154
+ test("falls back to default project when .vercel/project.json is invalid", async () => {
155
+ const dir = await getTempDir();
156
+ await fs.mkdir(path.join(dir, ".vercel"));
157
+ await fs.writeFile(
158
+ path.join(dir, ".vercel", "project.json"),
159
+ "not valid json",
160
+ );
161
+
162
+ fetchApiMock.mockResolvedValue({});
163
+
164
+ const scope = await inferScope({
165
+ token: "token",
166
+ teamId: "my-team",
167
+ cwd: dir,
168
+ });
169
+
170
+ expect(scope).toEqual({
171
+ created: false,
172
+ projectId: "vercel-sandbox-default-project",
173
+ teamId: "my-team",
174
+ });
175
+ expect(fetchApiMock).toHaveBeenCalled();
176
+ });
177
+ });
178
+ });