storyshelf 0.3.2 → 0.5.3

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.
@@ -2,15 +2,49 @@
2
2
  import "node:path";
3
3
  import "node:url";
4
4
  import.meta.url;
5
- import { createClient } from "../client.js";
6
5
  import { loadStorybookConfig } from "../config.js";
7
6
  import { createSpinner, printLine, spinnerFrames } from "../output.js";
8
- import { execSync } from "node:child_process";
9
- import { access, readdir } from "node:fs/promises";
7
+ import { assertBuildOutput, ensureBuildDir } from "./storybook-build.js";
8
+ import { createClient } from "../client.js";
10
9
  import { resolve } from "node:path";
10
+ import { LOCAL_BRANCH, computeAffected, gitBranchName, gitHeadSha, localSha } from "@storyshelf/affected";
11
11
  import { ZipArchive } from "archiver";
12
12
  import * as picomatch from "picomatch";
13
13
  //#region src/commands/upload.ts
14
+ /**
15
+ * Upload a built Storybook and create a build record.
16
+ *
17
+ * @param options - Upload command options.
18
+ */
19
+ async function runUpload(options) {
20
+ const cwd = options.cwd ?? process.cwd();
21
+ const collected = collectUploadOptions(cwd, options, await loadStorybookConfig(cwd, options.config));
22
+ if (collected.synthesized) printLine(`No git checkout detected — using synthetic identity sha=${collected.sha} branch="${collected.branch}" (pass --sha/--branch to override)`);
23
+ if (shouldSkipUpload(collected.skip, collected.branch)) {
24
+ printLine(`Skipped per config skip="${collected.skip}" for branch "${collected.branch}"`);
25
+ return;
26
+ }
27
+ assertUploadOptions(collected);
28
+ await buildAndPost(cwd, collected, options.forceBuild, options.dryRun);
29
+ }
30
+ const defaultProbe = {
31
+ headSha: gitHeadSha,
32
+ branchName: gitBranchName
33
+ };
34
+ /**
35
+ * Resolve sha/branch per field: flags > env > local git > synthesized local
36
+ * identity. Synthesis always succeeds, so checkouts without git can upload
37
+ * with zero identity flags.
38
+ */
39
+ function resolveIdentity(cwd, options, probe = defaultProbe) {
40
+ const sha = options.sha ?? envOf("GITHUB_SHA", "VERCEL_GIT_COMMIT_SHA", "CI_COMMIT_SHA");
41
+ const branch = options.branch ?? envOf("GITHUB_REF_NAME", "VERCEL_GIT_COMMIT_REF", "CI_COMMIT_REF_NAME");
42
+ return {
43
+ sha: sha ?? probe.headSha(cwd) ?? localSha(),
44
+ branch: branch ?? probe.branchName(cwd) ?? LOCAL_BRANCH,
45
+ synthesized: sha === void 0 || branch === void 0
46
+ };
47
+ }
14
48
  /** First set value among the given env var names. */
15
49
  function envOf(...names) {
16
50
  for (const name of names) {
@@ -19,13 +53,15 @@ function envOf(...names) {
19
53
  }
20
54
  }
21
55
  /** Layer explicit options over env and the client config file. */
22
- function collectUploadOptions(options, cfg) {
56
+ function collectUploadOptions(cwd, options, cfg) {
57
+ const identity = resolveIdentity(cwd, options);
23
58
  return {
24
59
  url: options.url ?? cfg?.url ?? envOf("STORYSHELF_URL"),
25
60
  slug: options.slug ?? cfg?.slug ?? envOf("STORYSHELF_SLUG"),
26
61
  token: options.token ?? envOf("STORYSHELF_TOKEN", "SHELF_TOKEN"),
27
- sha: options.sha ?? envOf("GITHUB_SHA", "VERCEL_GIT_COMMIT_SHA", "CI_COMMIT_SHA"),
28
- branch: options.branch ?? envOf("GITHUB_REF_NAME", "VERCEL_GIT_COMMIT_REF", "CI_COMMIT_REF_NAME"),
62
+ sha: identity.sha,
63
+ branch: identity.branch,
64
+ synthesized: identity.synthesized,
29
65
  buildDir: options.buildDir ?? cfg?.buildDir ?? "storybook-static",
30
66
  buildCommand: options.buildCommand ?? cfg?.buildCommand,
31
67
  buildScriptName: options.buildScriptName ?? cfg?.buildScriptName,
@@ -33,16 +69,26 @@ function collectUploadOptions(options, cfg) {
33
69
  message: options.message,
34
70
  authorEmail: options.authorEmail,
35
71
  authorName: options.authorName,
36
- label: options.label
72
+ label: options.label,
73
+ affectedOnly: resolveAffectedOnly(options, cfg),
74
+ untraced: mergeUntraced(cfg?.untraced, options.untraced),
75
+ statsFile: options.statsFile
37
76
  };
38
77
  }
78
+ /** Combine config-file and flag untraced globs, deduplicated in order. */
79
+ function mergeUntraced(configured, flagged) {
80
+ return [.../* @__PURE__ */ new Set([...configured ?? [], ...flagged ?? []])];
81
+ }
82
+ /** Affected capture is on by default; `--full` (or `STORYSHELF_FULL=1`) opts out. */
83
+ function resolveAffectedOnly(options, cfg) {
84
+ if (options.full === true || envOf("STORYSHELF_FULL") === "1") return false;
85
+ return options.affectedOnly ?? cfg?.affectedOnly ?? true;
86
+ }
39
87
  /** Throw on the first missing required upload option. */
40
88
  function assertUploadOptions(collected) {
41
89
  if (!collected.url) throw new Error("--url is required (or .storybook/storyshelf.json / STORYSHELF_URL)");
42
90
  if (!collected.slug) throw new Error("--slug is required (or .storybook/storyshelf.json / STORYSHELF_SLUG)");
43
91
  if (!collected.token) throw new Error("--token is required (or STORYSHELF_TOKEN env)");
44
- if (!collected.sha) throw new Error("--sha is required (or GITHUB_SHA env)");
45
- if (!collected.branch) throw new Error("--branch is required (or GITHUB_REF_NAME env)");
46
92
  }
47
93
  function shouldSkipUpload(skip, branch) {
48
94
  return Boolean(skip && branch && picomatch.isMatch(branch, skip));
@@ -71,23 +117,8 @@ function zipBuildDirStream(cwd, buildDir) {
71
117
  archive.finalize().catch(() => {});
72
118
  return archive;
73
119
  }
74
- /**
75
- * Upload a built Storybook and create a build record.
76
- *
77
- * @param options - Upload command options.
78
- */
79
- async function runUpload(options) {
80
- const cwd = options.cwd ?? process.cwd();
81
- const collected = collectUploadOptions(options, await loadStorybookConfig(cwd, options.config));
82
- if (shouldSkipUpload(collected.skip, collected.branch)) {
83
- printLine(`Skipped per config skip="${collected.skip}" for branch "${collected.branch}"`);
84
- return;
85
- }
86
- assertUploadOptions(collected);
87
- await buildAndPost(cwd, collected, options.forceBuild);
88
- }
89
120
  /** Ensure, create, and stream the build directory. */
90
- async function buildAndPost(cwd, collected, force) {
121
+ async function buildAndPost(cwd, collected, force, dryRun) {
91
122
  await ensureBuildDir({
92
123
  cwd,
93
124
  buildDir: collected.buildDir,
@@ -95,6 +126,11 @@ async function buildAndPost(cwd, collected, force) {
95
126
  buildScriptName: collected.buildScriptName,
96
127
  force
97
128
  });
129
+ await assertBuildOutput(cwd, collected.buildDir);
130
+ if (dryRun) {
131
+ printLine(`Dry run: would upload ${collected.buildDir} as ${collected.sha} on ${collected.branch}`);
132
+ return;
133
+ }
98
134
  const client = createClient(collected.url, collected.token);
99
135
  const created = await client.projects.builds.createJson(collected.slug, {
100
136
  gitSha: collected.sha,
@@ -102,11 +138,54 @@ async function buildAndPost(cwd, collected, force) {
102
138
  message: collected.message,
103
139
  authorEmail: collected.authorEmail,
104
140
  authorName: collected.authorName,
141
+ affectedOnly: collected.affectedOnly,
105
142
  labels: parseLabels(collected.label)
106
143
  });
107
- await putZipStream(client, created, cwd, collected.buildDir);
144
+ await reportAffectedCapture(client, created, cwd, collected);
145
+ if (!await tryDedupUpload(client, created, cwd, collected.buildDir)) await putZipStream(client, created, cwd, collected.buildDir);
108
146
  printLine(`Build created: ${created.build.id}`);
109
147
  }
148
+ /** Compute the affected set and post it, never failing the upload. */
149
+ async function reportAffectedCapture(client, created, cwd, collected) {
150
+ if (!collected.affectedOnly) {
151
+ printLine("Full capture (--full): rendering all stories");
152
+ return;
153
+ }
154
+ if (!created.baselineSha) {
155
+ printLine("Full capture (no baseline build yet): rendering all stories");
156
+ return;
157
+ }
158
+ try {
159
+ const result = await computeAffected({
160
+ cwd,
161
+ buildDir: collected.buildDir,
162
+ baseSha: created.baselineSha,
163
+ headSha: collected.sha,
164
+ untraced: collected.untraced,
165
+ statsFile: collected.statsFile
166
+ });
167
+ await client.projects.builds.postAffected(collected.slug, created.build.id, {
168
+ baselineSha: result.baselineSha,
169
+ changedFiles: result.changedFiles,
170
+ affectedImportPaths: result.affectedImportPaths
171
+ });
172
+ printLine(describeAffected(result, created.baselineSha));
173
+ } catch (error) {
174
+ const reason = error instanceof Error ? error.message : String(error);
175
+ printLine(`Full capture (affected post failed: ${reason}): rendering all stories`);
176
+ }
177
+ }
178
+ /** Human-readable summary of an affected computation. */
179
+ function describeAffected(result, baselineSha) {
180
+ if (result.affectedImportPaths === null) return `Full capture (${result.fullReason ?? "unknown reason"}): rendering all stories`;
181
+ const count = result.affectedImportPaths.length;
182
+ const short = baselineSha.slice(0, 7);
183
+ if (count === 0) return `Affected capture: no stories affected (baseline ${short})`;
184
+ return `Affected capture: rendering ${count} ${count === 1 ? "story" : "stories"} (baseline ${short})`;
185
+ }
186
+ async function tryDedupUpload(_client, _created, _cwd, _buildDir) {
187
+ return false;
188
+ }
110
189
  /** PUT the streamed zip, then report the created build id. */
111
190
  async function putZipStream(client, created, cwd, buildDir) {
112
191
  const spinner = createSpinner("Uploading...", spinnerFrames);
@@ -118,28 +197,5 @@ async function putZipStream(client, created, cwd, buildDir) {
118
197
  throw error;
119
198
  }
120
199
  }
121
- async function ensureBuildDir(opts) {
122
- if (opts.buildCommand && opts.buildScriptName) throw new Error("buildCommand and buildScriptName are mutually exclusive");
123
- const full = resolve(opts.cwd, opts.buildDir);
124
- if (!(opts.force ?? await needsBuild(full))) return;
125
- const command = opts.buildCommand ?? `npm run ${opts.buildScriptName ?? "build-storybook"} -- --output-dir ${opts.buildDir}`;
126
- printLine(`Building Storybook: ${command}`);
127
- execSync(command, {
128
- stdio: "inherit",
129
- env: {
130
- ...process.env,
131
- STORYBOOK_BUILD_STORIES_JSON: "true"
132
- }
133
- });
134
- }
135
- /** True when the build directory is missing or empty. */
136
- async function needsBuild(full) {
137
- try {
138
- await access(full);
139
- return (await readdir(full)).length === 0;
140
- } catch {
141
- return true;
142
- }
143
- }
144
200
  //#endregion
145
- export { runUpload };
201
+ export { resolveIdentity, runUpload };
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { printLine } from "../output.js";
6
+ import { createClient } from "../client.js";
7
+ import { resolveConnection } from "./connection.js";
8
+ //#region src/commands/whoami.ts
9
+ /**
10
+ * Verify the token against the server and print the project it can read.
11
+ *
12
+ * @param options - Connection options (url/slug/token via flags, config, or env).
13
+ */
14
+ async function runWhoami(options) {
15
+ const { url, slug, token } = await resolveConnection(options);
16
+ const project = await createClient(url, token).projects.get(slug);
17
+ const name = typeof project.name === "string" && project.name.length > 0 ? project.name : slug;
18
+ printLine(`Server: ${url}`);
19
+ printLine(`Project: ${name} (${slug})`);
20
+ }
21
+ //#endregion
22
+ export { runWhoami };
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { detectPackageRunner, installCommand } from "../../config.js";
6
+ import { printError, printLine } from "../../output.js";
7
+ import { detectInstalledAdapters } from "../shared/detect-adapters.js";
8
+ import { generateDockerignore, generateWorkerDockerfile } from "../server/docker.js";
9
+ import { PROJECT_PROMPTS, WORKER_INFRA_PROMPTS } from "../server/prompts.js";
10
+ import { join, resolve } from "node:path";
11
+ import { mkdir, writeFile } from "node:fs/promises";
12
+ import prompts from "prompts";
13
+ //#region src/commands/worker/init.ts
14
+ const DB_PACKAGE = {
15
+ sqlite: "@storyshelf/db-sqlite",
16
+ turso: "@storyshelf/db-sqlite",
17
+ "better-sqlite3": "@storyshelf/db-sqlite",
18
+ "bun-sqlite": "@storyshelf/db-sqlite",
19
+ d1: "@storyshelf/db-sqlite",
20
+ postgres: "@storyshelf/db-postgres",
21
+ pg: "@storyshelf/db-postgres",
22
+ neon: "@storyshelf/db-postgres",
23
+ "neon-http": "@storyshelf/db-postgres",
24
+ vercel: "@storyshelf/db-postgres",
25
+ pglite: "@storyshelf/db-postgres",
26
+ mysql: "@storyshelf/db-mysql",
27
+ planetscale: "@storyshelf/db-mysql",
28
+ tidb: "@storyshelf/db-mysql"
29
+ };
30
+ const STORAGE_PACKAGE = {
31
+ local: "@storyshelf/storage-local",
32
+ s3: "@storyshelf/storage-s3",
33
+ azure: "@storyshelf/storage-azure",
34
+ gcs: "@storyshelf/storage-gcs"
35
+ };
36
+ const QUEUE_PACKAGE = {
37
+ sqs: "@storyshelf/queue-sqs",
38
+ "azure-storage-queues": "@storyshelf/queue-azure",
39
+ "azure-service-bus": "@storyshelf/queue-azure",
40
+ "gcp-pubsub": "@storyshelf/queue-gcp",
41
+ memory: null
42
+ };
43
+ /** Azure SDK pins (must match @storyshelf/queue-azure peerDependencies). */
44
+ const AZURE_STORAGE_QUEUE_SDK = "^12.31.0";
45
+ const AZURE_SERVICE_BUS_SDK = "^7.9.5";
46
+ /** Pub/Sub SDK pin (must match @storyshelf/queue-gcp peerDependencies). */
47
+ const GCP_PUBSUB_SDK = "^6.1.0";
48
+ /** DB peer SDK pins (must match db-* peerDependencies). */
49
+ const LIBSQL_CLIENT_SDK = "^0.15.0";
50
+ const BETTER_SQLITE3_SDK = "^12.0.0";
51
+ const PG_SDK = "^8.0.0";
52
+ const NEON_SDK = "^1.1.0";
53
+ const VERCEL_POSTGRES_SDK = "^0.10.0";
54
+ const PGLITE_SDK = "^0.5.8";
55
+ const MYSQL2_SDK = "^3.9.0";
56
+ const PLANETSCALE_SDK = "^1.19.0";
57
+ const TIDBCLOUD_SDK = "^0.1.0";
58
+ const DB_IMPORT = {
59
+ sqlite: `import { createSqliteDatabase } from "@storyshelf/db-sqlite";`,
60
+ turso: `import { createTursoDatabase } from "@storyshelf/db-sqlite/turso";`,
61
+ "better-sqlite3": `import { createBetterSqlite3Database } from "@storyshelf/db-sqlite/better-sqlite3";`,
62
+ "bun-sqlite": `import { createBunSqliteDatabase } from "@storyshelf/db-sqlite/bun-sqlite";`,
63
+ d1: `import { createD1Database } from "@storyshelf/db-sqlite/d1";`,
64
+ postgres: `import { createPostgresDatabase } from "@storyshelf/db-postgres";`,
65
+ pg: `import { createPgDatabase } from "@storyshelf/db-postgres/pg";`,
66
+ neon: `import { createNeonDatabase } from "@storyshelf/db-postgres/neon";`,
67
+ "neon-http": `import { createNeonHttpDatabase } from "@storyshelf/db-postgres/neon-http";`,
68
+ vercel: `import { createVercelDatabase } from "@storyshelf/db-postgres/vercel";`,
69
+ pglite: `import { createPgliteDatabase } from "@storyshelf/db-postgres/pglite";`,
70
+ mysql: `import { createMysqlDatabase } from "@storyshelf/db-mysql";`,
71
+ planetscale: `import { createPlanetscaleDatabase } from "@storyshelf/db-mysql/planetscale";`,
72
+ tidb: `import { createTidbDatabase } from "@storyshelf/db-mysql/tidb";`
73
+ };
74
+ const STORAGE_IMPORT = {
75
+ local: `import { createLocalStorage } from "@storyshelf/storage-local";`,
76
+ s3: `import { createS3Storage } from "@storyshelf/storage-s3";`,
77
+ azure: `import { createAzureStorage } from "@storyshelf/storage-azure";`,
78
+ gcs: `import { createGcsStorage } from "@storyshelf/storage-gcs";`
79
+ };
80
+ const DB_INIT = {
81
+ sqlite: `createSqliteDatabase(\`\${dataDir}/shelf.db\`)`,
82
+ turso: `createTursoDatabase({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN })`,
83
+ "better-sqlite3": `createBetterSqlite3Database({ path: \`\${dataDir}/shelf.db\` })`,
84
+ "bun-sqlite": `createBunSqliteDatabase({ path: \`\${dataDir}/shelf.db\` })`,
85
+ d1: `createD1Database({ client: getD1Binding() })`,
86
+ postgres: `createPostgresDatabase({ url: process.env.DATABASE_URL! })`,
87
+ pg: `createPgDatabase({ url: process.env.DATABASE_URL! })`,
88
+ neon: `createNeonDatabase({ url: process.env.DATABASE_URL! })`,
89
+ "neon-http": `createNeonHttpDatabase({ url: process.env.DATABASE_URL! })`,
90
+ vercel: `createVercelDatabase()`,
91
+ pglite: `createPgliteDatabase()`,
92
+ mysql: `createMysqlDatabase({ url: process.env.DATABASE_URL! })`,
93
+ planetscale: `createPlanetscaleDatabase({ url: process.env.DATABASE_URL! })`,
94
+ tidb: `createTidbDatabase({ url: process.env.DATABASE_URL! })`
95
+ };
96
+ /** Extra scaffolding before `const database = ...` (D1 binding stub). */
97
+ function dbPrelude(database) {
98
+ if (database !== "d1") return [];
99
+ return [
100
+ `// TODO: provide your D1 binding (Cloudflare Workers: env.DB).`,
101
+ `function getD1Binding(): never {`,
102
+ ` throw new Error("Provide your D1 database binding (env.DB).");`,
103
+ `}`
104
+ ];
105
+ }
106
+ const STORAGE_INIT = {
107
+ local: `createLocalStorage(dataDir)`,
108
+ s3: `createS3Storage({ bucket: process.env.S3_BUCKET!, region: process.env.AWS_REGION })`,
109
+ azure: `createAzureStorage({ container: "storybook", connectionString: process.env.AZURE_STORAGE_CONNECTION! })`,
110
+ gcs: `createGcsStorage({ bucket: process.env.GCS_BUCKET! })`
111
+ };
112
+ function workerQueueImport(queue) {
113
+ if (queue === "azure-storage-queues") return `import { createAzureStorageQueuesQueue } from "@storyshelf/queue-azure/storage-queues";`;
114
+ if (queue === "azure-service-bus") return `import { createAzureServiceBusQueue } from "@storyshelf/queue-azure/service-bus";`;
115
+ if (queue === "gcp-pubsub") return `import { createGcpPubSubQueue } from "@storyshelf/queue-gcp";`;
116
+ if (queue === "sqs") return `import { createSqsCaptureQueue } from "@storyshelf/queue-sqs";`;
117
+ return `// In-memory queue is server-side only; worker uses a remote queue.`;
118
+ }
119
+ function workerQueueInit(queue) {
120
+ if (queue === "azure-storage-queues") return `const queue = createAzureStorageQueuesQueue({ queueName: "capture-jobs", connectionString: process.env.AZURE_STORAGE_CONNECTION! });`;
121
+ if (queue === "azure-service-bus") return `const queue = createAzureServiceBusQueue({ queueName: "capture-jobs", connectionString: process.env.AZURE_SERVICE_BUS_CONNECTION! });`;
122
+ if (queue === "gcp-pubsub") return `const queue = createGcpPubSubQueue({ topic: "capture-jobs", subscription: "capture-jobs-worker", projectId: process.env.GOOGLE_CLOUD_PROJECT! });`;
123
+ if (queue === "sqs") return `const queue = createSqsCaptureQueue({ queueUrl: process.env.QUEUE_URL! });`;
124
+ return `const queue = null as unknown as never; // replace with a remote queue`;
125
+ }
126
+ function generateWorker(answers) {
127
+ const queueLine = workerQueueImport(answers.queue);
128
+ const queueInit = workerQueueInit(answers.queue);
129
+ return [
130
+ queueLine,
131
+ `import { createCaptureWorker } from "@storyshelf/worker";`,
132
+ `import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";`,
133
+ DB_IMPORT[answers.database],
134
+ STORAGE_IMPORT[answers.storage],
135
+ ``,
136
+ `const dataDir = process.env.DATA_DIR || "./data";`,
137
+ ...dbPrelude(answers.database),
138
+ `const database = ${DB_INIT[answers.database]};`,
139
+ `const storage = ${STORAGE_INIT[answers.storage]};`,
140
+ queueInit,
141
+ `const runner = createPlaywrightCaptureRunner();`,
142
+ ``,
143
+ `const worker = createCaptureWorker({`,
144
+ ` queue,`,
145
+ ` db: database,`,
146
+ ` storage,`,
147
+ ` runner,`,
148
+ ` scratchDir: dataDir,`,
149
+ ` config: { concurrency: Number(process.env.WORKER_CONCURRENCY) || 2 },`,
150
+ `});`,
151
+ ``,
152
+ `await worker.start();`,
153
+ ``,
154
+ `const shutdown = async () => { await worker.stop(); };`,
155
+ `process.on("SIGTERM", () => { shutdown().catch(() => {}); });`,
156
+ `process.on("SIGINT", () => { shutdown().catch(() => {}); });`,
157
+ ``
158
+ ].join("\n");
159
+ }
160
+ function buildDeps(answers) {
161
+ const deps = {
162
+ "@storyshelf/core": "0.5.3",
163
+ "@storyshelf/worker": "0.5.3",
164
+ "@storyshelf/runner-playwright": "0.5.3",
165
+ [DB_PACKAGE[answers.database]]: "0.5.3"
166
+ };
167
+ if (answers.database === "turso") deps["@libsql/client"] = LIBSQL_CLIENT_SDK;
168
+ if (answers.database === "better-sqlite3") deps["better-sqlite3"] = BETTER_SQLITE3_SDK;
169
+ if (answers.database === "pg") deps["pg"] = PG_SDK;
170
+ if (answers.database === "neon" || answers.database === "neon-http") deps["@neondatabase/serverless"] = NEON_SDK;
171
+ if (answers.database === "vercel") deps["@vercel/postgres"] = VERCEL_POSTGRES_SDK;
172
+ if (answers.database === "pglite") deps["@electric-sql/pglite"] = PGLITE_SDK;
173
+ if (answers.database === "mysql") deps["mysql2"] = MYSQL2_SDK;
174
+ if (answers.database === "planetscale") deps["@planetscale/database"] = PLANETSCALE_SDK;
175
+ if (answers.database === "tidb") deps["@tidbcloud/serverless"] = TIDBCLOUD_SDK;
176
+ if (answers.storage !== "local") deps[STORAGE_PACKAGE[answers.storage]] = "0.5.3";
177
+ const qp = QUEUE_PACKAGE[answers.queue];
178
+ if (qp) deps[qp] = "0.5.3";
179
+ if (answers.queue === "azure-storage-queues") deps["@azure/storage-queue"] = AZURE_STORAGE_QUEUE_SDK;
180
+ if (answers.queue === "azure-service-bus") deps["@azure/service-bus"] = AZURE_SERVICE_BUS_SDK;
181
+ if (answers.queue === "gcp-pubsub") deps["@google-cloud/pubsub"] = GCP_PUBSUB_SDK;
182
+ return deps;
183
+ }
184
+ function generatePackageJson(answers) {
185
+ const pkg = {
186
+ name: answers.name,
187
+ version: "0.1.0",
188
+ type: "module",
189
+ private: true,
190
+ description: "StoryShelf capture worker (remote queue).",
191
+ scripts: {
192
+ start: "node --experimental-transform-types worker.ts",
193
+ dev: "node --experimental-transform-types --watch worker.ts"
194
+ },
195
+ dependencies: buildDeps(answers),
196
+ devDependencies: { typescript: "^7.0.2" }
197
+ };
198
+ return JSON.stringify(pkg, null, 2);
199
+ }
200
+ async function writeFiles(outDir, answers) {
201
+ await writeFile(join(outDir, "worker.ts"), generateWorker(answers));
202
+ printLine(`Created worker.ts`);
203
+ await writeFile(join(outDir, "package.json"), generatePackageJson(answers));
204
+ printLine(`Created package.json`);
205
+ if (answers.docker) {
206
+ await writeFile(join(outDir, "Dockerfile"), generateWorkerDockerfile());
207
+ await writeFile(join(outDir, ".dockerignore"), generateDockerignore());
208
+ printLine(`Created Dockerfile, .dockerignore`);
209
+ }
210
+ }
211
+ function printNextSteps(answers, runner) {
212
+ printLine(`\nScaffolded ${answers.name} in ${resolve(answers.dir)}`);
213
+ printLine(`\nNext steps:`);
214
+ printLine(` cd ${answers.dir}`);
215
+ if (answers.docker) {
216
+ printLine(` docker build -t storyshelf-worker .`);
217
+ printLine(` docker run --env-file .env storyshelf-worker`);
218
+ } else {
219
+ printLine(` ${installCommand(runner)}`);
220
+ printLine(` npx storyshelf worker serve --dir .`);
221
+ }
222
+ }
223
+ function promptInitial(choices, value) {
224
+ if (value === void 0 || value === "") return void 0;
225
+ const idx = choices.findIndex((c) => c.value === value);
226
+ if (idx === -1) return void 0;
227
+ return idx;
228
+ }
229
+ async function runWorkerInit(_options) {
230
+ const cwdPkg = detectInstalledAdapters(process.cwd());
231
+ const infraWithInitial = WORKER_INFRA_PROMPTS.map((prompt) => {
232
+ if (prompt.type !== "select") return prompt;
233
+ const detected = cwdPkg[prompt.name];
234
+ const initial = promptInitial(prompt.choices, detected);
235
+ if (initial !== void 0) return {
236
+ ...prompt,
237
+ initial
238
+ };
239
+ return prompt;
240
+ });
241
+ const responses = await prompts([...PROJECT_PROMPTS, ...infraWithInitial]);
242
+ if (!responses["name"] || !responses["dir"]) {
243
+ printError("Cancelled.");
244
+ return;
245
+ }
246
+ const answers = responses;
247
+ if (!answers.queue) answers.queue = "sqs";
248
+ const outDir = resolve(answers.dir);
249
+ await mkdir(outDir, { recursive: true });
250
+ await writeFiles(outDir, answers);
251
+ printNextSteps(answers, await detectPackageRunner(process.cwd()));
252
+ }
253
+ //#endregion
254
+ export { runWorkerInit };
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { detectPackageRunner, installCommand } from "../../config.js";
6
+ import { printLine } from "../../output.js";
7
+ import { join, resolve } from "node:path";
8
+ import { spawn } from "node:child_process";
9
+ import { access } from "node:fs/promises";
10
+ //#region src/commands/worker/serve.ts
11
+ const WORKER_CANDIDATES = [
12
+ "worker.ts",
13
+ "worker.js",
14
+ "worker.mjs",
15
+ "index.ts",
16
+ "index.js",
17
+ "index.mjs"
18
+ ];
19
+ async function resolveWorkerFile(dir) {
20
+ for (const candidate of WORKER_CANDIDATES) {
21
+ const full = join(dir, candidate);
22
+ try {
23
+ await access(full);
24
+ return full;
25
+ } catch {
26
+ continue;
27
+ }
28
+ }
29
+ throw new Error(`No worker entry found in ${dir} (looked for ${WORKER_CANDIDATES.join(", ")}). Run "storyshelf worker init" first.`);
30
+ }
31
+ function nodeArgs(file) {
32
+ return file.endsWith(".ts") ? ["--experimental-transform-types", file] : [file];
33
+ }
34
+ function relaySignals(child) {
35
+ let relayed = false;
36
+ const relay = (signal) => {
37
+ relayed = true;
38
+ child.kill(signal);
39
+ };
40
+ const onSigint = () => {
41
+ relay("SIGINT");
42
+ };
43
+ const onSigterm = () => {
44
+ relay("SIGTERM");
45
+ };
46
+ process.on("SIGINT", onSigint);
47
+ process.on("SIGTERM", onSigterm);
48
+ return {
49
+ stop: () => {
50
+ process.off("SIGINT", onSigint);
51
+ process.off("SIGTERM", onSigterm);
52
+ },
53
+ relayed: () => relayed
54
+ };
55
+ }
56
+ function waitForExit(child, relay) {
57
+ return new Promise((resolvePromise, rejectPromise) => {
58
+ child.on("error", rejectPromise);
59
+ child.on("exit", (code, signal) => {
60
+ if (signal && relay.relayed()) resolvePromise(0);
61
+ else resolvePromise(code ?? 1);
62
+ });
63
+ });
64
+ }
65
+ async function defaultSpawnProcess(command, args, options) {
66
+ const child = spawn(command, [...args], {
67
+ stdio: "inherit",
68
+ cwd: options.cwd,
69
+ env: options.env
70
+ });
71
+ const relay = relaySignals(child);
72
+ try {
73
+ return await waitForExit(child, relay);
74
+ } finally {
75
+ relay.stop();
76
+ }
77
+ }
78
+ async function warnIfUninstalled(dir) {
79
+ try {
80
+ await access(join(dir, "node_modules"));
81
+ } catch {
82
+ const runner = await detectPackageRunner(dir);
83
+ printLine(`Warning: no node_modules in ${dir} — run "${installCommand(runner)}" there first if the worker fails to start.`);
84
+ }
85
+ }
86
+ async function runWorkerServe(options, deps = {}) {
87
+ const dir = resolve(options.dir ?? process.cwd());
88
+ const file = await resolveWorkerFile(dir);
89
+ await warnIfUninstalled(dir);
90
+ printLine(`Starting worker: ${file}`);
91
+ const code = await (deps.spawnProcess ?? defaultSpawnProcess)(process.execPath, nodeArgs(file), {
92
+ cwd: dir,
93
+ env: childEnv(options)
94
+ });
95
+ if (code !== 0) throw new Error(`Worker exited with code ${code}`);
96
+ }
97
+ function childEnv(options) {
98
+ const env = { ...process.env };
99
+ if (options.port !== void 0) env["PORT"] = options.port;
100
+ if (options.queueUrl !== void 0) env["QUEUE_URL"] = options.queueUrl;
101
+ if (options.concurrency !== void 0) env["WORKER_CONCURRENCY"] = options.concurrency;
102
+ return env;
103
+ }
104
+ //#endregion
105
+ export { resolveWorkerFile, runWorkerServe };