@valbuild/cli 0.133.0 → 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.
@@ -0,0 +1,160 @@
1
+ import { DEFAULT_CONTENT_HOST } from "@valbuild/core";
2
+
3
+ /**
4
+ * The one host `val publish` talks to.
5
+ *
6
+ * Not a flag, and not several: publishing is a conversation with
7
+ * content.val.build, which holds the operator relationship with whatever
8
+ * actually serves the site and calls it on our behalf. A second host here
9
+ * would be a second thing to configure in every repository that publishes,
10
+ * and a second place for a credential to go.
11
+ *
12
+ * `VAL_CONTENT_URL` overrides it, as it does everywhere else in Val, so a
13
+ * test can point the CLI at a fake and a developer at a local content service.
14
+ */
15
+ export function getContentHost(env: NodeJS.ProcessEnv = process.env): string {
16
+ const configured = env.VAL_CONTENT_URL;
17
+ if (!configured) {
18
+ return DEFAULT_CONTENT_HOST;
19
+ }
20
+ // A trailing slash turns every path below into a double slash, which some
21
+ // routers answer with a redirect and others with a 404.
22
+ return configured.replace(/\/+$/, "");
23
+ }
24
+
25
+ /**
26
+ * A refusal from content, carrying the status so a caller can tell "your
27
+ * credential is no good" (401/403) from "content is down" (5xx) and say
28
+ * something different about each.
29
+ */
30
+ export class ContentHostError extends Error {
31
+ readonly statusCode: number;
32
+ /**
33
+ * Whatever was in `details`, unread.
34
+ *
35
+ * The publish routes put a `PublishProblem[]` there - every problem with a
36
+ * declaration rather than the first - and the older routes put a sentence.
37
+ * Keeping it unparsed here lets each caller read the one it expects without
38
+ * this file having to know about either.
39
+ */
40
+ readonly details: unknown;
41
+ /**
42
+ * The whole answer.
43
+ *
44
+ * Some refusals carry a field of their own beside the message - a stale
45
+ * pointer answers with the `head` the branch is at now, which is the one
46
+ * thing that tells a publisher what happened - so the body is kept rather
47
+ * than reduced to two strings on the way past.
48
+ */
49
+ readonly body: unknown;
50
+ constructor(
51
+ statusCode: number,
52
+ message: string,
53
+ details?: unknown,
54
+ body?: unknown,
55
+ ) {
56
+ super(message);
57
+ this.name = "ContentHostError";
58
+ this.statusCode = statusCode;
59
+ this.details = details;
60
+ this.body = body;
61
+ }
62
+ }
63
+
64
+ /** The `details` of a refusal, when it is a sentence rather than a list. */
65
+ export function detailText(details: unknown): string | null {
66
+ return typeof details === "string" && details !== "" ? details : null;
67
+ }
68
+
69
+ /** GET JSON from content. Same envelope, same failures, one less body. */
70
+ export async function getJson(options: {
71
+ url: string;
72
+ headers: Record<string, string>;
73
+ fetchImpl?: typeof fetch;
74
+ }): Promise<unknown> {
75
+ return requestJson({ method: "GET", ...options });
76
+ }
77
+
78
+ /**
79
+ * POST JSON to content and read JSON back.
80
+ *
81
+ * Content answers an error as `{ statusCode, message, details? }` (its
82
+ * `sendResult`), so the message a user sees is the one the service wrote
83
+ * rather than a status code they then have to look up. A body that is not
84
+ * that shape - a proxy's HTML error page, say - still has to produce a
85
+ * sentence, hence the fallback.
86
+ */
87
+ export async function postJson(options: {
88
+ url: string;
89
+ headers: Record<string, string>;
90
+ body?: unknown;
91
+ fetchImpl?: typeof fetch;
92
+ }): Promise<unknown> {
93
+ return requestJson({ method: "POST", ...options });
94
+ }
95
+
96
+ async function requestJson(options: {
97
+ method: "GET" | "POST";
98
+ url: string;
99
+ headers: Record<string, string>;
100
+ body?: unknown;
101
+ fetchImpl?: typeof fetch;
102
+ }): Promise<unknown> {
103
+ const fetchImpl = options.fetchImpl ?? fetch;
104
+ let res: Response;
105
+ try {
106
+ res = await fetchImpl(options.url, {
107
+ method: options.method,
108
+ headers:
109
+ options.method === "GET"
110
+ ? options.headers
111
+ : { "Content-Type": "application/json", ...options.headers },
112
+ ...(options.method === "GET"
113
+ ? {}
114
+ : { body: JSON.stringify(options.body ?? {}) }),
115
+ });
116
+ } catch (err) {
117
+ // No status at all: DNS, TLS, a dropped connection. 0 is the shape the
118
+ // rest of this file expects, and it is never a status a server sends.
119
+ throw new ContentHostError(
120
+ 0,
121
+ `Could not reach ${options.url}`,
122
+ err instanceof Error ? err.message : String(err),
123
+ );
124
+ }
125
+ const text = await res.text();
126
+ let parsed: unknown = undefined;
127
+ if (text !== "") {
128
+ try {
129
+ parsed = JSON.parse(text);
130
+ } catch {
131
+ parsed = undefined;
132
+ }
133
+ }
134
+ if (!res.ok) {
135
+ throw new ContentHostError(
136
+ res.status,
137
+ errorMessageOf(parsed) ?? `${res.status} ${res.statusText}`,
138
+ errorDetailsOf(parsed),
139
+ parsed,
140
+ );
141
+ }
142
+ return parsed;
143
+ }
144
+
145
+ function errorMessageOf(body: unknown): string | undefined {
146
+ if (typeof body === "object" && body !== null && "message" in body) {
147
+ const message = body.message;
148
+ if (typeof message === "string" && message !== "") {
149
+ return message;
150
+ }
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ function errorDetailsOf(body: unknown): unknown {
156
+ if (typeof body === "object" && body !== null && "details" in body) {
157
+ return body.details;
158
+ }
159
+ return undefined;
160
+ }
@@ -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
+ });