@codepress/design-kit 0.8.0 → 0.9.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/README.md +92 -4
- package/package.json +3 -2
- package/schema/design-kit.config.schema.json +48 -0
- package/src/ci.mjs +76 -113
- package/src/cli.mjs +37 -4
- package/src/config.mjs +40 -0
- package/src/init.mjs +263 -73
- package/src/storage/bucket.mjs +237 -0
- package/src/storage/codepress.mjs +363 -0
- package/src/storage/common.mjs +125 -0
- package/src/storage/github.mjs +175 -0
- package/src/storage/index.mjs +149 -0
- package/src/storage/zip.mjs +249 -0
- package/src/templates/design-kit-skill.md +18 -18
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `s3` and `r2` storage: your own AWS S3 or Cloudflare R2 bucket, through
|
|
3
|
+
* the `aws` CLI (`aws s3 cp`). Credentials come from the environment the
|
|
4
|
+
* CLI reads (AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY).
|
|
5
|
+
*
|
|
6
|
+
* Layout, under the optional prefix:
|
|
7
|
+
*
|
|
8
|
+
* <prefix>/<branch>/<sha>.zip each kit
|
|
9
|
+
* <prefix>/<branch>/latest.json {sha, created_at, size_bytes, key, manifest}
|
|
10
|
+
*
|
|
11
|
+
* An upload writes the zip, then latest.json, so latest.json never names a
|
|
12
|
+
* zip that is not there yet. `r2` is `s3` with the endpoint
|
|
13
|
+
* `https://<accountId>.r2.cloudflarestorage.com` and the region `auto`.
|
|
14
|
+
*/
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { ConfigError } from "../config.mjs";
|
|
21
|
+
import {
|
|
22
|
+
StorageError,
|
|
23
|
+
checkSha,
|
|
24
|
+
installKit,
|
|
25
|
+
isKit,
|
|
26
|
+
kitCacheDir,
|
|
27
|
+
} from "./common.mjs";
|
|
28
|
+
import { kitSummary } from "./codepress.mjs";
|
|
29
|
+
import { zipDirectory } from "./zip.mjs";
|
|
30
|
+
|
|
31
|
+
/** The endpoint of a Cloudflare R2 account. */
|
|
32
|
+
export function r2Endpoint(accountId) {
|
|
33
|
+
return `https://${accountId}.r2.cloudflarestorage.com`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Runs `aws` with `args`; resolves its stdout, rejects with its stderr. */
|
|
37
|
+
export function runAws(args, { env = process.env } = {}) {
|
|
38
|
+
return new Promise((resolvePromise, reject) => {
|
|
39
|
+
execFile(
|
|
40
|
+
"aws",
|
|
41
|
+
args,
|
|
42
|
+
{ env, encoding: "buffer", maxBuffer: 512 * 1024 * 1024 },
|
|
43
|
+
(error, stdout, stderr) => {
|
|
44
|
+
if (error) {
|
|
45
|
+
error.stderr = stderr?.toString("utf8") ?? "";
|
|
46
|
+
reject(error);
|
|
47
|
+
} else resolvePromise(stdout);
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const NOT_FOUND = /\(404\)|NoSuchKey|Not Found|does not exist/i;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The `s3` or `r2` store. `storage` is the config's `storage` object;
|
|
57
|
+
* `aws` runs the CLI (tests pass their own, or put a stub on PATH).
|
|
58
|
+
*/
|
|
59
|
+
export function bucketStorage(
|
|
60
|
+
storage,
|
|
61
|
+
{ env = process.env, aws = runAws, now = () => new Date() } = {},
|
|
62
|
+
) {
|
|
63
|
+
const r2 = storage.type === "r2";
|
|
64
|
+
const endpoint = r2 ? r2Endpoint(storage.accountId) : storage.endpoint;
|
|
65
|
+
const region = r2 ? "auto" : storage.region;
|
|
66
|
+
const common = [
|
|
67
|
+
...(endpoint ? ["--endpoint-url", endpoint] : []),
|
|
68
|
+
...(region ? ["--region", region] : []),
|
|
69
|
+
];
|
|
70
|
+
const prefix = String(storage.prefix ?? "").replace(/^\/+|\/+$/g, "");
|
|
71
|
+
const key = (branch, name) =>
|
|
72
|
+
[prefix, branch, name].filter(Boolean).join("/");
|
|
73
|
+
const url = (path) => `s3://${storage.bucket}/${path}`;
|
|
74
|
+
const label = r2 ? "R2" : "S3";
|
|
75
|
+
|
|
76
|
+
const call = async (args) => {
|
|
77
|
+
try {
|
|
78
|
+
return await aws([...args, ...common], { env });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error?.code === "ENOENT")
|
|
81
|
+
throw new StorageError(
|
|
82
|
+
"aws_cli_missing",
|
|
83
|
+
`${storage.type} storage uses the aws CLI, and there is no aws on PATH. Install it (https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html); GitHub's ubuntu runners have it already.`,
|
|
84
|
+
);
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const needBranch = (branch) => {
|
|
89
|
+
if (!branch)
|
|
90
|
+
throw new ConfigError(
|
|
91
|
+
"--branch <name> is needed: GITHUB_REF_NAME is not set and the checkout's origin/HEAD names no default branch",
|
|
92
|
+
);
|
|
93
|
+
return branch;
|
|
94
|
+
};
|
|
95
|
+
const failed = (what, error) =>
|
|
96
|
+
new StorageError(
|
|
97
|
+
"kit_storage_unavailable",
|
|
98
|
+
`${what} in ${label} bucket ${storage.bucket} failed: ${String(
|
|
99
|
+
error?.stderr || error?.message || error,
|
|
100
|
+
)
|
|
101
|
+
.trim()
|
|
102
|
+
.split("\n")
|
|
103
|
+
.pop()}`,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const readLatest = async (branch) => {
|
|
107
|
+
let out;
|
|
108
|
+
try {
|
|
109
|
+
out = await call(["s3", "cp", url(key(branch, "latest.json")), "-"]);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof StorageError) throw error;
|
|
112
|
+
if (NOT_FOUND.test(String(error?.stderr ?? ""))) return null;
|
|
113
|
+
throw failed(`Reading ${key(branch, "latest.json")}`, error);
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const latest = JSON.parse(out.toString("utf8"));
|
|
117
|
+
if (!latest?.sha) throw new Error("no sha");
|
|
118
|
+
return latest;
|
|
119
|
+
} catch {
|
|
120
|
+
throw new StorageError(
|
|
121
|
+
"kit_storage_unavailable",
|
|
122
|
+
`${key(branch, "latest.json")} is not a kit pointer`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
type: storage.type,
|
|
129
|
+
async latest({ branch } = {}) {
|
|
130
|
+
const latest = await readLatest(needBranch(branch));
|
|
131
|
+
return latest
|
|
132
|
+
? { sha: latest.sha, createdAt: latest.created_at, branch }
|
|
133
|
+
: null;
|
|
134
|
+
},
|
|
135
|
+
async fetch({ branch, out } = {}) {
|
|
136
|
+
const name = needBranch(branch);
|
|
137
|
+
const latest = await readLatest(name);
|
|
138
|
+
if (!latest)
|
|
139
|
+
throw new StorageError(
|
|
140
|
+
"no_kit",
|
|
141
|
+
`no kit on ${name} in ${label} bucket ${storage.bucket}`,
|
|
142
|
+
);
|
|
143
|
+
checkSha(latest.sha, `${key(name, "latest.json")}`);
|
|
144
|
+
const path = resolve(out ?? kitCacheDir(latest.sha, env));
|
|
145
|
+
const result = { path, sha: latest.sha, branch: name };
|
|
146
|
+
if (isKit(path)) return { ...result, cached: true };
|
|
147
|
+
const scratch = mkdtempSync(
|
|
148
|
+
join(env.TMPDIR || tmpdir(), "design-kit-zip-"),
|
|
149
|
+
);
|
|
150
|
+
try {
|
|
151
|
+
const zipFile = join(scratch, "kit.zip");
|
|
152
|
+
try {
|
|
153
|
+
await call([
|
|
154
|
+
"s3",
|
|
155
|
+
"cp",
|
|
156
|
+
url(latest.key ?? key(name, `${latest.sha}.zip`)),
|
|
157
|
+
zipFile,
|
|
158
|
+
]);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error instanceof StorageError) throw error;
|
|
161
|
+
throw failed(`Downloading ${latest.sha}.zip`, error);
|
|
162
|
+
}
|
|
163
|
+
installKit(readFileSync(zipFile), path);
|
|
164
|
+
} finally {
|
|
165
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
return { ...result, cached: false };
|
|
168
|
+
},
|
|
169
|
+
async upload({ dir, branch, sha } = {}) {
|
|
170
|
+
const name = needBranch(branch);
|
|
171
|
+
const manifest = kitSummary(dir);
|
|
172
|
+
const kitSha = sha ?? manifest.sha;
|
|
173
|
+
if (!kitSha)
|
|
174
|
+
throw new ConfigError(
|
|
175
|
+
"--sha <commit> is needed: GITHUB_SHA is not set and the kit's manifest names no commit",
|
|
176
|
+
);
|
|
177
|
+
checkSha(kitSha, sha ? "--sha (or GITHUB_SHA)" : "the kit's manifest");
|
|
178
|
+
const scratch = mkdtempSync(
|
|
179
|
+
join(env.TMPDIR || tmpdir(), "design-kit-zip-"),
|
|
180
|
+
);
|
|
181
|
+
try {
|
|
182
|
+
const zip = zipDirectory(dir);
|
|
183
|
+
const zipFile = join(scratch, `${kitSha}.zip`);
|
|
184
|
+
writeFileSync(zipFile, zip);
|
|
185
|
+
const zipKey = key(name, `${kitSha}.zip`);
|
|
186
|
+
try {
|
|
187
|
+
await call([
|
|
188
|
+
"s3",
|
|
189
|
+
"cp",
|
|
190
|
+
zipFile,
|
|
191
|
+
url(zipKey),
|
|
192
|
+
"--content-type",
|
|
193
|
+
"application/zip",
|
|
194
|
+
"--only-show-errors",
|
|
195
|
+
]);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error instanceof StorageError) throw error;
|
|
198
|
+
throw failed(`Uploading ${zipKey}`, error);
|
|
199
|
+
}
|
|
200
|
+
const pointer = join(scratch, "latest.json");
|
|
201
|
+
writeFileSync(
|
|
202
|
+
pointer,
|
|
203
|
+
`${JSON.stringify({
|
|
204
|
+
sha: kitSha,
|
|
205
|
+
branch: name,
|
|
206
|
+
created_at: now().toISOString(),
|
|
207
|
+
size_bytes: zip.length,
|
|
208
|
+
key: zipKey,
|
|
209
|
+
manifest,
|
|
210
|
+
})}\n`,
|
|
211
|
+
);
|
|
212
|
+
try {
|
|
213
|
+
await call([
|
|
214
|
+
"s3",
|
|
215
|
+
"cp",
|
|
216
|
+
pointer,
|
|
217
|
+
url(key(name, "latest.json")),
|
|
218
|
+
"--content-type",
|
|
219
|
+
"application/json",
|
|
220
|
+
"--only-show-errors",
|
|
221
|
+
]);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error instanceof StorageError) throw error;
|
|
224
|
+
throw failed(`Writing ${key(name, "latest.json")}`, error);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
sha: kitSha,
|
|
228
|
+
branch: name,
|
|
229
|
+
sizeBytes: zip.length,
|
|
230
|
+
key: zipKey,
|
|
231
|
+
};
|
|
232
|
+
} finally {
|
|
233
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `codepress` storage: CodePress keeps the newest kit of each branch.
|
|
3
|
+
*
|
|
4
|
+
* - Upload (GitHub Actions only): the workflow's OIDC token, requested with
|
|
5
|
+
* the CodePress address as its audience, signs in; the repository holds
|
|
6
|
+
* no secret. `POST /v1/design-kits/uploads` with the zip's size and a
|
|
7
|
+
* summary of the kit answers a presigned PUT, the zip goes straight to
|
|
8
|
+
* storage with exactly the headers it names (its `Content-Length` must be
|
|
9
|
+
* the declared size), and `POST /v1/design-kits/uploads/{id}/complete`
|
|
10
|
+
* makes it the branch's kit.
|
|
11
|
+
* - Reads: `GET /v1/design-kits/repos/{owner}/{repo}/latest?branch=` with a
|
|
12
|
+
* CodePress API key (CODEPRESS_API_KEY, or a file named by --token-file),
|
|
13
|
+
* which answers the kit's commit and a short-lived download URL.
|
|
14
|
+
*
|
|
15
|
+
* A refusal keeps the backend's name for it (`forbidden`, `no_kit`,
|
|
16
|
+
* `repo_not_connected`, …); a store that cannot be reached is
|
|
17
|
+
* `kit_storage_unavailable`.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import http from "node:http";
|
|
21
|
+
import https from "node:https";
|
|
22
|
+
import { join, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { ConfigError } from "../config.mjs";
|
|
25
|
+
import {
|
|
26
|
+
StorageError,
|
|
27
|
+
checkSha,
|
|
28
|
+
installKit,
|
|
29
|
+
isKit,
|
|
30
|
+
kitCacheDir,
|
|
31
|
+
} from "./common.mjs";
|
|
32
|
+
import { zipDirectory } from "./zip.mjs";
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_URL = "https://codepress.dev";
|
|
35
|
+
export const DEFAULT_API_URL = "https://api.codepress.dev/v1";
|
|
36
|
+
const UNAVAILABLE = "kit_storage_unavailable";
|
|
37
|
+
/** How long one request may take before the store counts as unreachable. */
|
|
38
|
+
const REQUEST_TIMEOUT_MS = Number(
|
|
39
|
+
process.env.DESIGN_KIT_REQUEST_TIMEOUT_MS || 120_000,
|
|
40
|
+
);
|
|
41
|
+
const MANIFEST_BYTES = 4096;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The API base for a storage config: `apiUrl` when set; the CodePress API
|
|
45
|
+
* for the default address; else `<url>/v1`.
|
|
46
|
+
*/
|
|
47
|
+
export function apiBase(storage = {}) {
|
|
48
|
+
if (storage.apiUrl) return storage.apiUrl.replace(/\/+$/, "");
|
|
49
|
+
const url = (storage.url ?? DEFAULT_URL).replace(/\/+$/, "");
|
|
50
|
+
return url === DEFAULT_URL ? DEFAULT_API_URL : `${url}/v1`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One HTTP request: `{status, headers, body}` (body a Buffer). Headers are
|
|
55
|
+
* sent exactly as given, so a presigned PUT carries its signed
|
|
56
|
+
* Content-Length. A failure to connect, or no answer within the timeout, is
|
|
57
|
+
* `kit_storage_unavailable`.
|
|
58
|
+
*/
|
|
59
|
+
export function request(url, { method = "GET", headers = {}, body } = {}) {
|
|
60
|
+
return new Promise((resolvePromise, reject) => {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = new URL(url);
|
|
64
|
+
} catch {
|
|
65
|
+
reject(new StorageError(UNAVAILABLE, `not a URL: ${url}`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const client = parsed.protocol === "http:" ? http : https;
|
|
69
|
+
const req = client.request(
|
|
70
|
+
parsed,
|
|
71
|
+
{ method, headers, timeout: REQUEST_TIMEOUT_MS },
|
|
72
|
+
(res) => {
|
|
73
|
+
const chunks = [];
|
|
74
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
75
|
+
res.on("end", () =>
|
|
76
|
+
resolvePromise({
|
|
77
|
+
status: res.statusCode ?? 0,
|
|
78
|
+
headers: res.headers,
|
|
79
|
+
body: Buffer.concat(chunks),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
res.on("error", (error) =>
|
|
83
|
+
reject(new StorageError(UNAVAILABLE, error.message)),
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
req.on("timeout", () =>
|
|
88
|
+
req.destroy(new Error(`no answer from ${parsed.host} in time`)),
|
|
89
|
+
);
|
|
90
|
+
req.on("error", (error) =>
|
|
91
|
+
reject(
|
|
92
|
+
new StorageError(
|
|
93
|
+
UNAVAILABLE,
|
|
94
|
+
`could not reach ${parsed.host} (${error.code ?? error.message})`,
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
if (body !== undefined) req.end(body);
|
|
99
|
+
else req.end();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The backend's named error for a non-2xx answer. */
|
|
104
|
+
function refusal(response, what) {
|
|
105
|
+
let payload = null;
|
|
106
|
+
try {
|
|
107
|
+
payload = JSON.parse(response.body.toString("utf8"));
|
|
108
|
+
} catch {
|
|
109
|
+
// Not JSON: a proxy or gateway answered.
|
|
110
|
+
}
|
|
111
|
+
const reason = payload?.reason ?? payload?.code;
|
|
112
|
+
if (typeof reason === "string" && reason)
|
|
113
|
+
return new StorageError(
|
|
114
|
+
reason,
|
|
115
|
+
`${what} was refused (${response.status}): ${payload.detail ?? reason}`,
|
|
116
|
+
);
|
|
117
|
+
if (response.status >= 500 || response.status === 0)
|
|
118
|
+
return new StorageError(
|
|
119
|
+
UNAVAILABLE,
|
|
120
|
+
`${what} failed: CodePress answered ${response.status}`,
|
|
121
|
+
);
|
|
122
|
+
const named = { 401: "unauthorized", 403: "forbidden", 404: "not_found" };
|
|
123
|
+
const detail =
|
|
124
|
+
typeof payload?.detail === "string" ? `: ${payload.detail}` : "";
|
|
125
|
+
return new StorageError(
|
|
126
|
+
named[response.status] ?? `http_${response.status}`,
|
|
127
|
+
`${what} was refused (${response.status})${detail}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function json(url, options, what) {
|
|
132
|
+
const response = await request(url, options);
|
|
133
|
+
if (response.status < 200 || response.status >= 300)
|
|
134
|
+
throw refusal(response, what);
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(response.body.toString("utf8"));
|
|
137
|
+
} catch {
|
|
138
|
+
throw new StorageError(UNAVAILABLE, `${what}: the answer is not JSON`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The read credential: CODEPRESS_API_KEY, or the token in `tokenFile`. The
|
|
144
|
+
* file may hold the token alone, or `KEY=value` lines (an env file, as
|
|
145
|
+
* CodePress Terminal's `create_backend_access_token_file` writes): then
|
|
146
|
+
* CODEPRESS_API_KEY's line, or the only line.
|
|
147
|
+
*/
|
|
148
|
+
export function readToken({ tokenFile, env = process.env } = {}) {
|
|
149
|
+
if (tokenFile) {
|
|
150
|
+
let text;
|
|
151
|
+
try {
|
|
152
|
+
text = readFileSync(tokenFile, "utf8");
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw new ConfigError(
|
|
155
|
+
`--token-file ${tokenFile}: could not read it (${error.code ?? error.message})`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const lines = text
|
|
159
|
+
.split(/\r?\n/)
|
|
160
|
+
.map((line) => line.trim())
|
|
161
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
162
|
+
const assignments = lines
|
|
163
|
+
.map((line) =>
|
|
164
|
+
line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/),
|
|
165
|
+
)
|
|
166
|
+
.filter(Boolean);
|
|
167
|
+
const unquote = (value) => value.replace(/^(['"])(.*)\1$/, "$2");
|
|
168
|
+
if (assignments.length) {
|
|
169
|
+
const named = assignments.find(([, key]) => key === "CODEPRESS_API_KEY");
|
|
170
|
+
const chosen =
|
|
171
|
+
named ?? (assignments.length === 1 ? assignments[0] : undefined);
|
|
172
|
+
if (!chosen)
|
|
173
|
+
throw new ConfigError(
|
|
174
|
+
`--token-file ${tokenFile}: it has several KEY=value lines and none is CODEPRESS_API_KEY`,
|
|
175
|
+
);
|
|
176
|
+
return unquote(chosen[2].trim());
|
|
177
|
+
}
|
|
178
|
+
if (lines.length === 1) return lines[0];
|
|
179
|
+
throw new ConfigError(`--token-file ${tokenFile}: it holds no token`);
|
|
180
|
+
}
|
|
181
|
+
if (env.CODEPRESS_API_KEY) return env.CODEPRESS_API_KEY.trim();
|
|
182
|
+
throw new StorageError(
|
|
183
|
+
"no_credentials",
|
|
184
|
+
"codepress storage needs a CodePress API key to read kits. Set CODEPRESS_API_KEY, or pass --token-file <path> (a file holding the key alone, or a KEY=value env file with a CODEPRESS_API_KEY line)",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The credential a read sends: an API key (CODEPRESS_API_KEY or
|
|
190
|
+
* --token-file) when there is one; otherwise, inside a GitHub Actions job
|
|
191
|
+
* with `id-token: write`, the repository's own OIDC token for `audience`
|
|
192
|
+
* (the backend reads it as that repository, #22530). Neither is
|
|
193
|
+
* `no_credentials`.
|
|
194
|
+
*/
|
|
195
|
+
export async function readCredential({
|
|
196
|
+
tokenFile,
|
|
197
|
+
env = process.env,
|
|
198
|
+
audience,
|
|
199
|
+
}) {
|
|
200
|
+
if (
|
|
201
|
+
!tokenFile &&
|
|
202
|
+
!env.CODEPRESS_API_KEY &&
|
|
203
|
+
env.ACTIONS_ID_TOKEN_REQUEST_URL &&
|
|
204
|
+
env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
|
205
|
+
)
|
|
206
|
+
return oidcToken(audience, env);
|
|
207
|
+
return readToken({ tokenFile, env });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The workflow's GitHub OIDC token for `audience`. Needs
|
|
212
|
+
* ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, which
|
|
213
|
+
* GitHub sets only in a job with `permissions: id-token: write`.
|
|
214
|
+
*/
|
|
215
|
+
export async function oidcToken(audience, env = process.env) {
|
|
216
|
+
const url = env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
217
|
+
const token = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
218
|
+
if (!url || !token)
|
|
219
|
+
throw new StorageError(
|
|
220
|
+
"oidc_unavailable",
|
|
221
|
+
"design-kit upload with codepress storage signs in with the GitHub Actions OIDC token, and ACTIONS_ID_TOKEN_REQUEST_URL is not set. Run it in a GitHub Actions job with `permissions: id-token: write` (the workflow design-kit init writes does).",
|
|
222
|
+
);
|
|
223
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
224
|
+
const body = await json(
|
|
225
|
+
`${url}${separator}audience=${encodeURIComponent(audience)}`,
|
|
226
|
+
{
|
|
227
|
+
headers: { Authorization: `bearer ${token}`, Accept: "application/json" },
|
|
228
|
+
},
|
|
229
|
+
"Requesting the GitHub OIDC token",
|
|
230
|
+
);
|
|
231
|
+
if (!body?.value)
|
|
232
|
+
throw new StorageError("oidc_unavailable", "GitHub answered no OIDC token");
|
|
233
|
+
return body.value;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The kit summary sent with an upload: at most 4 KB. */
|
|
237
|
+
export function kitSummary(dir) {
|
|
238
|
+
let manifest = {};
|
|
239
|
+
try {
|
|
240
|
+
manifest = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
|
|
241
|
+
} catch (error) {
|
|
242
|
+
throw new ConfigError(
|
|
243
|
+
`${dir} is not a kit: no readable manifest.json (${error.code ?? error.message})`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const summary = {};
|
|
247
|
+
for (const key of ["kit_version", "sha", "built_at", "counts"])
|
|
248
|
+
if (manifest[key] !== undefined) summary[key] = manifest[key];
|
|
249
|
+
if (Buffer.byteLength(JSON.stringify(summary)) > MANIFEST_BYTES)
|
|
250
|
+
delete summary.counts;
|
|
251
|
+
return summary;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The `codepress` store. `storage` is the config's `storage` object; `repo`
|
|
256
|
+
* is `owner/name` (reads only).
|
|
257
|
+
*/
|
|
258
|
+
export function codepressStorage(
|
|
259
|
+
storage = {},
|
|
260
|
+
{ repo, env = process.env, tokenFile } = {},
|
|
261
|
+
) {
|
|
262
|
+
const audience = (storage.url ?? DEFAULT_URL).replace(/\/+$/, "");
|
|
263
|
+
const api = apiBase(storage);
|
|
264
|
+
const readLatest = async (branch) => {
|
|
265
|
+
if (!repo)
|
|
266
|
+
throw new ConfigError(
|
|
267
|
+
"--repo <owner/name> is needed: no GITHUB_REPOSITORY and no github.com origin remote",
|
|
268
|
+
);
|
|
269
|
+
const token = await readCredential({ tokenFile, env, audience });
|
|
270
|
+
const [owner, name] = repo.split("/");
|
|
271
|
+
const query = branch ? `?branch=${encodeURIComponent(branch)}` : "";
|
|
272
|
+
const response = await request(
|
|
273
|
+
`${api}/design-kits/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/latest${query}`,
|
|
274
|
+
{
|
|
275
|
+
headers: {
|
|
276
|
+
Authorization: `Bearer ${token}`,
|
|
277
|
+
Accept: "application/json",
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
);
|
|
281
|
+
if (response.status < 200 || response.status >= 300) {
|
|
282
|
+
const error = refusal(response, `Reading the newest kit of ${repo}`);
|
|
283
|
+
if (error.code === "no_kit") return null;
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(response.body.toString("utf8"));
|
|
288
|
+
} catch {
|
|
289
|
+
throw new StorageError(UNAVAILABLE, "the latest kit answer is not JSON");
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
return {
|
|
293
|
+
type: "codepress",
|
|
294
|
+
async latest({ branch } = {}) {
|
|
295
|
+
const kit = await readLatest(branch);
|
|
296
|
+
if (!kit) return null;
|
|
297
|
+
return {
|
|
298
|
+
sha: kit.commit_sha,
|
|
299
|
+
createdAt: kit.created_at,
|
|
300
|
+
branch: kit.branch,
|
|
301
|
+
sizeBytes: kit.size_bytes,
|
|
302
|
+
};
|
|
303
|
+
},
|
|
304
|
+
async fetch({ branch, out } = {}) {
|
|
305
|
+
const kit = await readLatest(branch);
|
|
306
|
+
if (!kit)
|
|
307
|
+
throw new StorageError(
|
|
308
|
+
"no_kit",
|
|
309
|
+
`CodePress has no kit for ${repo}${branch ? ` on ${branch}` : " on its default branch"}`,
|
|
310
|
+
);
|
|
311
|
+
checkSha(kit.commit_sha, "CodePress");
|
|
312
|
+
const path = resolve(out ?? kitCacheDir(kit.commit_sha, env));
|
|
313
|
+
const result = { path, sha: kit.commit_sha, branch: kit.branch };
|
|
314
|
+
if (isKit(path)) return { ...result, cached: true };
|
|
315
|
+
const response = await request(kit.download_url);
|
|
316
|
+
if (response.status < 200 || response.status >= 300)
|
|
317
|
+
throw refusal(response, "Downloading the kit");
|
|
318
|
+
installKit(response.body, path);
|
|
319
|
+
return { ...result, cached: false };
|
|
320
|
+
},
|
|
321
|
+
async upload({ dir } = {}) {
|
|
322
|
+
const manifest = kitSummary(dir);
|
|
323
|
+
const token = await oidcToken(audience, env);
|
|
324
|
+
const zip = zipDirectory(dir);
|
|
325
|
+
const auth = {
|
|
326
|
+
Authorization: `Bearer ${token}`,
|
|
327
|
+
Accept: "application/json",
|
|
328
|
+
"Content-Type": "application/json",
|
|
329
|
+
};
|
|
330
|
+
const started = await json(
|
|
331
|
+
`${api}/design-kits/uploads`,
|
|
332
|
+
{
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: auth,
|
|
335
|
+
body: JSON.stringify({ size_bytes: zip.length, manifest }),
|
|
336
|
+
},
|
|
337
|
+
"Starting the upload",
|
|
338
|
+
);
|
|
339
|
+
const target = started.upload ?? {};
|
|
340
|
+
const put = await request(target.url, {
|
|
341
|
+
method: target.method ?? "PUT",
|
|
342
|
+
headers: { ...target.headers },
|
|
343
|
+
body: zip,
|
|
344
|
+
});
|
|
345
|
+
if (put.status < 200 || put.status >= 300)
|
|
346
|
+
throw new StorageError(
|
|
347
|
+
"upload_failed",
|
|
348
|
+
`storage refused the zip (${put.status}): ${put.body.toString("utf8").slice(0, 300)}`,
|
|
349
|
+
);
|
|
350
|
+
const done = await json(
|
|
351
|
+
`${api}/design-kits/uploads/${encodeURIComponent(started.upload_id)}/complete`,
|
|
352
|
+
{ method: "POST", headers: auth, body: "{}" },
|
|
353
|
+
"Completing the upload",
|
|
354
|
+
);
|
|
355
|
+
return {
|
|
356
|
+
sha: done.commit_sha ?? started.commit_sha,
|
|
357
|
+
branch: done.branch ?? started.branch,
|
|
358
|
+
sizeBytes: zip.length,
|
|
359
|
+
uploadId: started.upload_id,
|
|
360
|
+
};
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|