@valbuild/cli 0.133.0 → 0.134.1
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 +58 -0
- package/cli/dist/valbuild-cli-cli.cjs.dev.js +1424 -0
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +1424 -0
- package/cli/dist/valbuild-cli-cli.esm.js +1422 -0
- package/package.json +7 -5
- package/src/cli.ts +51 -0
- 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,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
|
+
}
|