@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 +22 -2
- package/package.json +1 -1
- package/src/cli.mjs +8 -2
- package/src/kit.d.ts +24 -0
- package/src/kit.mjs +114 -6
- package/src/storage/index.mjs +37 -11
- package/src/storage/url.mjs +174 -0
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
|
|
415
|
-
|
|
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
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
|
|
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"
|
|
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
|
-
*
|
|
2172
|
-
* failed ids when there are five or fewer. `
|
|
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
|
|
2175
|
-
const manifest = kit.manifest ?? {
|
|
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("&", "&")
|
|
2224
|
+
.replaceAll("<", "<")
|
|
2225
|
+
.replaceAll(">", ">")
|
|
2226
|
+
.replaceAll('"', """);
|
|
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") {
|
package/src/storage/index.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
}
|