@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +14 -8
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +12 -0
- package/__mocks__/picocolors.ts +13 -0
- package/dist/api-client/api-client.d.ts +2 -2
- package/dist/api-client/api-client.js +3 -1
- package/dist/api-client/api-client.js.map +1 -1
- package/dist/api-client/api-error.d.ts +4 -1
- package/dist/api-client/api-error.js +3 -1
- package/dist/api-client/api-error.js.map +1 -1
- package/dist/api-client/base-client.js +13 -0
- package/dist/api-client/base-client.js.map +1 -1
- package/dist/api-client/validators.d.ts +10 -10
- package/dist/api-client/with-retry.js +1 -1
- package/dist/api-client/with-retry.js.map +1 -1
- package/dist/auth/api.d.ts +6 -0
- package/dist/auth/api.js +28 -0
- package/dist/auth/api.js.map +1 -0
- package/dist/auth/error.d.ts +11 -0
- package/dist/auth/error.js +12 -0
- package/dist/auth/error.js.map +1 -0
- package/dist/auth/file.d.ts +22 -0
- package/dist/auth/file.js +66 -0
- package/dist/auth/file.js.map +1 -0
- package/dist/auth/index.d.ts +6 -0
- package/dist/auth/index.js +27 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/linked-project.d.ts +10 -0
- package/dist/auth/linked-project.js +69 -0
- package/dist/auth/linked-project.js.map +1 -0
- package/dist/auth/oauth.d.ts +131 -0
- package/dist/auth/oauth.js +269 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/auth/poll-for-token.d.ts +20 -0
- package/dist/auth/poll-for-token.js +66 -0
- package/dist/auth/poll-for-token.js.map +1 -0
- package/dist/auth/project.d.ts +40 -0
- package/dist/auth/project.js +80 -0
- package/dist/auth/project.js.map +1 -0
- package/dist/auth/zod.d.ts +5 -0
- package/dist/auth/zod.js +20 -0
- package/dist/auth/zod.js.map +1 -0
- package/dist/command.d.ts +7 -0
- package/dist/command.js +39 -7
- package/dist/command.js.map +1 -1
- package/dist/sandbox.js +1 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/utils/dev-credentials.d.ts +37 -0
- package/dist/utils/dev-credentials.js +191 -0
- package/dist/utils/dev-credentials.js.map +1 -0
- package/dist/utils/get-credentials.d.ts +16 -0
- package/dist/utils/get-credentials.js +66 -7
- package/dist/utils/get-credentials.js.map +1 -1
- package/dist/utils/log.d.ts +2 -0
- package/dist/utils/log.js +24 -0
- package/dist/utils/log.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -1
- package/src/api-client/api-client.test.ts +176 -0
- package/src/api-client/api-client.ts +7 -1
- package/src/api-client/api-error.ts +6 -1
- package/src/api-client/base-client.ts +15 -0
- package/src/api-client/with-retry.ts +1 -1
- package/src/auth/api.ts +31 -0
- package/src/auth/error.ts +8 -0
- package/src/auth/file.ts +69 -0
- package/src/auth/index.ts +9 -0
- package/src/auth/infer-scope.test.ts +178 -0
- package/src/auth/linked-project.test.ts +86 -0
- package/src/auth/linked-project.ts +40 -0
- package/src/auth/oauth.ts +333 -0
- package/src/auth/poll-for-token.ts +89 -0
- package/src/auth/project.ts +92 -0
- package/src/auth/zod.ts +16 -0
- package/src/command.ts +50 -7
- package/src/sandbox.ts +1 -1
- package/src/utils/dev-credentials.test.ts +217 -0
- package/src/utils/dev-credentials.ts +189 -0
- package/src/utils/get-credentials.test.ts +20 -0
- package/src/utils/get-credentials.ts +72 -8
- package/src/utils/log.ts +20 -0
- package/src/version.ts +1 -1
- package/test-utils/mock-response.ts +12 -0
- 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.
|
|
1
|
+
export declare const VERSION = "1.1.6";
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vercel/sandbox",
|
|
3
|
-
"version": "1.1.
|
|
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(
|
|
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 {
|
package/src/auth/api.ts
ADDED
|
@@ -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
|
+
}
|
package/src/auth/file.ts
ADDED
|
@@ -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
|
+
});
|