@codepress/design-kit 0.7.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 +97 -5
- 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/kit.mjs +54 -7
- package/src/storage/bucket.mjs +237 -0
- package/src/storage/codepress.mjs +363 -0
- package/src/storage/common.mjs +125 -0
- package/src/storage/github.mjs +175 -0
- package/src/storage/index.mjs +149 -0
- package/src/storage/zip.mjs +249 -0
- package/src/templates/design-kit-skill.md +18 -18
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every kit store shares: the error a store raises, where a fetched kit
|
|
3
|
+
* is cached, and how the repository and its default branch are found.
|
|
4
|
+
*/
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
import { unzipTo } from "./zip.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A store refused or could not be reached. `code` is the store's own name
|
|
20
|
+
* for it (the backend's `reason`, such as `forbidden` or `no_kit`), or
|
|
21
|
+
* `kit_storage_unavailable` when the store could not be reached at all.
|
|
22
|
+
* Exits 1.
|
|
23
|
+
*/
|
|
24
|
+
export class StorageError extends Error {
|
|
25
|
+
constructor(code, message) {
|
|
26
|
+
super(message ? `${code}: ${message}` : code);
|
|
27
|
+
this.code = code;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The first line of what a failed command said. */
|
|
32
|
+
export function errorText(error) {
|
|
33
|
+
const stderr = error?.stderr ? String(error.stderr).trim() : "";
|
|
34
|
+
return (stderr || error?.message || String(error)).split("\n")[0];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `sha` when it is a commit id (7 to 64 hex digits), else a StorageError. A
|
|
39
|
+
* store's answer names the commit, and it becomes a folder and file name,
|
|
40
|
+
* so nothing else (a `/`, a `..`) may reach a path.
|
|
41
|
+
*/
|
|
42
|
+
export function checkSha(sha, from) {
|
|
43
|
+
if (typeof sha === "string" && /^[0-9a-f]{7,64}$/i.test(sha)) return sha;
|
|
44
|
+
throw new StorageError(
|
|
45
|
+
"invalid_kit_sha",
|
|
46
|
+
`${from} names the kit's commit as ${JSON.stringify(sha)}, which is not a commit id`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Where a fetched kit is kept: `$TMPDIR/design-kit-<sha>`. */
|
|
51
|
+
export function kitCacheDir(sha, env = process.env) {
|
|
52
|
+
return join(
|
|
53
|
+
env.TMPDIR || tmpdir(),
|
|
54
|
+
`design-kit-${checkSha(sha, "the store")}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Unzips `zip` into `out` through a sibling scratch folder and a rename,
|
|
60
|
+
* so `out` never holds half a kit.
|
|
61
|
+
*/
|
|
62
|
+
export function installKit(zip, out) {
|
|
63
|
+
const target = resolve(out);
|
|
64
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
65
|
+
const scratch = mkdtempSync(`${target}.partial-`);
|
|
66
|
+
try {
|
|
67
|
+
unzipTo(zip, scratch);
|
|
68
|
+
if (!existsSync(join(scratch, "manifest.json")))
|
|
69
|
+
throw new Error("the stored kit has no manifest.json");
|
|
70
|
+
rmSync(target, { recursive: true, force: true });
|
|
71
|
+
renameSync(scratch, target);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
return target;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A cached kit is a folder with its manifest.json. */
|
|
80
|
+
export function isKit(dir) {
|
|
81
|
+
return existsSync(join(dir, "manifest.json"));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function git(cwd, args) {
|
|
85
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
88
|
+
}).trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `owner/name` from a github.com remote URL, or null. */
|
|
92
|
+
export function repoFromRemote(url) {
|
|
93
|
+
const match = String(url).match(
|
|
94
|
+
/github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
95
|
+
);
|
|
96
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The repository as `owner/name`: `--repo`, then GITHUB_REPOSITORY, then
|
|
101
|
+
* the `origin` remote of the checkout holding `cwd`. Null when none says.
|
|
102
|
+
*/
|
|
103
|
+
export function findRepo({ repo, env = process.env, cwd = process.cwd() }) {
|
|
104
|
+
if (repo) return repo;
|
|
105
|
+
if (env.GITHUB_REPOSITORY) return env.GITHUB_REPOSITORY;
|
|
106
|
+
try {
|
|
107
|
+
return repoFromRemote(git(cwd, ["remote", "get-url", "origin"]));
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The default branch `origin/HEAD` names in the checkout, or null. */
|
|
114
|
+
export function originDefaultBranch(cwd = process.cwd()) {
|
|
115
|
+
try {
|
|
116
|
+
return (
|
|
117
|
+
git(cwd, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]).replace(
|
|
118
|
+
/^origin\//,
|
|
119
|
+
"",
|
|
120
|
+
) || null
|
|
121
|
+
);
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `github` storage (the default): each kit is a `design-kit` GitHub Actions
|
|
3
|
+
* artifact, uploaded by the workflow's `actions/upload-artifact` step and
|
|
4
|
+
* read through the `gh` CLI. Artifacts last at most 90 days on a public
|
|
5
|
+
* repository (400 on a private one), so the workflow rebuilds before then.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs";
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { ConfigError } from "../config.mjs";
|
|
12
|
+
import {
|
|
13
|
+
StorageError,
|
|
14
|
+
checkSha,
|
|
15
|
+
errorText,
|
|
16
|
+
isKit,
|
|
17
|
+
kitCacheDir,
|
|
18
|
+
} from "./common.mjs";
|
|
19
|
+
|
|
20
|
+
export const ARTIFACT = "design-kit";
|
|
21
|
+
const PAGES = 5;
|
|
22
|
+
|
|
23
|
+
/** Runs `gh` and returns its stdout. */
|
|
24
|
+
export function runGh(args, { env = process.env } = {}) {
|
|
25
|
+
return execFileSync("gh", args, {
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
env,
|
|
28
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
29
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Every `design-kit` artifact of the repository, newest first. */
|
|
34
|
+
function listKits({ repo, gh }) {
|
|
35
|
+
const artifacts = [];
|
|
36
|
+
for (let page = 1; page <= PAGES; page += 1) {
|
|
37
|
+
const body = JSON.parse(
|
|
38
|
+
gh([
|
|
39
|
+
"api",
|
|
40
|
+
`repos/${repo}/actions/artifacts?name=${ARTIFACT}&per_page=100&page=${page}`,
|
|
41
|
+
]),
|
|
42
|
+
);
|
|
43
|
+
const batch = body?.artifacts ?? [];
|
|
44
|
+
artifacts.push(...batch);
|
|
45
|
+
if (batch.length < 100) break;
|
|
46
|
+
}
|
|
47
|
+
return artifacts
|
|
48
|
+
.filter((artifact) => artifact.name === ARTIFACT)
|
|
49
|
+
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The newest unexpired kit on `branch`: `{id, sha, createdAt, runId}`, or
|
|
54
|
+
* null when there is none. Throws when the store cannot be read.
|
|
55
|
+
*/
|
|
56
|
+
export function findNewestKit({ repo, branch, gh = runGh }) {
|
|
57
|
+
const kit = listKits({ repo, gh }).find(
|
|
58
|
+
(artifact) =>
|
|
59
|
+
!artifact.expired && artifact.workflow_run?.head_branch === branch,
|
|
60
|
+
);
|
|
61
|
+
if (!kit?.workflow_run?.head_sha) return null;
|
|
62
|
+
return {
|
|
63
|
+
id: String(kit.id),
|
|
64
|
+
sha: kit.workflow_run.head_sha,
|
|
65
|
+
createdAt: kit.created_at,
|
|
66
|
+
runId: kit.workflow_run.id ? String(kit.workflow_run.id) : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Deletes the kits on `branch` older than the one `keep` names (an artifact
|
|
72
|
+
* id). Returns `{deleted: [ids], failed: [{id, error}], keptFound}`; never
|
|
73
|
+
* throws for a single failed delete. When the kept kit is not in the listing
|
|
74
|
+
* (not listed yet, or on another branch), nothing is deleted: "older" has
|
|
75
|
+
* nothing to be measured against.
|
|
76
|
+
*/
|
|
77
|
+
export function deleteOlderKits({ repo, branch, keep, gh = runGh }) {
|
|
78
|
+
const kits = listKits({ repo, gh }).filter(
|
|
79
|
+
(artifact) => artifact.workflow_run?.head_branch === branch,
|
|
80
|
+
);
|
|
81
|
+
const kept = kits.find((artifact) => String(artifact.id) === String(keep));
|
|
82
|
+
const deleted = [];
|
|
83
|
+
const failed = [];
|
|
84
|
+
if (!kept) return { deleted, failed, keptFound: false };
|
|
85
|
+
for (const artifact of kits) {
|
|
86
|
+
if (String(artifact.id) === String(keep)) continue;
|
|
87
|
+
// Only kits uploaded before the one just kept: a newer one, or one
|
|
88
|
+
// uploaded in the same instant, is never removed.
|
|
89
|
+
if (!(Date.parse(artifact.created_at) < Date.parse(kept.created_at)))
|
|
90
|
+
continue;
|
|
91
|
+
try {
|
|
92
|
+
gh([
|
|
93
|
+
"api",
|
|
94
|
+
"--method",
|
|
95
|
+
"DELETE",
|
|
96
|
+
`repos/${repo}/actions/artifacts/${artifact.id}`,
|
|
97
|
+
]);
|
|
98
|
+
deleted.push(String(artifact.id));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
failed.push({ id: String(artifact.id), error: errorText(error) });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { deleted, failed, keptFound: Boolean(kept) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The `github` store. `repo` is `owner/name`; `gh` runs the GitHub CLI
|
|
108
|
+
* (tests pass their own).
|
|
109
|
+
*/
|
|
110
|
+
export function githubStorage({ repo, env = process.env, gh } = {}) {
|
|
111
|
+
const run = gh ?? ((args) => runGh(args, { env }));
|
|
112
|
+
const needRepo = () => {
|
|
113
|
+
if (!repo)
|
|
114
|
+
throw new ConfigError(
|
|
115
|
+
"--repo <owner/name> is needed: no GITHUB_REPOSITORY and no github.com origin remote",
|
|
116
|
+
);
|
|
117
|
+
return repo;
|
|
118
|
+
};
|
|
119
|
+
const defaultBranch = () =>
|
|
120
|
+
JSON.parse(run(["api", `repos/${needRepo()}`])).default_branch;
|
|
121
|
+
return {
|
|
122
|
+
type: "github",
|
|
123
|
+
latest({ branch }) {
|
|
124
|
+
return findNewestKit({
|
|
125
|
+
repo: needRepo(),
|
|
126
|
+
branch: branch ?? defaultBranch(),
|
|
127
|
+
gh: run,
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
upload() {
|
|
131
|
+
throw new ConfigError(
|
|
132
|
+
'github storage uploads the kit with the workflow\'s actions/upload-artifact step, so there is nothing for design-kit upload to do. Set "storage" in the config to codepress, s3 or r2 to upload with this command.',
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
fetch({ branch, out }) {
|
|
136
|
+
const name = branch ?? defaultBranch();
|
|
137
|
+
const kit = findNewestKit({ repo: needRepo(), branch: name, gh: run });
|
|
138
|
+
if (!kit?.runId)
|
|
139
|
+
throw new StorageError(
|
|
140
|
+
"no_kit",
|
|
141
|
+
`no unexpired design-kit artifact on ${name} in ${repo}`,
|
|
142
|
+
);
|
|
143
|
+
checkSha(kit.sha, "The artifact's workflow run");
|
|
144
|
+
const path = resolve(out ?? kitCacheDir(kit.sha, env));
|
|
145
|
+
if (isKit(path))
|
|
146
|
+
return { path, sha: kit.sha, branch: name, cached: true };
|
|
147
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
148
|
+
const scratch = mkdtempSync(`${path}.partial-`);
|
|
149
|
+
try {
|
|
150
|
+
run([
|
|
151
|
+
"run",
|
|
152
|
+
"download",
|
|
153
|
+
kit.runId,
|
|
154
|
+
"--repo",
|
|
155
|
+
repo,
|
|
156
|
+
"-n",
|
|
157
|
+
ARTIFACT,
|
|
158
|
+
"-D",
|
|
159
|
+
scratch,
|
|
160
|
+
]);
|
|
161
|
+
if (!isKit(scratch))
|
|
162
|
+
throw new StorageError(
|
|
163
|
+
"no_kit",
|
|
164
|
+
`the artifact of run ${kit.runId} has no manifest.json`,
|
|
165
|
+
);
|
|
166
|
+
rmSync(path, { recursive: true, force: true });
|
|
167
|
+
renameSync(scratch, path);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
return { path, sha: kit.sha, branch: name, cached: false };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a kit is stored, chosen by the config's `storage` key, behind one
|
|
3
|
+
* interface:
|
|
4
|
+
*
|
|
5
|
+
* latest({branch}) → {sha, createdAt} | null
|
|
6
|
+
* upload({dir, branch, sha}) → {sha, branch, sizeBytes}
|
|
7
|
+
* fetch({branch, out}) → {path, sha, branch, cached}
|
|
8
|
+
*
|
|
9
|
+
* `github` (the default) is a GitHub Actions artifact; `codepress` is
|
|
10
|
+
* CodePress; `s3` and `r2` are the user's own bucket. The commands
|
|
11
|
+
* `design-kit upload`, `fetch` and `latest` are here too.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
import { ConfigError, loadConfig } from "../config.mjs";
|
|
17
|
+
import { bucketStorage } from "./bucket.mjs";
|
|
18
|
+
import { codepressStorage } from "./codepress.mjs";
|
|
19
|
+
import { findRepo, originDefaultBranch } from "./common.mjs";
|
|
20
|
+
import { githubStorage } from "./github.mjs";
|
|
21
|
+
|
|
22
|
+
export { StorageError } from "./common.mjs";
|
|
23
|
+
|
|
24
|
+
export const STORAGE_TYPES = ["github", "codepress", "s3", "r2"];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The store for a config's `storage` object (`{type: "github"}` when
|
|
28
|
+
* absent). `context`: `repo` (owner/name), `env`, `tokenFile`, and for
|
|
29
|
+
* tests `gh` and `aws`.
|
|
30
|
+
*/
|
|
31
|
+
export function storageFor(storage, context = {}) {
|
|
32
|
+
const config = storage ?? { type: "github" };
|
|
33
|
+
if (config.type === "github") return githubStorage(context);
|
|
34
|
+
if (config.type === "codepress") return codepressStorage(config, context);
|
|
35
|
+
if (config.type === "s3" || config.type === "r2")
|
|
36
|
+
return bucketStorage(config, context);
|
|
37
|
+
throw new ConfigError(
|
|
38
|
+
`storage.type: must be one of ${STORAGE_TYPES.join(", ")}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The branch a read means when --branch is not given. */
|
|
43
|
+
function readBranch(options, storage, configDir) {
|
|
44
|
+
if (options.branch) return options.branch;
|
|
45
|
+
// github asks GitHub and codepress asks CodePress for the default branch;
|
|
46
|
+
// a bucket knows no repository, so the checkout's origin/HEAD says.
|
|
47
|
+
if (storage.type === "s3" || storage.type === "r2")
|
|
48
|
+
return originDefaultBranch(configDir) ?? undefined;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function contextFor(options, config, { env, gh, aws }) {
|
|
53
|
+
return {
|
|
54
|
+
repo: findRepo({ repo: options.repo, env, cwd: config.configDir }),
|
|
55
|
+
env,
|
|
56
|
+
tokenFile: options["token-file"],
|
|
57
|
+
gh,
|
|
58
|
+
aws,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function storageOf(config) {
|
|
63
|
+
return config.storage ?? { type: "github" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `design-kit upload --dir <kit>`: uploads a built kit to the configured
|
|
68
|
+
* store. `github` storage exits 2: the workflow's upload-artifact step
|
|
69
|
+
* uploads it.
|
|
70
|
+
*/
|
|
71
|
+
export async function uploadCommand(
|
|
72
|
+
options,
|
|
73
|
+
{ env = process.env, gh, aws } = {},
|
|
74
|
+
) {
|
|
75
|
+
const config = loadConfig(options.config);
|
|
76
|
+
const storage = storageOf(config);
|
|
77
|
+
const dir = resolve(options.dir ?? config.out ?? "design-kit");
|
|
78
|
+
if (storage.type !== "github" && !existsSync(join(dir, "manifest.json")))
|
|
79
|
+
throw new ConfigError(
|
|
80
|
+
`${dir} is not a kit (no manifest.json); pass --dir <kit folder>`,
|
|
81
|
+
);
|
|
82
|
+
const store = storageFor(
|
|
83
|
+
storage,
|
|
84
|
+
contextFor(options, config, { env, gh, aws }),
|
|
85
|
+
);
|
|
86
|
+
const result = await store.upload({
|
|
87
|
+
dir,
|
|
88
|
+
branch: options.branch ?? env.GITHUB_REF_NAME,
|
|
89
|
+
sha: options.sha ?? env.GITHUB_SHA,
|
|
90
|
+
});
|
|
91
|
+
console.log(
|
|
92
|
+
`Uploaded the kit of ${String(result.sha).slice(0, 7)} on ${result.branch} to ${storage.type} storage (${result.sizeBytes} bytes zipped).`,
|
|
93
|
+
);
|
|
94
|
+
console.log(`sha=${result.sha}`);
|
|
95
|
+
console.log(`branch=${result.branch}`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `design-kit fetch [--branch b] [--out dir]`: downloads the newest kit
|
|
101
|
+
* (once per commit: it is cached under $TMPDIR/design-kit-<sha>) and prints
|
|
102
|
+
* `path=`, `sha=` and `branch=`.
|
|
103
|
+
*/
|
|
104
|
+
export async function fetchCommand(
|
|
105
|
+
options,
|
|
106
|
+
{ env = process.env, gh, aws } = {},
|
|
107
|
+
) {
|
|
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
|
+
});
|
|
118
|
+
console.log(`path=${kit.path}`);
|
|
119
|
+
console.log(`sha=${kit.sha}`);
|
|
120
|
+
if (kit.branch) console.log(`branch=${kit.branch}`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `design-kit latest [--branch b]`: prints the newest kit's `sha=` and
|
|
126
|
+
* `created=`. No kit exits 1.
|
|
127
|
+
*/
|
|
128
|
+
export async function latestCommand(
|
|
129
|
+
options,
|
|
130
|
+
{ env = process.env, gh, aws } = {},
|
|
131
|
+
) {
|
|
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
|
+
const branch = readBranch(options, storage, config.configDir);
|
|
139
|
+
const kit = await store.latest({ branch });
|
|
140
|
+
if (!kit) {
|
|
141
|
+
console.error(
|
|
142
|
+
`design-kit: no_kit: no kit stored${branch ? ` on ${branch}` : " on the default branch"} in ${storage.type} storage`,
|
|
143
|
+
);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
console.log(`sha=${kit.sha}`);
|
|
147
|
+
console.log(`created=${kit.createdAt ?? ""}`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zips a kit folder and unzips one, with Node's zlib and no other
|
|
3
|
+
* dependency. The archive is deterministic: entries sorted by path, every
|
|
4
|
+
* timestamp 1980-01-01, so the same kit always zips to the same bytes.
|
|
5
|
+
* ZIP64 end records are written and read when a kit has 65,535 or more
|
|
6
|
+
* files; a single file must stay under 4 GB.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
|
11
|
+
|
|
12
|
+
const CRC_TABLE = (() => {
|
|
13
|
+
const table = new Uint32Array(256);
|
|
14
|
+
for (let n = 0; n < 256; n += 1) {
|
|
15
|
+
let c = n;
|
|
16
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
17
|
+
table[n] = c >>> 0;
|
|
18
|
+
}
|
|
19
|
+
return table;
|
|
20
|
+
})();
|
|
21
|
+
|
|
22
|
+
/** The CRC-32 a zip entry records. */
|
|
23
|
+
export function crc32(buffer) {
|
|
24
|
+
let crc = 0xffffffff;
|
|
25
|
+
for (let i = 0; i < buffer.length; i += 1)
|
|
26
|
+
crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8);
|
|
27
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every regular file under `dir`, as forward-slash paths relative to it. */
|
|
31
|
+
function filesUnder(dir) {
|
|
32
|
+
const files = [];
|
|
33
|
+
const walk = (folder) => {
|
|
34
|
+
for (const entry of readdirSync(folder, { withFileTypes: true })) {
|
|
35
|
+
const path = join(folder, entry.name);
|
|
36
|
+
if (entry.isDirectory()) walk(path);
|
|
37
|
+
else if (entry.isFile())
|
|
38
|
+
files.push(relative(dir, path).split(sep).join("/"));
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
walk(dir);
|
|
42
|
+
return files.sort();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DOS_TIME = 0;
|
|
46
|
+
const DOS_DATE = (1 << 5) | 1; // 1980-01-01
|
|
47
|
+
const MAX32 = 0xffffffff;
|
|
48
|
+
const MAX16 = 0xffff;
|
|
49
|
+
|
|
50
|
+
/** The folder `dir`, zipped, as one Buffer. */
|
|
51
|
+
export function zipDirectory(dir) {
|
|
52
|
+
const parts = [];
|
|
53
|
+
const central = [];
|
|
54
|
+
let offset = 0;
|
|
55
|
+
for (const path of filesUnder(dir)) {
|
|
56
|
+
const data = readFileSync(join(dir, path));
|
|
57
|
+
const name = Buffer.from(path, "utf8");
|
|
58
|
+
const deflated = deflateRawSync(data);
|
|
59
|
+
// Stored when deflating does not help (fonts, images).
|
|
60
|
+
const method = deflated.length < data.length ? 8 : 0;
|
|
61
|
+
const body = method === 8 ? deflated : data;
|
|
62
|
+
if (data.length >= MAX32 || body.length >= MAX32)
|
|
63
|
+
throw new Error(`${path} is too large to zip (4 GB or more)`);
|
|
64
|
+
const crc = crc32(data);
|
|
65
|
+
const local = Buffer.alloc(30);
|
|
66
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
67
|
+
local.writeUInt16LE(20, 4); // version needed
|
|
68
|
+
local.writeUInt16LE(0x0800, 6); // UTF-8 names
|
|
69
|
+
local.writeUInt16LE(method, 8);
|
|
70
|
+
local.writeUInt16LE(DOS_TIME, 10);
|
|
71
|
+
local.writeUInt16LE(DOS_DATE, 12);
|
|
72
|
+
local.writeUInt32LE(crc, 14);
|
|
73
|
+
local.writeUInt32LE(body.length, 18);
|
|
74
|
+
local.writeUInt32LE(data.length, 22);
|
|
75
|
+
local.writeUInt16LE(name.length, 26);
|
|
76
|
+
local.writeUInt16LE(0, 28);
|
|
77
|
+
parts.push(local, name, body);
|
|
78
|
+
|
|
79
|
+
const zip64Offset = offset >= MAX32;
|
|
80
|
+
const extra = zip64Offset ? Buffer.alloc(12) : Buffer.alloc(0);
|
|
81
|
+
if (zip64Offset) {
|
|
82
|
+
extra.writeUInt16LE(0x0001, 0);
|
|
83
|
+
extra.writeUInt16LE(8, 2);
|
|
84
|
+
extra.writeBigUInt64LE(BigInt(offset), 4);
|
|
85
|
+
}
|
|
86
|
+
const header = Buffer.alloc(46);
|
|
87
|
+
header.writeUInt32LE(0x02014b50, 0);
|
|
88
|
+
header.writeUInt16LE(zip64Offset ? 45 : 20, 4); // version made by
|
|
89
|
+
header.writeUInt16LE(zip64Offset ? 45 : 20, 6); // version needed
|
|
90
|
+
header.writeUInt16LE(0x0800, 8);
|
|
91
|
+
header.writeUInt16LE(method, 10);
|
|
92
|
+
header.writeUInt16LE(DOS_TIME, 12);
|
|
93
|
+
header.writeUInt16LE(DOS_DATE, 14);
|
|
94
|
+
header.writeUInt32LE(crc, 16);
|
|
95
|
+
header.writeUInt32LE(body.length, 20);
|
|
96
|
+
header.writeUInt32LE(data.length, 24);
|
|
97
|
+
header.writeUInt16LE(name.length, 28);
|
|
98
|
+
header.writeUInt16LE(extra.length, 30);
|
|
99
|
+
header.writeUInt16LE(0, 32); // comment
|
|
100
|
+
header.writeUInt16LE(0, 34); // disk
|
|
101
|
+
header.writeUInt16LE(0, 36); // internal attributes
|
|
102
|
+
header.writeUInt32LE(0, 38); // external attributes
|
|
103
|
+
header.writeUInt32LE(zip64Offset ? MAX32 : offset, 42);
|
|
104
|
+
central.push(header, name, extra);
|
|
105
|
+
offset += local.length + name.length + body.length;
|
|
106
|
+
}
|
|
107
|
+
const count = central.length / 3;
|
|
108
|
+
const centralSize = central.reduce((sum, part) => sum + part.length, 0);
|
|
109
|
+
const centralOffset = offset;
|
|
110
|
+
const tail = [];
|
|
111
|
+
const zip64 =
|
|
112
|
+
count >= MAX16 || centralOffset >= MAX32 || centralSize >= MAX32;
|
|
113
|
+
if (zip64) {
|
|
114
|
+
const record = Buffer.alloc(56);
|
|
115
|
+
record.writeUInt32LE(0x06064b50, 0);
|
|
116
|
+
record.writeBigUInt64LE(44n, 4);
|
|
117
|
+
record.writeUInt16LE(45, 12);
|
|
118
|
+
record.writeUInt16LE(45, 14);
|
|
119
|
+
record.writeUInt32LE(0, 16);
|
|
120
|
+
record.writeUInt32LE(0, 20);
|
|
121
|
+
record.writeBigUInt64LE(BigInt(count), 24);
|
|
122
|
+
record.writeBigUInt64LE(BigInt(count), 32);
|
|
123
|
+
record.writeBigUInt64LE(BigInt(centralSize), 40);
|
|
124
|
+
record.writeBigUInt64LE(BigInt(centralOffset), 48);
|
|
125
|
+
const locator = Buffer.alloc(20);
|
|
126
|
+
locator.writeUInt32LE(0x07064b50, 0);
|
|
127
|
+
locator.writeUInt32LE(0, 4);
|
|
128
|
+
locator.writeBigUInt64LE(BigInt(centralOffset + centralSize), 8);
|
|
129
|
+
locator.writeUInt32LE(1, 16);
|
|
130
|
+
tail.push(record, locator);
|
|
131
|
+
}
|
|
132
|
+
const end = Buffer.alloc(22);
|
|
133
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
134
|
+
end.writeUInt16LE(zip64 ? MAX16 : count, 8);
|
|
135
|
+
end.writeUInt16LE(zip64 ? MAX16 : count, 10);
|
|
136
|
+
end.writeUInt32LE(zip64 ? MAX32 : centralSize, 12);
|
|
137
|
+
end.writeUInt32LE(zip64 ? MAX32 : centralOffset, 16);
|
|
138
|
+
end.writeUInt16LE(0, 20);
|
|
139
|
+
tail.push(end);
|
|
140
|
+
return Buffer.concat([...parts, ...central, ...tail]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Where the end-of-central-directory record starts, or -1. */
|
|
144
|
+
function findEnd(zip) {
|
|
145
|
+
const last = Math.max(0, zip.length - 22 - MAX16);
|
|
146
|
+
for (let i = zip.length - 22; i >= last; i -= 1)
|
|
147
|
+
if (zip.readUInt32LE(i) === 0x06054b50) return i;
|
|
148
|
+
return -1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The entries of a zip: `[{path, data}]`. Throws on anything it cannot
|
|
153
|
+
* read: a damaged archive, an encrypted entry, or an unknown method.
|
|
154
|
+
*/
|
|
155
|
+
export function readZip(zip) {
|
|
156
|
+
const end = findEnd(zip);
|
|
157
|
+
if (end < 0) throw new Error("not a zip archive (no end record)");
|
|
158
|
+
let count = zip.readUInt16LE(end + 10);
|
|
159
|
+
let centralOffset = zip.readUInt32LE(end + 16);
|
|
160
|
+
if (
|
|
161
|
+
(count === MAX16 || centralOffset === MAX32) &&
|
|
162
|
+
end >= 20 &&
|
|
163
|
+
zip.readUInt32LE(end - 20) === 0x07064b50
|
|
164
|
+
) {
|
|
165
|
+
const record = Number(zip.readBigUInt64LE(end - 20 + 8));
|
|
166
|
+
if (zip.readUInt32LE(record) !== 0x06064b50)
|
|
167
|
+
throw new Error("not a zip archive (bad ZIP64 record)");
|
|
168
|
+
count = Number(zip.readBigUInt64LE(record + 32));
|
|
169
|
+
centralOffset = Number(zip.readBigUInt64LE(record + 48));
|
|
170
|
+
}
|
|
171
|
+
const entries = [];
|
|
172
|
+
let at = centralOffset;
|
|
173
|
+
for (let index = 0; index < count; index += 1) {
|
|
174
|
+
if (zip.readUInt32LE(at) !== 0x02014b50)
|
|
175
|
+
throw new Error("damaged zip archive (bad central directory)");
|
|
176
|
+
const flags = zip.readUInt16LE(at + 8);
|
|
177
|
+
const method = zip.readUInt16LE(at + 10);
|
|
178
|
+
const crc = zip.readUInt32LE(at + 16);
|
|
179
|
+
let compressed = zip.readUInt32LE(at + 20);
|
|
180
|
+
let size = zip.readUInt32LE(at + 24);
|
|
181
|
+
const nameLength = zip.readUInt16LE(at + 28);
|
|
182
|
+
const extraLength = zip.readUInt16LE(at + 30);
|
|
183
|
+
const commentLength = zip.readUInt16LE(at + 32);
|
|
184
|
+
let local = zip.readUInt32LE(at + 42);
|
|
185
|
+
const path = zip.toString("utf8", at + 46, at + 46 + nameLength);
|
|
186
|
+
// ZIP64 extra field: the 64-bit values, in order, for the 32-bit
|
|
187
|
+
// fields that are 0xffffffff.
|
|
188
|
+
let extraAt = at + 46 + nameLength;
|
|
189
|
+
const extraEnd = extraAt + extraLength;
|
|
190
|
+
while (extraAt + 4 <= extraEnd) {
|
|
191
|
+
const id = zip.readUInt16LE(extraAt);
|
|
192
|
+
const length = zip.readUInt16LE(extraAt + 2);
|
|
193
|
+
if (id === 0x0001) {
|
|
194
|
+
let field = extraAt + 4;
|
|
195
|
+
if (size === MAX32) {
|
|
196
|
+
size = Number(zip.readBigUInt64LE(field));
|
|
197
|
+
field += 8;
|
|
198
|
+
}
|
|
199
|
+
if (compressed === MAX32) {
|
|
200
|
+
compressed = Number(zip.readBigUInt64LE(field));
|
|
201
|
+
field += 8;
|
|
202
|
+
}
|
|
203
|
+
if (local === MAX32) local = Number(zip.readBigUInt64LE(field));
|
|
204
|
+
}
|
|
205
|
+
extraAt += 4 + length;
|
|
206
|
+
}
|
|
207
|
+
at = extraEnd + commentLength;
|
|
208
|
+
if (path.endsWith("/")) continue;
|
|
209
|
+
if (flags & 1) throw new Error(`${path}: encrypted entries are not read`);
|
|
210
|
+
if (zip.readUInt32LE(local) !== 0x04034b50)
|
|
211
|
+
throw new Error(`damaged zip archive (${path} has no local header)`);
|
|
212
|
+
const start =
|
|
213
|
+
local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28);
|
|
214
|
+
const body = zip.subarray(start, start + compressed);
|
|
215
|
+
let data;
|
|
216
|
+
if (method === 0) data = body;
|
|
217
|
+
else if (method === 8) data = inflateRawSync(body);
|
|
218
|
+
else throw new Error(`${path}: compression method ${method} is not read`);
|
|
219
|
+
if (data.length !== size || crc32(data) !== crc)
|
|
220
|
+
throw new Error(`damaged zip archive (${path} fails its checksum)`);
|
|
221
|
+
entries.push({ path, data });
|
|
222
|
+
}
|
|
223
|
+
return entries;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Unzips `zip` into `dir`. An entry whose path is absolute or climbs out of
|
|
228
|
+
* `dir` (`..`) is refused before anything is written.
|
|
229
|
+
*/
|
|
230
|
+
export function unzipTo(zip, dir) {
|
|
231
|
+
const root = resolve(dir);
|
|
232
|
+
const entries = readZip(zip);
|
|
233
|
+
for (const { path } of entries) {
|
|
234
|
+
const target = resolve(root, path);
|
|
235
|
+
if (
|
|
236
|
+
/^[\\/]/.test(path) ||
|
|
237
|
+
/^[A-Za-z]:/.test(path) ||
|
|
238
|
+
!`${target}`.startsWith(`${root}${sep}`)
|
|
239
|
+
)
|
|
240
|
+
throw new Error(`refusing the zip entry ${path}: it is outside the kit`);
|
|
241
|
+
}
|
|
242
|
+
mkdirSync(root, { recursive: true });
|
|
243
|
+
for (const { path, data } of entries) {
|
|
244
|
+
const target = resolve(root, path);
|
|
245
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
246
|
+
writeFileSync(target, data);
|
|
247
|
+
}
|
|
248
|
+
return entries.length;
|
|
249
|
+
}
|