@codepress/design-kit 0.12.0 → 0.13.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
@@ -392,6 +392,7 @@ design-kit init [--root dir] [--dry-run] [--force] [--workflow-only]
392
392
  design-kit upload --dir kit [--config f] [--branch b] [--sha commit]
393
393
  design-kit fetch [--branch b] [--out dir] [--config f] [--repo owner/name]
394
394
  [--token-file f]
395
+ design-kit fetch --url download-url --sha commit [--branch b] [--out dir]
395
396
  design-kit latest [--branch b] [--config f] [--repo owner/name] [--token-file f]
396
397
  design-kit capture stories [--config f] [--out dir] [--storybook-dir dir]
397
398
  [--only id,... | --only-file f] [--base previous-kit]
@@ -411,8 +412,19 @@ design-kit storybook-guide
411
412
  kit (`codepress`, `s3` and `r2`; with `github` it exits 2, since the
412
413
  workflow's upload step does it). `fetch` downloads the newest kit once per
413
414
  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=`.
415
+ prints `path=`, `sha=`, `branch=` and the kit's summary line (as `kit info`
416
+ prints it); a second `fetch` of the same commit downloads nothing.
417
+ `fetch --url <download_url> --sha <commit>` needs no key and no config: it
418
+ installs from a download URL already looked up (with `codepress` storage, the
419
+ `download_url` of the latest route, read through the CodePress Platform MCP)
420
+ into the same cache folder, which it reuses when complete. It streams the zip to
421
+ a scratch folder beside the target, unzips it there, checks that
422
+ `manifest.json` names `--sha`, and renames it into place, so a refused or
423
+ expired URL (`forbidden`), a broken zip (`bad_archive`) or another commit's kit
424
+ (`sha_mismatch`) exits 1 with one line and leaves nothing behind, as does an
425
+ `--out` folder that holds files but no kit (`out_not_empty`, left untouched). Of two
426
+ fetches racing for one commit, the loser keeps the winner's folder. The URL's
427
+ query string is never printed. `latest` prints the newest kit's `sha=` and `created=`.
416
428
  Without `--branch` they read the default branch. A store's refusal keeps its
417
429
  name (`forbidden`, `no_kit`, `repo_not_connected`, …), a store that cannot be
418
430
  reached is `kit_storage_unavailable`, and both exit 1.
@@ -527,8 +539,16 @@ node kit.mjs find "transfer app" # the words on screen work too
527
539
  node kit.mjs show <id> [--full] # path, size and an outline (≤ 2 KB) or the markup
528
540
  node kit.mjs compose --frame route:dashboard@1280 --slot main=new.html \
529
541
  [--theme light|dark] [--show-new] [--css pruned|full] --out page.html
542
+ node kit.mjs figure --img shot.png [--href '#cp-page:<key>'] [--caption text] \
543
+ [--alt text] [--width px] --out figure.html
530
544
  ```
531
545
 
546
+ `figure` needs no kit: it writes a self-contained `<figure>` fragment with
547
+ the `.png`, `.jpg` or `.webp` image as a data URI, inside a link when `--href`
548
+ is given (a fragment such as `#cp-page:<key>`, which links a plan page to a
549
+ screen page, or a relative link), and the caption and alt text escaped. It
550
+ warns on stderr when the image is over 1 MB.
551
+
532
552
  `info` prints one line, such as
533
553
  `480e11d built 2026-11-15T18:58:59.954Z · stories 12821 · routes 133 · failed 13`,
534
554
  and names the failed ids from `gaps.json` when there are five or fewer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codepress/design-kit",
3
- "version": "0.12.0",
3
+ "version": "0.13.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": {
package/src/cli.mjs CHANGED
@@ -35,9 +35,13 @@ const USAGE = `usage: design-kit <command> [options]
35
35
  (codepress, s3 or r2; github uploads in the workflow)
36
36
  --dir <kit> [--config <file>] [--branch <name>] [--sha <commit>]
37
37
  fetch download the newest kit, once per commit (cached under
38
- $TMPDIR/design-kit-<sha>); prints path=, sha= and branch=
38
+ $TMPDIR/design-kit-<sha>); prints path=, sha=, branch=
39
+ and the kit's summary line
39
40
  [--branch <name>] [--out <dir>] [--config <file>] [--repo <owner/name>]
40
41
  [--token-file <path>] codepress: the API key, if not CODEPRESS_API_KEY
42
+ --url <download_url> --sha <commit> [--branch <name>] [--out <dir>]
43
+ install from the download_url of the latest route (read
44
+ through the Platform MCP): no key and no config needed
41
45
  latest print the newest kit's sha= and created=
42
46
  [--branch <name>] [--config <file>] [--repo <owner/name>]
43
47
  [--token-file <path>]
@@ -245,7 +249,9 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
245
249
  ...(command === "upload"
246
250
  ? { dir: "value", sha: "value" }
247
251
  : { "token-file": "value" }),
248
- ...(command === "fetch" ? { out: "value" } : {}),
252
+ ...(command === "fetch"
253
+ ? { out: "value", url: "value", sha: "value" }
254
+ : {}),
249
255
  };
250
256
  const options = parse(argv.slice(1), spec);
251
257
  const storage = await import("./storage/index.mjs");
package/src/kit.d.ts CHANGED
@@ -134,3 +134,27 @@ export function run(
134
134
 
135
135
  /** Whether this file is the script node was started with. */
136
136
  export function isMain(moduleUrl?: string, script?: string): boolean;
137
+
138
+ /**
139
+ * The kit's one-line summary, as `kit info` prints it:
140
+ * `sha7 built <iso> · stories N · routes N · failed N` (the failed ids when
141
+ * there are five or fewer).
142
+ */
143
+ export function kitSummaryLine(kit: Kit): string;
144
+
145
+ /** Image bytes above which `kit figure` warns: 1 MB. */
146
+ export const FIGURE_WARN_BYTES: number;
147
+
148
+ /**
149
+ * `kit figure`'s fragment: a `<figure>` with the image as a data URI, inside
150
+ * `<a href>` when `href` is given (a fragment such as `#cp-page:<key>` or a
151
+ * relative link), and the caption escaped. `width` is CSS pixels.
152
+ */
153
+ export function figureHtml(options: {
154
+ image: Uint8Array;
155
+ type: "image/png" | "image/jpeg" | "image/webp";
156
+ href?: string;
157
+ caption?: string;
158
+ alt?: string;
159
+ width?: string | number;
160
+ }): string;
package/src/kit.mjs CHANGED
@@ -8,6 +8,8 @@
8
8
  * node kit.mjs show <id> [--full]
9
9
  * node kit.mjs compose --frame <id> [--slot <name>=<file>]… [--theme light|dark]
10
10
  * [--show-new] [--css pruned|full] --out <file>
11
+ * node kit.mjs figure --img <file> [--href <#cp-page:key>] [--caption <text>]
12
+ * [--alt <text>] [--width <px>] --out <file>
11
13
  *
12
14
  * `--kit <dir>` (or DESIGN_KIT_DIR) points at a kit; the default is the
13
15
  * folder this file sits in. Format: the @codepress/design-kit README.
@@ -2168,12 +2170,12 @@ function failedIds(dir) {
2168
2170
  }
2169
2171
 
2170
2172
  /**
2171
- * One line: `sha7 built <iso> · stories N · routes N · failed N`, with the
2172
- * failed ids when there are five or fewer. `json` prints manifest.json.
2173
+ * The kit's one-line summary: `sha7 built <iso> · stories N · routes N ·
2174
+ * failed N`, with the failed ids when there are five or fewer. `kit info`
2175
+ * prints it, and so does `design-kit fetch` after `path=`.
2173
2176
  */
2174
- function infoText(kit, json) {
2175
- const manifest = kit.manifest ?? { missing: "manifest.json" };
2176
- if (json) return JSON.stringify(manifest, null, 2);
2177
+ export function kitSummaryLine(kit) {
2178
+ const manifest = kit.manifest ?? {};
2177
2179
  const routes = kit.rows.filter((row) => rowSource(row) === "routes").length;
2178
2180
  const sha = typeof manifest.sha === "string" ? manifest.sha.slice(0, 7) : "";
2179
2181
  const built =
@@ -2192,6 +2194,99 @@ function infoText(kit, json) {
2192
2194
  ].join(" · ");
2193
2195
  }
2194
2196
 
2197
+ /** `info`: the summary line; `json` prints manifest.json. */
2198
+ function infoText(kit, json) {
2199
+ if (json)
2200
+ return JSON.stringify(
2201
+ kit.manifest ?? { missing: "manifest.json" },
2202
+ null,
2203
+ 2,
2204
+ );
2205
+ return kitSummaryLine(kit);
2206
+ }
2207
+
2208
+ // ── figure ──────────────────────────────────────────────────────────────────
2209
+
2210
+ /** Image types `figure` embeds, by file extension. */
2211
+ const FIGURE_TYPES = {
2212
+ ".png": "image/png",
2213
+ ".jpg": "image/jpeg",
2214
+ ".jpeg": "image/jpeg",
2215
+ ".webp": "image/webp",
2216
+ };
2217
+
2218
+ /** Above this many image bytes `figure` warns: the page carries them all. */
2219
+ export const FIGURE_WARN_BYTES = 1024 * 1024;
2220
+
2221
+ function escapeHtml(value) {
2222
+ return String(value)
2223
+ .replaceAll("&", "&amp;")
2224
+ .replaceAll("<", "&lt;")
2225
+ .replaceAll(">", "&gt;")
2226
+ .replaceAll('"', "&quot;");
2227
+ }
2228
+
2229
+ /**
2230
+ * A self-contained `<figure>`: the image as a data URI, inside a link when
2231
+ * `href` is given, with an escaped caption. `href` must be a fragment or a
2232
+ * relative link (`#cp-page:<key>` links plan pages); `width` is CSS pixels.
2233
+ */
2234
+ export function figureHtml({ image, type, href, caption, alt, width }) {
2235
+ if (
2236
+ href !== undefined &&
2237
+ (!href || !isSafeUrl(href) || /^data:/i.test(href.trim()))
2238
+ )
2239
+ throw new KitError(
2240
+ `--href ${href}: use a fragment such as '#cp-page:<key>' or a relative link`,
2241
+ );
2242
+ let style = "max-width:100%;height:auto;display:block";
2243
+ if (width !== undefined) {
2244
+ const px = Number(width);
2245
+ if (!(Number.isFinite(px) && px > 0))
2246
+ throw new KitError(
2247
+ `--width ${width}: use a positive number of CSS pixels`,
2248
+ );
2249
+ style = `width:${px}px;${style}`;
2250
+ }
2251
+ const src = `data:${type};base64,${image.toString("base64")}`;
2252
+ let img = `<img src="${src}" alt="${escapeHtml(alt ?? caption ?? "")}" style="${style}">`;
2253
+ if (href !== undefined) img = `<a href="${escapeHtml(href)}">${img}</a>`;
2254
+ const figcaption =
2255
+ caption === undefined
2256
+ ? ""
2257
+ : `\n <figcaption>${escapeHtml(caption)}</figcaption>`;
2258
+ return `<figure style="margin:0">\n ${img}${figcaption}\n</figure>\n`;
2259
+ }
2260
+
2261
+ /** `figure --img <file> --out <file>`: writes the fragment; no kit needed. */
2262
+ function figureCommand(options, io) {
2263
+ if (!options.img)
2264
+ throw new KitError("figure needs --img <file.png|jpg|webp>");
2265
+ if (!options.out) throw new KitError("figure needs --out <file>");
2266
+ const file = resolve(options.img);
2267
+ const type = FIGURE_TYPES[extname(file).toLowerCase()];
2268
+ if (!type)
2269
+ throw new KitError(`--img ${options.img}: use a .png, .jpg or .webp file`);
2270
+ if (!existsSync(file))
2271
+ throw new KitError(`--img ${options.img}: no such file`);
2272
+ const image = readFileSync(file);
2273
+ const html = figureHtml({
2274
+ image,
2275
+ type,
2276
+ href: options.href,
2277
+ caption: options.caption,
2278
+ alt: options.alt,
2279
+ width: options.width,
2280
+ });
2281
+ if (image.length > FIGURE_WARN_BYTES)
2282
+ io.err(
2283
+ `kit: warning: ${options.img} is ${formatBytes(image.length)}; the page carries it all (shrink it below 1 MB)`,
2284
+ );
2285
+ const out = resolve(options.out);
2286
+ writeFileSync(out, html);
2287
+ io.out(`${out} ${formatBytes(Buffer.byteLength(html))}`);
2288
+ }
2289
+
2195
2290
  // ── CLI ─────────────────────────────────────────────────────────────────────
2196
2291
 
2197
2292
  const USAGE = `usage: kit.mjs [--kit <dir>] <command>
@@ -2201,7 +2296,10 @@ const USAGE = `usage: kit.mjs [--kit <dir>] <command>
2201
2296
  show <id> [--full] path, size and an outline (--full: the markup)
2202
2297
  compose --frame <id> [--slot <name>=<file>]… [--theme light|dark] [--show-new] [--css pruned|full] --out <file>
2203
2298
  --css full keeps every rule of the kit's CSS; the default
2204
- keeps only rules the page's classes and ids can use`;
2299
+ keeps only rules the page's classes and ids can use
2300
+ figure --img <file.png|jpg|webp> [--href '#cp-page:<key>'] [--caption <text>] [--alt <text>] [--width <px>] --out <file>
2301
+ a self-contained <figure>: the image as a data URI, linked when
2302
+ --href is given, the caption escaped (no kit needed)`;
2205
2303
 
2206
2304
  function parseArgs(argv) {
2207
2305
  const options = { slots: [], positional: [] };
@@ -2219,6 +2317,11 @@ function parseArgs(argv) {
2219
2317
  else if (arg === "--theme") options.theme = value();
2220
2318
  else if (arg === "--out") options.out = value();
2221
2319
  else if (arg === "--css") options.css = value();
2320
+ else if (arg === "--img") options.img = value();
2321
+ else if (arg === "--href") options.href = value();
2322
+ else if (arg === "--caption") options.caption = value();
2323
+ else if (arg === "--alt") options.alt = value();
2324
+ else if (arg === "--width") options.width = value();
2222
2325
  else if (arg === "--full") options.full = true;
2223
2326
  else if (arg === "--json") options.json = true;
2224
2327
  else if (arg === "--show-new") options.showNew = true;
@@ -2243,6 +2346,11 @@ export function run(
2243
2346
  io.out(USAGE);
2244
2347
  return command || options.help ? 0 : 2;
2245
2348
  }
2349
+ if (command === "figure") {
2350
+ if (rest.length) throw new KitError(`figure takes no ${rest[0]}`);
2351
+ figureCommand(options, io);
2352
+ return 0;
2353
+ }
2246
2354
  const dir = resolve(options.kit ?? env.DESIGN_KIT_DIR ?? defaultDir);
2247
2355
  const kit = loadKit(dir);
2248
2356
  if (command === "info") {
@@ -6,6 +6,8 @@
6
6
  * upload({dir, branch, sha}) → {sha, branch, sizeBytes}
7
7
  * fetch({branch, out}) → {path, sha, branch, cached}
8
8
  *
9
+ * `fetchFromUrl` (url.mjs) installs a kit from a download URL with no store.
10
+ *
9
11
  * `github` (the default) is a GitHub Actions artifact; `codepress` is
10
12
  * CodePress; `s3` and `r2` are the user's own bucket. The commands
11
13
  * `design-kit upload`, `fetch` and `latest` are here too.
@@ -18,6 +20,8 @@ import { bucketStorage } from "./bucket.mjs";
18
20
  import { codepressStorage } from "./codepress.mjs";
19
21
  import { findRepo, originDefaultBranch } from "./common.mjs";
20
22
  import { githubStorage } from "./github.mjs";
23
+ import { fetchFromUrl } from "./url.mjs";
24
+ import { kitSummaryLine, loadKit } from "../kit.mjs";
21
25
 
22
26
  export { StorageError } from "./common.mjs";
23
27
 
@@ -99,25 +103,47 @@ export async function uploadCommand(
99
103
  /**
100
104
  * `design-kit fetch [--branch b] [--out dir]`: downloads the newest kit
101
105
  * (once per commit: it is cached under $TMPDIR/design-kit-<sha>) and prints
102
- * `path=`, `sha=` and `branch=`.
106
+ * `path=`, `sha=`, `branch=` and the kit's summary line.
107
+ *
108
+ * `--url <download_url> --sha <commit>` installs from a download URL already
109
+ * looked up (no credential, no config): same cache, same output.
103
110
  */
104
111
  export async function fetchCommand(
105
112
  options,
106
113
  { env = process.env, gh, aws } = {},
107
114
  ) {
108
- const config = loadConfig(options.config);
109
- const storage = storageOf(config);
110
- const store = storageFor(
111
- storage,
112
- contextFor(options, config, { env, gh, aws }),
113
- );
114
- const kit = await store.fetch({
115
- branch: readBranch(options, storage, config.configDir),
116
- out: options.out,
117
- });
115
+ let kit;
116
+ if (options.url !== undefined || options.sha !== undefined) {
117
+ if (!options.url || !options.sha)
118
+ throw new ConfigError("fetch --url <download_url> needs --sha <commit>");
119
+ for (const name of ["config", "repo", "token-file"])
120
+ if (options[name] !== undefined)
121
+ throw new ConfigError(
122
+ `fetch --url takes no --${name}: it reads no store`,
123
+ );
124
+ kit = await fetchFromUrl({
125
+ url: options.url,
126
+ sha: options.sha,
127
+ branch: options.branch,
128
+ out: options.out,
129
+ env,
130
+ });
131
+ } else {
132
+ const config = loadConfig(options.config);
133
+ const storage = storageOf(config);
134
+ const store = storageFor(
135
+ storage,
136
+ contextFor(options, config, { env, gh, aws }),
137
+ );
138
+ kit = await store.fetch({
139
+ branch: readBranch(options, storage, config.configDir),
140
+ out: options.out,
141
+ });
142
+ }
118
143
  console.log(`path=${kit.path}`);
119
144
  console.log(`sha=${kit.sha}`);
120
145
  if (kit.branch) console.log(`branch=${kit.branch}`);
146
+ console.log(kitSummaryLine(loadKit(kit.path)));
121
147
  return 0;
122
148
  }
123
149
 
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `design-kit fetch --url <download_url> --sha <commit>`: installs a kit
3
+ * from a download URL someone else already looked up (the `download_url` of
4
+ * `GET /design-kits/repos/{owner}/{repo}/latest`, read through the Platform
5
+ * MCP), with no credential and no config.
6
+ *
7
+ * The kit lands where the key path puts it, `$TMPDIR/design-kit-<sha>`, and a
8
+ * complete folder there is reused without downloading. Otherwise the zip is
9
+ * streamed to a scratch folder beside the target, unzipped there with the
10
+ * package's own reader, checked (its manifest.json must name `sha`), and
11
+ * renamed into place in one step. A fetch that loses a race to another for
12
+ * the same commit keeps the winner's folder. A failure leaves nothing behind.
13
+ */
14
+ import {
15
+ createWriteStream,
16
+ mkdirSync,
17
+ mkdtempSync,
18
+ readFileSync,
19
+ renameSync,
20
+ rmSync,
21
+ } from "node:fs";
22
+ import http from "node:http";
23
+ import https from "node:https";
24
+ import { dirname, join, resolve } from "node:path";
25
+ import { pipeline } from "node:stream/promises";
26
+
27
+ import { StorageError, checkSha, isKit, kitCacheDir } from "./common.mjs";
28
+ import { unzipTo } from "./zip.mjs";
29
+
30
+ /** How long the download may stall before it counts as failed. */
31
+ const DOWNLOAD_TIMEOUT_MS = Number(
32
+ process.env.DESIGN_KIT_REQUEST_TIMEOUT_MS || 120_000,
33
+ );
34
+
35
+ /** What an S3- or R2-style refusal is called, by status. */
36
+ const REFUSED = {
37
+ 400: "bad_request",
38
+ 401: "unauthorized",
39
+ 403: "forbidden",
40
+ 404: "not_found",
41
+ 410: "expired",
42
+ };
43
+
44
+ /** The query string of a URL replaced by `?…`, so a signature never prints. */
45
+ export function redactUrl(url) {
46
+ return String(url).replace(/\?.*$/s, "?…");
47
+ }
48
+
49
+ /** Streams `url` into `file`. A non-2xx answer or a broken stream throws. */
50
+ function download(url, file) {
51
+ let parsed;
52
+ try {
53
+ parsed = new URL(url);
54
+ } catch {
55
+ throw new StorageError("invalid_url", "--url is not a URL");
56
+ }
57
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
58
+ throw new StorageError(
59
+ "invalid_url",
60
+ "--url must be an https: download URL",
61
+ );
62
+ const client = parsed.protocol === "http:" ? http : https;
63
+ return new Promise((resolvePromise, reject) => {
64
+ const req = client.get(parsed, { timeout: DOWNLOAD_TIMEOUT_MS }, (res) => {
65
+ const status = res.statusCode ?? 0;
66
+ if (status < 200 || status >= 300) {
67
+ res.resume();
68
+ const code =
69
+ REFUSED[status] ??
70
+ (status >= 500 ? "kit_storage_unavailable" : `http_${status}`);
71
+ const hint =
72
+ status === 403 || status === 400 || status === 410
73
+ ? "; the download URL may have expired, so ask for a fresh one"
74
+ : "";
75
+ reject(
76
+ new StorageError(
77
+ code,
78
+ `downloading ${redactUrl(url)} was refused (${status})${hint}`,
79
+ ),
80
+ );
81
+ return;
82
+ }
83
+ pipeline(res, createWriteStream(file)).then(resolvePromise, (error) =>
84
+ reject(
85
+ new StorageError(
86
+ "download_failed",
87
+ `downloading ${redactUrl(url)} broke off (${error.code ?? error.message})`,
88
+ ),
89
+ ),
90
+ );
91
+ });
92
+ req.on("timeout", () => req.destroy(new Error("no answer in time")));
93
+ req.on("error", (error) =>
94
+ reject(
95
+ new StorageError(
96
+ "kit_storage_unavailable",
97
+ `could not reach ${parsed.host} (${error.code ?? error.message})`,
98
+ ),
99
+ ),
100
+ );
101
+ });
102
+ }
103
+
104
+ /** Nothing when manifest.json's `sha` starts with `sha`, else a StorageError. */
105
+ function checkManifestSha(dir, sha) {
106
+ let manifest;
107
+ try {
108
+ manifest = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
109
+ } catch {
110
+ throw new StorageError(
111
+ "not_a_kit",
112
+ "the download has no readable manifest.json",
113
+ );
114
+ }
115
+ const named =
116
+ typeof manifest?.sha === "string" ? manifest.sha.toLowerCase() : "";
117
+ const wanted = sha.toLowerCase();
118
+ // A short --sha may name the manifest's full sha, never the other way round.
119
+ if (!named || !named.startsWith(wanted))
120
+ throw new StorageError(
121
+ "sha_mismatch",
122
+ `the downloaded kit is of ${named.slice(0, 12) || "no commit"}, not ${wanted.slice(0, 12)}`,
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Installs the kit of commit `sha` from `url` into `out` (default
128
+ * `$TMPDIR/design-kit-<sha>`): `{path, sha, branch, cached}`.
129
+ */
130
+ export async function fetchFromUrl({
131
+ url,
132
+ sha,
133
+ branch,
134
+ out,
135
+ env = process.env,
136
+ }) {
137
+ if (!url)
138
+ throw new StorageError("invalid_url", "--url needs the kit's download URL");
139
+ checkSha(sha, "--sha");
140
+ const path = resolve(out ?? kitCacheDir(sha, env));
141
+ const result = { path, sha, branch };
142
+ if (isKit(path)) return { ...result, cached: true };
143
+ mkdirSync(dirname(path), { recursive: true });
144
+ const scratch = mkdtempSync(`${path}.partial-`);
145
+ try {
146
+ const zipFile = join(scratch, "kit.zip");
147
+ const unpacked = join(scratch, "kit");
148
+ await download(url, zipFile);
149
+ try {
150
+ unzipTo(readFileSync(zipFile), unpacked);
151
+ } catch (error) {
152
+ throw new StorageError(
153
+ "bad_archive",
154
+ `the download is not a whole kit zip (${error.message})`,
155
+ );
156
+ }
157
+ checkManifestSha(unpacked, sha);
158
+ try {
159
+ renameSync(unpacked, path);
160
+ } catch (error) {
161
+ // Another fetch of the same commit got there first: keep its folder.
162
+ if (isKit(path)) return { ...result, cached: true };
163
+ if (error.code === "ENOTEMPTY" || error.code === "EEXIST")
164
+ throw new StorageError(
165
+ "out_not_empty",
166
+ `${path} exists and is not a kit; choose another --out`,
167
+ );
168
+ throw error;
169
+ }
170
+ return { ...result, cached: false };
171
+ } finally {
172
+ rmSync(scratch, { recursive: true, force: true });
173
+ }
174
+ }