@valbuild/cli 0.132.1 → 0.134.0
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/CHANGELOG.md +113 -0
- package/cli/dist/valbuild-cli-cli.cjs.dev.js +1428 -1
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +1428 -1
- package/cli/dist/valbuild-cli-cli.esm.js +1426 -1
- package/package.json +7 -5
- package/src/cli.ts +51 -0
- package/src/debug/context.ts +1 -2
- package/src/publish/artifacts.ts +194 -0
- package/src/publish/client.ts +177 -0
- package/src/publish/contentApi.ts +210 -0
- package/src/publish/contentHost.ts +160 -0
- package/src/publish/credentials.test.ts +348 -0
- package/src/publish/credentials.ts +258 -0
- package/src/publish/fakeContent.ts +482 -0
- package/src/publish/protocol.ts +174 -0
- package/src/publish/publishWireContract.test.ts +292 -0
- package/src/publish/runPublish.test.ts +536 -0
- package/src/publish/runPublish.ts +633 -0
- package/src/publish.ts +112 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import http from "http";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { resolvePublishCredential } from "./credentials";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A fake content service.
|
|
9
|
+
*
|
|
10
|
+
* `val publish` talks to content and to nothing else, so a fake that answers
|
|
11
|
+
* content's routes is the whole world the command needs - no loader, no
|
|
12
|
+
* storage, no credential that has to be real. It records what it was asked so a
|
|
13
|
+
* test can assert on the headers, which is where the interesting rules live:
|
|
14
|
+
* the personal access token goes in `x-val-pat` and never anywhere else.
|
|
15
|
+
*/
|
|
16
|
+
type FakeContent = {
|
|
17
|
+
url: string;
|
|
18
|
+
requests: Array<{
|
|
19
|
+
method: string;
|
|
20
|
+
url: string;
|
|
21
|
+
headers: http.IncomingHttpHeaders;
|
|
22
|
+
}>;
|
|
23
|
+
close: () => Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
async function startFakeContent(
|
|
27
|
+
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
|
|
28
|
+
): Promise<FakeContent> {
|
|
29
|
+
const requests: FakeContent["requests"] = [];
|
|
30
|
+
const server = http.createServer((req, res) => {
|
|
31
|
+
requests.push({
|
|
32
|
+
method: req.method ?? "",
|
|
33
|
+
url: req.url ?? "",
|
|
34
|
+
headers: req.headers,
|
|
35
|
+
});
|
|
36
|
+
handler(req, res);
|
|
37
|
+
});
|
|
38
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
39
|
+
const address = server.address();
|
|
40
|
+
if (address === null || typeof address === "string") {
|
|
41
|
+
throw new Error("Fake content service did not get a port");
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
45
|
+
requests,
|
|
46
|
+
close: () =>
|
|
47
|
+
new Promise<void>((resolve, reject) =>
|
|
48
|
+
server.close((err) => (err ? reject(err) : resolve())),
|
|
49
|
+
),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function json(res: http.ServerResponse, statusCode: number, body: unknown) {
|
|
54
|
+
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
|
55
|
+
res.end(JSON.stringify(body));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A project root, optionally holding the file `val login` writes. */
|
|
59
|
+
function makeRoot(pat?: string): string {
|
|
60
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "val-publish-"));
|
|
61
|
+
if (pat !== undefined) {
|
|
62
|
+
fs.mkdirSync(path.join(root, ".val"), { recursive: true });
|
|
63
|
+
fs.writeFileSync(
|
|
64
|
+
path.join(root, ".val", "pat.json"),
|
|
65
|
+
JSON.stringify({ pat }),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return root;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const A_PAT = "a".repeat(64);
|
|
72
|
+
|
|
73
|
+
describe("the publish credential", () => {
|
|
74
|
+
test("VAL_PROJECT_TOKEN is used as it is, without an exchange", async () => {
|
|
75
|
+
const content = await startFakeContent((_req, res) => {
|
|
76
|
+
json(res, 500, { statusCode: 500, message: "should not be called" });
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
const resolved = await resolvePublishCredential({
|
|
80
|
+
root: makeRoot(A_PAT),
|
|
81
|
+
project: "acme/site",
|
|
82
|
+
env: {
|
|
83
|
+
VAL_PROJECT_TOKEN: "val_pt_deadbeef",
|
|
84
|
+
VAL_CONTENT_URL: content.url,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(resolved).toEqual({
|
|
89
|
+
status: "ok",
|
|
90
|
+
credential: {
|
|
91
|
+
token: "val_pt_deadbeef",
|
|
92
|
+
origin: "VAL_PROJECT_TOKEN",
|
|
93
|
+
expiresAt: null,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
// The token names its project, so there is nothing to ask and nobody to
|
|
97
|
+
// ask it of. A repository that holds one secret holds no variables.
|
|
98
|
+
expect(content.requests).toEqual([]);
|
|
99
|
+
} finally {
|
|
100
|
+
await content.close();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("an unset CI secret reads as no credential, not as a bad one", async () => {
|
|
105
|
+
// An unset secret reaches the job as "", and "" presented to content is a
|
|
106
|
+
// 401 that sends the reader looking for a revoked token.
|
|
107
|
+
const resolved = await resolvePublishCredential({
|
|
108
|
+
root: makeRoot(),
|
|
109
|
+
project: "acme/site",
|
|
110
|
+
env: { VAL_PROJECT_TOKEN: " " },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(resolved.status).toBe("error");
|
|
114
|
+
if (resolved.status === "error") {
|
|
115
|
+
expect(resolved.message).toContain("VAL_PROJECT_TOKEN");
|
|
116
|
+
expect(resolved.message).toContain("val login");
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a personal access token pasted into VAL_PROJECT_TOKEN is named as one", async () => {
|
|
121
|
+
const resolved = await resolvePublishCredential({
|
|
122
|
+
root: makeRoot(),
|
|
123
|
+
project: "acme/site",
|
|
124
|
+
env: { VAL_PROJECT_TOKEN: A_PAT },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(resolved.status).toBe("error");
|
|
128
|
+
if (resolved.status === "error") {
|
|
129
|
+
expect(resolved.message).toContain("personal access token");
|
|
130
|
+
expect(resolved.message).toContain("val_pt_");
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a val login is exchanged for a project token, and the login stays here", async () => {
|
|
135
|
+
const content = await startFakeContent((req, res) => {
|
|
136
|
+
if (req.url === "/v1/acme/site/publish-token") {
|
|
137
|
+
json(res, 200, {
|
|
138
|
+
token: "val_pt_minted",
|
|
139
|
+
expiresAt: "2026-09-19T12:10:00.000Z",
|
|
140
|
+
publicProjectId: "p1",
|
|
141
|
+
productionUrl: "https://site.example",
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
json(res, 404, { statusCode: 404, message: "no such route" });
|
|
146
|
+
});
|
|
147
|
+
try {
|
|
148
|
+
const resolved = await resolvePublishCredential({
|
|
149
|
+
root: makeRoot(A_PAT),
|
|
150
|
+
project: "acme/site",
|
|
151
|
+
env: { VAL_CONTENT_URL: content.url },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(resolved).toEqual({
|
|
155
|
+
status: "ok",
|
|
156
|
+
credential: {
|
|
157
|
+
token: "val_pt_minted",
|
|
158
|
+
origin: "val login",
|
|
159
|
+
expiresAt: "2026-09-19T12:10:00.000Z",
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
expect(content.requests).toHaveLength(1);
|
|
163
|
+
const [request] = content.requests;
|
|
164
|
+
expect(request.method).toBe("POST");
|
|
165
|
+
expect(request.url).toBe("/v1/acme/site/publish-token");
|
|
166
|
+
// The person's credential is presented to val.build and to nothing
|
|
167
|
+
// further: what publishes is the ten minute token that came back.
|
|
168
|
+
expect(request.headers["x-val-pat"]).toBe(A_PAT);
|
|
169
|
+
expect(request.headers.authorization).toBeUndefined();
|
|
170
|
+
} finally {
|
|
171
|
+
await content.close();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a trailing slash on VAL_CONTENT_URL does not double the path", async () => {
|
|
176
|
+
const content = await startFakeContent((req, res) => {
|
|
177
|
+
if (req.url === "/v1/acme/site/publish-token") {
|
|
178
|
+
// The whole answer, as `Api["/publish-token"]["POST"]["res"]` declares
|
|
179
|
+
// it. A fixture that sends less is a fixture testing a service that
|
|
180
|
+
// does not exist.
|
|
181
|
+
json(res, 200, {
|
|
182
|
+
token: "val_pt_minted",
|
|
183
|
+
expiresAt: null,
|
|
184
|
+
publicProjectId: "p1",
|
|
185
|
+
productionUrl: null,
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
json(res, 404, { statusCode: 404, message: "no such route" });
|
|
190
|
+
});
|
|
191
|
+
try {
|
|
192
|
+
const resolved = await resolvePublishCredential({
|
|
193
|
+
root: makeRoot(A_PAT),
|
|
194
|
+
project: "acme/site",
|
|
195
|
+
env: { VAL_CONTENT_URL: `${content.url}/` },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(resolved.status).toBe("ok");
|
|
199
|
+
expect(content.requests[0].url).toBe("/v1/acme/site/publish-token");
|
|
200
|
+
} finally {
|
|
201
|
+
await content.close();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a login with no project says which of the two is missing", async () => {
|
|
206
|
+
const resolved = await resolvePublishCredential({
|
|
207
|
+
root: makeRoot(A_PAT),
|
|
208
|
+
project: null,
|
|
209
|
+
env: {},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(resolved.status).toBe("error");
|
|
213
|
+
if (resolved.status === "error") {
|
|
214
|
+
expect(resolved.message).toContain("VAL_PROJECT");
|
|
215
|
+
expect(resolved.message).toContain("val.config");
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("an expired login says to log in again, not that the project is wrong", async () => {
|
|
220
|
+
const content = await startFakeContent((_req, res) => {
|
|
221
|
+
json(res, 401, { statusCode: 401, message: "Invalid token" });
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
const resolved = await resolvePublishCredential({
|
|
225
|
+
root: makeRoot(A_PAT),
|
|
226
|
+
project: "acme/site",
|
|
227
|
+
env: { VAL_CONTENT_URL: content.url },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(resolved.status).toBe("error");
|
|
231
|
+
if (resolved.status === "error") {
|
|
232
|
+
expect(resolved.message).toContain("val login");
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
await content.close();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("a refusal keeps the sentence content wrote", async () => {
|
|
240
|
+
const content = await startFakeContent((_req, res) => {
|
|
241
|
+
json(res, 403, {
|
|
242
|
+
statusCode: 403,
|
|
243
|
+
message: "Your role in this organization may not publish",
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
try {
|
|
247
|
+
const resolved = await resolvePublishCredential({
|
|
248
|
+
root: makeRoot(A_PAT),
|
|
249
|
+
project: "acme/site",
|
|
250
|
+
env: { VAL_CONTENT_URL: content.url },
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(resolved.status).toBe("error");
|
|
254
|
+
if (resolved.status === "error") {
|
|
255
|
+
expect(resolved.message).toContain(
|
|
256
|
+
"Your role in this organization may not publish",
|
|
257
|
+
);
|
|
258
|
+
expect(resolved.message).toContain("acme/site");
|
|
259
|
+
}
|
|
260
|
+
} finally {
|
|
261
|
+
await content.close();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("the old names are still read, after a login and before failing", async () => {
|
|
266
|
+
// This repository's own CI still passes VAL_APP_TOKEN, and a rename that
|
|
267
|
+
// breaks the publisher is a rename that gets reverted. Content says what
|
|
268
|
+
// it thinks of the value; that is not this function's call to make.
|
|
269
|
+
const resolved = await resolvePublishCredential({
|
|
270
|
+
root: makeRoot(),
|
|
271
|
+
project: "acme/site",
|
|
272
|
+
env: { VAL_APP_TOKEN: "val_pt_old" },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(resolved).toEqual({
|
|
276
|
+
status: "ok",
|
|
277
|
+
credential: {
|
|
278
|
+
token: "val_pt_old",
|
|
279
|
+
origin: "VAL_APP_TOKEN",
|
|
280
|
+
expiresAt: null,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a login is preferred to the old names", async () => {
|
|
286
|
+
const content = await startFakeContent((req, res) => {
|
|
287
|
+
if (req.url === "/v1/acme/site/publish-token") {
|
|
288
|
+
// The whole answer, as `Api["/publish-token"]["POST"]["res"]` declares
|
|
289
|
+
// it. A fixture that sends less is a fixture testing a service that
|
|
290
|
+
// does not exist.
|
|
291
|
+
json(res, 200, {
|
|
292
|
+
token: "val_pt_minted",
|
|
293
|
+
expiresAt: null,
|
|
294
|
+
publicProjectId: "p1",
|
|
295
|
+
productionUrl: null,
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
json(res, 404, { statusCode: 404, message: "no such route" });
|
|
300
|
+
});
|
|
301
|
+
try {
|
|
302
|
+
const resolved = await resolvePublishCredential({
|
|
303
|
+
root: makeRoot(A_PAT),
|
|
304
|
+
project: "acme/site",
|
|
305
|
+
env: { VAL_APP_TOKEN: "val_pt_old", VAL_CONTENT_URL: content.url },
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(resolved.status).toBe("ok");
|
|
309
|
+
if (resolved.status === "ok") {
|
|
310
|
+
expect(resolved.credential.token).toBe("val_pt_minted");
|
|
311
|
+
}
|
|
312
|
+
} finally {
|
|
313
|
+
await content.close();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("no credential at all names both ways of having one", async () => {
|
|
318
|
+
const resolved = await resolvePublishCredential({
|
|
319
|
+
root: makeRoot(),
|
|
320
|
+
project: "acme/site",
|
|
321
|
+
env: {},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(resolved.status).toBe("error");
|
|
325
|
+
if (resolved.status === "error") {
|
|
326
|
+
expect(resolved.message).toContain("VAL_PROJECT_TOKEN");
|
|
327
|
+
expect(resolved.message).toContain("val login");
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("an unreadable login file is a fixable error, not a fall through", async () => {
|
|
332
|
+
const root = makeRoot();
|
|
333
|
+
fs.mkdirSync(path.join(root, ".val"), { recursive: true });
|
|
334
|
+
fs.writeFileSync(path.join(root, ".val", "pat.json"), "{ not json");
|
|
335
|
+
|
|
336
|
+
const resolved = await resolvePublishCredential({
|
|
337
|
+
root,
|
|
338
|
+
project: "acme/site",
|
|
339
|
+
env: {},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
expect(resolved.status).toBe("error");
|
|
343
|
+
if (resolved.status === "error") {
|
|
344
|
+
expect(resolved.message).toContain("pat.json");
|
|
345
|
+
expect(resolved.message).toContain("val login");
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import {
|
|
3
|
+
getPersonalAccessTokenPath,
|
|
4
|
+
parsePersonalAccessTokenFile,
|
|
5
|
+
} from "@valbuild/server";
|
|
6
|
+
import { ContentHostError, getContentHost, postJson } from "./contentHost";
|
|
7
|
+
import { PublishProtocolError, parsePublishToken } from "./protocol";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The credential `val publish` presents to content, and where it came from.
|
|
11
|
+
*
|
|
12
|
+
* Whatever the publisher started with, this is a *project* token: it can do one
|
|
13
|
+
* thing to one project. A personal access token is a person's credential -
|
|
14
|
+
* org-wide, read and write on every project they can reach - so it never
|
|
15
|
+
* travels further than val.build, which exchanges it for one of these.
|
|
16
|
+
*/
|
|
17
|
+
export type PublishCredential = {
|
|
18
|
+
/** Presented as `Authorization: Bearer`. Never logged, never in argv. */
|
|
19
|
+
token: string;
|
|
20
|
+
/**
|
|
21
|
+
* Which of the ways below produced it. For messages only: "your login
|
|
22
|
+
* expired" and "the repository's token was revoked" have different fixes and
|
|
23
|
+
* different people to tell.
|
|
24
|
+
*/
|
|
25
|
+
origin: "VAL_PROJECT_TOKEN" | "val login" | "VAL_APP_TOKEN";
|
|
26
|
+
/** ISO 8601, or null for a standing token that does not expire. */
|
|
27
|
+
expiresAt: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ResolvedCredential =
|
|
31
|
+
| { status: "ok"; credential: PublishCredential }
|
|
32
|
+
| { status: "error"; message: string };
|
|
33
|
+
|
|
34
|
+
const NO_CREDENTIAL =
|
|
35
|
+
"Publishing needs a credential.\n\n" +
|
|
36
|
+
" In CI: set VAL_PROJECT_TOKEN as a repository secret.\n" +
|
|
37
|
+
" On your machine: run\n\n" +
|
|
38
|
+
" npx val login\n\n" +
|
|
39
|
+
"Neither is a command line flag, deliberately: an argument is visible to\n" +
|
|
40
|
+
"anyone who can list processes, and it is kept in shell history and in the\n" +
|
|
41
|
+
"log of every CI job that echoes its command line.";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The credential, in the order the publisher should prefer them.
|
|
45
|
+
*
|
|
46
|
+
* 1. `VAL_PROJECT_TOKEN` from the environment - used as it is, no exchange.
|
|
47
|
+
* This is what a repository created by `/new` holds, and the only secret it
|
|
48
|
+
* needs: the token names its project, so content can answer *where* to
|
|
49
|
+
* publish as well as *whether*.
|
|
50
|
+
* 2. The `val login` token in `<root>/.val/pat.json` - exchanged first, because
|
|
51
|
+
* what publishes must be a project token. The exchange is addressed by org
|
|
52
|
+
* and project, since a personal access token does not name one; that is why
|
|
53
|
+
* this way needs `project` and the first does not.
|
|
54
|
+
* 3. Neither, which is not an error we can guess our way out of.
|
|
55
|
+
*
|
|
56
|
+
* Returns a result rather than throwing: "no credential" is one of the things a
|
|
57
|
+
* publisher has to say well, and the sentence differs by which half was missing.
|
|
58
|
+
*/
|
|
59
|
+
export async function resolvePublishCredential(options: {
|
|
60
|
+
/** Project root - where `.val/pat.json` is looked for. */
|
|
61
|
+
root: string;
|
|
62
|
+
/** `"<org>/<project>"`, from val.config or VAL_PROJECT. Only (2) needs it. */
|
|
63
|
+
project: string | null;
|
|
64
|
+
env?: NodeJS.ProcessEnv;
|
|
65
|
+
fetchImpl?: typeof fetch;
|
|
66
|
+
}): Promise<ResolvedCredential> {
|
|
67
|
+
const env = options.env ?? process.env;
|
|
68
|
+
// An unset secret reaches a CI job as the empty string rather than as absent,
|
|
69
|
+
// and an empty credential presented to content is a 401 that reads as "your
|
|
70
|
+
// token is bad" instead of "you never set one".
|
|
71
|
+
const projectToken = env.VAL_PROJECT_TOKEN?.trim();
|
|
72
|
+
if (projectToken) {
|
|
73
|
+
if (looksLikePersonalAccessToken(projectToken)) {
|
|
74
|
+
return {
|
|
75
|
+
status: "error",
|
|
76
|
+
message:
|
|
77
|
+
"VAL_PROJECT_TOKEN looks like a personal access token, not a project\n" +
|
|
78
|
+
"token. A project token begins with `val_pt_` and is made for one\n" +
|
|
79
|
+
"project; a personal access token belongs to a person and cannot\n" +
|
|
80
|
+
"publish. Mint a project token on the project's settings page.",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
status: "ok",
|
|
85
|
+
credential: {
|
|
86
|
+
token: projectToken,
|
|
87
|
+
origin: "VAL_PROJECT_TOKEN",
|
|
88
|
+
// A standing token need not expire, and nothing here has been told
|
|
89
|
+
// otherwise. Content is where an expiry would come from.
|
|
90
|
+
expiresAt: null,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const pat = readPersonalAccessToken(options.root);
|
|
96
|
+
if (pat.status === "error") {
|
|
97
|
+
return pat;
|
|
98
|
+
}
|
|
99
|
+
if (pat.status === "none") {
|
|
100
|
+
/*
|
|
101
|
+
* The old names, still read.
|
|
102
|
+
*
|
|
103
|
+
* This is where the api key used to be passed, and this platform's own CI
|
|
104
|
+
* still passes it: a rename that breaks the publisher is a rename that gets
|
|
105
|
+
* reverted. It will not be accepted for long - publishing is being taken
|
|
106
|
+
* out of what an api key may do - and content says so in its own words
|
|
107
|
+
* when it refuses one, which is a better sentence than a guess here.
|
|
108
|
+
*/
|
|
109
|
+
const legacy = (env.VAL_APP_TOKEN ?? env.PLATFORM_TOKEN)?.trim();
|
|
110
|
+
if (legacy) {
|
|
111
|
+
return {
|
|
112
|
+
status: "ok",
|
|
113
|
+
credential: {
|
|
114
|
+
token: legacy,
|
|
115
|
+
origin: "VAL_APP_TOKEN",
|
|
116
|
+
expiresAt: null,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return { status: "error", message: NO_CREDENTIAL };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!options.project) {
|
|
124
|
+
return {
|
|
125
|
+
status: "error",
|
|
126
|
+
message:
|
|
127
|
+
"You are logged in, but nothing says which project to publish.\n\n" +
|
|
128
|
+
'Set `project` ("<org>/<project>") in val.config, or the VAL_PROJECT\n' +
|
|
129
|
+
"environment variable.\n\n" +
|
|
130
|
+
"A `val login` token belongs to you rather than to a project, so it\n" +
|
|
131
|
+
"cannot say which one you are standing in.",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const parts = options.project.split("/");
|
|
135
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
136
|
+
return {
|
|
137
|
+
status: "error",
|
|
138
|
+
message: `Invalid project: "${options.project}". Expected "<org>/<project>".`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const [orgName, projectName] = parts;
|
|
142
|
+
|
|
143
|
+
return exchangePersonalAccessToken({
|
|
144
|
+
pat: pat.pat,
|
|
145
|
+
orgName,
|
|
146
|
+
projectName,
|
|
147
|
+
env,
|
|
148
|
+
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Trade the token you have for the token that may publish.
|
|
154
|
+
*
|
|
155
|
+
* Ten minutes, one project, one scope. The person's identity survives the
|
|
156
|
+
* exchange even though the credential will not outlive the command, which is
|
|
157
|
+
* what gives "who published this" an answer afterwards.
|
|
158
|
+
*/
|
|
159
|
+
async function exchangePersonalAccessToken(options: {
|
|
160
|
+
pat: string;
|
|
161
|
+
orgName: string;
|
|
162
|
+
projectName: string;
|
|
163
|
+
env: NodeJS.ProcessEnv;
|
|
164
|
+
fetchImpl?: typeof fetch;
|
|
165
|
+
}): Promise<ResolvedCredential> {
|
|
166
|
+
const host = getContentHost(options.env);
|
|
167
|
+
const url = `${host}/v1/${encodeURIComponent(
|
|
168
|
+
options.orgName,
|
|
169
|
+
)}/${encodeURIComponent(options.projectName)}/publish-token`;
|
|
170
|
+
let body: unknown;
|
|
171
|
+
try {
|
|
172
|
+
body = await postJson({
|
|
173
|
+
url,
|
|
174
|
+
headers: { "x-val-pat": options.pat },
|
|
175
|
+
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err instanceof ContentHostError) {
|
|
179
|
+
return {
|
|
180
|
+
status: "error",
|
|
181
|
+
message: exchangeFailureMessage(err, options),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
let exchanged;
|
|
187
|
+
try {
|
|
188
|
+
exchanged = parsePublishToken(body, `POST ${url}`);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err instanceof PublishProtocolError) {
|
|
191
|
+
return { status: "error", message: err.message };
|
|
192
|
+
}
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
status: "ok",
|
|
197
|
+
credential: {
|
|
198
|
+
token: exchanged.token,
|
|
199
|
+
origin: "val login",
|
|
200
|
+
expiresAt: exchanged.expiresAt,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function exchangeFailureMessage(
|
|
206
|
+
err: ContentHostError,
|
|
207
|
+
options: { orgName: string; projectName: string },
|
|
208
|
+
): string {
|
|
209
|
+
const project = `${options.orgName}/${options.projectName}`;
|
|
210
|
+
if (err.statusCode === 401) {
|
|
211
|
+
return (
|
|
212
|
+
"Your Val login is no longer valid - it may have expired. Log in again:\n\n" +
|
|
213
|
+
" npx val login"
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (err.statusCode === 403) {
|
|
217
|
+
return `Your Val account may not publish ${project}: ${err.message}`;
|
|
218
|
+
}
|
|
219
|
+
if (err.statusCode === 404) {
|
|
220
|
+
return (
|
|
221
|
+
`No project ${project}. Check \`project\` in val.config (or VAL_PROJECT):\n` +
|
|
222
|
+
"it is the org and project as val.build names them, not the repository."
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return (
|
|
226
|
+
`Could not get a publish token for ${project}: ${err.message}` +
|
|
227
|
+
(err.details ? `\n${err.details}` : "")
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A personal access token is 32 random bytes as hex, and carries no prefix. */
|
|
232
|
+
function looksLikePersonalAccessToken(token: string): boolean {
|
|
233
|
+
return /^[0-9a-f]{64}$/i.test(token);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function readPersonalAccessToken(
|
|
237
|
+
root: string,
|
|
238
|
+
):
|
|
239
|
+
| { status: "ok"; pat: string }
|
|
240
|
+
| { status: "none" }
|
|
241
|
+
| { status: "error"; message: string } {
|
|
242
|
+
const patFile = getPersonalAccessTokenPath(root);
|
|
243
|
+
if (!fs.existsSync(patFile)) {
|
|
244
|
+
return { status: "none" };
|
|
245
|
+
}
|
|
246
|
+
const parsed = parsePersonalAccessTokenFile(
|
|
247
|
+
fs.readFileSync(patFile, "utf-8"),
|
|
248
|
+
);
|
|
249
|
+
if (!parsed.success) {
|
|
250
|
+
return {
|
|
251
|
+
status: "error",
|
|
252
|
+
message:
|
|
253
|
+
`Could not read the Val login at ${patFile}: ${parsed.error}.\n` +
|
|
254
|
+
"Log in again:\n\n npx val login",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return { status: "ok", pat: parsed.data.pat };
|
|
258
|
+
}
|