storyshelf 0.3.2
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/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/client.js +86 -0
- package/dist/commands/create.js +65 -0
- package/dist/commands/default.js +50 -0
- package/dist/commands/init.js +197 -0
- package/dist/commands/purge.js +19 -0
- package/dist/commands/retry.js +19 -0
- package/dist/commands/server/docker.js +62 -0
- package/dist/commands/server/init.js +188 -0
- package/dist/commands/server/prompts.js +88 -0
- package/dist/commands/upload.js +145 -0
- package/dist/config.js +153 -0
- package/dist/index.js +62 -0
- package/dist/output.js +37 -0
- package/package.json +67 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { printError, printLine } from "../../output.js";
|
|
6
|
+
import { generateComposeYaml, generateDockerfile, generateDockerignore } from "./docker.js";
|
|
7
|
+
import { INFRA_PROMPTS, PROJECT_PROMPTS } from "./prompts.js";
|
|
8
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
import prompts from "prompts";
|
|
11
|
+
//#region src/commands/server/init.ts
|
|
12
|
+
const DB_PACKAGE = {
|
|
13
|
+
sqlite: "@storyshelf/db-sqlite",
|
|
14
|
+
turso: "@storyshelf/db-turso"
|
|
15
|
+
};
|
|
16
|
+
const STORAGE_PACKAGE = {
|
|
17
|
+
local: "@storyshelf/storage-local",
|
|
18
|
+
s3: "@storyshelf/storage-s3"
|
|
19
|
+
};
|
|
20
|
+
const AUTH_PACKAGE = {
|
|
21
|
+
none: null,
|
|
22
|
+
password: "@storyshelf/auth-password",
|
|
23
|
+
oauth: "@storyshelf/auth-oauth"
|
|
24
|
+
};
|
|
25
|
+
const GIT_PACKAGE = {
|
|
26
|
+
none: null,
|
|
27
|
+
github: "@storyshelf/git-github",
|
|
28
|
+
gitlab: "@storyshelf/git-gitlab"
|
|
29
|
+
};
|
|
30
|
+
const DB_IMPORT = {
|
|
31
|
+
sqlite: `import { createSqliteDatabase } from "@storyshelf/db-sqlite";`,
|
|
32
|
+
turso: `import { createTursoDatabase } from "@storyshelf/db-turso";`
|
|
33
|
+
};
|
|
34
|
+
const STORAGE_IMPORT = {
|
|
35
|
+
local: `import { createLocalStorage } from "@storyshelf/storage-local";`,
|
|
36
|
+
s3: `import { createS3Storage } from "@storyshelf/storage-s3";`
|
|
37
|
+
};
|
|
38
|
+
const DB_INIT = {
|
|
39
|
+
sqlite: `createSqliteDatabase(\`\${dataDir}/shelf.db\`)`,
|
|
40
|
+
turso: `createTursoDatabase({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN })`
|
|
41
|
+
};
|
|
42
|
+
const STORAGE_INIT = {
|
|
43
|
+
local: `createLocalStorage(dataDir)`,
|
|
44
|
+
s3: `createS3Storage({ bucket: process.env.S3_BUCKET!, region: process.env.AWS_REGION })`
|
|
45
|
+
};
|
|
46
|
+
function buildImports(answers) {
|
|
47
|
+
const imports = [
|
|
48
|
+
`import { serve } from "@hono/node-server";`,
|
|
49
|
+
`import { createShelfRouter } from "@storyshelf/router";`,
|
|
50
|
+
`import { createShelfLogger } from "@storyshelf/core/logger";`,
|
|
51
|
+
DB_IMPORT[answers.database],
|
|
52
|
+
STORAGE_IMPORT[answers.storage]
|
|
53
|
+
];
|
|
54
|
+
if (answers.auth !== "none") imports.push(`import { createPasswordAuth } from "${AUTH_PACKAGE[answers.auth]}";`);
|
|
55
|
+
if (answers.git !== "none") {
|
|
56
|
+
const host = answers.git === "github" ? "gitHubHost" : "gitLabHost";
|
|
57
|
+
imports.push(`import { ${host} } from "${GIT_PACKAGE[answers.git]}";`);
|
|
58
|
+
}
|
|
59
|
+
imports.push(`import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";`);
|
|
60
|
+
return imports;
|
|
61
|
+
}
|
|
62
|
+
function buildAdapterLines(answers) {
|
|
63
|
+
return [
|
|
64
|
+
`// Adapters — swap these for your deployment`,
|
|
65
|
+
`const database = ${DB_INIT[answers.database]};`,
|
|
66
|
+
`const storage = ${STORAGE_INIT[answers.storage]};`,
|
|
67
|
+
`const captureRunner = createPlaywrightCaptureRunner();`,
|
|
68
|
+
`const logger = createShelfLogger({ level: process.env.LOG_LEVEL });`
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
function buildRouterLines(answers) {
|
|
72
|
+
const lines = [
|
|
73
|
+
`const app = createShelfRouter({`,
|
|
74
|
+
` database,`,
|
|
75
|
+
` storage,`,
|
|
76
|
+
` captureRunner,`,
|
|
77
|
+
` logger,`
|
|
78
|
+
];
|
|
79
|
+
if (answers.auth !== "none") lines.push(` auth: createPasswordAuth({ password: process.env.AUTH_PASSWORD! }),`);
|
|
80
|
+
if (answers.git !== "none") {
|
|
81
|
+
const host = answers.git === "github" ? "gitHubHost" : "gitLabHost";
|
|
82
|
+
lines.push(` gitHosts: [${host}],`);
|
|
83
|
+
}
|
|
84
|
+
lines.push(` config: {`, ` secret: process.env.SECRET,`, ` scratchDir: dataDir,`, ` },`, `});`, ``, `await app.lifecycle.init();`);
|
|
85
|
+
return lines;
|
|
86
|
+
}
|
|
87
|
+
function generateServer(answers) {
|
|
88
|
+
return [
|
|
89
|
+
...buildImports(answers),
|
|
90
|
+
``,
|
|
91
|
+
`const dataDir = process.env.DATA_DIR || "./data";`,
|
|
92
|
+
`const port = Number(process.env.PORT) || 3000;`,
|
|
93
|
+
``,
|
|
94
|
+
...buildAdapterLines(answers),
|
|
95
|
+
``,
|
|
96
|
+
...buildRouterLines(answers),
|
|
97
|
+
``,
|
|
98
|
+
`const server = serve({ fetch: app.fetch, port }, () => {`,
|
|
99
|
+
` logger.info({ port }, "StoryShelf server listening");`,
|
|
100
|
+
`});`,
|
|
101
|
+
``,
|
|
102
|
+
`const shutdown = async () => {`,
|
|
103
|
+
` await app.lifecycle.close();`,
|
|
104
|
+
` server.close();`,
|
|
105
|
+
`};`,
|
|
106
|
+
`process.on("SIGTERM", () => {`,
|
|
107
|
+
` shutdown().catch(() => {});`,
|
|
108
|
+
`});`,
|
|
109
|
+
`process.on("SIGINT", () => {`,
|
|
110
|
+
` shutdown().catch(() => {});`,
|
|
111
|
+
`});`,
|
|
112
|
+
``
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
|
115
|
+
function buildDeps(answers) {
|
|
116
|
+
const deps = {
|
|
117
|
+
"@hono/node-server": "^1.17.0",
|
|
118
|
+
"@storyshelf/core": "0.3.2",
|
|
119
|
+
"@storyshelf/router": "0.3.2",
|
|
120
|
+
[DB_PACKAGE[answers.database]]: "0.3.2",
|
|
121
|
+
"@storyshelf/runner-playwright": "0.3.2"
|
|
122
|
+
};
|
|
123
|
+
if (answers.storage !== "local") deps[STORAGE_PACKAGE[answers.storage]] = "0.3.2";
|
|
124
|
+
if (answers.auth !== "none") deps[AUTH_PACKAGE[answers.auth] ?? ""] = "0.3.2";
|
|
125
|
+
if (answers.git !== "none") deps[GIT_PACKAGE[answers.git] ?? ""] = "0.3.2";
|
|
126
|
+
return deps;
|
|
127
|
+
}
|
|
128
|
+
function generatePackageJson(answers) {
|
|
129
|
+
const pkg = {
|
|
130
|
+
name: answers.name,
|
|
131
|
+
version: "0.1.0",
|
|
132
|
+
type: "module",
|
|
133
|
+
private: true,
|
|
134
|
+
description: "StoryShelf self-hosted visual testing server.",
|
|
135
|
+
scripts: {
|
|
136
|
+
start: "node --experimental-transform-types server.ts",
|
|
137
|
+
dev: "node --experimental-transform-types --watch server.ts"
|
|
138
|
+
},
|
|
139
|
+
dependencies: buildDeps(answers),
|
|
140
|
+
devDependencies: { typescript: "^7.0.2" }
|
|
141
|
+
};
|
|
142
|
+
return JSON.stringify(pkg, null, 2);
|
|
143
|
+
}
|
|
144
|
+
async function writeFiles(outDir, answers) {
|
|
145
|
+
const serverCode = generateServer(answers);
|
|
146
|
+
await writeFile(join(outDir, "server.ts"), serverCode);
|
|
147
|
+
printLine(`Created server.ts`);
|
|
148
|
+
const pkgCode = generatePackageJson(answers);
|
|
149
|
+
await writeFile(join(outDir, "package.json"), pkgCode);
|
|
150
|
+
printLine(`Created package.json`);
|
|
151
|
+
await writeDockerFiles(outDir, answers.docker);
|
|
152
|
+
}
|
|
153
|
+
async function writeDockerFiles(outDir, docker) {
|
|
154
|
+
if (!docker) return;
|
|
155
|
+
await writeFile(join(outDir, "Dockerfile"), generateDockerfile());
|
|
156
|
+
await writeFile(join(outDir, ".dockerignore"), generateDockerignore());
|
|
157
|
+
await writeFile(join(outDir, "compose.yaml"), generateComposeYaml());
|
|
158
|
+
printLine(`Created Dockerfile, .dockerignore, compose.yaml`);
|
|
159
|
+
}
|
|
160
|
+
function printNextSteps(answers) {
|
|
161
|
+
printLine(`\nScaffolded ${answers.name} in ${resolve(answers.dir)}`);
|
|
162
|
+
printLine(`\nNext steps:`);
|
|
163
|
+
printLine(` cd ${answers.dir}`);
|
|
164
|
+
if (answers.docker) printLine(` docker compose up`);
|
|
165
|
+
else {
|
|
166
|
+
printLine(` npm install`);
|
|
167
|
+
printLine(` npm start`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Scaffold a new StoryShelf server project.
|
|
172
|
+
*
|
|
173
|
+
* @param _options - Server init command options.
|
|
174
|
+
*/
|
|
175
|
+
async function runServerInit(_options) {
|
|
176
|
+
const responses = await prompts([...PROJECT_PROMPTS, ...INFRA_PROMPTS]);
|
|
177
|
+
if (!responses["name"] || !responses["dir"]) {
|
|
178
|
+
printError("Cancelled.");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const answers = responses;
|
|
182
|
+
const outDir = resolve(answers.dir);
|
|
183
|
+
await mkdir(outDir, { recursive: true });
|
|
184
|
+
await writeFiles(outDir, answers);
|
|
185
|
+
printNextSteps(answers);
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
export { runServerInit };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
//#region src/commands/server/prompts.ts
|
|
6
|
+
const PROJECT_PROMPTS = [{
|
|
7
|
+
type: "text",
|
|
8
|
+
name: "name",
|
|
9
|
+
message: "Project name?",
|
|
10
|
+
initial: "my-storyshelf"
|
|
11
|
+
}, {
|
|
12
|
+
type: "text",
|
|
13
|
+
name: "dir",
|
|
14
|
+
message: "Directory?",
|
|
15
|
+
initial: (prev) => `./${prev}`
|
|
16
|
+
}];
|
|
17
|
+
const INFRA_PROMPTS = [
|
|
18
|
+
{
|
|
19
|
+
type: "select",
|
|
20
|
+
name: "database",
|
|
21
|
+
message: "Which database?",
|
|
22
|
+
choices: [{
|
|
23
|
+
title: "SQLite (local)",
|
|
24
|
+
value: "sqlite"
|
|
25
|
+
}, {
|
|
26
|
+
title: "Turso (serverless)",
|
|
27
|
+
value: "turso"
|
|
28
|
+
}]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
type: "select",
|
|
32
|
+
name: "storage",
|
|
33
|
+
message: "Which storage?",
|
|
34
|
+
choices: [{
|
|
35
|
+
title: "Local filesystem",
|
|
36
|
+
value: "local"
|
|
37
|
+
}, {
|
|
38
|
+
title: "S3-compatible",
|
|
39
|
+
value: "s3"
|
|
40
|
+
}]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: "select",
|
|
44
|
+
name: "auth",
|
|
45
|
+
message: "Which auth?",
|
|
46
|
+
choices: [
|
|
47
|
+
{
|
|
48
|
+
title: "None",
|
|
49
|
+
value: "none"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
title: "Shared password",
|
|
53
|
+
value: "password"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
title: "OAuth/OIDC",
|
|
57
|
+
value: "oauth"
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: "select",
|
|
63
|
+
name: "git",
|
|
64
|
+
message: "Which git provider?",
|
|
65
|
+
choices: [
|
|
66
|
+
{
|
|
67
|
+
title: "None",
|
|
68
|
+
value: "none"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
title: "GitHub",
|
|
72
|
+
value: "github"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
title: "GitLab",
|
|
76
|
+
value: "gitlab"
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
type: "confirm",
|
|
82
|
+
name: "docker",
|
|
83
|
+
message: "Generate Docker files?",
|
|
84
|
+
initial: true
|
|
85
|
+
}
|
|
86
|
+
];
|
|
87
|
+
//#endregion
|
|
88
|
+
export { INFRA_PROMPTS, PROJECT_PROMPTS };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { createClient } from "../client.js";
|
|
6
|
+
import { loadStorybookConfig } from "../config.js";
|
|
7
|
+
import { createSpinner, printLine, spinnerFrames } from "../output.js";
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import { access, readdir } from "node:fs/promises";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { ZipArchive } from "archiver";
|
|
12
|
+
import * as picomatch from "picomatch";
|
|
13
|
+
//#region src/commands/upload.ts
|
|
14
|
+
/** First set value among the given env var names. */
|
|
15
|
+
function envOf(...names) {
|
|
16
|
+
for (const name of names) {
|
|
17
|
+
const value = process.env[name];
|
|
18
|
+
if (value) return value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Layer explicit options over env and the client config file. */
|
|
22
|
+
function collectUploadOptions(options, cfg) {
|
|
23
|
+
return {
|
|
24
|
+
url: options.url ?? cfg?.url ?? envOf("STORYSHELF_URL"),
|
|
25
|
+
slug: options.slug ?? cfg?.slug ?? envOf("STORYSHELF_SLUG"),
|
|
26
|
+
token: options.token ?? envOf("STORYSHELF_TOKEN", "SHELF_TOKEN"),
|
|
27
|
+
sha: options.sha ?? envOf("GITHUB_SHA", "VERCEL_GIT_COMMIT_SHA", "CI_COMMIT_SHA"),
|
|
28
|
+
branch: options.branch ?? envOf("GITHUB_REF_NAME", "VERCEL_GIT_COMMIT_REF", "CI_COMMIT_REF_NAME"),
|
|
29
|
+
buildDir: options.buildDir ?? cfg?.buildDir ?? "storybook-static",
|
|
30
|
+
buildCommand: options.buildCommand ?? cfg?.buildCommand,
|
|
31
|
+
buildScriptName: options.buildScriptName ?? cfg?.buildScriptName,
|
|
32
|
+
skip: options.skip ?? cfg?.skip,
|
|
33
|
+
message: options.message,
|
|
34
|
+
authorEmail: options.authorEmail,
|
|
35
|
+
authorName: options.authorName,
|
|
36
|
+
label: options.label
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Throw on the first missing required upload option. */
|
|
40
|
+
function assertUploadOptions(collected) {
|
|
41
|
+
if (!collected.url) throw new Error("--url is required (or .storybook/storyshelf.json / STORYSHELF_URL)");
|
|
42
|
+
if (!collected.slug) throw new Error("--slug is required (or .storybook/storyshelf.json / STORYSHELF_SLUG)");
|
|
43
|
+
if (!collected.token) throw new Error("--token is required (or STORYSHELF_TOKEN env)");
|
|
44
|
+
if (!collected.sha) throw new Error("--sha is required (or GITHUB_SHA env)");
|
|
45
|
+
if (!collected.branch) throw new Error("--branch is required (or GITHUB_REF_NAME env)");
|
|
46
|
+
}
|
|
47
|
+
function shouldSkipUpload(skip, branch) {
|
|
48
|
+
return Boolean(skip && branch && picomatch.isMatch(branch, skip));
|
|
49
|
+
}
|
|
50
|
+
/** Parse `--label key=value` flags into label pairs. */
|
|
51
|
+
function parseLabels(flags) {
|
|
52
|
+
return (flags ?? []).map((flag) => {
|
|
53
|
+
const eq = flag.indexOf("=");
|
|
54
|
+
if (eq <= 0) throw new Error(`--label must be key=value, got "${flag}"`);
|
|
55
|
+
return {
|
|
56
|
+
key: flag.slice(0, eq),
|
|
57
|
+
value: flag.slice(eq + 1)
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Zip the built Storybook directory as a stream (bounded memory). */
|
|
62
|
+
function zipBuildDirStream(cwd, buildDir) {
|
|
63
|
+
const archive = new ZipArchive({ zlib: { level: 9 } });
|
|
64
|
+
archive.on("warning", (warning) => {
|
|
65
|
+
printLine(`Zip warning: ${warning.message}`);
|
|
66
|
+
});
|
|
67
|
+
archive.on("error", (error) => {
|
|
68
|
+
archive.destroy(error);
|
|
69
|
+
});
|
|
70
|
+
archive.directory(resolve(cwd, buildDir), false);
|
|
71
|
+
archive.finalize().catch(() => {});
|
|
72
|
+
return archive;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Upload a built Storybook and create a build record.
|
|
76
|
+
*
|
|
77
|
+
* @param options - Upload command options.
|
|
78
|
+
*/
|
|
79
|
+
async function runUpload(options) {
|
|
80
|
+
const cwd = options.cwd ?? process.cwd();
|
|
81
|
+
const collected = collectUploadOptions(options, await loadStorybookConfig(cwd, options.config));
|
|
82
|
+
if (shouldSkipUpload(collected.skip, collected.branch)) {
|
|
83
|
+
printLine(`Skipped per config skip="${collected.skip}" for branch "${collected.branch}"`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
assertUploadOptions(collected);
|
|
87
|
+
await buildAndPost(cwd, collected, options.forceBuild);
|
|
88
|
+
}
|
|
89
|
+
/** Ensure, create, and stream the build directory. */
|
|
90
|
+
async function buildAndPost(cwd, collected, force) {
|
|
91
|
+
await ensureBuildDir({
|
|
92
|
+
cwd,
|
|
93
|
+
buildDir: collected.buildDir,
|
|
94
|
+
buildCommand: collected.buildCommand,
|
|
95
|
+
buildScriptName: collected.buildScriptName,
|
|
96
|
+
force
|
|
97
|
+
});
|
|
98
|
+
const client = createClient(collected.url, collected.token);
|
|
99
|
+
const created = await client.projects.builds.createJson(collected.slug, {
|
|
100
|
+
gitSha: collected.sha,
|
|
101
|
+
gitBranch: collected.branch,
|
|
102
|
+
message: collected.message,
|
|
103
|
+
authorEmail: collected.authorEmail,
|
|
104
|
+
authorName: collected.authorName,
|
|
105
|
+
labels: parseLabels(collected.label)
|
|
106
|
+
});
|
|
107
|
+
await putZipStream(client, created, cwd, collected.buildDir);
|
|
108
|
+
printLine(`Build created: ${created.build.id}`);
|
|
109
|
+
}
|
|
110
|
+
/** PUT the streamed zip, then report the created build id. */
|
|
111
|
+
async function putZipStream(client, created, cwd, buildDir) {
|
|
112
|
+
const spinner = createSpinner("Uploading...", spinnerFrames);
|
|
113
|
+
try {
|
|
114
|
+
await client.projects.builds.uploadZip(created.uploadUrl, zipBuildDirStream(cwd, buildDir));
|
|
115
|
+
spinner.stop("Upload complete");
|
|
116
|
+
} catch (error) {
|
|
117
|
+
spinner.stop("Upload failed");
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function ensureBuildDir(opts) {
|
|
122
|
+
if (opts.buildCommand && opts.buildScriptName) throw new Error("buildCommand and buildScriptName are mutually exclusive");
|
|
123
|
+
const full = resolve(opts.cwd, opts.buildDir);
|
|
124
|
+
if (!(opts.force ?? await needsBuild(full))) return;
|
|
125
|
+
const command = opts.buildCommand ?? `npm run ${opts.buildScriptName ?? "build-storybook"} -- --output-dir ${opts.buildDir}`;
|
|
126
|
+
printLine(`Building Storybook: ${command}`);
|
|
127
|
+
execSync(command, {
|
|
128
|
+
stdio: "inherit",
|
|
129
|
+
env: {
|
|
130
|
+
...process.env,
|
|
131
|
+
STORYBOOK_BUILD_STORIES_JSON: "true"
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/** True when the build directory is missing or empty. */
|
|
136
|
+
async function needsBuild(full) {
|
|
137
|
+
try {
|
|
138
|
+
await access(full);
|
|
139
|
+
return (await readdir(full)).length === 0;
|
|
140
|
+
} catch {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
export { runUpload };
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
//#region src/config.ts
|
|
10
|
+
const storybookConfigSchema = z.object({
|
|
11
|
+
slug: z.string().min(1, "slug is required"),
|
|
12
|
+
url: z.string().url().optional(),
|
|
13
|
+
buildDir: z.string().min(1).optional(),
|
|
14
|
+
buildCommand: z.string().min(1).optional(),
|
|
15
|
+
buildScriptName: z.string().min(1).optional(),
|
|
16
|
+
skip: z.string().min(1).optional()
|
|
17
|
+
}).refine((data) => !(data.buildCommand && data.buildScriptName), {
|
|
18
|
+
message: "buildCommand and buildScriptName are mutually exclusive",
|
|
19
|
+
path: ["buildCommand"]
|
|
20
|
+
});
|
|
21
|
+
const CONFIG_RELATIVE = join(".storybook", "storyshelf.json");
|
|
22
|
+
const MAIN_CANDIDATES = [
|
|
23
|
+
".storybook/main.js",
|
|
24
|
+
".storybook/main.ts",
|
|
25
|
+
".storybook/main.mjs",
|
|
26
|
+
".storybook/main.cjs",
|
|
27
|
+
".storybook/main.mts",
|
|
28
|
+
".storybook/main.cts",
|
|
29
|
+
".storybook/main.jsx",
|
|
30
|
+
".storybook/main.tsx"
|
|
31
|
+
];
|
|
32
|
+
async function findStorybookMain(cwd = process.cwd()) {
|
|
33
|
+
for (const candidate of MAIN_CANDIDATES) {
|
|
34
|
+
const full = resolve(cwd, candidate);
|
|
35
|
+
try {
|
|
36
|
+
await access(full);
|
|
37
|
+
return full;
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
async function assertStorybookMain(cwd = process.cwd()) {
|
|
43
|
+
if (!await findStorybookMain(cwd)) throw new Error(".storybook/main.* not found — ensure Storybook is set up in this project");
|
|
44
|
+
}
|
|
45
|
+
async function loadStorybookConfig(cwd = process.cwd(), customPath) {
|
|
46
|
+
const full = customPath ? resolve(cwd, customPath) : resolve(cwd, CONFIG_RELATIVE);
|
|
47
|
+
try {
|
|
48
|
+
const raw = await readFile(full, "utf8");
|
|
49
|
+
const parsed = JSON.parse(raw);
|
|
50
|
+
const result = storybookConfigSchema.safeParse(parsed);
|
|
51
|
+
if (!result.success) return null;
|
|
52
|
+
return result.data;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Merge an existing config with new values. */
|
|
58
|
+
function mergeConfigs(existing, config) {
|
|
59
|
+
return existing ? {
|
|
60
|
+
...existing,
|
|
61
|
+
...config
|
|
62
|
+
} : { ...config };
|
|
63
|
+
}
|
|
64
|
+
async function writeStorybookConfig(config, cwd = process.cwd(), customPath) {
|
|
65
|
+
const full = customPath ? resolve(cwd, customPath) : resolve(cwd, CONFIG_RELATIVE);
|
|
66
|
+
const dir = dirname(full);
|
|
67
|
+
await mkdir(dir, { recursive: true });
|
|
68
|
+
const merged = mergeConfigs(await loadStorybookConfig(cwd, customPath), config);
|
|
69
|
+
const result = storybookConfigSchema.safeParse(merged);
|
|
70
|
+
if (!result.success) throw new Error(`Invalid storybook config: ${result.error.message}`);
|
|
71
|
+
await writeFile(full, `${JSON.stringify(result.data, null, 2)}\n`, "utf8");
|
|
72
|
+
return full;
|
|
73
|
+
}
|
|
74
|
+
async function detectStorybookMeta(cwd = process.cwd()) {
|
|
75
|
+
const mainPath = await findStorybookMain(cwd);
|
|
76
|
+
if (!mainPath) return { packagePath: "." };
|
|
77
|
+
const meta = await parseMetaSource(mainPath);
|
|
78
|
+
const rel = relative(cwd, dirname(mainPath));
|
|
79
|
+
meta.packagePath = rel === "" ? "." : rel;
|
|
80
|
+
return meta;
|
|
81
|
+
}
|
|
82
|
+
function parseFrameworkName(raw) {
|
|
83
|
+
return /framework\s*:\s*\{\s*name\s*:\s*["'](?<name>[^"']+)["']/u.exec(raw)?.groups?.["name"];
|
|
84
|
+
}
|
|
85
|
+
function parseQuotedList(raw, key) {
|
|
86
|
+
const content = new RegExp(`${key}\\s*:\\s*\\[(?<content>[\\s\\S]*?)\\]`, "u").exec(raw)?.groups?.["content"];
|
|
87
|
+
if (!content) return [];
|
|
88
|
+
return [...content.matchAll(/["'](?<item>[^"']+)["']/gu)].map((match) => match.groups?.["item"]).filter((item) => item !== void 0 && item.length > 0);
|
|
89
|
+
}
|
|
90
|
+
async function parseMetaSource(mainPath) {
|
|
91
|
+
const meta = {};
|
|
92
|
+
try {
|
|
93
|
+
const raw = await readFile(mainPath, "utf8");
|
|
94
|
+
Object.assign(meta, parseMetaLists(raw));
|
|
95
|
+
const framework = parseFrameworkName(raw);
|
|
96
|
+
if (framework) meta.framework = { name: framework };
|
|
97
|
+
} catch {}
|
|
98
|
+
return meta;
|
|
99
|
+
}
|
|
100
|
+
function parseMetaLists(raw) {
|
|
101
|
+
const lists = {};
|
|
102
|
+
for (const [key, prop] of [
|
|
103
|
+
["addons", "addons"],
|
|
104
|
+
["stories", "storiesGlobs"],
|
|
105
|
+
["staticDirs", "staticDirs"]
|
|
106
|
+
]) {
|
|
107
|
+
const values = parseQuotedList(raw, key);
|
|
108
|
+
if (values.length > 0) lists[prop] = values;
|
|
109
|
+
}
|
|
110
|
+
return lists;
|
|
111
|
+
}
|
|
112
|
+
async function detectPackageName(cwd = process.cwd()) {
|
|
113
|
+
try {
|
|
114
|
+
const raw = await readFile(resolve(cwd, "package.json"), "utf8");
|
|
115
|
+
const parsed = JSON.parse(raw);
|
|
116
|
+
if (typeof parsed.name === "string" && parsed.name.length > 0) return parsed.name;
|
|
117
|
+
return null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function detectGitRepository(cwd = process.cwd()) {
|
|
123
|
+
try {
|
|
124
|
+
const url = execSync("git config --get remote.origin.url", {
|
|
125
|
+
cwd,
|
|
126
|
+
encoding: "utf8"
|
|
127
|
+
}).trim();
|
|
128
|
+
if (!url) return null;
|
|
129
|
+
return url.replace(/\.git$/u, "").replace(/^git@[^:]+:/u, "").replace(/^https?:\/\/[^/]+\//u, "").replace(/^ssh:\/\/[^/]+\//u, "") || null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function detectGitDefaultBranch(cwd = process.cwd()) {
|
|
135
|
+
try {
|
|
136
|
+
const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
|
|
137
|
+
cwd,
|
|
138
|
+
encoding: "utf8"
|
|
139
|
+
}).trim();
|
|
140
|
+
const match = /refs\/remotes\/origin\/(?<branch>.+)/u.exec(ref);
|
|
141
|
+
if (match?.[1]) return match[1];
|
|
142
|
+
} catch {}
|
|
143
|
+
try {
|
|
144
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
145
|
+
cwd,
|
|
146
|
+
encoding: "utf8"
|
|
147
|
+
}).trim();
|
|
148
|
+
if (branch && branch !== "HEAD") return branch;
|
|
149
|
+
} catch {}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
export { assertStorybookMain, detectGitDefaultBranch, detectGitRepository, detectPackageName, detectStorybookMeta, findStorybookMain, loadStorybookConfig, storybookConfigSchema, writeStorybookConfig };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { runCreate } from "./commands/create.js";
|
|
6
|
+
import { runUpload } from "./commands/upload.js";
|
|
7
|
+
import { handleError, runDefaultCommand } from "./commands/default.js";
|
|
8
|
+
import { runInit } from "./commands/init.js";
|
|
9
|
+
import { runPurge } from "./commands/purge.js";
|
|
10
|
+
import { runRetry } from "./commands/retry.js";
|
|
11
|
+
import { runServerInit } from "./commands/server/init.js";
|
|
12
|
+
import { Command } from "commander";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
//#region src/index.ts
|
|
15
|
+
function run(fn) {
|
|
16
|
+
return async (args) => {
|
|
17
|
+
await fn(args).catch(handleError);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function buildInitCommand() {
|
|
21
|
+
return new Command("init").description("Initialize Storybook project with .storybook/storyshelf.json (client config)").option("--url <url>", "server base URL").option("--slug <slug>", "project slug").option("--build-dir <dir>", "built Storybook directory (default storybook-static)").option("--build-command <cmd>", "build command (e.g. \"npm run build-storybook\")").option("--build-script-name <name>", "npm script to build Storybook (default build-storybook)").option("--skip <glob>", "skip upload for matching branch (glob)").option("-c, --config <path>", "config file path (default .storybook/storyshelf.json)").option("--token <token>", "auth token for sync (or STORYSHELF_TOKEN/STORYSHELF_ADMIN_TOKEN env)").action(run(runInit));
|
|
22
|
+
}
|
|
23
|
+
function buildCreateCommand() {
|
|
24
|
+
return new Command("create").description("Create a project on StoryShelf server (requires admin token)").option("--url <url>", "server base URL").option("--name <name>", "project name").option("--token <token>", "admin token (or STORYSHELF_ADMIN_TOKEN env)").action(run(runCreate));
|
|
25
|
+
}
|
|
26
|
+
function buildServerCommand() {
|
|
27
|
+
const server = new Command("server").description("Server operations");
|
|
28
|
+
server.command("init").description("Scaffold a new StoryShelf server project").option("--dir <dir>", "output directory").action(run(runServerInit));
|
|
29
|
+
return server;
|
|
30
|
+
}
|
|
31
|
+
function buildPurgeCommand() {
|
|
32
|
+
return new Command("purge").description("Purge expired builds on a StoryShelf server").requiredOption("--url <url>", "server base URL").option("--token <token>", "admin token (or STORYSHELF_ADMIN_TOKEN env)").action(run(runPurge));
|
|
33
|
+
}
|
|
34
|
+
function buildUploadCommand() {
|
|
35
|
+
return new Command("upload").description("Create a build record for a StoryShelf project").option("--url <url>", "server base URL (or .storybook/storyshelf.json)").option("--slug <slug>", "project slug (or .storybook/storyshelf.json)").option("--token <token>", "CI token (or STORYSHELF_TOKEN env)").option("--sha <sha>", "git sha (or GITHUB_SHA env)").option("--branch <branch>", "git branch (or GITHUB_REF_NAME env)").option("--build-dir <dir>", "built Storybook directory (default storybook-static)").option("-c, --config <path>", "config file path (default .storybook/storyshelf.json)").option("--build-command <cmd>", "build command to run if buildDir missing/empty").option("--build-script-name <name>", "npm script to build Storybook (default build-storybook)").option("--force-build", "force rebuild even if buildDir exists").option("--skip <glob>", "skip upload for matching branch (glob)").option("--message <message>", "commit message").option("--author-email <email>", "author email").option("--author-name <name>", "author name").option("--label <key=value>", "build label (repeatable: --label pr=123)", (value, previous) => [...previous, value], []).action(run(runUpload));
|
|
36
|
+
}
|
|
37
|
+
function buildRetryCommand() {
|
|
38
|
+
return new Command("retry").description("Retry a failed StoryShelf build").requiredOption("--url <url>", "server base URL").requiredOption("--slug <slug>", "project slug").requiredOption("--build-id <id>", "build id").option("--token <token>", "CI token (or STORYSHELF_TOKEN env)").action(run(runRetry));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build the StoryShelf CLI program with all subcommands registered.
|
|
42
|
+
*
|
|
43
|
+
* @returns The configured commander Command instance.
|
|
44
|
+
*/
|
|
45
|
+
function createProgram() {
|
|
46
|
+
const program = new Command();
|
|
47
|
+
program.name("storyshelf").description("Self-hosted visual testing for Storybook.").version("0.2.0");
|
|
48
|
+
program.addCommand(buildInitCommand());
|
|
49
|
+
program.addCommand(buildCreateCommand());
|
|
50
|
+
program.addCommand(buildServerCommand());
|
|
51
|
+
program.addCommand(buildPurgeCommand());
|
|
52
|
+
program.addCommand(buildUploadCommand());
|
|
53
|
+
program.addCommand(buildRetryCommand());
|
|
54
|
+
return program;
|
|
55
|
+
}
|
|
56
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
57
|
+
const program = createProgram();
|
|
58
|
+
if (process.argv.length <= 2) await runDefaultCommand(program);
|
|
59
|
+
else program.parse(process.argv);
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
export { createProgram };
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
//#region src/output.ts
|
|
6
|
+
function printLine(message) {
|
|
7
|
+
process.stdout.write(`${message}\n`);
|
|
8
|
+
}
|
|
9
|
+
function printError(message) {
|
|
10
|
+
process.stderr.write(`${message}\n`);
|
|
11
|
+
}
|
|
12
|
+
const spinnerFrames = [
|
|
13
|
+
"⠋",
|
|
14
|
+
"⠙",
|
|
15
|
+
"⠹",
|
|
16
|
+
"⠸",
|
|
17
|
+
"⠼",
|
|
18
|
+
"⠴",
|
|
19
|
+
"⠦",
|
|
20
|
+
"⠧",
|
|
21
|
+
"⠇",
|
|
22
|
+
"⠏"
|
|
23
|
+
];
|
|
24
|
+
function createSpinner(message, frames = spinnerFrames) {
|
|
25
|
+
let frame = 0;
|
|
26
|
+
const interval = setInterval(() => {
|
|
27
|
+
process.stdout.write(`\r${frames[frame % frames.length]} ${message}`);
|
|
28
|
+
frame += 1;
|
|
29
|
+
}, 80);
|
|
30
|
+
return { stop: (finalMessage) => {
|
|
31
|
+
clearInterval(interval);
|
|
32
|
+
if (finalMessage) process.stdout.write(`\r${finalMessage}\n`);
|
|
33
|
+
else process.stdout.write("\r");
|
|
34
|
+
} };
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { createSpinner, printError, printLine, spinnerFrames };
|