@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
package/README.md
CHANGED
|
@@ -191,6 +191,77 @@ start command, sign-in, a missing Storybook. It never overwrites an existing
|
|
|
191
191
|
file without `--force` (a repository with a `design-kit.config.json` exits 1,
|
|
192
192
|
"already set up") and never writes a secret.
|
|
193
193
|
|
|
194
|
+
## Where the kit is stored
|
|
195
|
+
|
|
196
|
+
The config's `storage` key says where the workflow puts each kit and where
|
|
197
|
+
`design-kit fetch` and `ci-check` read it. Agents and CI use the same commands
|
|
198
|
+
whichever you pick. Choose one at setup with
|
|
199
|
+
`init --storage github|codepress|s3|r2`, or later with
|
|
200
|
+
`init --workflow-only --force --storage <type>`, which sets the key and
|
|
201
|
+
rewrites the workflow in the same run.
|
|
202
|
+
|
|
203
|
+
| `storage` | Where | Upload | Reading |
|
|
204
|
+
| -------------------------------------------------- | ----------------------------- | ------------------------------------- | ------------------------------------- |
|
|
205
|
+
| absent, or `{"type": "github"}` | a GitHub Actions artifact | the workflow's `upload-artifact` step | `gh`, signed in to the repository |
|
|
206
|
+
| `{"type": "codepress"}` | CodePress | `design-kit upload`, no secret | a CodePress API key |
|
|
207
|
+
| `{"type": "s3", "bucket", "region"?, "prefix"?}` | your own AWS S3 bucket | `design-kit upload`, two secrets | the `aws` CLI with read access |
|
|
208
|
+
| `{"type": "r2", "accountId", "bucket", "prefix"?}` | your own Cloudflare R2 bucket | `design-kit upload`, two secrets | the `aws` CLI with an R2 token's keys |
|
|
209
|
+
|
|
210
|
+
**GitHub** (the default). Nothing to set up. Each kit is a `design-kit`
|
|
211
|
+
artifact, kept 90 days on a public repository (the workflow rebuilds before
|
|
212
|
+
then) and read through the `gh` CLI.
|
|
213
|
+
|
|
214
|
+
**CodePress.** CodePress keeps the newest kit of each branch for as long as
|
|
215
|
+
the repository is connected. Connect the repository in
|
|
216
|
+
[CodePress](https://codepress.dev), then add
|
|
217
|
+
`"storage": {"type": "codepress"}`. No secret is needed: the build job has
|
|
218
|
+
`permissions: id-token: write`, and `design-kit upload` signs in with the
|
|
219
|
+
workflow's GitHub OIDC token, which proves which repository and branch it
|
|
220
|
+
comes from. To read a kit, set `CODEPRESS_API_KEY` to an API key of an
|
|
221
|
+
organization that connected the repository, or pass `--token-file <path>`
|
|
222
|
+
(a file holding the key alone, or a `CODEPRESS_API_KEY=` line). The daily
|
|
223
|
+
check reads the newest kit with the key when the repository has a
|
|
224
|
+
`CODEPRESS_API_KEY` secret, and otherwise with its own OIDC token (its job
|
|
225
|
+
gets `id-token: write` too); when the read fails, it builds. `url` (default
|
|
226
|
+
`https://codepress.dev`) and `apiUrl` (default
|
|
227
|
+
`https://api.codepress.dev/v1`) point at another CodePress. This needs
|
|
228
|
+
CodePress's kit storage, which is on codepress.dev; a repository that is not
|
|
229
|
+
connected is refused with `repo_not_connected`.
|
|
230
|
+
|
|
231
|
+
**Your own S3 bucket.** Create the bucket and an IAM user that may read and
|
|
232
|
+
write it, add `"storage": {"type": "s3", "bucket": "my-kits", "region":
|
|
233
|
+
"us-east-1"}`, and add the user's keys as the `AWS_ACCESS_KEY_ID` and
|
|
234
|
+
`AWS_SECRET_ACCESS_KEY` repository secrets (Settings > Secrets and variables
|
|
235
|
+
|
|
236
|
+
> Actions). `endpoint` points at another S3-compatible store.
|
|
237
|
+
|
|
238
|
+
**Your own Cloudflare R2 bucket.** Create the bucket, then an R2 API token
|
|
239
|
+
with **Object Read & Write** on it (R2 > Manage API tokens). Add
|
|
240
|
+
`"storage": {"type": "r2", "accountId": "<your account id>", "bucket":
|
|
241
|
+
"my-kits"}`; the endpoint (`https://<accountId>.r2.cloudflarestorage.com`) and
|
|
242
|
+
region (`auto`) follow from the account id. Add the token's keys as the
|
|
243
|
+
`R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY` repository secrets; the
|
|
244
|
+
workflow passes them to the `aws` CLI as `AWS_ACCESS_KEY_ID` and
|
|
245
|
+
`AWS_SECRET_ACCESS_KEY`.
|
|
246
|
+
|
|
247
|
+
In a bucket, each kit is `<prefix>/<branch>/<sha>.zip`, and
|
|
248
|
+
`<prefix>/<branch>/latest.json` names the newest; an upload writes the zip
|
|
249
|
+
first and `latest.json` last. Older zips stay until you delete them, for
|
|
250
|
+
example with a lifecycle rule on the prefix. `s3` and `r2` need the `aws` CLI,
|
|
251
|
+
which GitHub's Ubuntu runners have; elsewhere a missing `aws` is an error
|
|
252
|
+
that says so.
|
|
253
|
+
|
|
254
|
+
**GitHub Enterprise Server.** `codepress` storage works only for
|
|
255
|
+
repositories on github.com: CodePress connects github.com repositories and
|
|
256
|
+
accepts only github.com's OIDC tokens. On GitHub Enterprise Server, use
|
|
257
|
+
`github`, `s3` or `r2`, and pass `--repo <owner/name>` where the commands
|
|
258
|
+
cannot read it from `GITHUB_REPOSITORY`.
|
|
259
|
+
|
|
260
|
+
**Adding `storage` by hand?** Bump the pinned version too:
|
|
261
|
+
`DESIGN_KIT_PACKAGE` in the workflow and the version in the config's
|
|
262
|
+
`$schema`. Versions before `storage` existed reject the key as unknown, so an
|
|
263
|
+
older pin fails the run. `init --storage` does both for you.
|
|
264
|
+
|
|
194
265
|
## Configuration
|
|
195
266
|
|
|
196
267
|
The commands read `design-kit.config.json` from the working directory or the
|
|
@@ -317,6 +388,11 @@ value>]` then replaces `slots`.
|
|
|
317
388
|
|
|
318
389
|
```bash
|
|
319
390
|
design-kit init [--root dir] [--dry-run] [--force] [--workflow-only]
|
|
391
|
+
[--storage github|codepress|s3|r2]
|
|
392
|
+
design-kit upload --dir kit [--config f] [--branch b] [--sha commit]
|
|
393
|
+
design-kit fetch [--branch b] [--out dir] [--config f] [--repo owner/name]
|
|
394
|
+
[--token-file f]
|
|
395
|
+
design-kit latest [--branch b] [--config f] [--repo owner/name] [--token-file f]
|
|
320
396
|
design-kit capture stories [--config f] [--out dir] [--storybook-dir dir]
|
|
321
397
|
[--only id,... | --only-file f] [--base previous-kit]
|
|
322
398
|
design-kit capture pages [--config f] [--out dir] [--base-url url] [--only name,...]
|
|
@@ -330,8 +406,20 @@ design-kit setup-guide
|
|
|
330
406
|
design-kit storybook-guide
|
|
331
407
|
```
|
|
332
408
|
|
|
333
|
-
`
|
|
334
|
-
|
|
409
|
+
`upload`, `fetch` and `latest` work with the configured storage (see
|
|
410
|
+
[Where the kit is stored](#where-the-kit-is-stored)). `upload` sends a built
|
|
411
|
+
kit (`codepress`, `s3` and `r2`; with `github` it exits 2, since the
|
|
412
|
+
workflow's upload step does it). `fetch` downloads the newest kit once per
|
|
413
|
+
commit, into `$TMPDIR/design-kit-<sha>` unless `--out` says otherwise, and
|
|
414
|
+
prints `path=`, `sha=` and `branch=`; a second `fetch` of the same commit
|
|
415
|
+
downloads nothing. `latest` prints the newest kit's `sha=` and `created=`.
|
|
416
|
+
Without `--branch` they read the default branch. A store's refusal keeps its
|
|
417
|
+
name (`forbidden`, `no_kit`, `repo_not_connected`, …), a store that cannot be
|
|
418
|
+
reached is `kit_storage_unavailable`, and both exit 1.
|
|
419
|
+
|
|
420
|
+
`ci-check` and `ci-cleanup` are the workflow's steps. `ci-check` gets the
|
|
421
|
+
newest kit on the branch from the storage's `latest` (for `github`, the
|
|
422
|
+
newest unexpired `design-kit` artifact through `gh`; from
|
|
335
423
|
`GITHUB_REPOSITORY` and `GITHUB_REF_NAME`, or `--repo` and `--branch`), or
|
|
336
424
|
takes it from `--since` and `--kit-created`. It fetches that commit (shallow,
|
|
337
425
|
no file contents) and compares the git tree or blob id of each `watch` path
|
|
@@ -340,8 +428,8 @@ counts as changed. It prints `build=true` or `build=false` and the reason
|
|
|
340
428
|
(`changed: src`, `watched files unchanged since 3f2a9c1`, `refresh before
|
|
341
429
|
expiry: …`, `no kit yet on this branch`, `forced …`, `an explicit package was
|
|
342
430
|
given …`) and writes them to `$GITHUB_OUTPUT` and the step summary. A kit
|
|
343
|
-
that cannot be looked up or fetched builds. `ci-cleanup`
|
|
344
|
-
branch's kits older than `--keep` and warns about any it could not delete;
|
|
431
|
+
that cannot be looked up or fetched builds. `ci-cleanup` (`github` storage
|
|
432
|
+
only) deletes the branch's kits older than `--keep` and warns about any it could not delete;
|
|
345
433
|
both always exit 0 unless the config is invalid.
|
|
346
434
|
|
|
347
435
|
`setup-guide` prints the agent skill for setting a kit up in a repository
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codepress/design-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Capture your Storybook and real app pages as a static design kit, and compose self-contained mockups from it",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@playwright/test": "1.58.2",
|
|
52
52
|
"oxlint": "^1.51.0",
|
|
53
|
-
"prettier": "^3.8.1"
|
|
53
|
+
"prettier": "^3.8.1",
|
|
54
|
+
"yaml": "2.8.3"
|
|
54
55
|
},
|
|
55
56
|
"keywords": [
|
|
56
57
|
"design-kit",
|
|
@@ -31,11 +31,59 @@
|
|
|
31
31
|
},
|
|
32
32
|
"description": "The paths whose change rebuilds the kit in CI (`design-kit ci-check`), relative to the repository root. A run where none of them changed since the newest kit's commit builds nothing. Without it, any change in the repository rebuilds. `init` writes the Storybook and app folders, the workspace packages they use, global styles, this file, the lockfile and the workflow."
|
|
33
33
|
},
|
|
34
|
+
"storage": { "$ref": "#/$defs/storage" },
|
|
34
35
|
"theme": { "$ref": "#/$defs/theme" },
|
|
35
36
|
"stories": { "$ref": "#/$defs/stories" },
|
|
36
37
|
"pages": { "$ref": "#/$defs/pages" }
|
|
37
38
|
},
|
|
38
39
|
"$defs": {
|
|
40
|
+
"storage": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"additionalProperties": false,
|
|
43
|
+
"required": ["type"],
|
|
44
|
+
"description": "Where the kit is stored. Without it, \"github\": a GitHub Actions artifact. Older versions of the package reject this key, so bump the pinned version (DESIGN_KIT_PACKAGE in the workflow) when you add it by hand.",
|
|
45
|
+
"properties": {
|
|
46
|
+
"type": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"enum": ["github", "codepress", "s3", "r2"],
|
|
49
|
+
"description": "\"github\" (default): a GitHub Actions artifact. \"codepress\": CodePress keeps the newest kit of each branch; the workflow signs in with its GitHub OIDC token. \"s3\": your own AWS S3 bucket. \"r2\": your own Cloudflare R2 bucket."
|
|
50
|
+
},
|
|
51
|
+
"url": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"pattern": "^https?://[^\\s/]+/?$",
|
|
54
|
+
"description": "codepress: the CodePress address, which is also the upload token's audience. Default https://codepress.dev."
|
|
55
|
+
},
|
|
56
|
+
"apiUrl": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"pattern": "^https?://\\S+$",
|
|
59
|
+
"description": "codepress: the CodePress API base. Default https://api.codepress.dev/v1 for the default url, else <url>/v1."
|
|
60
|
+
},
|
|
61
|
+
"bucket": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"minLength": 1,
|
|
64
|
+
"description": "s3 and r2: the bucket's name."
|
|
65
|
+
},
|
|
66
|
+
"prefix": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"description": "s3 and r2: a folder in the bucket for the kits, such as \"design-kits\". Kits are <prefix>/<branch>/<sha>.zip with <prefix>/<branch>/latest.json."
|
|
69
|
+
},
|
|
70
|
+
"region": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"minLength": 1,
|
|
73
|
+
"description": "s3: the bucket's AWS region, such as \"us-east-1\". Default: the aws CLI's own (AWS_REGION)."
|
|
74
|
+
},
|
|
75
|
+
"endpoint": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"pattern": "^https?://\\S+$",
|
|
78
|
+
"description": "s3: the endpoint of another S3-compatible store. Not needed for AWS S3, or for R2 (use type \"r2\")."
|
|
79
|
+
},
|
|
80
|
+
"accountId": {
|
|
81
|
+
"type": "string",
|
|
82
|
+
"minLength": 1,
|
|
83
|
+
"description": "r2: your Cloudflare account id (letters and digits). The endpoint https://<accountId>.r2.cloudflarestorage.com and region auto follow from it."
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
39
87
|
"size": {
|
|
40
88
|
"type": "object",
|
|
41
89
|
"additionalProperties": false,
|
package/src/ci.mjs
CHANGED
|
@@ -9,113 +9,30 @@
|
|
|
9
9
|
* - `design-kit ci-cleanup` deletes the older kits on the branch once a new
|
|
10
10
|
* one is uploaded. A failed delete is a warning, never a failure.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The newest kit comes from the configured store's `latest` (see
|
|
13
|
+
* src/storage/); cleanup is for `github` storage only, since CodePress and
|
|
14
|
+
* a bucket keep one pointer per branch.
|
|
15
15
|
*/
|
|
16
16
|
import { execFileSync } from "node:child_process";
|
|
17
17
|
import { appendFileSync } from "node:fs";
|
|
18
18
|
import { posix } from "node:path";
|
|
19
19
|
|
|
20
20
|
import { CONFIG_NAME, ConfigError, loadConfig } from "./config.mjs";
|
|
21
|
+
import { errorText } from "./storage/common.mjs";
|
|
22
|
+
import {
|
|
23
|
+
ARTIFACT,
|
|
24
|
+
deleteOlderKits,
|
|
25
|
+
findNewestKit,
|
|
26
|
+
runGh,
|
|
27
|
+
} from "./storage/github.mjs";
|
|
28
|
+
import { storageFor } from "./storage/index.mjs";
|
|
21
29
|
|
|
22
|
-
export
|
|
30
|
+
export { ARTIFACT, deleteOlderKits, findNewestKit, runGh };
|
|
23
31
|
/** Rebuild a kit this old even with no UI change, so it never expires. */
|
|
24
32
|
export const DEFAULT_MAX_AGE_DAYS = 75;
|
|
25
33
|
/** What `watch` means when a config has none: the whole repository. */
|
|
26
34
|
export const DEFAULT_WATCH = ["."];
|
|
27
35
|
const DAY_MS = 86_400_000;
|
|
28
|
-
const PAGES = 5;
|
|
29
|
-
|
|
30
|
-
// ── storage: GitHub Actions artifacts ───────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
/** Runs `gh` and returns its stdout. */
|
|
33
|
-
export function runGh(args, { env = process.env } = {}) {
|
|
34
|
-
return execFileSync("gh", args, {
|
|
35
|
-
encoding: "utf8",
|
|
36
|
-
env,
|
|
37
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
38
|
-
maxBuffer: 32 * 1024 * 1024,
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Every `design-kit` artifact of the repository, newest first. */
|
|
43
|
-
function listKits({ repo, gh }) {
|
|
44
|
-
const artifacts = [];
|
|
45
|
-
for (let page = 1; page <= PAGES; page += 1) {
|
|
46
|
-
const body = JSON.parse(
|
|
47
|
-
gh([
|
|
48
|
-
"api",
|
|
49
|
-
`repos/${repo}/actions/artifacts?name=${ARTIFACT}&per_page=100&page=${page}`,
|
|
50
|
-
]),
|
|
51
|
-
);
|
|
52
|
-
const batch = body?.artifacts ?? [];
|
|
53
|
-
artifacts.push(...batch);
|
|
54
|
-
if (batch.length < 100) break;
|
|
55
|
-
}
|
|
56
|
-
return artifacts
|
|
57
|
-
.filter((artifact) => artifact.name === ARTIFACT)
|
|
58
|
-
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* The newest unexpired kit on `branch`: `{id, sha, createdAt, runId}`, or
|
|
63
|
-
* null when there is none. Throws when the store cannot be read.
|
|
64
|
-
*/
|
|
65
|
-
export function findNewestKit({ repo, branch, gh = runGh }) {
|
|
66
|
-
const kit = listKits({ repo, gh }).find(
|
|
67
|
-
(artifact) =>
|
|
68
|
-
!artifact.expired && artifact.workflow_run?.head_branch === branch,
|
|
69
|
-
);
|
|
70
|
-
if (!kit?.workflow_run?.head_sha) return null;
|
|
71
|
-
return {
|
|
72
|
-
id: String(kit.id),
|
|
73
|
-
sha: kit.workflow_run.head_sha,
|
|
74
|
-
createdAt: kit.created_at,
|
|
75
|
-
runId: kit.workflow_run.id ? String(kit.workflow_run.id) : null,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Deletes the kits on `branch` older than the one `keep` names (an artifact
|
|
81
|
-
* id). Returns `{deleted: [ids], failed: [{id, error}], keptFound}`; never
|
|
82
|
-
* throws for a single failed delete. When the kept kit is not in the listing
|
|
83
|
-
* (not listed yet, or on another branch), nothing is deleted: "older" has
|
|
84
|
-
* nothing to be measured against.
|
|
85
|
-
*/
|
|
86
|
-
export function deleteOlderKits({ repo, branch, keep, gh = runGh }) {
|
|
87
|
-
const kits = listKits({ repo, gh }).filter(
|
|
88
|
-
(artifact) => artifact.workflow_run?.head_branch === branch,
|
|
89
|
-
);
|
|
90
|
-
const kept = kits.find((artifact) => String(artifact.id) === String(keep));
|
|
91
|
-
const deleted = [];
|
|
92
|
-
const failed = [];
|
|
93
|
-
if (!kept) return { deleted, failed, keptFound: false };
|
|
94
|
-
for (const artifact of kits) {
|
|
95
|
-
if (String(artifact.id) === String(keep)) continue;
|
|
96
|
-
// Only kits uploaded before the one just kept: a newer one, or one
|
|
97
|
-
// uploaded in the same instant, is never removed.
|
|
98
|
-
if (!(Date.parse(artifact.created_at) < Date.parse(kept.created_at)))
|
|
99
|
-
continue;
|
|
100
|
-
try {
|
|
101
|
-
gh([
|
|
102
|
-
"api",
|
|
103
|
-
"--method",
|
|
104
|
-
"DELETE",
|
|
105
|
-
`repos/${repo}/actions/artifacts/${artifact.id}`,
|
|
106
|
-
]);
|
|
107
|
-
deleted.push(String(artifact.id));
|
|
108
|
-
} catch (error) {
|
|
109
|
-
failed.push({ id: String(artifact.id), error: errorText(error) });
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return { deleted, failed, keptFound: Boolean(kept) };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function errorText(error) {
|
|
116
|
-
const stderr = error?.stderr ? String(error.stderr).trim() : "";
|
|
117
|
-
return (stderr || error?.message || String(error)).split("\n")[0];
|
|
118
|
-
}
|
|
119
36
|
|
|
120
37
|
// ── git: ids of the watched paths ───────────────────────────────────────────
|
|
121
38
|
|
|
@@ -282,9 +199,9 @@ export function decide({
|
|
|
282
199
|
|
|
283
200
|
/**
|
|
284
201
|
* Looks everything up and decides. `kit` given (`{sha, createdAt}`, or null
|
|
285
|
-
* for "no kit"
|
|
286
|
-
* `head` is the commit being built
|
|
287
|
-
* `{build, reason, kit}`.
|
|
202
|
+
* for "no kit", with `lookupError` when the lookup failed) skips the store
|
|
203
|
+
* lookup; otherwise `findKit()` is called. `head` is the commit being built
|
|
204
|
+
* (default HEAD). Returns `{build, reason, kit}`.
|
|
288
205
|
*/
|
|
289
206
|
export function ciCheck({
|
|
290
207
|
repoDir,
|
|
@@ -293,12 +210,12 @@ export function ciCheck({
|
|
|
293
210
|
force = false,
|
|
294
211
|
packageSpec = "",
|
|
295
212
|
kit,
|
|
213
|
+
lookupError,
|
|
296
214
|
findKit,
|
|
297
215
|
now = Date.now(),
|
|
298
216
|
maxAgeDays = DEFAULT_MAX_AGE_DAYS,
|
|
299
217
|
}) {
|
|
300
218
|
const inputs = { force, packageSpec, now, maxAgeDays };
|
|
301
|
-
let lookupError;
|
|
302
219
|
if (kit === undefined && !force && !packageSpec) {
|
|
303
220
|
try {
|
|
304
221
|
kit = findKit();
|
|
@@ -343,12 +260,16 @@ function maxAgeFrom(value, env) {
|
|
|
343
260
|
return days;
|
|
344
261
|
}
|
|
345
262
|
|
|
346
|
-
function repoAndBranch(options, env) {
|
|
263
|
+
function repoAndBranch(options, env, storage = { type: "github" }) {
|
|
347
264
|
const repo = options.repo ?? env.GITHUB_REPOSITORY;
|
|
348
265
|
const branch = options.branch ?? env.GITHUB_REF_NAME;
|
|
349
|
-
|
|
266
|
+
// A bucket is not keyed by repository; GitHub and CodePress are.
|
|
267
|
+
const needsRepo = storage.type === "github" || storage.type === "codepress";
|
|
268
|
+
if ((needsRepo && !repo) || !branch)
|
|
350
269
|
throw new ConfigError(
|
|
351
|
-
|
|
270
|
+
needsRepo
|
|
271
|
+
? "--repo <owner/name> and --branch <name> are needed outside GitHub Actions"
|
|
272
|
+
: "--branch <name> is needed outside GitHub Actions",
|
|
352
273
|
);
|
|
353
274
|
return { repo, branch };
|
|
354
275
|
}
|
|
@@ -371,9 +292,15 @@ function summary(text, env) {
|
|
|
371
292
|
|
|
372
293
|
/**
|
|
373
294
|
* `design-kit ci-check`: prints `build=true|false` and `reason=…` (and
|
|
374
|
-
* writes them to $GITHUB_OUTPUT and the step summary when set).
|
|
295
|
+
* writes them to $GITHUB_OUTPUT and the step summary when set). The newest
|
|
296
|
+
* kit comes from the configured store's `latest`. With `github` storage
|
|
297
|
+
* (or when no lookup is needed) it runs synchronously and returns 0; the
|
|
298
|
+
* other stores answer over the network, so it returns a promise of 0.
|
|
375
299
|
*/
|
|
376
|
-
export function ciCheckCommand(
|
|
300
|
+
export function ciCheckCommand(
|
|
301
|
+
options,
|
|
302
|
+
{ env = process.env, gh, aws, now } = {},
|
|
303
|
+
) {
|
|
377
304
|
const config = loadConfig(options.config);
|
|
378
305
|
const watch = config.raw.watch?.length ? config.raw.watch : DEFAULT_WATCH;
|
|
379
306
|
if ((options.since === undefined) !== (options["kit-created"] === undefined))
|
|
@@ -392,20 +319,57 @@ export function ciCheckCommand(options, { env = process.env, gh, now } = {}) {
|
|
|
392
319
|
"rev-parse",
|
|
393
320
|
"--show-toplevel",
|
|
394
321
|
]).trim();
|
|
395
|
-
const
|
|
322
|
+
const storage = config.storage ?? { type: "github" };
|
|
323
|
+
const check = {
|
|
396
324
|
repoDir,
|
|
397
325
|
watch,
|
|
398
326
|
force: Boolean(options.force),
|
|
399
327
|
packageSpec: options.package ?? "",
|
|
400
|
-
kit,
|
|
401
|
-
findKit: () =>
|
|
402
|
-
findNewestKit({
|
|
403
|
-
...repoAndBranch(options, env),
|
|
404
|
-
gh: gh ?? ((args) => runGh(args, { env })),
|
|
405
|
-
}),
|
|
406
328
|
now: now ?? Date.now(),
|
|
407
329
|
maxAgeDays: maxAgeFrom(options["max-age-days"], env),
|
|
408
|
-
}
|
|
330
|
+
};
|
|
331
|
+
const store = () => {
|
|
332
|
+
const { repo, branch } = repoAndBranch(options, env, storage);
|
|
333
|
+
return {
|
|
334
|
+
branch,
|
|
335
|
+
store: storageFor(storage, {
|
|
336
|
+
repo,
|
|
337
|
+
env,
|
|
338
|
+
gh: gh ?? ((args) => runGh(args, { env })),
|
|
339
|
+
aws,
|
|
340
|
+
}),
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
const finish = (result) => {
|
|
344
|
+
report(result, env);
|
|
345
|
+
return 0;
|
|
346
|
+
};
|
|
347
|
+
if (kit !== undefined || check.force || check.packageSpec)
|
|
348
|
+
return finish(ciCheck({ ...check, kit }));
|
|
349
|
+
if (storage.type === "github")
|
|
350
|
+
return finish(
|
|
351
|
+
ciCheck({
|
|
352
|
+
...check,
|
|
353
|
+
findKit: () => {
|
|
354
|
+
const { store: github, branch } = store();
|
|
355
|
+
return github.latest({ branch });
|
|
356
|
+
},
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
return (async () => {
|
|
360
|
+
let found = null;
|
|
361
|
+
let lookupError;
|
|
362
|
+
try {
|
|
363
|
+
const { store: other, branch } = store();
|
|
364
|
+
found = await other.latest({ branch });
|
|
365
|
+
} catch (error) {
|
|
366
|
+
lookupError = errorText(error);
|
|
367
|
+
}
|
|
368
|
+
return finish(ciCheck({ ...check, kit: found, lookupError }));
|
|
369
|
+
})();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function report(result, env) {
|
|
409
373
|
if (result.missing?.length)
|
|
410
374
|
console.log(
|
|
411
375
|
`::warning title=Design kit::These watch paths do not exist at this commit, so they never trigger a rebuild: ${result.missing.join(", ")}. Fix or remove them in ${CONFIG_NAME}.`,
|
|
@@ -422,7 +386,6 @@ export function ciCheckCommand(options, { env = process.env, gh, now } = {}) {
|
|
|
422
386
|
`Design kit: ${result.build ? "building" : "nothing to build"}, ${result.reason}.`,
|
|
423
387
|
env,
|
|
424
388
|
);
|
|
425
|
-
return 0;
|
|
426
389
|
}
|
|
427
390
|
|
|
428
391
|
/**
|
package/src/cli.mjs
CHANGED
|
@@ -28,7 +28,19 @@ const USAGE = `usage: design-kit <command> [options]
|
|
|
28
28
|
init set a kit up in this repository: the config, a daily
|
|
29
29
|
GitHub Actions build, and a skill for its agents
|
|
30
30
|
[--root <dir>] [--dry-run] [--force] [--workflow-only]
|
|
31
|
-
|
|
31
|
+
[--storage github|codepress|s3|r2]
|
|
32
|
+
--workflow-only --force rewrites just the workflow;
|
|
33
|
+
with --storage it also sets where the kit is stored
|
|
34
|
+
upload upload a built kit to the configured storage
|
|
35
|
+
(codepress, s3 or r2; github uploads in the workflow)
|
|
36
|
+
--dir <kit> [--config <file>] [--branch <name>] [--sha <commit>]
|
|
37
|
+
fetch download the newest kit, once per commit (cached under
|
|
38
|
+
$TMPDIR/design-kit-<sha>); prints path=, sha= and branch=
|
|
39
|
+
[--branch <name>] [--out <dir>] [--config <file>] [--repo <owner/name>]
|
|
40
|
+
[--token-file <path>] codepress: the API key, if not CODEPRESS_API_KEY
|
|
41
|
+
latest print the newest kit's sha= and created=
|
|
42
|
+
[--branch <name>] [--config <file>] [--repo <owner/name>]
|
|
43
|
+
[--token-file <path>]
|
|
32
44
|
capture stories capture every story of a Storybook 7-9 static build
|
|
33
45
|
[--config <file>] [--out <dir>] [--storybook-dir <dir>]
|
|
34
46
|
[--only <id,...> | --only-file <file>] [--base <previous kit>]
|
|
@@ -57,8 +69,8 @@ const USAGE = `usage: design-kit <command> [options]
|
|
|
57
69
|
|
|
58
70
|
The config is the nearest design-kit.config.json at or above the working
|
|
59
71
|
directory unless --config names one. Exit codes: 0 done, 1 a capture could
|
|
60
|
-
not run
|
|
61
|
-
error.`;
|
|
72
|
+
not run, a store refused or could not be reached (or init found the
|
|
73
|
+
repository already set up), 2 a config or usage error.`;
|
|
62
74
|
|
|
63
75
|
function parse(argv, spec) {
|
|
64
76
|
const options = {};
|
|
@@ -190,6 +202,7 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
|
190
202
|
"dry-run": "flag",
|
|
191
203
|
force: "flag",
|
|
192
204
|
"workflow-only": "flag",
|
|
205
|
+
storage: "value",
|
|
193
206
|
});
|
|
194
207
|
const { init } = await import("./init.mjs");
|
|
195
208
|
return init({
|
|
@@ -197,6 +210,7 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
|
197
210
|
dryRun: Boolean(options["dry-run"]),
|
|
198
211
|
force: Boolean(options.force),
|
|
199
212
|
workflowOnly: Boolean(options["workflow-only"]),
|
|
213
|
+
storage: options.storage,
|
|
200
214
|
});
|
|
201
215
|
}
|
|
202
216
|
if (command === "ci-check") {
|
|
@@ -212,7 +226,7 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
|
212
226
|
branch: "value",
|
|
213
227
|
});
|
|
214
228
|
const { ciCheckCommand } = await import("./ci.mjs");
|
|
215
|
-
return ciCheckCommand(options, { env });
|
|
229
|
+
return await ciCheckCommand(options, { env });
|
|
216
230
|
}
|
|
217
231
|
if (command === "ci-cleanup") {
|
|
218
232
|
const options = parse(argv.slice(1), {
|
|
@@ -223,6 +237,25 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
|
223
237
|
const { ciCleanupCommand } = await import("./ci.mjs");
|
|
224
238
|
return ciCleanupCommand(options, { env });
|
|
225
239
|
}
|
|
240
|
+
if (command === "upload" || command === "fetch" || command === "latest") {
|
|
241
|
+
const spec = {
|
|
242
|
+
config: "value",
|
|
243
|
+
branch: "value",
|
|
244
|
+
repo: "value",
|
|
245
|
+
...(command === "upload"
|
|
246
|
+
? { dir: "value", sha: "value" }
|
|
247
|
+
: { "token-file": "value" }),
|
|
248
|
+
...(command === "fetch" ? { out: "value" } : {}),
|
|
249
|
+
};
|
|
250
|
+
const options = parse(argv.slice(1), spec);
|
|
251
|
+
const storage = await import("./storage/index.mjs");
|
|
252
|
+
const run = {
|
|
253
|
+
upload: storage.uploadCommand,
|
|
254
|
+
fetch: storage.fetchCommand,
|
|
255
|
+
latest: storage.latestCommand,
|
|
256
|
+
}[command];
|
|
257
|
+
return await run(options, { env });
|
|
258
|
+
}
|
|
226
259
|
if (command === "setup-guide") {
|
|
227
260
|
parse(argv.slice(1), {});
|
|
228
261
|
process.stdout.write(readFileSync(SETUP_GUIDE, "utf8"));
|
package/src/config.mjs
CHANGED
|
@@ -194,6 +194,7 @@ export function resolveConfig(raw, path) {
|
|
|
194
194
|
errors.push(`pages.pages[${index}].name: "${page.name}" is used twice`);
|
|
195
195
|
names.add(page?.name);
|
|
196
196
|
});
|
|
197
|
+
if (!errors.length && raw.storage) errors.push(...storageErrors(raw.storage));
|
|
197
198
|
if (errors.length) {
|
|
198
199
|
throw new ConfigError(
|
|
199
200
|
`${path}:\n${errors.map((error) => ` ${error}`).join("\n")}`,
|
|
@@ -208,6 +209,7 @@ export function resolveConfig(raw, path) {
|
|
|
208
209
|
configDir,
|
|
209
210
|
raw,
|
|
210
211
|
out: at(raw.out),
|
|
212
|
+
storage: raw.storage ? { ...raw.storage } : { type: "github" },
|
|
211
213
|
theme: raw.theme ? normalizeTheme(raw.theme) : undefined,
|
|
212
214
|
};
|
|
213
215
|
if (raw.stories) {
|
|
@@ -264,6 +266,44 @@ export function resolveConfig(raw, path) {
|
|
|
264
266
|
return config;
|
|
265
267
|
}
|
|
266
268
|
|
|
269
|
+
/** The keys each storage type takes, and which of them it needs. */
|
|
270
|
+
const STORAGE_KEYS = {
|
|
271
|
+
github: { allowed: [], required: [] },
|
|
272
|
+
codepress: { allowed: ["url", "apiUrl"], required: [] },
|
|
273
|
+
s3: {
|
|
274
|
+
allowed: ["bucket", "prefix", "region", "endpoint"],
|
|
275
|
+
required: ["bucket"],
|
|
276
|
+
},
|
|
277
|
+
r2: {
|
|
278
|
+
allowed: ["accountId", "bucket", "prefix"],
|
|
279
|
+
required: ["accountId", "bucket"],
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** What the schema cannot say about `storage`: the keys each type takes. */
|
|
284
|
+
function storageErrors(storage) {
|
|
285
|
+
const keys = STORAGE_KEYS[storage.type];
|
|
286
|
+
if (!keys) return [];
|
|
287
|
+
const errors = [];
|
|
288
|
+
for (const key of keys.required)
|
|
289
|
+
if (!(key in storage))
|
|
290
|
+
errors.push(`storage.${key}: is required for ${storage.type} storage`);
|
|
291
|
+
for (const key of Object.keys(storage)) {
|
|
292
|
+
if (key === "type") continue;
|
|
293
|
+
if (!keys.allowed.includes(key))
|
|
294
|
+
errors.push(
|
|
295
|
+
`storage.${key}: not used by ${storage.type} storage${key === "endpoint" && storage.type === "r2" ? " (r2 derives it from accountId)" : ""}`,
|
|
296
|
+
);
|
|
297
|
+
else if (/^TODO/.test(String(storage[key])))
|
|
298
|
+
errors.push(`storage.${key}: still ${storage[key]}`);
|
|
299
|
+
else if (key === "accountId" && !/^[A-Za-z0-9]+$/.test(storage[key]))
|
|
300
|
+
errors.push(
|
|
301
|
+
"storage.accountId: must be your Cloudflare account id, letters and digits only",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return errors;
|
|
305
|
+
}
|
|
306
|
+
|
|
267
307
|
/** The theme as the manifest records it, with its defaults filled in. */
|
|
268
308
|
export function normalizeTheme(theme) {
|
|
269
309
|
const attribute = theme.attribute ?? "class";
|