@codepress/design-kit 0.8.0 → 0.10.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 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
@@ -280,7 +351,7 @@ fresh browser context, signed in from `auth.storageState` (a Playwright
280
351
  storage state file) or from the state the `auth.setup` module's default
281
352
  export returns (`async ({ browser, baseUrl, config }) => state`).
282
353
 
283
- - `pages[]`: `{name, title?, path, ready, sourceFile?, slots, layoutSlots?}`.
354
+ - `pages[]`: `{name, title?, path, ready, sourceFile?, slots, layoutSlots?, widths?}`. `widths` names which of `pages.widths` a page is captured at, for a view the app draws at some sizes only; it defaults to all of them.
284
355
  `ready` is a Playwright selector that shows once the page has its content.
285
356
  `slots` names the regions a mockup replaces: the first selector that matches
286
357
  a visible, non-empty element gets `data-kit-slot="<name>"`, and `main` is
@@ -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
- `ci-check` and `ci-cleanup` are the workflow's steps. `ci-check` finds the
334
- newest unexpired `design-kit` artifact on the branch (through `gh`, from
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` deletes the
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
@@ -470,11 +558,62 @@ the frame, the snippet ids and how many `data-kit-new` elements the page has.
470
558
  The record stays in the page: `kit.mjs` makes no network call. Scripts, event handlers,
471
559
  `<link>`s and SVG animation are removed, and URLs pass an allowlist: only
472
560
  relative references, fragments and `data:` survive, read the way the browser
473
- reads them. `--theme dark` applies the manifest's theme. Elements marked
474
- `data-kit-new` get a visible outline with `--show-new`. An unknown frame, slot
561
+ reads them. The one exception is your own code: an inline `<script>` (no
562
+ `src`) in a `--slot` fragment file is kept in place, with only its `type` and
563
+ `id`. Nothing executable from the kit's captured markup survives, a fragment's
564
+ `<script src>` is removed, and `on…=` handlers are removed everywhere, your
565
+ fragments included. `--theme dark` applies the manifest's theme. Elements marked
566
+ `data-kit-new` get a visible outline with `--show-new`, and behaviour triggers
567
+ (below) a blue one. An unknown frame, slot
475
568
  or snippet id exits 2, naming near matches. `--kit <dir>` (or
476
569
  `DESIGN_KIT_DIR`) points at a kit other than the tool's own folder.
477
570
 
571
+ ### Clickable mockups
572
+
573
+ A composed page can switch tabs, open and close menus and swap between a
574
+ component's captured states on click or tap. Mark it up with these attributes
575
+ in your slot fragment, and `compose` inlines a small behaviour script once, at
576
+ the end of the body (no network, no storage; it works in a
577
+ `sandbox="allow-scripts"` frame). A page that uses none of them composes
578
+ exactly as before.
579
+
580
+ | Attribute | What a click does |
581
+ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
582
+ | `data-kit-toggle="<id>"` on a trigger | Shows or hides the element with that id and sets `aria-expanded`. A click outside it, or Esc, closes what a toggle opened. |
583
+ | `data-kit-tabs` on a container | Its `[data-kit-tab="<name>"]` triggers show the matching `[data-kit-panel="<name>"]` and hide the others, with `aria-selected`. The first tab starts selected unless one has `aria-selected="true"`. |
584
+ | `data-kit-state="<group>:<name>"` on each state | Nothing by itself: the group starts in the state of its first element without `hidden`. |
585
+ | `data-kit-set="<group>:<name>"` on a trigger | Shows every element of the group in that state and hides the rest of the group. |
586
+
587
+ To make captured stories interactive, compose each state as its own snippet
588
+ and put the attributes on the `<kit-snippet>`: they (and `hidden`) are copied
589
+ onto every top-level element the snippet expands to, a story's portal
590
+ included.
591
+
592
+ ```html
593
+ <kit-snippet
594
+ id="story:primitives-dropdownmenu--closed@1280"
595
+ data-kit-state="menu:closed"
596
+ data-kit-set="menu:open"
597
+ ></kit-snippet>
598
+ <kit-snippet
599
+ id="story:primitives-dropdownmenu--open@1280"
600
+ data-kit-state="menu:open"
601
+ data-kit-set="menu:closed"
602
+ hidden
603
+ ></kit-snippet>
604
+ ```
605
+
606
+ A click anywhere on the closed story shows the open one, and a click on the
607
+ open one goes back. Links and buttons inside a trigger only switch the state;
608
+ they do not navigate or submit. A trigger that already has `data-state`
609
+ (Radix-style markup) gets `open`/`closed` or `active`/`inactive` there too, so
610
+ its own styling follows, and a trigger that is not a button or link gets
611
+ `tabindex="0"` so Enter and Space work. Elements the behaviours hide are
612
+ hidden even when their own CSS sets a `display`. Native interactivity needs
613
+ nothing: `<details>`/`<summary>`, `popover` with `popovertarget`, form
614
+ controls and `aria-*` all survive compose. Anything else is a few lines of
615
+ your own `<script>` in the fragment.
616
+
478
617
  ## Programmatic use
479
618
 
480
619
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codepress/design-kit",
3
- "version": "0.8.0",
3
+ "version": "0.10.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,
@@ -232,6 +280,12 @@
232
280
  "description": "The page's source module, shown by `kit show` and searched by `kit find`."
233
281
  },
234
282
  "slots": { "$ref": "#/$defs/slots" },
283
+ "widths": {
284
+ "type": "array",
285
+ "minItems": 1,
286
+ "items": { "type": "integer", "minimum": 1 },
287
+ "description": "Only these of `pages.widths`, for a page the app draws at some sizes only (a laptop-only rail tab). Default: every width."
288
+ },
235
289
  "layoutSlots": {
236
290
  "type": "object",
237
291
  "description": "Slots for a layout the detector reports, replacing `slots` there.",
@@ -543,6 +543,7 @@ export async function capturePages(config, options = {}) {
543
543
  for (const viewport of settings.widths) {
544
544
  for (const page of pages) {
545
545
  if (session.interrupted) return 130;
546
+ if (page.widths && !page.widths.includes(viewport.width)) continue;
546
547
  const id = kitRouteId(page.name, viewport.width);
547
548
  const context = await session.browser.newContext({
548
549
  baseURL: baseUrl,
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
- * Where kits are stored is behind two functions, `findNewestKit` and
13
- * `deleteOlderKits` (GitHub Actions artifacts, through `gh`), so another
14
- * store changes only those and the workflow's upload step.
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 const ARTIFACT = "design-kit";
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") skips the store lookup; otherwise `findKit()` is called.
286
- * `head` is the commit being built (default HEAD). Returns
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
- if (!repo || !branch)
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
- "--repo <owner/name> and --branch <name> are needed outside GitHub Actions",
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(options, { env = process.env, gh, now } = {}) {
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 result = ciCheck({
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
- --workflow-only --force rewrites just the workflow
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 (or init found the repository already set up), 2 a config or usage
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"));