@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,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The publish API's types, COPIED from the service that serves them.
|
|
3
|
+
*
|
|
4
|
+
* Source: `content/src/handlers/Api.ts` in valbuild/home, branch
|
|
5
|
+
* `claude/new-project-studio-saves-0g9ksb`, commit `ecf3b8d`. The routes below
|
|
6
|
+
* are that file's `/publish*` entries and the two types they use, verbatim,
|
|
7
|
+
* comments included, so that the two can be diffed by eye.
|
|
8
|
+
*
|
|
9
|
+
* **Copied rather than imported, because it cannot be imported.** That file
|
|
10
|
+
* lives in a private repository which publishes nothing to npm, and its own
|
|
11
|
+
* header says this is how it is kept in step: "We have also used it (by just
|
|
12
|
+
* copying it in and setting the types there) in the @valbuild/server package to
|
|
13
|
+
* check that we are more or less in sync."
|
|
14
|
+
*
|
|
15
|
+
* **When home's `Api.ts` changes, change this with it.** Everything in
|
|
16
|
+
* `protocol.ts` is checked against these types, so a copy brought up to date
|
|
17
|
+
* fails to compile wherever this CLI has not caught up. That is the whole
|
|
18
|
+
* value: without it a wire change is a runtime 500 in somebody's CI, which is
|
|
19
|
+
* how `home` and `@valbuild/server` have already diverged three times - see
|
|
20
|
+
* `homeWireContract.test.ts` in `@valbuild/server` for the last one, and
|
|
21
|
+
* `publishWireContract.test.ts` here for the fixtures that go with these types.
|
|
22
|
+
*
|
|
23
|
+
* A copy is not a guarantee, only a tripwire: nothing checks it against the
|
|
24
|
+
* service, and the parsers in `protocol.ts` are what actually holds at runtime.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type ContentPublishApi = {
|
|
28
|
+
/**
|
|
29
|
+
* Publishing a build, as a resource with a lifecycle.
|
|
30
|
+
*
|
|
31
|
+
* ```
|
|
32
|
+
* POST /publish declare -> an upload slot per MISSING artifact
|
|
33
|
+
* PUT <presigned url> upload -> each artifact, straight to storage
|
|
34
|
+
* POST /publish/{id}/artifacts confirm -> the uploads are checked
|
|
35
|
+
* POST /publish/{id}/verify render -> a canary build, server side
|
|
36
|
+
* POST /publish/{id}/promote go live -> the pointer moves
|
|
37
|
+
* GET /publish/{id} status
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* Four steps rather than one `POST /publish`, because they fail differently
|
|
41
|
+
* and one call cannot say "the bytes are fine but it did not render" -- which
|
|
42
|
+
* is the sentence a publisher most needs. `promote` is separate from `verify`
|
|
43
|
+
* so that a dry run is the absence of a call rather than a flag.
|
|
44
|
+
*
|
|
45
|
+
* Not keyed by a project, like `/publish-target` above and for the same
|
|
46
|
+
* reason: a project token names one. That is what lets a generated repository
|
|
47
|
+
* hold one secret and no variables at all.
|
|
48
|
+
*
|
|
49
|
+
* ## What is deliberately not here
|
|
50
|
+
*
|
|
51
|
+
* No loader URL, no `vendorRev`, no `x-platform-project`. This service holds
|
|
52
|
+
* the operator relationship with the build platform and calls it; a publisher
|
|
53
|
+
* talks to this API and nothing else. The one exception is the presigned
|
|
54
|
+
* upload URL, which points at object storage -- bytes do not travel through
|
|
55
|
+
* here.
|
|
56
|
+
*
|
|
57
|
+
* It is also what makes this publishable by a project token at all. The
|
|
58
|
+
* platform's own verify step publishes a canary to a throwaway project, and a
|
|
59
|
+
* throwaway has no secrets, so the loader has nothing to check a caller
|
|
60
|
+
* against and refuses with a 503 naming a project nobody has heard of. Behind
|
|
61
|
+
* this API the caller is never involved in that exchange.
|
|
62
|
+
*/
|
|
63
|
+
"/publish": {
|
|
64
|
+
POST: {
|
|
65
|
+
body: {
|
|
66
|
+
/** The build's own hash. Repeating it returns the same publish. */
|
|
67
|
+
buildHash: string;
|
|
68
|
+
/** Null only for a seed publish. See `publishPlan.ts`. */
|
|
69
|
+
commit: string | null;
|
|
70
|
+
/** The branch content saves commit to. Required when `commit` is set. */
|
|
71
|
+
branch: string | null;
|
|
72
|
+
/** Which dependency layer this was built against, sent or not. */
|
|
73
|
+
layerRev: string | null;
|
|
74
|
+
/**
|
|
75
|
+
* Whether the app links its own CSS.
|
|
76
|
+
*
|
|
77
|
+
* Build metadata the loader needs and no artifact carries, so it has to
|
|
78
|
+
* be declared. Null is a third answer -- "this build did not say" --
|
|
79
|
+
* and is not false.
|
|
80
|
+
*/
|
|
81
|
+
linksOwnCss: boolean | null;
|
|
82
|
+
artifacts: {
|
|
83
|
+
/** See `publishPlan.ts` for the namespace. */
|
|
84
|
+
key: string;
|
|
85
|
+
sha256: string;
|
|
86
|
+
bytes: number;
|
|
87
|
+
}[];
|
|
88
|
+
};
|
|
89
|
+
res: {
|
|
90
|
+
publishId: string;
|
|
91
|
+
state: PublishState;
|
|
92
|
+
project: {
|
|
93
|
+
publicProjectId: string;
|
|
94
|
+
/** Null when the project has no site yet -- reported, not refused. */
|
|
95
|
+
siteUrl: string | null;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* One per artifact this project does not already hold, and nothing else.
|
|
99
|
+
*
|
|
100
|
+
* So this doubles as the answer to "what is missing": there is no
|
|
101
|
+
* separate field to keep in step with it. Slots expire; a presigned PUT
|
|
102
|
+
* that answers 403 means declare again, not that the publish failed.
|
|
103
|
+
*/
|
|
104
|
+
uploads: {
|
|
105
|
+
key: string;
|
|
106
|
+
url: string;
|
|
107
|
+
method: "PUT";
|
|
108
|
+
headers: Record<string, string>;
|
|
109
|
+
/** ISO 8601. */
|
|
110
|
+
expiresAt: string;
|
|
111
|
+
}[];
|
|
112
|
+
/** Keys already held, so a caller can see what it did not have to send. */
|
|
113
|
+
have: string[];
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
"/publish/:publishId": {
|
|
118
|
+
GET: {
|
|
119
|
+
res: {
|
|
120
|
+
publishId: string;
|
|
121
|
+
state: PublishState;
|
|
122
|
+
buildHash: string;
|
|
123
|
+
/** Artifact keys still not uploaded. */
|
|
124
|
+
missing: string[];
|
|
125
|
+
problems: PublishProblem[];
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
/** Everything asked for has been uploaded. The uploads are checked here. */
|
|
130
|
+
"/publish/:publishId/artifacts": {
|
|
131
|
+
POST: {
|
|
132
|
+
res: {
|
|
133
|
+
state: PublishState;
|
|
134
|
+
problems: PublishProblem[];
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
/** A canary build and render, on the build platform, with our credential. */
|
|
139
|
+
"/publish/:publishId/verify": {
|
|
140
|
+
POST: {
|
|
141
|
+
res: {
|
|
142
|
+
state: PublishState;
|
|
143
|
+
ok: boolean;
|
|
144
|
+
/** Where the canary can be looked at, when it rendered. */
|
|
145
|
+
previewUrl: string | null;
|
|
146
|
+
problems: PublishProblem[];
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* Move the project's pointer to this build.
|
|
152
|
+
*
|
|
153
|
+
* Refused when the commit is no longer the branch head -- which this service
|
|
154
|
+
* is the authority on (see `getGitHead`), so the rule is enforced where the
|
|
155
|
+
* data already is rather than a round trip away.
|
|
156
|
+
*/
|
|
157
|
+
"/publish/:publishId/promote": {
|
|
158
|
+
POST: {
|
|
159
|
+
res: {
|
|
160
|
+
state: PublishState;
|
|
161
|
+
url: string | null;
|
|
162
|
+
commit: string | null;
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
/** Exchange a personal access token for a short-lived publish token. */
|
|
167
|
+
"/publish-token": {
|
|
168
|
+
POST: {
|
|
169
|
+
res: {
|
|
170
|
+
/** Shown once, here. Nothing can produce it again. */
|
|
171
|
+
token: string;
|
|
172
|
+
/** ISO 8601, because a `Date` does not survive JSON as one. */
|
|
173
|
+
expiresAt: string | null;
|
|
174
|
+
publicProjectId: string;
|
|
175
|
+
productionUrl: string | null;
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Where a publish is. Mirrors `PublishState` in `utils/publishPlan.ts`, which
|
|
183
|
+
* owns the transition table; this is the wire spelling of it.
|
|
184
|
+
*/
|
|
185
|
+
export type PublishState =
|
|
186
|
+
| "awaiting-artifacts"
|
|
187
|
+
| "ready"
|
|
188
|
+
| "verified"
|
|
189
|
+
| "live"
|
|
190
|
+
| "failed"
|
|
191
|
+
| "expired";
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Why a publish is not going anywhere.
|
|
195
|
+
*
|
|
196
|
+
* `code` is the stable half -- a pipeline gates on it -- and carries both this
|
|
197
|
+
* API's own codes (`ARTIFACT_MISMATCH`, `POINTER_STALE`, the declaration codes
|
|
198
|
+
* in `publishPlan.ts`) and the build platform's `PLATFORM*` codes passed
|
|
199
|
+
* through from a verify, because rewording those would lose the only sentence
|
|
200
|
+
* that says what to change.
|
|
201
|
+
*
|
|
202
|
+
* `hint` is not decoration. A gate that merely fails is useless in somebody
|
|
203
|
+
* else's pipeline: they get a red build and no idea why.
|
|
204
|
+
*/
|
|
205
|
+
export type PublishProblem = {
|
|
206
|
+
code: string;
|
|
207
|
+
message: string;
|
|
208
|
+
hint?: string;
|
|
209
|
+
keys?: string[];
|
|
210
|
+
};
|
|
@@ -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
|
+
}
|