storyshelf 0.6.0 → 0.6.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/README.md CHANGED
@@ -96,7 +96,7 @@ jobs:
96
96
  steps:
97
97
  - uses: actions/checkout@v4
98
98
  - uses: actions/setup-node@v4
99
- with: { node-version: 20 }
99
+ with: { node-version: 24 }
100
100
  - run: nub install && nub run build
101
101
  - run: nubx storybook build -o storybook-static
102
102
  - run:
@@ -7,8 +7,8 @@ import { printLine } from "../output.js";
7
7
  import { needsBuild, resolveBuildCommand } from "./storybook-build.js";
8
8
  import { createClient } from "../client.js";
9
9
  import { resolveConnection } from "./connection.js";
10
- import { resolve } from "node:path";
11
10
  import { access, readdir } from "node:fs/promises";
11
+ import { resolve } from "node:path";
12
12
  import { gitRepoStatus } from "@storyshelf/affected";
13
13
  //#region src/commands/doctor.ts
14
14
  function mark(status) {
@@ -3,26 +3,29 @@ import "node:path";
3
3
  import "node:url";
4
4
  import.meta.url;
5
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
- ];
6
+ /** Playwright base image: its bundled browsers must match the `playwright-core` pin. */
7
+ const PLAYWRIGHT_IMAGE = "mcr.microsoft.com/playwright:v1.63.0-noble";
8
+ /**
9
+ * Images run the TypeScript sources directly (Node strips types), exactly like
10
+ * `npm start`. Bundling is deliberately avoided: a single-file ESM bundle breaks
11
+ * CommonJS dependencies ("Dynamic require ... is not supported") and the vendored
12
+ * assets `@storyshelf/app` reads from disk next to its own modules.
13
+ */
14
+ function imageLines(baseImage, entry, exposePort) {
15
+ return [
16
+ `FROM ${baseImage}`,
17
+ "WORKDIR /app",
18
+ "COPY package*.json ./",
19
+ "RUN npm install --omit=dev",
20
+ "COPY src/ ./src/",
21
+ ...exposePort ? ["EXPOSE 3000", "ENV PORT=3000"] : [],
22
+ "ENV DATA_DIR=/data",
23
+ `CMD ["node", "${entry}"]`
24
+ ];
25
+ }
26
+ const DOCKERFILE_LINES = imageLines(PLAYWRIGHT_IMAGE, "src/index.ts", true);
27
+ /** Server image without a browser: the capture worker runs in its own image. */
28
+ const SLIM_DOCKERFILE_LINES = imageLines("node:24-alpine", "src/index.ts", true);
26
29
  const DOCKERIGNORE_LINES = [
27
30
  "node_modules/",
28
31
  ".git/",
@@ -68,60 +71,53 @@ const COMPOSE_POSTGRES_VOLUMES_LINES = [" postgres-data:"];
68
71
  function generateDockerfile() {
69
72
  return DOCKERFILE_LINES.join("\n");
70
73
  }
74
+ function generateSlimServerDockerfile() {
75
+ return SLIM_DOCKERFILE_LINES.join("\n");
76
+ }
71
77
  function generateDockerignore() {
72
78
  return DOCKERIGNORE_LINES.join("\n");
73
79
  }
74
- function generateComposeYaml(database = "sqlite") {
80
+ function composeLines(database, withWorker) {
75
81
  return [
76
82
  ...COMPOSE_BASE_LINES,
77
83
  " # Add your env vars here:",
78
84
  " # - AUTH_PASSWORD=your-password",
79
85
  ...database === "turso" ? COMPOSE_TURSO_LINES : [],
80
86
  ...database === "postgres" ? COMPOSE_POSTGRES_LINES : [],
87
+ ...withWorker ? workerComposeLines(database) : [],
81
88
  ...COMPOSE_VOLUMES_LINES,
82
89
  ...database === "postgres" ? COMPOSE_POSTGRES_VOLUMES_LINES : []
83
- ].join("\n");
90
+ ];
91
+ }
92
+ function generateComposeYaml(database = "sqlite") {
93
+ return composeLines(database, false).join("\n");
94
+ }
95
+ const WORKER_DOCKERFILE_LINES = imageLines(PLAYWRIGHT_IMAGE, "src/worker.ts", false);
96
+ function workerComposeLines(database) {
97
+ const postgres = database === "postgres";
98
+ return [
99
+ " worker:",
100
+ " build:",
101
+ " dockerfile: Dockerfile.worker",
102
+ " volumes:",
103
+ " - storyshelf-data:/data",
104
+ " environment:",
105
+ " - SECRET=change-me",
106
+ " - DATA_DIR=/data",
107
+ ...postgres ? [" - DATABASE_URL=postgres://shelf:shelf@postgres:5432/shelf"] : [],
108
+ " - QUEUE_URL=${QUEUE_URL}",
109
+ " - WORKER_CONCURRENCY=2",
110
+ " depends_on:",
111
+ " storyshelf:",
112
+ " condition: service_started",
113
+ ...postgres ? [" postgres:", " condition: service_healthy"] : []
114
+ ];
84
115
  }
85
- const WORKER_DOCKERFILE_LINES = [
86
- "FROM node:lts-alpine AS builder",
87
- "WORKDIR /app",
88
- "COPY package.json ./",
89
- "RUN npm install",
90
- "COPY worker.ts ./",
91
- "RUN npx esbuild worker.ts --bundle --platform=node --format=esm \\",
92
- " --external:playwright-core \\",
93
- " --outfile=dist/worker.mjs",
94
- "",
95
- "FROM mcr.microsoft.com/playwright:v1.63.0-noble",
96
- "WORKDIR /app",
97
- "COPY --from=builder /app/dist/worker.mjs ./",
98
- "COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-core",
99
- "ENV DATA_DIR=/data",
100
- "CMD [\"node\", \"dist/worker.mjs\"]"
101
- ];
102
- const WORKER_COMPOSE_LINES = [
103
- " worker:",
104
- " build:",
105
- " dockerfile: Dockerfile.worker",
106
- " volumes:",
107
- " - storyshelf-data:/data",
108
- " environment:",
109
- " - SECRET=change-me",
110
- " - DATA_DIR=/data",
111
- " - QUEUE_URL=${QUEUE_URL}",
112
- " - WORKER_CONCURRENCY=2",
113
- " depends_on:",
114
- " storyshelf:",
115
- " condition: service_started"
116
- ];
117
116
  function generateWorkerDockerfile() {
118
117
  return WORKER_DOCKERFILE_LINES.join("\n");
119
118
  }
120
- function generateWorkerComposeSnippet() {
121
- return WORKER_COMPOSE_LINES.join("\n");
122
- }
123
119
  function generateComposeYamlWithWorker(database = "sqlite") {
124
- return `${generateComposeYaml(database)}\n${generateWorkerComposeSnippet()}`;
120
+ return composeLines(database, true).join("\n");
125
121
  }
126
122
  //#endregion
127
- export { generateComposeYaml, generateComposeYamlWithWorker, generateDockerfile, generateDockerignore, generateWorkerComposeSnippet, generateWorkerDockerfile };
123
+ export { generateComposeYaml, generateComposeYamlWithWorker, generateDockerfile, generateDockerignore, generateSlimServerDockerfile, generateWorkerDockerfile };
@@ -5,13 +5,14 @@ import.meta.url;
5
5
  import { detectPackageRunner, installCommand, startCommand } from "../../config.js";
6
6
  import { printError, printLine } from "../../output.js";
7
7
  import { detectInstalledAdapters } from "../shared/detect-adapters.js";
8
- import { generateComposeYaml, generateComposeYamlWithWorker, generateDockerfile, generateDockerignore, generateWorkerDockerfile } from "./docker.js";
8
+ import { SCAFFOLD_DEV_DEPENDENCIES, SERVER_ENTRY, WORKER_ENTRY, generateTsconfig, writeScaffoldFile } from "../shared/scaffold.js";
9
+ import { generateComposeYaml, generateComposeYamlWithWorker, generateDockerfile, generateDockerignore, generateSlimServerDockerfile, generateWorkerDockerfile } from "./docker.js";
9
10
  import { ADVANCED_ADAPTERS_PROMPT, AWS_INFRA_PROMPTS, AZURE_INFRA_PROMPTS, DEPLOY_TARGET_PROMPT, GCP_INFRA_PROMPTS, PROJECT_PROMPTS, localInfraPrompts } from "./prompts.js";
10
11
  import { generateAwsTerraformFiles } from "./terraform-aws.js";
11
12
  import { generateAzureTerraformFiles } from "./terraform-azure.js";
12
13
  import { generateGcpTerraformFiles } from "./terraform-gcp.js";
13
- import { join, resolve } from "node:path";
14
14
  import { mkdir, writeFile } from "node:fs/promises";
15
+ import { join, resolve } from "node:path";
15
16
  import prompts from "prompts";
16
17
  //#region src/commands/server/init.ts
17
18
  /** Azure SDK pins (must match @storyshelf/queue-azure peerDependencies). */
@@ -143,20 +144,34 @@ const STORAGE_INIT = {
143
144
  azure: `createAzureStorage({ container: "storybook", connectionString: process.env.AZURE_STORAGE_CONNECTION! })`,
144
145
  gcs: `createGcsStorage({ bucket: process.env.GCS_BUCKET! })`
145
146
  };
146
- /** Whether the generated server wires notification providers. */
147
+ /** Whether the generated server wires notification providers (opt-in). */
147
148
  function wantsNotifications(answers) {
148
- return answers.notifications ?? true;
149
+ return answers.notifications ?? false;
150
+ }
151
+ /** Whether the generated server/worker wire OpenTelemetry (opt-in). */
152
+ function wantsObservability(answers) {
153
+ return answers.observability ?? false;
154
+ }
155
+ const NAMED_IMPORT_RE = /^import \{ (?<names>[^}]+) \} from "(?<from>[^"]+)";$/u;
156
+ /** Merge named imports that share a module into one line (keeps first-seen order). */
157
+ function mergeImports(lines) {
158
+ const merged = /* @__PURE__ */ new Map();
159
+ for (const line of lines) {
160
+ const match = NAMED_IMPORT_RE.exec(line);
161
+ const key = match?.groups?.["from"] ?? line;
162
+ const names = match?.groups?.["names"]?.split(", ") ?? [];
163
+ merged.set(key, [...merged.get(key) ?? [], ...names]);
164
+ }
165
+ return [...merged].map(([from, names]) => names.length > 0 ? `import { ${names.join(", ")} } from "${from}";` : from);
149
166
  }
150
167
  function buildImports(answers) {
151
168
  const imports = [
152
169
  `import { serve } from "@hono/node-server";`,
153
170
  `import { createShelfApp } from "@storyshelf/app";`,
154
- `import { createShelfLogger } from "@storyshelf/core/logger";`,
155
- `import { otelLogMixin } from "@storyshelf/observability";`,
156
- `import { initObservabilityFromEnv } from "@storyshelf/observability/node";`,
157
171
  DB_IMPORT[answers.database],
158
172
  STORAGE_IMPORT[answers.storage]
159
173
  ];
174
+ if (wantsObservability(answers)) imports.push(`import { createShelfLogger } from "@storyshelf/core/logger";`, `import { otelLogMixin } from "@storyshelf/observability";`, `import { initObservabilityFromEnv } from "@storyshelf/observability/node";`);
160
175
  if (answers.queue === "sqs") imports.push(`import { createSqsCaptureQueue } from "@storyshelf/queue-sqs";`);
161
176
  if (answers.queue === "redis") imports.push(`import { createRedisCaptureQueue } from "@storyshelf/queue-redis";`);
162
177
  if (answers.queue === "azure-storage-queues") imports.push(`import { createAzureStorageQueuesQueue } from "@storyshelf/queue-azure/storage-queues";`);
@@ -177,11 +192,10 @@ function buildImports(answers) {
177
192
  if (answers.auth !== "none") imports.push(`import { createAuthSystemHook } from "@storyshelf/app";`);
178
193
  }
179
194
  if (answers.queue === "memory") imports.push(`import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";`);
180
- return imports;
195
+ return mergeImports(imports);
181
196
  }
182
197
  function buildAdapterLines(answers) {
183
198
  const lines = [
184
- `// Adapters — swap these for your deployment`,
185
199
  ...dbPrelude(answers.database),
186
200
  `const database = ${DB_INIT[answers.database]};`,
187
201
  `const storage = ${STORAGE_INIT[answers.storage]};`
@@ -192,26 +206,25 @@ function buildAdapterLines(answers) {
192
206
  if (answers.queue === "azure-service-bus") lines.push(`const captureQueue = createAzureServiceBusQueue({ queueName: "capture-jobs", connectionString: process.env.AZURE_SERVICE_BUS_CONNECTION! });`);
193
207
  if (answers.queue === "gcp-pubsub") lines.push(`const captureQueue = createGcpPubSubQueue({ topic: "capture-jobs", subscription: "capture-jobs-worker", projectId: process.env.GOOGLE_CLOUD_PROJECT! });`);
194
208
  if (answers.queue === "memory") lines.push(`const captureRunner = createPlaywrightCaptureRunner();`);
195
- if (wantsNotifications(answers)) lines.push(`// Notifications: SMTP sender from SMTP_* env (undefined keeps invites out-of-band).`, `const emailSender = smtpPresetFromEnv(process.env);`, `const emailNotifiers = emailSender ? [createEmailNotifier(emailSender, { from: process.env.SMTP_FROM })] : [];`);
209
+ if (wantsNotifications(answers)) lines.push(`// SMTP sender from SMTP_* env (undefined keeps invites out-of-band).`, `const emailSender = smtpPresetFromEnv(process.env);`, `const emailNotifiers = emailSender`, ` ? [createEmailNotifier(emailSender, { from: process.env.SMTP_FROM })]`, ` : [];`);
196
210
  return lines;
197
211
  }
198
212
  function buildRouterLines(answers) {
199
- const lines = [
200
- `// Observability (noop unless OTEL_EXPORTER_OTLP_ENDPOINT is set).`,
213
+ const lines = wantsObservability(answers) ? [
214
+ `// Noop unless OTEL_EXPORTER_OTLP_ENDPOINT is set.`,
201
215
  `const observability = await initObservabilityFromEnv();`,
202
216
  `const shelfLogger = createShelfLogger({ mixin: otelLogMixin });`,
203
- ``,
204
- `const app = createShelfApp({`,
205
- ` database,`,
206
- ` storage,`
207
- ];
217
+ ``
218
+ ] : [];
219
+ lines.push(`const app = createShelfApp({`, ` database,`, ` storage,`);
208
220
  if (isRemoteQueue(answers.queue)) lines.push(` captureQueue,`);
209
221
  else lines.push(` captureRunner,`);
210
222
  if (wantsNotifications(answers)) lines.push(` notifiers: [...chatNotifiers, ...emailNotifiers],`);
211
- if (answers.auth === "password") {
212
- const notifyArgs = wantsNotifications(answers) ? `, emailSender, fromEmail: process.env.SMTP_FROM, onAuthSystemEvent: createAuthSystemHook()` : ``;
213
- lines.push(` auth: createShelfAuth({ db: database, secret: process.env.SECRET!, baseURL: process.env.PUBLIC_BASE_URL!${notifyArgs} }).adapter,`);
214
- }
223
+ if (answers.auth === "password") lines.push(` auth: createShelfAuth({`, ` db: database,`, ` secret: process.env.SECRET!,`, ` baseURL: process.env.PUBLIC_BASE_URL!,`, ...wantsNotifications(answers) ? [
224
+ ` emailSender,`,
225
+ ` fromEmail: process.env.SMTP_FROM,`,
226
+ ` onAuthSystemEvent: createAuthSystemHook(),`
227
+ ] : [], ` }).adapter,`);
215
228
  if (answers.auth === "oauth" && resolveDeployTarget(answers) === "aws") lines.push(` auth: createShelfAuth({`, ` db: database,`, ` secret: process.env.SECRET!,`, ` baseURL: process.env.PUBLIC_BASE_URL!,`, ...wantsNotifications(answers) ? [
216
229
  ` emailSender,`,
217
230
  ` fromEmail: process.env.SMTP_FROM,`,
@@ -226,17 +239,20 @@ function buildRouterLines(answers) {
226
239
  const host = answers.git === "github" ? "gitHubHost" : "gitLabHost";
227
240
  lines.push(` gitHosts: [${host}],`);
228
241
  }
229
- if (answers.queue === "memory") lines.push(` logger: shelfLogger,`, ` observability,`, ` config: {`, ` secret: process.env.SECRET,`, ` // Rotation: keep the old SECRET here until credentials are re-encrypted.`, ` previousSecret: process.env.SECRET_PREVIOUS || undefined,`, ` migrateCredentialsOnBoot: process.env.SECRET_MIGRATE === "true",`, ` scratchDir: dataDir,`, ` },`);
230
- else lines.push(` logger: shelfLogger,`, ` observability,`, ` config: {`, ` secret: process.env.SECRET,`, ` // Rotation: keep the old SECRET here until credentials are re-encrypted.`, ` previousSecret: process.env.SECRET_PREVIOUS || undefined,`, ` migrateCredentialsOnBoot: process.env.SECRET_MIGRATE === "true",`, ` },`);
231
- lines.push(`});`, ``, `await app.lifecycle.setup();`, `const logger = app.lifecycle.logger;`);
232
- if (answers.auth === "password") lines.push(``, `// Provision the env-driven local admin (AUTH_PASSWORD, at least 12 chars).`, `await ensurePasswordAdmin(database, {`, ` email: process.env.AUTH_EMAIL ?? "admin@local",`, ` password: process.env.AUTH_PASSWORD!,`, `});`);
242
+ if (wantsObservability(answers)) lines.push(` logger: shelfLogger,`, ` observability,`);
243
+ lines.push(` config: {`, ` secret: process.env.SECRET,`, ` previousSecret: process.env.SECRET_PREVIOUS || undefined,`, ` migrateCredentialsOnBoot: process.env.SECRET_MIGRATE === "true",`, ...answers.queue === "memory" ? [` scratchDir: dataDir,`] : [], ` },`, `});`, ``, `await app.lifecycle.setup();`);
244
+ if (answers.auth === "password") lines.push(``, `// Provision the env-driven local admin (AUTH_PASSWORD, at least 12 chars).`, `await ensurePasswordAdmin(database, {`, ` email: process.env.AUTH_EMAIL ?? "admin@example.com",`, ` password: process.env.AUTH_PASSWORD!,`, `});`);
233
245
  return lines;
234
246
  }
247
+ /** Whether the generated server reads `dataDir` (local sqlite/storage or in-process scratch). */
248
+ function usesDataDir(answers) {
249
+ return answers.queue === "memory" || DB_INIT[answers.database].includes("dataDir") || STORAGE_INIT[answers.storage].includes("dataDir");
250
+ }
235
251
  function generateServer(answers) {
236
252
  return [
237
253
  ...buildImports(answers),
238
254
  ``,
239
- `const dataDir = process.env.DATA_DIR || "./data";`,
255
+ ...usesDataDir(answers) ? [`const dataDir = process.env.DATA_DIR || "./data";`] : [],
240
256
  `const port = Number(process.env.PORT) || 3000;`,
241
257
  ``,
242
258
  ...buildAdapterLines(answers),
@@ -244,19 +260,14 @@ function generateServer(answers) {
244
260
  ...buildRouterLines(answers),
245
261
  ``,
246
262
  `const server = serve({ fetch: app.fetch, port }, () => {`,
247
- ` logger.info({ port }, "StoryShelf server listening");`,
263
+ ` app.lifecycle.logger.info({ port }, "StoryShelf server listening");`,
248
264
  `});`,
249
265
  ``,
250
- `const shutdown = async () => {`,
251
- ` await app.lifecycle.teardown();`,
252
- ` server.close();`,
253
- `};`,
254
- `process.on("SIGTERM", () => {`,
255
- ` shutdown().catch(() => {});`,
256
- `});`,
257
- `process.on("SIGINT", () => {`,
258
- ` shutdown().catch(() => {});`,
259
- `});`,
266
+ `for (const signal of ["SIGINT", "SIGTERM"] as const) {`,
267
+ ` process.once(signal, () => {`,
268
+ ` app.lifecycle.teardown().finally(() => server.close());`,
269
+ ` });`,
270
+ `}`,
260
271
  ``
261
272
  ].join("\n");
262
273
  }
@@ -275,54 +286,53 @@ function workerQueueInit(queue) {
275
286
  return `const queue = createSqsCaptureQueue({ queueUrl: process.env.QUEUE_URL! });`;
276
287
  }
277
288
  function generateWorkerFile(answers) {
278
- const queueImport = workerQueueImport(answers.queue);
279
- const queueInit = workerQueueInit(answers.queue);
289
+ const observability = wantsObservability(answers);
280
290
  return [
281
- queueImport,
291
+ workerQueueImport(answers.queue),
282
292
  `import { createCaptureWorker } from "@storyshelf/worker";`,
283
- `import { initObservabilityFromEnv } from "@storyshelf/observability/node";`,
293
+ ...observability ? [`import { initObservabilityFromEnv } from "@storyshelf/observability/node";`] : [],
284
294
  `import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";`,
285
295
  DB_IMPORT[answers.database],
286
296
  STORAGE_IMPORT[answers.storage],
287
297
  ``,
288
- `// Observability (noop unless OTEL_EXPORTER_OTLP_ENDPOINT is set).`,
289
- `const observability = await initObservabilityFromEnv();`,
290
- ``,
298
+ ...observability ? [
299
+ `// Noop unless OTEL_EXPORTER_OTLP_ENDPOINT is set.`,
300
+ `const observability = await initObservabilityFromEnv();`,
301
+ ``
302
+ ] : [],
291
303
  `const dataDir = process.env.DATA_DIR || "./data";`,
292
304
  ...dbPrelude(answers.database),
293
- `const database = ${DB_INIT[answers.database]};`,
294
- `const storage = ${STORAGE_INIT[answers.storage]};`,
295
- queueInit,
296
- `const runner = createPlaywrightCaptureRunner();`,
305
+ workerQueueInit(answers.queue),
297
306
  ``,
298
307
  `const worker = createCaptureWorker({`,
299
308
  ` queue,`,
300
- ` db: database,`,
301
- ` storage,`,
302
- ` runner,`,
309
+ ` db: ${DB_INIT[answers.database]},`,
310
+ ` storage: ${STORAGE_INIT[answers.storage]},`,
311
+ ` runner: createPlaywrightCaptureRunner(),`,
303
312
  ` scratchDir: dataDir,`,
304
313
  ` config: { concurrency: Number(process.env.WORKER_CONCURRENCY) || 2 },`,
305
314
  `});`,
306
315
  ``,
307
316
  `await worker.start();`,
308
317
  ``,
309
- `const shutdown = async () => {`,
310
- ` await worker.stop();`,
311
- ` await observability.shutdown();`,
312
- `};`,
313
- `process.on("SIGTERM", () => { shutdown().catch(() => {}); });`,
314
- `process.on("SIGINT", () => { shutdown().catch(() => {}); });`,
318
+ `for (const signal of ["SIGINT", "SIGTERM"] as const) {`,
319
+ ` process.once(signal, () => {`,
320
+ observability ? ` worker.stop().finally(() => observability.shutdown());` : ` void worker.stop();`,
321
+ ` });`,
322
+ `}`,
315
323
  ``
316
324
  ].join("\n");
317
325
  }
318
326
  function buildDeps(answers) {
319
327
  const deps = {
320
328
  "@hono/node-server": "^1.17.0",
321
- "@storyshelf/core": "0.6.0",
322
- "@storyshelf/app": "0.6.0",
323
- "@storyshelf/observability": "0.6.0",
324
- [DB_PACKAGE[answers.database]]: "0.6.0"
329
+ "@storyshelf/app": "0.6.2",
330
+ [DB_PACKAGE[answers.database]]: "0.6.2"
325
331
  };
332
+ if (wantsObservability(answers)) {
333
+ deps["@storyshelf/core"] = "0.6.2";
334
+ deps["@storyshelf/observability"] = "0.6.2";
335
+ }
326
336
  if (answers.database === "turso") deps["@libsql/client"] = LIBSQL_CLIENT_SDK;
327
337
  if (answers.database === "better-sqlite3") deps["better-sqlite3"] = BETTER_SQLITE3_SDK;
328
338
  if (answers.database === "pg") deps["pg"] = PG_SDK;
@@ -332,40 +342,40 @@ function buildDeps(answers) {
332
342
  if (answers.database === "mysql") deps["mysql2"] = MYSQL2_SDK;
333
343
  if (answers.database === "planetscale") deps["@planetscale/database"] = PLANETSCALE_SDK;
334
344
  if (answers.database === "tidb") deps["@tidbcloud/serverless"] = TIDBCLOUD_SDK;
335
- if (answers.storage !== "local") deps[STORAGE_PACKAGE[answers.storage]] = "0.6.0";
345
+ if (answers.storage !== "local") deps[STORAGE_PACKAGE[answers.storage]] = "0.6.2";
336
346
  if (answers.queue === "sqs" || answers.queue === "redis") {
337
347
  const queuePkg = answers.queue === "redis" ? "@storyshelf/queue-redis" : "@storyshelf/queue-sqs";
338
- deps[queuePkg] = "0.6.0";
348
+ deps[queuePkg] = "0.6.2";
339
349
  if (answers.includeWorker) {
340
- deps["@storyshelf/worker"] = "0.6.0";
341
- deps["@storyshelf/runner-playwright"] = "0.6.0";
350
+ deps["@storyshelf/worker"] = "0.6.2";
351
+ deps["@storyshelf/runner-playwright"] = "0.6.2";
342
352
  }
343
353
  } else if (answers.queue === "azure-storage-queues" || answers.queue === "azure-service-bus") {
344
- deps["@storyshelf/queue-azure"] = "0.6.0";
354
+ deps["@storyshelf/queue-azure"] = "0.6.2";
345
355
  const sdk = azureSdkDep(answers.queue);
346
356
  deps[sdk.name] = sdk.version;
347
357
  if (answers.includeWorker) {
348
- deps["@storyshelf/worker"] = "0.6.0";
349
- deps["@storyshelf/runner-playwright"] = "0.6.0";
358
+ deps["@storyshelf/worker"] = "0.6.2";
359
+ deps["@storyshelf/runner-playwright"] = "0.6.2";
350
360
  }
351
361
  } else if (answers.queue === "gcp-pubsub") {
352
- deps["@storyshelf/queue-gcp"] = "0.6.0";
362
+ deps["@storyshelf/queue-gcp"] = "0.6.2";
353
363
  deps["@google-cloud/pubsub"] = GCP_PUBSUB_SDK;
354
364
  if (answers.includeWorker) {
355
- deps["@storyshelf/worker"] = "0.6.0";
356
- deps["@storyshelf/runner-playwright"] = "0.6.0";
365
+ deps["@storyshelf/worker"] = "0.6.2";
366
+ deps["@storyshelf/runner-playwright"] = "0.6.2";
357
367
  }
358
- } else deps["@storyshelf/runner-playwright"] = "0.6.0";
359
- if (answers.auth !== "none") deps[AUTH_PACKAGE[answers.auth] ?? ""] = "0.6.0";
360
- if (answers.git !== "none") deps[GIT_PACKAGE[answers.git] ?? ""] = "0.6.0";
368
+ } else deps["@storyshelf/runner-playwright"] = "0.6.2";
369
+ if (answers.auth !== "none") deps[AUTH_PACKAGE[answers.auth] ?? ""] = "0.6.2";
370
+ if (answers.git !== "none") deps[GIT_PACKAGE[answers.git] ?? ""] = "0.6.2";
361
371
  if (wantsNotifications(answers)) {
362
- deps["@storyshelf/notify-chat"] = "0.6.0";
363
- deps["@storyshelf/notify-email"] = "0.6.0";
372
+ deps["@storyshelf/notify-chat"] = "0.6.2";
373
+ deps["@storyshelf/notify-email"] = "0.6.2";
364
374
  deps["nodemailer"] = NODEMAILER_SDK;
365
375
  }
366
376
  if (answers.queue !== "memory") {
367
377
  const queuePkg = QUEUE_PACKAGE[answers.queue];
368
- if (queuePkg) deps[queuePkg] = "0.6.0";
378
+ if (queuePkg) deps[queuePkg] = "0.6.2";
369
379
  }
370
380
  return deps;
371
381
  }
@@ -376,6 +386,23 @@ function addInfraScripts(scripts) {
376
386
  scripts["infra:destroy"] = "terraform -chdir=terraform destroy";
377
387
  scripts["infra:outputs"] = "terraform -chdir=terraform output -json";
378
388
  }
389
+ /** Whether the Docker setup needs compose: more than one service (Postgres or a worker). */
390
+ function needsCompose(answers) {
391
+ return answers.database === "postgres" || isRemoteQueue(answers.queue) && Boolean(answers.includeWorker);
392
+ }
393
+ /** `docker:*` npm scripts: compose when several services, plain `docker` for one container. */
394
+ function dockerScripts(answers) {
395
+ if (needsCompose(answers)) return {
396
+ "docker:build": "docker compose build",
397
+ "docker:up": "docker compose up --build",
398
+ "docker:down": "docker compose down",
399
+ "docker:logs": "docker compose logs -f"
400
+ };
401
+ return {
402
+ "docker:build": `docker build -t ${answers.name} .`,
403
+ "docker:run": `docker run --rm -p 3000:3000 -v ${answers.name}-data:/data -e SECRET=change-me ${answers.name}`
404
+ };
405
+ }
379
406
  function generatePackageJson(answers) {
380
407
  const pkg = {
381
408
  name: answers.name,
@@ -383,39 +410,33 @@ function generatePackageJson(answers) {
383
410
  type: "module",
384
411
  private: true,
385
412
  description: "StoryShelf self-hosted visual testing server.",
413
+ engines: { node: ">=24" },
386
414
  scripts: {
387
- start: "node --experimental-transform-types server.ts",
388
- dev: "node --experimental-transform-types --watch server.ts"
415
+ start: `node ${SERVER_ENTRY}`,
416
+ dev: `node --watch ${SERVER_ENTRY}`,
417
+ typecheck: "tsc"
389
418
  },
390
419
  dependencies: buildDeps(answers),
391
- devDependencies: { typescript: "^7.0.2" }
420
+ devDependencies: SCAFFOLD_DEV_DEPENDENCIES
392
421
  };
393
422
  if (answers.includeWorker) {
394
- pkg.scripts["worker"] = "node --experimental-transform-types worker.ts";
395
- pkg.scripts["worker:dev"] = "node --experimental-transform-types --watch worker.ts";
396
- }
397
- if (resolveDeployTarget(answers) === "docker") {
398
- const scripts = pkg.scripts;
399
- scripts["docker:build"] = "docker compose build";
400
- scripts["docker:up"] = "docker compose up --build";
401
- scripts["docker:down"] = "docker compose down";
402
- scripts["docker:logs"] = "docker compose logs -f";
423
+ pkg.scripts["worker"] = `node ${WORKER_ENTRY}`;
424
+ pkg.scripts["worker:dev"] = `node --watch ${WORKER_ENTRY}`;
403
425
  }
426
+ if (resolveDeployTarget(answers) === "docker") Object.assign(pkg.scripts, dockerScripts(answers));
404
427
  if (resolveDeployTarget(answers) === "aws" || resolveDeployTarget(answers) === "azure" || resolveDeployTarget(answers) === "gcp") addInfraScripts(pkg.scripts);
405
428
  return JSON.stringify(pkg, null, 2);
406
429
  }
407
430
  async function writeFiles(outDir, answers) {
408
- const serverCode = generateServer(answers);
409
- await writeFile(join(outDir, "server.ts"), serverCode);
410
- printLine(`Created server.ts`);
431
+ await writeScaffoldFile(outDir, SERVER_ENTRY, generateServer(answers));
432
+ printLine(`Created ${SERVER_ENTRY}`);
411
433
  if (answers.includeWorker) {
412
- const workerCode = generateWorkerFile(answers);
413
- await writeFile(join(outDir, "worker.ts"), workerCode);
414
- printLine(`Created worker.ts`);
434
+ await writeScaffoldFile(outDir, WORKER_ENTRY, generateWorkerFile(answers));
435
+ printLine(`Created ${WORKER_ENTRY}`);
415
436
  }
416
- const pkgCode = generatePackageJson(answers);
417
- await writeFile(join(outDir, "package.json"), pkgCode);
418
- printLine(`Created package.json`);
437
+ await writeFile(join(outDir, "package.json"), generatePackageJson(answers));
438
+ await writeFile(join(outDir, "tsconfig.json"), generateTsconfig());
439
+ printLine(`Created package.json, tsconfig.json`);
419
440
  if (resolveDeployTarget(answers) === "aws") await writeTerraformFiles(outDir, answers);
420
441
  if (resolveDeployTarget(answers) === "azure") await writeAzureTerraformFiles(outDir, answers);
421
442
  if (resolveDeployTarget(answers) === "gcp") await writeGcpTerraformFiles(outDir, answers);
@@ -462,28 +483,20 @@ async function writeTerraformFiles(outDir, answers) {
462
483
  }
463
484
  async function writeDockerFiles(outDir, answers) {
464
485
  if (!answers.docker) return;
465
- if (isRemoteQueue(answers.queue) && answers.includeWorker) {
466
- const slimDockerfile = [
467
- "FROM node:lts-alpine",
468
- "WORKDIR /app",
469
- "COPY package.json ./",
470
- "RUN npm install --omit=dev",
471
- "COPY server.ts ./",
472
- "RUN npx esbuild server.ts --bundle --platform=node --format=esm --outfile=dist/server.mjs",
473
- "EXPOSE 3000",
474
- "CMD [\"node\", \"dist/server.mjs\"]"
475
- ].join("\n");
476
- await writeFile(join(outDir, "Dockerfile"), slimDockerfile);
486
+ const created = ["Dockerfile", ".dockerignore"];
487
+ const workerSplit = isRemoteQueue(answers.queue) && answers.includeWorker;
488
+ await writeFile(join(outDir, "Dockerfile"), workerSplit ? generateSlimServerDockerfile() : generateDockerfile());
489
+ await writeFile(join(outDir, ".dockerignore"), generateDockerignore());
490
+ if (workerSplit) {
477
491
  await writeFile(join(outDir, "Dockerfile.worker"), generateWorkerDockerfile());
478
- await writeFile(join(outDir, ".dockerignore"), generateDockerignore());
479
- await writeFile(join(outDir, "compose.yaml"), generateComposeYamlWithWorker(answers.database));
480
- printLine(`Created Dockerfile, Dockerfile.worker, .dockerignore, compose.yaml`);
481
- return;
492
+ created.splice(1, 0, "Dockerfile.worker");
482
493
  }
483
- await writeFile(join(outDir, "Dockerfile"), generateDockerfile());
484
- await writeFile(join(outDir, ".dockerignore"), generateDockerignore());
485
- await writeFile(join(outDir, "compose.yaml"), generateComposeYaml(answers.database));
486
- printLine(`Created Dockerfile, .dockerignore, compose.yaml`);
494
+ if (needsCompose(answers)) {
495
+ const compose = workerSplit ? generateComposeYamlWithWorker(answers.database) : generateComposeYaml(answers.database);
496
+ await writeFile(join(outDir, "compose.yaml"), compose);
497
+ created.push("compose.yaml");
498
+ }
499
+ printLine(`Created ${created.join(", ")}`);
487
500
  }
488
501
  function printNextSteps(answers, runner) {
489
502
  printLine(`\nScaffolded ${answers.name} in ${resolve(answers.dir)}`);
@@ -523,12 +536,17 @@ function printNextSteps(answers, runner) {
523
536
  return;
524
537
  }
525
538
  if (target === "docker") {
526
- printLine(` npm run docker:up # builds + starts app (+ worker)`);
527
- printLine(` npm run docker:logs # follow logs`);
528
- printLine(` npm run docker:down # tear down`);
529
- if (answers.includeWorker) {
530
- printLine(` # or separately:`);
531
- printLine(` # docker compose up storyshelf worker`);
539
+ if (needsCompose(answers)) {
540
+ printLine(` npm run docker:up # builds + starts app (+ worker)`);
541
+ printLine(` npm run docker:logs # follow logs`);
542
+ printLine(` npm run docker:down # tear down`);
543
+ if (answers.includeWorker) {
544
+ printLine(` # or separately:`);
545
+ printLine(` # docker compose up storyshelf worker`);
546
+ }
547
+ } else {
548
+ printLine(` npm run docker:build # builds the image`);
549
+ printLine(` npm run docker:run # runs it on :3000 (data in a named volume)`);
532
550
  }
533
551
  return;
534
552
  }
@@ -212,7 +212,13 @@ const NOTIFICATIONS_CONFIRM = {
212
212
  type: "confirm",
213
213
  name: "notifications",
214
214
  message: "Enable email + chat notifications (Slack/Teams/email channels)?",
215
- initial: true
215
+ initial: false
216
+ };
217
+ const OBSERVABILITY_CONFIRM = {
218
+ type: "confirm",
219
+ name: "observability",
220
+ message: "Add OpenTelemetry tracing + metrics wiring?",
221
+ initial: false
216
222
  };
217
223
  /**
218
224
  * Curated prompts for the local/docker path — deploy target is asked first
@@ -242,7 +248,8 @@ function localInfraPrompts(advanced) {
242
248
  choices: queueChoices(advanced)
243
249
  },
244
250
  DOCKER_CONFIRM,
245
- NOTIFICATIONS_CONFIRM
251
+ NOTIFICATIONS_CONFIRM,
252
+ OBSERVABILITY_CONFIRM
246
253
  ];
247
254
  }
248
255
  const WORKER_INFRA_PROMPTS = [
@@ -4,12 +4,15 @@ import "node:url";
4
4
  import.meta.url;
5
5
  import { detectPackageRunner, installCommand } from "../../config.js";
6
6
  import { printLine } from "../../output.js";
7
- import { join, resolve } from "node:path";
8
7
  import { spawn } from "node:child_process";
9
8
  import { access } from "node:fs/promises";
9
+ import { join, resolve } from "node:path";
10
10
  //#region src/commands/server/serve.ts
11
11
  /** Server entry filenames tried in order. */
12
12
  const SERVER_CANDIDATES = [
13
+ "src/index.ts",
14
+ "src/index.js",
15
+ "src/index.mjs",
13
16
  "server.ts",
14
17
  "server.js",
15
18
  "server.mjs",
@@ -19,7 +22,7 @@ const SERVER_CANDIDATES = [
19
22
  ];
20
23
  /**
21
24
  * Locate the server entry file in a scaffolded project.
22
- * Tries `server.ts`, `server.js`, `server.mjs`, then `index.*` fallbacks.
25
+ * Tries `src/index.*` first, then the legacy root `server.*` / `index.*` layouts.
23
26
  *
24
27
  * @param dir - Absolute path to the server project directory
25
28
  * @returns Absolute path to the first entry that exists
@@ -37,9 +40,9 @@ async function resolveServerFile(dir) {
37
40
  }
38
41
  throw new Error(`No server entry found in ${dir} (looked for ${SERVER_CANDIDATES.join(", ")}). Run "storyshelf server init" first.`);
39
42
  }
40
- /** Node flags for an entry file (TypeScript needs the transform flag). */
43
+ /** Node runs the entry directly: Node 24+ strips TypeScript types without a flag. */
41
44
  function nodeArgs(file) {
42
- return file.endsWith(".ts") ? ["--experimental-transform-types", file] : [file];
45
+ return [file];
43
46
  }
44
47
  /** Forward SIGINT/SIGTERM to the child; returns a handle to detach and inspect. */
45
48
  function relaySignals(child) {
@@ -2,8 +2,8 @@
2
2
  import "node:path";
3
3
  import "node:url";
4
4
  import.meta.url;
5
- import { join } from "node:path";
6
5
  import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
7
  //#region src/commands/shared/detect-adapters.ts
8
8
  /**
9
9
  * Detect installed adapter choices from a project's package.json.
@@ -95,7 +95,12 @@ function detectMysqlFlavor(dir, deps) {
95
95
  }
96
96
  function readGeneratedSources(dir) {
97
97
  let sources = "";
98
- for (const file of ["server.ts", "worker.ts"]) try {
98
+ for (const file of [
99
+ "src/index.ts",
100
+ "src/worker.ts",
101
+ "server.ts",
102
+ "worker.ts"
103
+ ]) try {
99
104
  sources += readFileSync(join(dir, file), "utf8");
100
105
  } catch {}
101
106
  return sources;
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { mkdir, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ //#region src/commands/shared/scaffold.ts
8
+ /** Server entry in a scaffolded project (a `src/` dir leaves room for helper modules). */
9
+ const SERVER_ENTRY = "src/index.ts";
10
+ /** Worker entry in a scaffolded project. */
11
+ const WORKER_ENTRY = "src/worker.ts";
12
+ /** Dev dependencies every scaffolded project gets (editor + `tsc --noEmit`). */
13
+ const SCAFFOLD_DEV_DEPENDENCIES = {
14
+ "@types/node": "^24.0.0",
15
+ typescript: "^7.0.2"
16
+ };
17
+ /** `tsconfig.json` for a scaffolded project: type-check only (Node strips types at runtime). */
18
+ function generateTsconfig() {
19
+ return `${JSON.stringify({
20
+ compilerOptions: {
21
+ target: "es2023",
22
+ module: "nodenext",
23
+ moduleResolution: "nodenext",
24
+ strict: true,
25
+ noEmit: true,
26
+ skipLibCheck: true,
27
+ verbatimModuleSyntax: true,
28
+ allowImportingTsExtensions: true,
29
+ types: ["node"]
30
+ },
31
+ include: ["src"]
32
+ }, null, 2)}\n`;
33
+ }
34
+ /** Write `contents` to `outDir/relativePath`, creating parent directories. */
35
+ async function writeScaffoldFile(outDir, relativePath, contents) {
36
+ const target = join(outDir, relativePath);
37
+ await mkdir(dirname(target), { recursive: true });
38
+ await writeFile(target, contents);
39
+ }
40
+ //#endregion
41
+ export { SCAFFOLD_DEV_DEPENDENCIES, SERVER_ENTRY, WORKER_ENTRY, generateTsconfig, writeScaffoldFile };
@@ -4,9 +4,9 @@ import "node:url";
4
4
  import.meta.url;
5
5
  import { detectPackageRunner } from "../config.js";
6
6
  import { printLine } from "../output.js";
7
- import { resolve } from "node:path";
8
7
  import { execSync } from "node:child_process";
9
8
  import { access, readdir } from "node:fs/promises";
9
+ import { resolve } from "node:path";
10
10
  //#region src/commands/storybook-build.ts
11
11
  /**
12
12
  * Script invocation per runner. npm needs `--` to forward args; pnpm, bun,
@@ -5,10 +5,11 @@ import.meta.url;
5
5
  import { detectPackageRunner, installCommand } from "../../config.js";
6
6
  import { printError, printLine } from "../../output.js";
7
7
  import { detectInstalledAdapters } from "../shared/detect-adapters.js";
8
+ import { SCAFFOLD_DEV_DEPENDENCIES, WORKER_ENTRY, generateTsconfig, writeScaffoldFile } from "../shared/scaffold.js";
8
9
  import { generateDockerignore, generateWorkerDockerfile } from "../server/docker.js";
9
10
  import { PROJECT_PROMPTS, WORKER_INFRA_PROMPTS } from "../server/prompts.js";
10
- import { join, resolve } from "node:path";
11
11
  import { mkdir, writeFile } from "node:fs/promises";
12
+ import { join, resolve } from "node:path";
12
13
  import prompts from "prompts";
13
14
  //#region src/commands/worker/init.ts
14
15
  const DB_PACKAGE = {
@@ -151,18 +152,20 @@ function generateWorker(answers) {
151
152
  ``,
152
153
  `await worker.start();`,
153
154
  ``,
154
- `const shutdown = async () => { await worker.stop(); };`,
155
- `process.on("SIGTERM", () => { shutdown().catch(() => {}); });`,
156
- `process.on("SIGINT", () => { shutdown().catch(() => {}); });`,
155
+ `for (const signal of ["SIGINT", "SIGTERM"] as const) {`,
156
+ ` process.once(signal, () => {`,
157
+ ` void worker.stop();`,
158
+ ` });`,
159
+ `}`,
157
160
  ``
158
161
  ].join("\n");
159
162
  }
160
163
  function buildDeps(answers) {
161
164
  const deps = {
162
- "@storyshelf/core": "0.6.0",
163
- "@storyshelf/worker": "0.6.0",
164
- "@storyshelf/runner-playwright": "0.6.0",
165
- [DB_PACKAGE[answers.database]]: "0.6.0"
165
+ "@storyshelf/core": "0.6.2",
166
+ "@storyshelf/worker": "0.6.2",
167
+ "@storyshelf/runner-playwright": "0.6.2",
168
+ [DB_PACKAGE[answers.database]]: "0.6.2"
166
169
  };
167
170
  if (answers.database === "turso") deps["@libsql/client"] = LIBSQL_CLIENT_SDK;
168
171
  if (answers.database === "better-sqlite3") deps["better-sqlite3"] = BETTER_SQLITE3_SDK;
@@ -173,9 +176,9 @@ function buildDeps(answers) {
173
176
  if (answers.database === "mysql") deps["mysql2"] = MYSQL2_SDK;
174
177
  if (answers.database === "planetscale") deps["@planetscale/database"] = PLANETSCALE_SDK;
175
178
  if (answers.database === "tidb") deps["@tidbcloud/serverless"] = TIDBCLOUD_SDK;
176
- if (answers.storage !== "local") deps[STORAGE_PACKAGE[answers.storage]] = "0.6.0";
179
+ if (answers.storage !== "local") deps[STORAGE_PACKAGE[answers.storage]] = "0.6.2";
177
180
  const qp = QUEUE_PACKAGE[answers.queue];
178
- if (qp) deps[qp] = "0.6.0";
181
+ if (qp) deps[qp] = "0.6.2";
179
182
  if (answers.queue === "azure-storage-queues") deps["@azure/storage-queue"] = AZURE_STORAGE_QUEUE_SDK;
180
183
  if (answers.queue === "azure-service-bus") deps["@azure/service-bus"] = AZURE_SERVICE_BUS_SDK;
181
184
  if (answers.queue === "gcp-pubsub") deps["@google-cloud/pubsub"] = GCP_PUBSUB_SDK;
@@ -188,20 +191,23 @@ function generatePackageJson(answers) {
188
191
  type: "module",
189
192
  private: true,
190
193
  description: "StoryShelf capture worker (remote queue).",
194
+ engines: { node: ">=24" },
191
195
  scripts: {
192
- start: "node --experimental-transform-types worker.ts",
193
- dev: "node --experimental-transform-types --watch worker.ts"
196
+ start: `node ${WORKER_ENTRY}`,
197
+ dev: `node --watch ${WORKER_ENTRY}`,
198
+ typecheck: "tsc"
194
199
  },
195
200
  dependencies: buildDeps(answers),
196
- devDependencies: { typescript: "^7.0.2" }
201
+ devDependencies: SCAFFOLD_DEV_DEPENDENCIES
197
202
  };
198
203
  return JSON.stringify(pkg, null, 2);
199
204
  }
200
205
  async function writeFiles(outDir, answers) {
201
- await writeFile(join(outDir, "worker.ts"), generateWorker(answers));
202
- printLine(`Created worker.ts`);
206
+ await writeScaffoldFile(outDir, WORKER_ENTRY, generateWorker(answers));
207
+ printLine(`Created ${WORKER_ENTRY}`);
203
208
  await writeFile(join(outDir, "package.json"), generatePackageJson(answers));
204
- printLine(`Created package.json`);
209
+ await writeFile(join(outDir, "tsconfig.json"), generateTsconfig());
210
+ printLine(`Created package.json, tsconfig.json`);
205
211
  if (answers.docker) {
206
212
  await writeFile(join(outDir, "Dockerfile"), generateWorkerDockerfile());
207
213
  await writeFile(join(outDir, ".dockerignore"), generateDockerignore());
@@ -4,11 +4,14 @@ import "node:url";
4
4
  import.meta.url;
5
5
  import { detectPackageRunner, installCommand } from "../../config.js";
6
6
  import { printLine } from "../../output.js";
7
- import { join, resolve } from "node:path";
8
7
  import { spawn } from "node:child_process";
9
8
  import { access } from "node:fs/promises";
9
+ import { join, resolve } from "node:path";
10
10
  //#region src/commands/worker/serve.ts
11
11
  const WORKER_CANDIDATES = [
12
+ "src/worker.ts",
13
+ "src/worker.js",
14
+ "src/worker.mjs",
12
15
  "worker.ts",
13
16
  "worker.js",
14
17
  "worker.mjs",
@@ -28,8 +31,9 @@ async function resolveWorkerFile(dir) {
28
31
  }
29
32
  throw new Error(`No worker entry found in ${dir} (looked for ${WORKER_CANDIDATES.join(", ")}). Run "storyshelf worker init" first.`);
30
33
  }
34
+ /** Node runs the entry directly: Node 24+ strips TypeScript types without a flag. */
31
35
  function nodeArgs(file) {
32
- return file.endsWith(".ts") ? ["--experimental-transform-types", file] : [file];
36
+ return [file];
33
37
  }
34
38
  function relaySignals(child) {
35
39
  let relayed = false;
package/dist/config.js CHANGED
@@ -2,11 +2,11 @@
2
2
  import "node:path";
3
3
  import "node:url";
4
4
  import.meta.url;
5
- import { fileURLToPath } from "node:url";
6
- import { dirname, join, relative, resolve, sep } from "node:path";
7
5
  import { execSync } from "node:child_process";
8
6
  import { existsSync } from "node:fs";
9
7
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
8
+ import { dirname, join, relative, resolve, sep } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
10
  //#region src/config.ts
11
11
  /** Parse an unknown value into a `StorybookConfig`, or return the reason it is invalid. */
12
12
  function parseStorybookConfig(value) {
@@ -233,7 +233,7 @@ const SCHEMA_BASE_URL = "https://unpkg.com/storyshelf";
233
233
  const SCHEMA_PACKAGE_PATH = "schema/storyshelf-config.json";
234
234
  /** Running CLI version, or undefined in dev/test without the build define. */
235
235
  function cliVersion() {
236
- return "0.6.0";
236
+ return "0.6.2";
237
237
  }
238
238
  /**
239
239
  * Resolve the `$schema` value for a config file: a path relative to the
@@ -523,4 +523,4 @@ async function runnerFromLockfiles(cwd) {
523
523
  }
524
524
  }
525
525
  //#endregion
526
- export { assertStorybookMain, detectGitDefaultBranch, detectGitRepository, detectPackageName, detectPackageRunner, detectStorybookMeta, findStorybookMain, installCommand, loadStorybookConfig, schemaReference, startCommand, writeStorybookConfig };
526
+ export { assertStorybookMain, cliVersion, detectGitDefaultBranch, detectGitRepository, detectPackageName, detectPackageRunner, detectStorybookMeta, findStorybookMain, installCommand, loadStorybookConfig, schemaReference, startCommand, writeStorybookConfig };
package/dist/index.js CHANGED
@@ -2,21 +2,9 @@
2
2
  import "node:path";
3
3
  import "node:url";
4
4
  import.meta.url;
5
- import { runBuild } from "./commands/build.js";
6
- import { runCreate } from "./commands/create.js";
7
- import { runUpload } from "./commands/upload.js";
8
- import { handleError, runDefaultCommand } from "./commands/default.js";
9
- import { runDoctor } from "./commands/doctor.js";
10
- import { runInit } from "./commands/init.js";
11
- import { runPurge } from "./commands/purge.js";
12
- import { runRetry } from "./commands/retry.js";
13
- import { runServerInit } from "./commands/server/init.js";
14
- import { runServerServe } from "./commands/server/serve.js";
15
- import { runWhoami } from "./commands/whoami.js";
16
- import { runWorkerInit } from "./commands/worker/init.js";
17
- import { runWorkerServe } from "./commands/worker/serve.js";
5
+ import { cliVersion } from "./config.js";
6
+ import { isMainModule } from "./is-main.js";
18
7
  import { Command } from "commander";
19
- import { pathToFileURL } from "node:url";
20
8
  //#region src/index.ts
21
9
  /**
22
10
  * Build the StoryShelf CLI program with all subcommands registered.
@@ -25,7 +13,7 @@ import { pathToFileURL } from "node:url";
25
13
  */
26
14
  function createProgram() {
27
15
  const program = new Command();
28
- program.name("storyshelf").description("Self-hosted visual testing for Storybook.").version("0.2.0");
16
+ program.name("storyshelf").description("Self-hosted visual testing for Storybook.").version(cliVersion() ?? "0.0.0-dev");
29
17
  const commands = [
30
18
  buildInitCommand(),
31
19
  buildCreateCommand(),
@@ -41,54 +29,66 @@ function createProgram() {
41
29
  for (const command of commands) program.addCommand(command);
42
30
  return program;
43
31
  }
44
- function run(fn) {
32
+ /**
33
+ * Wrap a command so its module (and heavy dependencies such as the zip library
34
+ * or prompts) loads only when that command runs. `storyshelf -h` and the other
35
+ * commands then skip ~40 ms of imports they never use.
36
+ */
37
+ function run(load) {
45
38
  return async (args) => {
46
- await fn(args).catch(handleError);
39
+ try {
40
+ await (await load())(args);
41
+ } catch (error) {
42
+ const { handleError } = await import("./commands/default.js");
43
+ handleError(error);
44
+ }
47
45
  };
48
46
  }
49
47
  function buildInitCommand() {
50
- 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));
48
+ 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(async () => (await import("./commands/init.js")).runInit));
51
49
  }
52
50
  function buildCreateCommand() {
53
- 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));
51
+ 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(async () => (await import("./commands/create.js")).runCreate));
54
52
  }
55
53
  function buildServerCommand() {
56
54
  const server = new Command("server").description("Server operations");
57
- server.command("init").description("Scaffold a new StoryShelf server project").option("--dir <dir>", "output directory").action(run(runServerInit));
58
- const serve = new Command("serve").description("Run a scaffolded StoryShelf server project").option("--dir <dir>", "server project directory (default cwd)").option("--port <port>", "port override (sets PORT)").action(run(runServerServe));
55
+ server.command("init").description("Scaffold a new StoryShelf server project").option("--dir <dir>", "output directory").action(run(async () => (await import("./commands/server/init.js")).runServerInit));
56
+ const serve = new Command("serve").description("Run a scaffolded StoryShelf server project").option("--dir <dir>", "server project directory (default cwd)").option("--port <port>", "port override (sets PORT)").action(run(async () => (await import("./commands/server/serve.js")).runServerServe));
59
57
  server.addCommand(serve, { isDefault: true });
60
58
  return server;
61
59
  }
62
60
  function buildWorkerCommand() {
63
61
  const worker = new Command("worker").description("Worker operations");
64
- worker.command("init").description("Scaffold a new StoryShelf worker project").option("--dir <dir>", "output directory").action(run(runWorkerInit));
65
- worker.command("serve").description("Run a scaffolded StoryShelf worker project").option("--dir <dir>", "worker project directory (default cwd)").option("--queue-url <url>", "SQS queue URL override (sets QUEUE_URL)").option("--concurrency <n>", "concurrency override (sets WORKER_CONCURRENCY)").action(run(runWorkerServe));
66
- const runCmd = new Command("run").description("Run a scaffolded worker (alias for worker serve)").option("--dir <dir>", "worker project directory (default cwd)").option("--queue-url <url>", "SQS queue URL override").option("--concurrency <n>", "concurrency override").action(run(runWorkerServe));
62
+ worker.command("init").description("Scaffold a new StoryShelf worker project").option("--dir <dir>", "output directory").action(run(async () => (await import("./commands/worker/init.js")).runWorkerInit));
63
+ worker.command("serve").description("Run a scaffolded StoryShelf worker project").option("--dir <dir>", "worker project directory (default cwd)").option("--queue-url <url>", "SQS queue URL override (sets QUEUE_URL)").option("--concurrency <n>", "concurrency override (sets WORKER_CONCURRENCY)").action(run(async () => (await import("./commands/worker/serve.js")).runWorkerServe));
64
+ const runCmd = new Command("run").description("Run a scaffolded worker (alias for worker serve)").option("--dir <dir>", "worker project directory (default cwd)").option("--queue-url <url>", "SQS queue URL override").option("--concurrency <n>", "concurrency override").action(run(async () => (await import("./commands/worker/serve.js")).runWorkerServe));
67
65
  worker.addCommand(runCmd);
68
66
  return worker;
69
67
  }
70
68
  function buildPurgeCommand() {
71
- 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));
69
+ 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(async () => (await import("./commands/purge.js")).runPurge));
72
70
  }
73
71
  function buildUploadCommand() {
74
- 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>", "revision SHA (flag, CI env, local git HEAD, or synthesized local-*)").option("--branch <branch>", "baseline namespace (flag, CI env, local git branch, or local)").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("--dry-run", "validate and build without sending any requests").option("--skip <glob>", "skip upload for matching branch (glob)").option("--message <message>", "build message").option("--author-email <email>", "author email").option("--author-name <name>", "author name").option("--full", "disable affected capture (render every story)").option("--untraced <glob>", "exclude matching files from affected tracing (repeatable)", (value, previous) => [...previous, value], []).option("--stats-file <path>", "bundler stats file (default <buildDir>/preview-stats.json)").option("--label <key=value>", "build label (repeatable: --label pr=123)", (value, previous) => [...previous, value], []).action(run(runUpload));
72
+ 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>", "revision SHA (flag, CI env, local git HEAD, or synthesized local-*)").option("--branch <branch>", "baseline namespace (flag, CI env, local git branch, or local)").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("--dry-run", "validate and build without sending any requests").option("--skip <glob>", "skip upload for matching branch (glob)").option("--message <message>", "build message").option("--author-email <email>", "author email").option("--author-name <name>", "author name").option("--full", "disable affected capture (render every story)").option("--untraced <glob>", "exclude matching files from affected tracing (repeatable)", (value, previous) => [...previous, value], []).option("--stats-file <path>", "bundler stats file (default <buildDir>/preview-stats.json)").option("--label <key=value>", "build label (repeatable: --label pr=123)", (value, previous) => [...previous, value], []).action(run(async () => (await import("./commands/upload.js")).runUpload));
75
73
  }
76
74
  function buildBuildCommand() {
77
- return new Command("build").description("Build Storybook for upload (no server contact)").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").action(run(runBuild));
75
+ return new Command("build").description("Build Storybook for upload (no server contact)").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").action(run(async () => (await import("./commands/build.js")).runBuild));
78
76
  }
79
77
  function buildDoctorCommand() {
80
- return new Command("doctor").description("Diagnose whether an upload would succeed (no side effects)").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("--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)").action(run(runDoctor));
78
+ return new Command("doctor").description("Diagnose whether an upload would succeed (no side effects)").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("--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)").action(run(async () => (await import("./commands/doctor.js")).runDoctor));
81
79
  }
82
80
  function buildWhoamiCommand() {
83
- return new Command("whoami").description("Verify the token against the server and print the 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("-c, --config <path>", "config file path (default .storybook/storyshelf.json)").action(run(runWhoami));
81
+ return new Command("whoami").description("Verify the token against the server and print the 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("-c, --config <path>", "config file path (default .storybook/storyshelf.json)").action(run(async () => (await import("./commands/whoami.js")).runWhoami));
84
82
  }
85
83
  function buildRetryCommand() {
86
- 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));
84
+ 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(async () => (await import("./commands/retry.js")).runRetry));
87
85
  }
88
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
86
+ if (isMainModule(import.meta.url, process.argv[1])) {
89
87
  const program = createProgram();
90
- if (process.argv.length <= 2) await runDefaultCommand(program);
91
- else program.parse(process.argv);
88
+ if (process.argv.length <= 2) {
89
+ const { runDefaultCommand } = await import("./commands/default.js");
90
+ await runDefaultCommand(program);
91
+ } else program.parse(process.argv);
92
92
  }
93
93
  //#endregion
94
94
  export { createProgram };
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { realpathSync } from "node:fs";
6
+ import { pathToFileURL } from "node:url";
7
+ //#region src/is-main.ts
8
+ /**
9
+ * Whether the module at `metaUrl` is the process entrypoint. Resolves symlinks
10
+ * in `argv[1]`: npm/npx launch bins through a `.bin/<name>` symlink, so a plain
11
+ * `pathToFileURL(argv[1])` comparison never matches and the CLI would exit
12
+ * silently without parsing its arguments.
13
+ */
14
+ function isMainModule(metaUrl, argv1) {
15
+ if (!argv1) return false;
16
+ try {
17
+ return metaUrl === pathToFileURL(realpathSync(argv1)).href;
18
+ } catch {
19
+ return metaUrl === pathToFileURL(argv1).href;
20
+ }
21
+ }
22
+ //#endregion
23
+ export { isMainModule };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyshelf",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "StoryShelf CLI: create projects, upload builds, purge, and retry from CI.",
5
5
  "keywords": [
6
6
  "ci",
@@ -31,6 +31,9 @@
31
31
  "schema"
32
32
  ],
33
33
  "type": "module",
34
+ "engines": {
35
+ "node": ">=24"
36
+ },
34
37
  "sideEffects": false,
35
38
  "types": "./dist/index.d.mts",
36
39
  "exports": {
@@ -50,15 +53,14 @@
50
53
  "prepublishOnly": "nub run build"
51
54
  },
52
55
  "dependencies": {
53
- "@storyshelf/affected": "0.6.0",
56
+ "@storyshelf/affected": "0.6.2",
54
57
  "archiver": "8.0.0",
55
58
  "commander": "^13.0.0",
56
- "hono": "^4.11.0",
57
59
  "picomatch": "^4.0.2",
58
60
  "prompts": "^2.4.2"
59
61
  },
60
62
  "devDependencies": {
61
- "@storyshelf/core": "0.6.0",
63
+ "@storyshelf/core": "0.6.2",
62
64
  "@types/node": "^26.0.0",
63
65
  "@types/picomatch": "^4.0.1",
64
66
  "@types/prompts": "^2.4.9",