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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Siddhant Gupta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# storyshelf
|
|
2
|
+
|
|
3
|
+
The StoryShelf command-line interface (binary `storyshelf`): scaffold servers, initialize client config, create projects and CI tokens, upload built Storybooks as builds, purge expired builds, and retry failed builds. It talks to a running StoryShelf server over `/api/v1` and has **no Playwright or server dependencies**, so it installs cleanly in CI.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
nub add storyshelf
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
or
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install -g storyshelf
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
Initialize client config (requires `.storybook/main.*`):
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
storyshelf init --url https://shelf.example.com --slug my-app
|
|
23
|
+
# writes .storybook/storyshelf.json
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Create a project on the server (requires site-admin token and `.storybook/main.*`):
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
storyshelf create --url https://shelf.example.com --name my-app --token $STORYSHELF_ADMIN_TOKEN
|
|
30
|
+
# prints slug + CI token and writes .storybook/storyshelf.json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Scaffold a server:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
storyshelf server init --dir ./my-shelf
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## API
|
|
40
|
+
|
|
41
|
+
### Commands
|
|
42
|
+
|
|
43
|
+
`storyshelf init` — initialize `.storybook/storyshelf.json` (client config). Fails if `.storybook/main.*` not found. Prompts if flags missing.
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
storyshelf init --url <url> --slug <slug> [--build-dir <dir> --build-command <cmd> --skip <glob> -c <config>]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`storyshelf create` — create a project and CI token on a server (requires admin token). Fails if `.storybook/main.*` not found and writes `.storybook/storyshelf.json`.
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
storyshelf create --url <url> --name <name> --token <admin-token>
|
|
53
|
+
# or env STORYSHELF_ADMIN_TOKEN / ADMIN_TOKEN
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`storyshelf server init` — scaffold a new StoryShelf server project.
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
storyshelf server init --dir <dir>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`storyshelf upload` — upload a built Storybook as a build. `--url/--slug` optional when `.storybook/storyshelf.json` exists (flags > env > file). `--token/--sha/--branch` fallback to `STORYSHELF_TOKEN`/`GITHUB_SHA`/`GITHUB_REF_NAME`. Running `storyshelf` with no args defaults to `upload` if config exists, else shows help to run `init`.
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
storyshelf upload --url <url> --slug <slug> --token <token> \
|
|
66
|
+
--sha <sha> --branch <branch> \
|
|
67
|
+
[--build-dir <dir> --build-command <cmd> --force-build --skip <glob> -c <config>] [--message <msg>] \
|
|
68
|
+
[--author-email <email>] [--author-name <name>]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The default `--build-dir` is `storybook-static` or `buildDir` from `.storybook/storyshelf.json` (see [Configuration](../../apps/website/src/content/docs/guides/config.md)). If `buildDir` is missing/empty, `upload` runs `buildCommand` or `npm run <buildScriptName>`.
|
|
72
|
+
|
|
73
|
+
`storyshelf purge` — purge expired builds (requires admin token when auth enabled).
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
storyshelf purge --url <url> [--token <admin-token>]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`storyshelf retry` — retry a failed build.
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
storyshelf retry --url <url> --slug <slug> --build-id <id> [--token <token>]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Example CI snippet
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
# .github/workflows/visual.yml
|
|
89
|
+
on:
|
|
90
|
+
push:
|
|
91
|
+
pull_request:
|
|
92
|
+
|
|
93
|
+
jobs:
|
|
94
|
+
visual:
|
|
95
|
+
runs-on: ubuntu-latest
|
|
96
|
+
steps:
|
|
97
|
+
- uses: actions/checkout@v4
|
|
98
|
+
- uses: actions/setup-node@v4
|
|
99
|
+
with: { node-version: 20 }
|
|
100
|
+
- run: nub install && nub run build
|
|
101
|
+
- run: nubx storybook build -o storybook-static
|
|
102
|
+
- run: nubx storyshelf upload \
|
|
103
|
+
--token "${{ secrets.STORYSHELF_TOKEN }}" \
|
|
104
|
+
--sha "${{ github.sha }}" \
|
|
105
|
+
--branch "${{ github.ref_name }}"
|
|
106
|
+
# --url/--slug from .storybook/storyshelf.json
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## How it fits in
|
|
110
|
+
|
|
111
|
+
The server is scaffolded via `storyshelf server init`, which assembles `@storyshelf/core`, `@storyshelf/db-sqlite`, and `@storyshelf/storage-local` and runs Playwright captures. The CLI never imports that stack — client-only by design.
|
|
112
|
+
|
|
113
|
+
See `docs/architecture.md` for the capture workflow and `docs/testing.md` for the gated browser integration suite.
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
//#region src/client.ts
|
|
6
|
+
function buildAuthHeaders(token) {
|
|
7
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
8
|
+
}
|
|
9
|
+
function buildRequestHeaders(authHeaders, contentType) {
|
|
10
|
+
const headers = { ...authHeaders };
|
|
11
|
+
if (contentType) headers["content-type"] = contentType;
|
|
12
|
+
return headers;
|
|
13
|
+
}
|
|
14
|
+
async function fetchJson(url, options) {
|
|
15
|
+
const res = await fetch(url, options);
|
|
16
|
+
if (!res.ok) throw new Error(`Request failed (${res.status}): ${await res.text()}`);
|
|
17
|
+
return res.json();
|
|
18
|
+
}
|
|
19
|
+
function createTokensApi(baseUrl, requestHeaders) {
|
|
20
|
+
return { create: async (slug, json) => await fetchJson(`${baseUrl}/api/v1/projects/${slug}/tokens`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: requestHeaders("application/json"),
|
|
23
|
+
body: JSON.stringify(json)
|
|
24
|
+
}) };
|
|
25
|
+
}
|
|
26
|
+
function createBuildsApi(baseUrl, authHeaders, requestHeaders) {
|
|
27
|
+
return {
|
|
28
|
+
createJson: async (slug, json) => await fetchJson(`${baseUrl}/api/v1/projects/${slug}/builds`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: requestHeaders("application/json"),
|
|
31
|
+
body: JSON.stringify(json)
|
|
32
|
+
}),
|
|
33
|
+
uploadZip: async (uploadUrl, body) => {
|
|
34
|
+
const init = {
|
|
35
|
+
method: "PUT",
|
|
36
|
+
headers: {
|
|
37
|
+
...authHeaders,
|
|
38
|
+
"content-type": "application/zip"
|
|
39
|
+
},
|
|
40
|
+
body,
|
|
41
|
+
duplex: "half"
|
|
42
|
+
};
|
|
43
|
+
const res = await fetch(`${baseUrl}${uploadUrl}`, init);
|
|
44
|
+
if (!res.ok) throw new Error(`Request failed (${res.status}): ${await res.text()}`);
|
|
45
|
+
return res.json();
|
|
46
|
+
},
|
|
47
|
+
retry: async (slug, buildId) => await fetchJson(`${baseUrl}/api/v1/projects/${slug}/builds/${buildId}/retry`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: requestHeaders("application/json"),
|
|
50
|
+
body: JSON.stringify({})
|
|
51
|
+
})
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function createAdminApi(baseUrl, requestHeaders) {
|
|
55
|
+
return { purge: async (json) => await fetchJson(`${baseUrl}/api/v1/admin/purge`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: requestHeaders("application/json"),
|
|
58
|
+
body: JSON.stringify(json)
|
|
59
|
+
}) };
|
|
60
|
+
}
|
|
61
|
+
function createProjectsApi(baseUrl, authHeaders, requestHeaders) {
|
|
62
|
+
return {
|
|
63
|
+
create: async (json) => await fetchJson(`${baseUrl}/api/v1/projects`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: requestHeaders("application/json"),
|
|
66
|
+
body: JSON.stringify(json)
|
|
67
|
+
}),
|
|
68
|
+
update: async (slug, json) => await fetchJson(`${baseUrl}/api/v1/projects/${slug}`, {
|
|
69
|
+
method: "PATCH",
|
|
70
|
+
headers: requestHeaders("application/json"),
|
|
71
|
+
body: JSON.stringify(json)
|
|
72
|
+
}),
|
|
73
|
+
get: async (slug) => await fetchJson(`${baseUrl}/api/v1/projects/${slug}`, { headers: requestHeaders() }),
|
|
74
|
+
list: async () => await fetchJson(`${baseUrl}/api/v1/projects`, { headers: requestHeaders() }),
|
|
75
|
+
tokens: createTokensApi(baseUrl, requestHeaders),
|
|
76
|
+
builds: createBuildsApi(baseUrl, authHeaders, requestHeaders),
|
|
77
|
+
admin: createAdminApi(baseUrl, requestHeaders)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function createClient(baseUrl, token) {
|
|
81
|
+
const authHeaders = buildAuthHeaders(token);
|
|
82
|
+
const requestHeaders = (contentType) => buildRequestHeaders(authHeaders, contentType);
|
|
83
|
+
return { projects: createProjectsApi(baseUrl, authHeaders, requestHeaders) };
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
export { createClient };
|
|
@@ -0,0 +1,65 @@
|
|
|
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 { assertStorybookMain, detectGitDefaultBranch, detectGitRepository, detectPackageName, detectStorybookMeta, writeStorybookConfig } from "../config.js";
|
|
7
|
+
import { printError, printLine } from "../output.js";
|
|
8
|
+
//#region src/commands/create.ts
|
|
9
|
+
async function readCreateInputs(options, cwd) {
|
|
10
|
+
const name = (options.name === "" ? void 0 : options.name) ?? await detectPackageName(cwd);
|
|
11
|
+
return {
|
|
12
|
+
url: options.url ?? process.env["STORYSHELF_URL"] ?? process.env["STORYSHELF_HOST"],
|
|
13
|
+
name: name ?? void 0,
|
|
14
|
+
token: options.token ?? process.env["STORYSHELF_ADMIN_TOKEN"] ?? process.env["ADMIN_TOKEN"],
|
|
15
|
+
gitRepository: detectGitRepository(cwd) ?? void 0,
|
|
16
|
+
gitDefaultBranch: detectGitDefaultBranch(cwd) ?? void 0,
|
|
17
|
+
storybookMeta: await detectStorybookMeta(cwd)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function createInputError(inputs) {
|
|
21
|
+
if (!inputs.url) return "--url is required (or STORYSHELF_URL env)";
|
|
22
|
+
if (!inputs.name) return "--name is required (or ensure package.json has a name)";
|
|
23
|
+
if (!inputs.token) return "--token is required for site-admin (or STORYSHELF_ADMIN_TOKEN env)";
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
function buildCreatePayload(name, inputs) {
|
|
27
|
+
const payload = { name };
|
|
28
|
+
if (inputs.gitRepository) payload.gitRepository = inputs.gitRepository;
|
|
29
|
+
if (inputs.gitDefaultBranch) payload.gitDefaultBranch = inputs.gitDefaultBranch;
|
|
30
|
+
if (Object.keys(inputs.storybookMeta).length > 0) payload.storybookMeta = inputs.storybookMeta;
|
|
31
|
+
return payload;
|
|
32
|
+
}
|
|
33
|
+
async function executeCreate(url, token, payload, cwd) {
|
|
34
|
+
const client = createClient(url, token);
|
|
35
|
+
const project = await client.projects.create(payload);
|
|
36
|
+
const tokenRes = await client.projects.tokens.create(project.slug, { name: "ci" });
|
|
37
|
+
const written = await writeStorybookConfig({
|
|
38
|
+
slug: project.slug,
|
|
39
|
+
url
|
|
40
|
+
}, cwd);
|
|
41
|
+
printLine(`Project slug: ${project.slug}`);
|
|
42
|
+
printLine(`CI token: ${tokenRes.token}`);
|
|
43
|
+
printLine(`Wrote ${written}`);
|
|
44
|
+
printLine("Store CI token in secrets (STORYSHELF_TOKEN) — not in git");
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Create a project and CI token on a StoryShelf server.
|
|
48
|
+
* Requires .storybook/main.* to exist and writes .storybook/storyshelf.json.
|
|
49
|
+
*
|
|
50
|
+
* @param options - Create command options.
|
|
51
|
+
*/
|
|
52
|
+
async function runCreate(options) {
|
|
53
|
+
const cwd = options.cwd ?? process.cwd();
|
|
54
|
+
await assertStorybookMain(cwd);
|
|
55
|
+
const inputs = await readCreateInputs(options, cwd);
|
|
56
|
+
const error = createInputError(inputs);
|
|
57
|
+
if (error || !inputs.url || !inputs.name || !inputs.token) {
|
|
58
|
+
printError(error ?? "Missing required options");
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await executeCreate(inputs.url, inputs.token, buildCreatePayload(inputs.name, inputs), cwd);
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
export { runCreate };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { loadStorybookConfig } from "../config.js";
|
|
6
|
+
import { printError } from "../output.js";
|
|
7
|
+
import { runUpload } from "./upload.js";
|
|
8
|
+
//#region src/commands/default.ts
|
|
9
|
+
/** Print an error and mark the process as failed. */
|
|
10
|
+
function handleError(error) {
|
|
11
|
+
printError(error instanceof Error ? error.message : String(error));
|
|
12
|
+
process.exitCode = 1;
|
|
13
|
+
}
|
|
14
|
+
function defaultUploadOptions(cfg) {
|
|
15
|
+
const url = cfg?.url ?? process.env["STORYSHELF_URL"];
|
|
16
|
+
const slug = cfg?.slug;
|
|
17
|
+
const token = process.env["STORYSHELF_TOKEN"] ?? process.env["SHELF_TOKEN"];
|
|
18
|
+
const sha = process.env["GITHUB_SHA"] ?? process.env["VERCEL_GIT_COMMIT_SHA"] ?? process.env["CI_COMMIT_SHA"];
|
|
19
|
+
const branch = process.env["GITHUB_REF_NAME"] ?? process.env["VERCEL_GIT_COMMIT_REF"] ?? process.env["CI_COMMIT_REF_NAME"];
|
|
20
|
+
if (!url || !slug || !token || !sha || !branch) return null;
|
|
21
|
+
return {
|
|
22
|
+
url,
|
|
23
|
+
slug,
|
|
24
|
+
token,
|
|
25
|
+
sha,
|
|
26
|
+
branch,
|
|
27
|
+
buildDir: cfg?.buildDir ?? "storybook-static",
|
|
28
|
+
buildCommand: cfg?.buildCommand,
|
|
29
|
+
buildScriptName: cfg?.buildScriptName,
|
|
30
|
+
skip: cfg?.skip
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Default behavior for bare `storyshelf` (no subcommand): upload when a
|
|
35
|
+
* client config exists, otherwise point at init.
|
|
36
|
+
*/
|
|
37
|
+
async function runDefaultCommand(program) {
|
|
38
|
+
const cfg = await loadStorybookConfig();
|
|
39
|
+
const options = defaultUploadOptions(cfg);
|
|
40
|
+
if (!options) {
|
|
41
|
+
program.outputHelp();
|
|
42
|
+
if (cfg) printError("Missing required upload options — ensure STORYSHELF_URL/SLUG/TOKEN and GITHUB_SHA/BRANCH are set or run `storyshelf upload --help`");
|
|
43
|
+
else printError("No .storybook/storyshelf.json found — run `storyshelf init --url <url> --slug <slug>` first");
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await runUpload(options).catch(handleError);
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { handleError, runDefaultCommand };
|
|
@@ -0,0 +1,197 @@
|
|
|
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 { assertStorybookMain, detectStorybookMeta, loadStorybookConfig, writeStorybookConfig } from "../config.js";
|
|
7
|
+
import { printError, printLine } from "../output.js";
|
|
8
|
+
import prompts from "prompts";
|
|
9
|
+
//#region src/commands/init.ts
|
|
10
|
+
/** Human description of the detected Storybook (framework, addons, path). */
|
|
11
|
+
function describeMeta(meta) {
|
|
12
|
+
const hintParts = [];
|
|
13
|
+
if (meta.framework?.name) hintParts.push(meta.framework.name);
|
|
14
|
+
if (meta.addons?.length) hintParts.push(`${meta.addons.length} addons`);
|
|
15
|
+
if (meta.packagePath && meta.packagePath !== ".") hintParts.push(meta.packagePath);
|
|
16
|
+
return hintParts.join(" • ");
|
|
17
|
+
}
|
|
18
|
+
function buildQuestions(url, slug, hint) {
|
|
19
|
+
const questions = [];
|
|
20
|
+
if (!url) questions.push({
|
|
21
|
+
type: "text",
|
|
22
|
+
name: "url",
|
|
23
|
+
message: `Server URL?${hint}`
|
|
24
|
+
});
|
|
25
|
+
if (!slug) questions.push({
|
|
26
|
+
type: "text",
|
|
27
|
+
name: "slug",
|
|
28
|
+
message: `Project slug?${hint}`
|
|
29
|
+
});
|
|
30
|
+
return questions;
|
|
31
|
+
}
|
|
32
|
+
/** Prompt for whichever of url/slug is still missing; null when cancelled. */
|
|
33
|
+
async function promptMissing(url, slug, hintSuffix) {
|
|
34
|
+
const questions = buildQuestions(url, slug, hintSuffix);
|
|
35
|
+
if (questions.length === 0) return {};
|
|
36
|
+
const answers = await prompts(questions);
|
|
37
|
+
if (questions.some((question) => !answers[question.name])) {
|
|
38
|
+
printError("Cancelled.");
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return answers;
|
|
42
|
+
}
|
|
43
|
+
function narrowAnswer(value) {
|
|
44
|
+
return typeof value === "string" ? value : void 0;
|
|
45
|
+
}
|
|
46
|
+
/** Layer explicit options over an existing config file. */
|
|
47
|
+
function mergeWithExisting(options, existing) {
|
|
48
|
+
const url = options.url ?? process.env["STORYSHELF_URL"];
|
|
49
|
+
const slug = options.slug ?? process.env["STORYSHELF_SLUG"];
|
|
50
|
+
if (!existing) return {
|
|
51
|
+
url,
|
|
52
|
+
slug,
|
|
53
|
+
buildDir: options.buildDir,
|
|
54
|
+
buildCommand: options.buildCommand,
|
|
55
|
+
buildScriptName: options.buildScriptName,
|
|
56
|
+
skip: options.skip
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
url: url ?? existing.url,
|
|
60
|
+
slug: slug ?? existing.slug,
|
|
61
|
+
buildDir: options.buildDir ?? existing.buildDir,
|
|
62
|
+
buildCommand: options.buildCommand ?? existing.buildCommand,
|
|
63
|
+
buildScriptName: options.buildScriptName ?? existing.buildScriptName,
|
|
64
|
+
skip: options.skip ?? existing.skip
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Resolve init answers from options, existing config, and prompts. */
|
|
68
|
+
async function resolveInitAnswers(options, cwd) {
|
|
69
|
+
const merged = mergeWithExisting(options, await loadStorybookConfig(cwd, options.config));
|
|
70
|
+
const detected = describeMeta(await detectStorybookMeta(cwd));
|
|
71
|
+
const hintSuffix = detected ? ` (detected ${detected})` : "";
|
|
72
|
+
const answers = await promptMissing(merged.url, merged.slug, hintSuffix);
|
|
73
|
+
if (!answers) return null;
|
|
74
|
+
return {
|
|
75
|
+
...merged,
|
|
76
|
+
url: narrowAnswer(answers.url) ?? merged.url,
|
|
77
|
+
slug: narrowAnswer(answers.slug) ?? merged.slug,
|
|
78
|
+
detected
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const OPTIONAL_INIT_KEYS = [
|
|
82
|
+
"url",
|
|
83
|
+
"buildDir",
|
|
84
|
+
"buildCommand",
|
|
85
|
+
"buildScriptName",
|
|
86
|
+
"skip"
|
|
87
|
+
];
|
|
88
|
+
function buildInitConfig(answers, slug) {
|
|
89
|
+
const config = { slug };
|
|
90
|
+
for (const key of OPTIONAL_INIT_KEYS) {
|
|
91
|
+
const value = answers[key];
|
|
92
|
+
if (value) config[key] = value;
|
|
93
|
+
}
|
|
94
|
+
return config;
|
|
95
|
+
}
|
|
96
|
+
/** Persist the resolved answers to .storybook/storyshelf.json. */
|
|
97
|
+
async function persistInitConfig(answers, slug, cwd, configPath) {
|
|
98
|
+
try {
|
|
99
|
+
const written = await writeStorybookConfig(buildInitConfig(answers, slug), cwd, configPath);
|
|
100
|
+
printLine(`Wrote ${written}`);
|
|
101
|
+
if (answers.detected) printLine(`Detected ${answers.detected}`);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
printError(error instanceof Error ? error.message : String(error));
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function isProviderError(error, status) {
|
|
108
|
+
return error instanceof Error && error.message.includes(status);
|
|
109
|
+
}
|
|
110
|
+
/** Fetch the project; classify get-phase failures. */
|
|
111
|
+
async function fetchProjectForSync(url, token, slug) {
|
|
112
|
+
try {
|
|
113
|
+
await createClient(url, token).projects.get(slug);
|
|
114
|
+
return "synced";
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (isProviderError(error, "404")) return "not-found";
|
|
117
|
+
return "next";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Patch storybook_meta; 403 means the token lacks admin (try next). */
|
|
121
|
+
async function patchProjectMeta(url, token, slug, metaToSync) {
|
|
122
|
+
try {
|
|
123
|
+
await createClient(url, token).projects.update(slug, { storybookMeta: metaToSync });
|
|
124
|
+
return "synced";
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (isProviderError(error, "403")) return "next";
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Try syncing storybook_meta with one token. */
|
|
131
|
+
async function trySyncToken(url, token, slug, metaToSync) {
|
|
132
|
+
const fetched = await fetchProjectForSync(url, token, slug);
|
|
133
|
+
if (fetched !== "synced") return fetched;
|
|
134
|
+
return await patchProjectMeta(url, token, slug, metaToSync);
|
|
135
|
+
}
|
|
136
|
+
/** Candidate tokens for the meta sync, in try order. */
|
|
137
|
+
function collectSyncTokens(token) {
|
|
138
|
+
return [token ?? process.env["STORYSHELF_TOKEN"] ?? process.env["SHELF_TOKEN"], process.env["STORYSHELF_ADMIN_TOKEN"] ?? process.env["ADMIN_TOKEN"]].filter((candidate) => Boolean(candidate));
|
|
139
|
+
}
|
|
140
|
+
/** Try each token in order until sync, not-found, or exhaustion. */
|
|
141
|
+
async function runTokenLoop(url, slug, metaToSync, tokens) {
|
|
142
|
+
for (const candidate of tokens) {
|
|
143
|
+
const outcome = await trySyncToken(url, candidate, slug, metaToSync);
|
|
144
|
+
if (outcome === "synced") {
|
|
145
|
+
printLine(`Synced storybook_meta for ${slug}`);
|
|
146
|
+
return {
|
|
147
|
+
synced: true,
|
|
148
|
+
notFound: false
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (outcome === "not-found") return {
|
|
152
|
+
synced: false,
|
|
153
|
+
notFound: true
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
synced: false,
|
|
158
|
+
notFound: false
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Sync storybook_meta to the server when the project already exists. */
|
|
162
|
+
async function syncStorybookMeta(url, slug, cwd, token) {
|
|
163
|
+
const metaToSync = await detectStorybookMeta(cwd);
|
|
164
|
+
if (Object.keys(metaToSync).length === 0) return;
|
|
165
|
+
const tokens = collectSyncTokens(token);
|
|
166
|
+
if (tokens.length === 0) return;
|
|
167
|
+
const { synced, notFound } = await runTokenLoop(url, slug, metaToSync, tokens);
|
|
168
|
+
if (!synced && notFound) {
|
|
169
|
+
printError(`Project "${slug}" does not exist on ${url} — run \`storyshelf create --url ${url} --name <name> --token $STORYSHELF_ADMIN_TOKEN\` first`);
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Write the config and sync meta once the slug is known. */
|
|
174
|
+
async function finalizeInit(answers, cwd, options) {
|
|
175
|
+
if (!answers.slug) {
|
|
176
|
+
printError("--slug is required (or run `storyshelf create --url <url> --name <name>`)");
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await persistInitConfig(answers, answers.slug, cwd, options.config);
|
|
181
|
+
if (answers.url) await syncStorybookMeta(answers.url, answers.slug, cwd, options.token);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Initialize .storybook/storyshelf.json for the current Storybook.
|
|
185
|
+
* Fails if .storybook/main.* is not found.
|
|
186
|
+
*
|
|
187
|
+
* @param options - Init command options.
|
|
188
|
+
*/
|
|
189
|
+
async function runInit(options) {
|
|
190
|
+
const cwd = options.cwd ?? process.cwd();
|
|
191
|
+
await assertStorybookMain(cwd);
|
|
192
|
+
const answers = await resolveInitAnswers(options, cwd);
|
|
193
|
+
if (!answers) return;
|
|
194
|
+
await finalizeInit(answers, cwd, options);
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
export { runInit };
|
|
@@ -0,0 +1,19 @@
|
|
|
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 { printLine } from "../output.js";
|
|
7
|
+
//#region src/commands/purge.ts
|
|
8
|
+
/**
|
|
9
|
+
* Purge expired builds on a StoryShelf server.
|
|
10
|
+
*
|
|
11
|
+
* @param options - Purge command options.
|
|
12
|
+
*/
|
|
13
|
+
async function runPurge(options) {
|
|
14
|
+
const token = options.token ?? process.env["STORYSHELF_ADMIN_TOKEN"] ?? process.env["ADMIN_TOKEN"];
|
|
15
|
+
const resultData = await createClient(options.url, token).projects.admin.purge({});
|
|
16
|
+
printLine(`Removed ${resultData.removedBuilds} build(s) and ${resultData.removedFiles ?? 0} file(s)`);
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { runPurge };
|
|
@@ -0,0 +1,19 @@
|
|
|
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 { printLine } from "../output.js";
|
|
7
|
+
//#region src/commands/retry.ts
|
|
8
|
+
/**
|
|
9
|
+
* Retry a failed StoryShelf build.
|
|
10
|
+
*
|
|
11
|
+
* @param options - Retry command options.
|
|
12
|
+
*/
|
|
13
|
+
async function runRetry(options) {
|
|
14
|
+
const token = options.token ?? process.env["STORYSHELF_TOKEN"] ?? process.env["SHELF_TOKEN"];
|
|
15
|
+
const buildData = await createClient(options.url, token).projects.builds.retry(options.slug, options.buildId);
|
|
16
|
+
printLine(`Build ${buildData.id} queued for retry`);
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { runRetry };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
//#region src/commands/server/docker.ts
|
|
6
|
+
const DOCKERFILE_LINES = [
|
|
7
|
+
"FROM node:lts-alpine AS builder",
|
|
8
|
+
"WORKDIR /app",
|
|
9
|
+
"COPY package.json ./",
|
|
10
|
+
"RUN npm install",
|
|
11
|
+
"COPY server.ts ./",
|
|
12
|
+
"RUN npx esbuild server.ts --bundle --platform=node --format=esm \\",
|
|
13
|
+
" --external:playwright \\",
|
|
14
|
+
" --outfile=dist/server.mjs",
|
|
15
|
+
"",
|
|
16
|
+
"FROM mcr.microsoft.com/playwright:latest",
|
|
17
|
+
"WORKDIR /app",
|
|
18
|
+
"COPY --from=builder /app/dist/server.mjs ./",
|
|
19
|
+
"COPY --from=builder /app/node_modules/playwright \\",
|
|
20
|
+
" ./node_modules/playwright/",
|
|
21
|
+
"EXPOSE 3000",
|
|
22
|
+
"ENV PORT=3000",
|
|
23
|
+
"ENV DATA_DIR=/data",
|
|
24
|
+
"CMD [\"node\", \"dist/server.mjs\"]"
|
|
25
|
+
];
|
|
26
|
+
const DOCKERIGNORE_LINES = [
|
|
27
|
+
"node_modules/",
|
|
28
|
+
".git/",
|
|
29
|
+
"*.md",
|
|
30
|
+
".env*",
|
|
31
|
+
"data/"
|
|
32
|
+
];
|
|
33
|
+
const COMPOSE_LINES = [
|
|
34
|
+
"services:",
|
|
35
|
+
" storyshelf:",
|
|
36
|
+
" build: .",
|
|
37
|
+
" ports:",
|
|
38
|
+
" - \"3000:3000\"",
|
|
39
|
+
" volumes:",
|
|
40
|
+
" - storyshelf-data:/data",
|
|
41
|
+
" environment:",
|
|
42
|
+
" - SECRET=change-me",
|
|
43
|
+
" - PORT=3000",
|
|
44
|
+
" - DATA_DIR=/data",
|
|
45
|
+
" # Add your env vars here:",
|
|
46
|
+
" # - AUTH_PASSWORD=your-password",
|
|
47
|
+
" # - TURSO_DATABASE_URL=libsql://...",
|
|
48
|
+
" # - TURSO_AUTH_TOKEN=...",
|
|
49
|
+
"volumes:",
|
|
50
|
+
" storyshelf-data:"
|
|
51
|
+
];
|
|
52
|
+
function generateDockerfile() {
|
|
53
|
+
return DOCKERFILE_LINES.join("\n");
|
|
54
|
+
}
|
|
55
|
+
function generateDockerignore() {
|
|
56
|
+
return DOCKERIGNORE_LINES.join("\n");
|
|
57
|
+
}
|
|
58
|
+
function generateComposeYaml() {
|
|
59
|
+
return COMPOSE_LINES.join("\n");
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
export { generateComposeYaml, generateDockerfile, generateDockerignore };
|