@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.
@@ -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
+ }