storyshelf 0.3.2 → 0.5.4

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,86 +2,234 @@
2
2
  import "node:path";
3
3
  import "node:url";
4
4
  import.meta.url;
5
+ import { detectPackageRunner, installCommand, startCommand } from "../../config.js";
5
6
  import { printError, printLine } from "../../output.js";
6
- import { generateComposeYaml, generateDockerfile, generateDockerignore } from "./docker.js";
7
- import { INFRA_PROMPTS, PROJECT_PROMPTS } from "./prompts.js";
8
- import { mkdir, writeFile } from "node:fs/promises";
7
+ import { detectInstalledAdapters } from "../shared/detect-adapters.js";
8
+ import { generateComposeYaml, generateComposeYamlWithWorker, generateDockerfile, generateDockerignore, generateWorkerDockerfile } from "./docker.js";
9
+ import { ADVANCED_ADAPTERS_PROMPT, AWS_INFRA_PROMPTS, AZURE_INFRA_PROMPTS, DEPLOY_TARGET_PROMPT, GCP_INFRA_PROMPTS, PROJECT_PROMPTS, localInfraPrompts } from "./prompts.js";
10
+ import { generateAwsTerraformFiles } from "./terraform-aws.js";
11
+ import { generateAzureTerraformFiles } from "./terraform-azure.js";
12
+ import { generateGcpTerraformFiles } from "./terraform-gcp.js";
9
13
  import { join, resolve } from "node:path";
14
+ import { mkdir, writeFile } from "node:fs/promises";
10
15
  import prompts from "prompts";
11
16
  //#region src/commands/server/init.ts
17
+ /** Azure SDK pins (must match @storyshelf/queue-azure peerDependencies). */
18
+ const AZURE_STORAGE_QUEUE_SDK = "^12.31.0";
19
+ const AZURE_SERVICE_BUS_SDK = "^7.9.5";
20
+ /** Pub/Sub SDK pin (must match @storyshelf/queue-gcp peerDependencies). */
21
+ const GCP_PUBSUB_SDK = "^6.1.0";
22
+ /** DB peer SDK pins (must match db-* peerDependencies). */
23
+ const LIBSQL_CLIENT_SDK = "^0.15.0";
24
+ const BETTER_SQLITE3_SDK = "^12.0.0";
25
+ const PG_SDK = "^8.0.0";
26
+ const NEON_SDK = "^1.1.0";
27
+ const VERCEL_POSTGRES_SDK = "^0.10.0";
28
+ const PGLITE_SDK = "^0.5.8";
29
+ const MYSQL2_SDK = "^3.9.0";
30
+ const PLANETSCALE_SDK = "^1.19.0";
31
+ const TIDBCLOUD_SDK = "^0.1.0";
32
+ /** Email peer SDK pin (must match @storyshelf/notify-email peerDependencies). */
33
+ const NODEMAILER_SDK = "^10.0.12";
34
+ /** npm peer SDK for the chosen Azure queue backend. */ function azureSdkDep(queue) {
35
+ if (queue === "azure-service-bus") return {
36
+ name: "@azure/service-bus",
37
+ version: AZURE_SERVICE_BUS_SDK
38
+ };
39
+ return {
40
+ name: "@azure/storage-queue",
41
+ version: AZURE_STORAGE_QUEUE_SDK
42
+ };
43
+ }
44
+ /** Remote queues need a separately-assembled worker; memory runs in-process. */
45
+ function isRemoteQueue(queue) {
46
+ return queue !== "memory";
47
+ }
48
+ /** Normalize the deploy target, preserving pre-target answers. */
49
+ function resolveDeployTarget(answers) {
50
+ return answers.deployTarget ?? (answers.docker ? "docker" : "local");
51
+ }
12
52
  const DB_PACKAGE = {
13
53
  sqlite: "@storyshelf/db-sqlite",
14
- turso: "@storyshelf/db-turso"
54
+ turso: "@storyshelf/db-sqlite",
55
+ "better-sqlite3": "@storyshelf/db-sqlite",
56
+ "bun-sqlite": "@storyshelf/db-sqlite",
57
+ d1: "@storyshelf/db-sqlite",
58
+ postgres: "@storyshelf/db-postgres",
59
+ pg: "@storyshelf/db-postgres",
60
+ neon: "@storyshelf/db-postgres",
61
+ "neon-http": "@storyshelf/db-postgres",
62
+ vercel: "@storyshelf/db-postgres",
63
+ pglite: "@storyshelf/db-postgres",
64
+ mysql: "@storyshelf/db-mysql",
65
+ planetscale: "@storyshelf/db-mysql",
66
+ tidb: "@storyshelf/db-mysql"
15
67
  };
16
68
  const STORAGE_PACKAGE = {
17
69
  local: "@storyshelf/storage-local",
18
- s3: "@storyshelf/storage-s3"
70
+ s3: "@storyshelf/storage-s3",
71
+ azure: "@storyshelf/storage-azure",
72
+ gcs: "@storyshelf/storage-gcs"
19
73
  };
20
74
  const AUTH_PACKAGE = {
21
75
  none: null,
22
- password: "@storyshelf/auth-password",
23
- oauth: "@storyshelf/auth-oauth"
76
+ password: "@storyshelf/auth",
77
+ oauth: "@storyshelf/auth"
24
78
  };
25
79
  const GIT_PACKAGE = {
26
80
  none: null,
27
81
  github: "@storyshelf/git-github",
28
82
  gitlab: "@storyshelf/git-gitlab"
29
83
  };
84
+ const QUEUE_PACKAGE = {
85
+ memory: null,
86
+ sqs: "@storyshelf/queue-sqs",
87
+ redis: "@storyshelf/queue-redis",
88
+ "azure-storage-queues": "@storyshelf/queue-azure",
89
+ "azure-service-bus": "@storyshelf/queue-azure",
90
+ "gcp-pubsub": "@storyshelf/queue-gcp"
91
+ };
30
92
  const DB_IMPORT = {
31
93
  sqlite: `import { createSqliteDatabase } from "@storyshelf/db-sqlite";`,
32
- turso: `import { createTursoDatabase } from "@storyshelf/db-turso";`
94
+ turso: `import { createTursoDatabase } from "@storyshelf/db-sqlite/turso";`,
95
+ "better-sqlite3": `import { createBetterSqlite3Database } from "@storyshelf/db-sqlite/better-sqlite3";`,
96
+ "bun-sqlite": `import { createBunSqliteDatabase } from "@storyshelf/db-sqlite/bun-sqlite";`,
97
+ d1: `import { createD1Database } from "@storyshelf/db-sqlite/d1";`,
98
+ postgres: `import { createPostgresDatabase } from "@storyshelf/db-postgres";`,
99
+ pg: `import { createPgDatabase } from "@storyshelf/db-postgres/pg";`,
100
+ neon: `import { createNeonDatabase } from "@storyshelf/db-postgres/neon";`,
101
+ "neon-http": `import { createNeonHttpDatabase } from "@storyshelf/db-postgres/neon-http";`,
102
+ vercel: `import { createVercelDatabase } from "@storyshelf/db-postgres/vercel";`,
103
+ pglite: `import { createPgliteDatabase } from "@storyshelf/db-postgres/pglite";`,
104
+ mysql: `import { createMysqlDatabase } from "@storyshelf/db-mysql";`,
105
+ planetscale: `import { createPlanetscaleDatabase } from "@storyshelf/db-mysql/planetscale";`,
106
+ tidb: `import { createTidbDatabase } from "@storyshelf/db-mysql/tidb";`
33
107
  };
34
108
  const STORAGE_IMPORT = {
35
109
  local: `import { createLocalStorage } from "@storyshelf/storage-local";`,
36
- s3: `import { createS3Storage } from "@storyshelf/storage-s3";`
110
+ s3: `import { createS3Storage } from "@storyshelf/storage-s3";`,
111
+ azure: `import { createAzureStorage } from "@storyshelf/storage-azure";`,
112
+ gcs: `import { createGcsStorage } from "@storyshelf/storage-gcs";`
37
113
  };
38
114
  const DB_INIT = {
39
115
  sqlite: `createSqliteDatabase(\`\${dataDir}/shelf.db\`)`,
40
- turso: `createTursoDatabase({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN })`
116
+ turso: `createTursoDatabase({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN })`,
117
+ "better-sqlite3": `createBetterSqlite3Database({ path: \`\${dataDir}/shelf.db\` })`,
118
+ "bun-sqlite": `createBunSqliteDatabase({ path: \`\${dataDir}/shelf.db\` })`,
119
+ d1: `createD1Database({ client: getD1Binding() })`,
120
+ postgres: `createPostgresDatabase({ url: process.env.DATABASE_URL! })`,
121
+ pg: `createPgDatabase({ url: process.env.DATABASE_URL! })`,
122
+ neon: `createNeonDatabase({ url: process.env.DATABASE_URL! })`,
123
+ "neon-http": `createNeonHttpDatabase({ url: process.env.DATABASE_URL! })`,
124
+ vercel: `createVercelDatabase()`,
125
+ pglite: `createPgliteDatabase()`,
126
+ mysql: `createMysqlDatabase({ url: process.env.DATABASE_URL! })`,
127
+ planetscale: `createPlanetscaleDatabase({ url: process.env.DATABASE_URL! })`,
128
+ tidb: `createTidbDatabase({ url: process.env.DATABASE_URL! })`
41
129
  };
130
+ /** Extra scaffolding before `const database = ...` (D1 binding stub). */
131
+ function dbPrelude(database) {
132
+ if (database !== "d1") return [];
133
+ return [
134
+ `// TODO: provide your D1 binding (Cloudflare Workers: env.DB).`,
135
+ `function getD1Binding(): never {`,
136
+ ` throw new Error("Provide your D1 database binding (env.DB).");`,
137
+ `}`
138
+ ];
139
+ }
42
140
  const STORAGE_INIT = {
43
141
  local: `createLocalStorage(dataDir)`,
44
- s3: `createS3Storage({ bucket: process.env.S3_BUCKET!, region: process.env.AWS_REGION })`
142
+ s3: `createS3Storage({ bucket: process.env.S3_BUCKET!, region: process.env.AWS_REGION })`,
143
+ azure: `createAzureStorage({ container: "storybook", connectionString: process.env.AZURE_STORAGE_CONNECTION! })`,
144
+ gcs: `createGcsStorage({ bucket: process.env.GCS_BUCKET! })`
45
145
  };
146
+ /** Whether the generated server wires notification providers. */
147
+ function wantsNotifications(answers) {
148
+ return answers.notifications ?? true;
149
+ }
46
150
  function buildImports(answers) {
47
151
  const imports = [
48
152
  `import { serve } from "@hono/node-server";`,
49
- `import { createShelfRouter } from "@storyshelf/router";`,
153
+ `import { createShelfApp } from "@storyshelf/app";`,
50
154
  `import { createShelfLogger } from "@storyshelf/core/logger";`,
155
+ `import { otelLogMixin } from "@storyshelf/observability";`,
156
+ `import { initObservabilityFromEnv } from "@storyshelf/observability/node";`,
51
157
  DB_IMPORT[answers.database],
52
158
  STORAGE_IMPORT[answers.storage]
53
159
  ];
54
- if (answers.auth !== "none") imports.push(`import { createPasswordAuth } from "${AUTH_PACKAGE[answers.auth]}";`);
160
+ if (answers.queue === "sqs") imports.push(`import { createSqsCaptureQueue } from "@storyshelf/queue-sqs";`);
161
+ if (answers.queue === "redis") imports.push(`import { createRedisCaptureQueue } from "@storyshelf/queue-redis";`);
162
+ if (answers.queue === "azure-storage-queues") imports.push(`import { createAzureStorageQueuesQueue } from "@storyshelf/queue-azure/storage-queues";`);
163
+ if (answers.queue === "azure-service-bus") imports.push(`import { createAzureServiceBusQueue } from "@storyshelf/queue-azure/service-bus";`);
164
+ if (answers.queue === "gcp-pubsub") imports.push(`import { createGcpPubSubQueue } from "@storyshelf/queue-gcp";`);
165
+ if (answers.auth === "password") imports.push(`import { createShelfAuth, ensurePasswordAdmin } from "@storyshelf/auth";`);
166
+ if (answers.auth === "oauth") {
167
+ imports.push(`import { createShelfAuth } from "@storyshelf/auth";`);
168
+ if (resolveDeployTarget(answers) === "aws") imports.push(`import { cognitoPreset } from "@storyshelf/auth";`);
169
+ else imports.push(`import { keycloakPreset } from "@storyshelf/auth";`);
170
+ }
55
171
  if (answers.git !== "none") {
56
172
  const host = answers.git === "github" ? "gitHubHost" : "gitLabHost";
57
173
  imports.push(`import { ${host} } from "${GIT_PACKAGE[answers.git]}";`);
58
174
  }
59
- imports.push(`import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";`);
175
+ if (wantsNotifications(answers)) {
176
+ imports.push(`import { chatNotifiers } from "@storyshelf/notify-chat";`, `import { createEmailNotifier, smtpPresetFromEnv } from "@storyshelf/notify-email";`);
177
+ if (answers.auth !== "none") imports.push(`import { createAuthSystemHook } from "@storyshelf/app";`);
178
+ }
179
+ if (answers.queue === "memory") imports.push(`import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";`);
60
180
  return imports;
61
181
  }
62
182
  function buildAdapterLines(answers) {
63
- return [
183
+ const lines = [
64
184
  `// Adapters — swap these for your deployment`,
185
+ ...dbPrelude(answers.database),
65
186
  `const database = ${DB_INIT[answers.database]};`,
66
- `const storage = ${STORAGE_INIT[answers.storage]};`,
67
- `const captureRunner = createPlaywrightCaptureRunner();`,
68
- `const logger = createShelfLogger({ level: process.env.LOG_LEVEL });`
187
+ `const storage = ${STORAGE_INIT[answers.storage]};`
69
188
  ];
189
+ if (answers.queue === "sqs") lines.push(`const captureQueue = createSqsCaptureQueue({ queueUrl: process.env.QUEUE_URL! });`);
190
+ if (answers.queue === "redis") lines.push(`const captureQueue = createRedisCaptureQueue({ url: process.env.REDIS_URL! });`);
191
+ if (answers.queue === "azure-storage-queues") lines.push(`const captureQueue = createAzureStorageQueuesQueue({ queueName: "capture-jobs", connectionString: process.env.AZURE_STORAGE_CONNECTION! });`);
192
+ if (answers.queue === "azure-service-bus") lines.push(`const captureQueue = createAzureServiceBusQueue({ queueName: "capture-jobs", connectionString: process.env.AZURE_SERVICE_BUS_CONNECTION! });`);
193
+ if (answers.queue === "gcp-pubsub") lines.push(`const captureQueue = createGcpPubSubQueue({ topic: "capture-jobs", subscription: "capture-jobs-worker", projectId: process.env.GOOGLE_CLOUD_PROJECT! });`);
194
+ 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 })] : [];`);
196
+ return lines;
70
197
  }
71
198
  function buildRouterLines(answers) {
72
199
  const lines = [
73
- `const app = createShelfRouter({`,
200
+ `// Observability (noop unless OTEL_EXPORTER_OTLP_ENDPOINT is set).`,
201
+ `const observability = await initObservabilityFromEnv();`,
202
+ `const shelfLogger = createShelfLogger({ mixin: otelLogMixin });`,
203
+ ``,
204
+ `const app = createShelfApp({`,
74
205
  ` database,`,
75
- ` storage,`,
76
- ` captureRunner,`,
77
- ` logger,`
206
+ ` storage,`
78
207
  ];
79
- if (answers.auth !== "none") lines.push(` auth: createPasswordAuth({ password: process.env.AUTH_PASSWORD! }),`);
208
+ if (isRemoteQueue(answers.queue)) lines.push(` captureQueue,`);
209
+ else lines.push(` captureRunner,`);
210
+ 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
+ }
215
+ 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
+ ` emailSender,`,
217
+ ` fromEmail: process.env.SMTP_FROM,`,
218
+ ` onAuthSystemEvent: createAuthSystemHook(),`
219
+ ] : [], ` social: [`, ` cognitoPreset({`, ` domain: process.env.COGNITO_DOMAIN!,`, ` region: process.env.COGNITO_REGION!,`, ` userPoolId: process.env.COGNITO_USER_POOL_ID!,`, ` clientId: process.env.OIDC_CLIENT_ID!,`, ` clientSecret: process.env.OIDC_CLIENT_SECRET,`, ` }),`, ` ],`, ` }).adapter,`);
220
+ 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) ? [
221
+ ` emailSender,`,
222
+ ` fromEmail: process.env.SMTP_FROM,`,
223
+ ` onAuthSystemEvent: createAuthSystemHook(),`
224
+ ] : [], ` oauth: [`, ` keycloakPreset(process.env.OIDC_ISSUER!, {`, ` clientId: process.env.OIDC_CLIENT_ID!,`, ` clientSecret: process.env.OIDC_CLIENT_SECRET,`, ` }),`, ` ],`, ` }).adapter,`);
80
225
  if (answers.git !== "none") {
81
226
  const host = answers.git === "github" ? "gitHubHost" : "gitLabHost";
82
227
  lines.push(` gitHosts: [${host}],`);
83
228
  }
84
- lines.push(` config: {`, ` secret: process.env.SECRET,`, ` scratchDir: dataDir,`, ` },`, `});`, ``, `await app.lifecycle.init();`);
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!,`, `});`);
85
233
  return lines;
86
234
  }
87
235
  function generateServer(answers) {
@@ -100,7 +248,7 @@ function generateServer(answers) {
100
248
  `});`,
101
249
  ``,
102
250
  `const shutdown = async () => {`,
103
- ` await app.lifecycle.close();`,
251
+ ` await app.lifecycle.teardown();`,
104
252
  ` server.close();`,
105
253
  `};`,
106
254
  `process.on("SIGTERM", () => {`,
@@ -112,19 +260,122 @@ function generateServer(answers) {
112
260
  ``
113
261
  ].join("\n");
114
262
  }
263
+ function workerQueueImport(queue) {
264
+ if (queue === "redis") return `import { createRedisCaptureQueue } from "@storyshelf/queue-redis";`;
265
+ if (queue === "azure-storage-queues") return `import { createAzureStorageQueuesQueue } from "@storyshelf/queue-azure/storage-queues";`;
266
+ if (queue === "azure-service-bus") return `import { createAzureServiceBusQueue } from "@storyshelf/queue-azure/service-bus";`;
267
+ if (queue === "gcp-pubsub") return `import { createGcpPubSubQueue } from "@storyshelf/queue-gcp";`;
268
+ return `import { createSqsCaptureQueue } from "@storyshelf/queue-sqs";`;
269
+ }
270
+ function workerQueueInit(queue) {
271
+ if (queue === "redis") return `const queue = createRedisCaptureQueue({ url: process.env.REDIS_URL! });`;
272
+ if (queue === "azure-storage-queues") return `const queue = createAzureStorageQueuesQueue({ queueName: "capture-jobs", connectionString: process.env.AZURE_STORAGE_CONNECTION! });`;
273
+ if (queue === "azure-service-bus") return `const queue = createAzureServiceBusQueue({ queueName: "capture-jobs", connectionString: process.env.AZURE_SERVICE_BUS_CONNECTION! });`;
274
+ if (queue === "gcp-pubsub") return `const queue = createGcpPubSubQueue({ topic: "capture-jobs", subscription: "capture-jobs-worker", projectId: process.env.GOOGLE_CLOUD_PROJECT! });`;
275
+ return `const queue = createSqsCaptureQueue({ queueUrl: process.env.QUEUE_URL! });`;
276
+ }
277
+ function generateWorkerFile(answers) {
278
+ const queueImport = workerQueueImport(answers.queue);
279
+ const queueInit = workerQueueInit(answers.queue);
280
+ return [
281
+ queueImport,
282
+ `import { createCaptureWorker } from "@storyshelf/worker";`,
283
+ `import { initObservabilityFromEnv } from "@storyshelf/observability/node";`,
284
+ `import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";`,
285
+ DB_IMPORT[answers.database],
286
+ STORAGE_IMPORT[answers.storage],
287
+ ``,
288
+ `// Observability (noop unless OTEL_EXPORTER_OTLP_ENDPOINT is set).`,
289
+ `const observability = await initObservabilityFromEnv();`,
290
+ ``,
291
+ `const dataDir = process.env.DATA_DIR || "./data";`,
292
+ ...dbPrelude(answers.database),
293
+ `const database = ${DB_INIT[answers.database]};`,
294
+ `const storage = ${STORAGE_INIT[answers.storage]};`,
295
+ queueInit,
296
+ `const runner = createPlaywrightCaptureRunner();`,
297
+ ``,
298
+ `const worker = createCaptureWorker({`,
299
+ ` queue,`,
300
+ ` db: database,`,
301
+ ` storage,`,
302
+ ` runner,`,
303
+ ` scratchDir: dataDir,`,
304
+ ` config: { concurrency: Number(process.env.WORKER_CONCURRENCY) || 2 },`,
305
+ `});`,
306
+ ``,
307
+ `await worker.start();`,
308
+ ``,
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(() => {}); });`,
315
+ ``
316
+ ].join("\n");
317
+ }
115
318
  function buildDeps(answers) {
116
319
  const deps = {
117
320
  "@hono/node-server": "^1.17.0",
118
- "@storyshelf/core": "0.3.2",
119
- "@storyshelf/router": "0.3.2",
120
- [DB_PACKAGE[answers.database]]: "0.3.2",
121
- "@storyshelf/runner-playwright": "0.3.2"
321
+ "@storyshelf/core": "0.5.4",
322
+ "@storyshelf/app": "0.5.4",
323
+ "@storyshelf/observability": "0.5.4",
324
+ [DB_PACKAGE[answers.database]]: "0.5.4"
122
325
  };
123
- if (answers.storage !== "local") deps[STORAGE_PACKAGE[answers.storage]] = "0.3.2";
124
- if (answers.auth !== "none") deps[AUTH_PACKAGE[answers.auth] ?? ""] = "0.3.2";
125
- if (answers.git !== "none") deps[GIT_PACKAGE[answers.git] ?? ""] = "0.3.2";
326
+ if (answers.database === "turso") deps["@libsql/client"] = LIBSQL_CLIENT_SDK;
327
+ if (answers.database === "better-sqlite3") deps["better-sqlite3"] = BETTER_SQLITE3_SDK;
328
+ if (answers.database === "pg") deps["pg"] = PG_SDK;
329
+ if (answers.database === "neon" || answers.database === "neon-http") deps["@neondatabase/serverless"] = NEON_SDK;
330
+ if (answers.database === "vercel") deps["@vercel/postgres"] = VERCEL_POSTGRES_SDK;
331
+ if (answers.database === "pglite") deps["@electric-sql/pglite"] = PGLITE_SDK;
332
+ if (answers.database === "mysql") deps["mysql2"] = MYSQL2_SDK;
333
+ if (answers.database === "planetscale") deps["@planetscale/database"] = PLANETSCALE_SDK;
334
+ if (answers.database === "tidb") deps["@tidbcloud/serverless"] = TIDBCLOUD_SDK;
335
+ if (answers.storage !== "local") deps[STORAGE_PACKAGE[answers.storage]] = "0.5.4";
336
+ if (answers.queue === "sqs" || answers.queue === "redis") {
337
+ const queuePkg = answers.queue === "redis" ? "@storyshelf/queue-redis" : "@storyshelf/queue-sqs";
338
+ deps[queuePkg] = "0.5.4";
339
+ if (answers.includeWorker) {
340
+ deps["@storyshelf/worker"] = "0.5.4";
341
+ deps["@storyshelf/runner-playwright"] = "0.5.4";
342
+ }
343
+ } else if (answers.queue === "azure-storage-queues" || answers.queue === "azure-service-bus") {
344
+ deps["@storyshelf/queue-azure"] = "0.5.4";
345
+ const sdk = azureSdkDep(answers.queue);
346
+ deps[sdk.name] = sdk.version;
347
+ if (answers.includeWorker) {
348
+ deps["@storyshelf/worker"] = "0.5.4";
349
+ deps["@storyshelf/runner-playwright"] = "0.5.4";
350
+ }
351
+ } else if (answers.queue === "gcp-pubsub") {
352
+ deps["@storyshelf/queue-gcp"] = "0.5.4";
353
+ deps["@google-cloud/pubsub"] = GCP_PUBSUB_SDK;
354
+ if (answers.includeWorker) {
355
+ deps["@storyshelf/worker"] = "0.5.4";
356
+ deps["@storyshelf/runner-playwright"] = "0.5.4";
357
+ }
358
+ } else deps["@storyshelf/runner-playwright"] = "0.5.4";
359
+ if (answers.auth !== "none") deps[AUTH_PACKAGE[answers.auth] ?? ""] = "0.5.4";
360
+ if (answers.git !== "none") deps[GIT_PACKAGE[answers.git] ?? ""] = "0.5.4";
361
+ if (wantsNotifications(answers)) {
362
+ deps["@storyshelf/notify-chat"] = "0.5.4";
363
+ deps["@storyshelf/notify-email"] = "0.5.4";
364
+ deps["nodemailer"] = NODEMAILER_SDK;
365
+ }
366
+ if (answers.queue !== "memory") {
367
+ const queuePkg = QUEUE_PACKAGE[answers.queue];
368
+ if (queuePkg) deps[queuePkg] = "0.5.4";
369
+ }
126
370
  return deps;
127
371
  }
372
+ function addInfraScripts(scripts) {
373
+ scripts["infra:init"] = "terraform -chdir=terraform init";
374
+ scripts["infra:plan"] = "terraform -chdir=terraform plan -out=tfplan";
375
+ scripts["infra:apply"] = "terraform -chdir=terraform apply tfplan";
376
+ scripts["infra:destroy"] = "terraform -chdir=terraform destroy";
377
+ scripts["infra:outputs"] = "terraform -chdir=terraform output -json";
378
+ }
128
379
  function generatePackageJson(answers) {
129
380
  const pkg = {
130
381
  name: answers.name,
@@ -139,50 +390,249 @@ function generatePackageJson(answers) {
139
390
  dependencies: buildDeps(answers),
140
391
  devDependencies: { typescript: "^7.0.2" }
141
392
  };
393
+ 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";
403
+ }
404
+ if (resolveDeployTarget(answers) === "aws" || resolveDeployTarget(answers) === "azure" || resolveDeployTarget(answers) === "gcp") addInfraScripts(pkg.scripts);
142
405
  return JSON.stringify(pkg, null, 2);
143
406
  }
144
407
  async function writeFiles(outDir, answers) {
145
408
  const serverCode = generateServer(answers);
146
409
  await writeFile(join(outDir, "server.ts"), serverCode);
147
410
  printLine(`Created server.ts`);
411
+ if (answers.includeWorker) {
412
+ const workerCode = generateWorkerFile(answers);
413
+ await writeFile(join(outDir, "worker.ts"), workerCode);
414
+ printLine(`Created worker.ts`);
415
+ }
148
416
  const pkgCode = generatePackageJson(answers);
149
417
  await writeFile(join(outDir, "package.json"), pkgCode);
150
418
  printLine(`Created package.json`);
151
- await writeDockerFiles(outDir, answers.docker);
419
+ if (resolveDeployTarget(answers) === "aws") await writeTerraformFiles(outDir, answers);
420
+ if (resolveDeployTarget(answers) === "azure") await writeAzureTerraformFiles(outDir, answers);
421
+ if (resolveDeployTarget(answers) === "gcp") await writeGcpTerraformFiles(outDir, answers);
422
+ await writeDockerFiles(outDir, answers);
423
+ }
424
+ /** Write the GCP Terraform reference stack (`terraform/*.tf` + README). */
425
+ async function writeGcpTerraformFiles(outDir, answers) {
426
+ const files = generateGcpTerraformFiles({
427
+ project: answers.name,
428
+ gcpProjectId: answers.gcpProjectId ?? "",
429
+ location: answers.gcpLocation ?? "us-central1",
430
+ ...answers.domainName ? { domainName: answers.domainName } : {},
431
+ ...answers.identityTenant ? { identityTenant: answers.identityTenant } : {}
432
+ });
433
+ await mkdir(join(outDir, "terraform"), { recursive: true });
434
+ for (const [relative, contents] of Object.entries(files)) await writeFile(join(outDir, relative), contents);
435
+ printLine(`Created terraform/ (${Object.keys(files).length} files)`);
436
+ }
437
+ /** Write the Azure Terraform reference stack (`terraform/*.tf` + README). */
438
+ async function writeAzureTerraformFiles(outDir, answers) {
439
+ const files = generateAzureTerraformFiles({
440
+ project: answers.name,
441
+ location: answers.azureLocation ?? "eastus",
442
+ queueBackend: answers.azureQueueBackend ?? "storage-queues",
443
+ ...answers.domainName ? { domainName: answers.domainName } : {},
444
+ ...answers.entraTenantId ? { entraTenantId: answers.entraTenantId } : {}
445
+ });
446
+ await mkdir(join(outDir, "terraform"), { recursive: true });
447
+ for (const [relative, contents] of Object.entries(files)) await writeFile(join(outDir, relative), contents);
448
+ printLine(`Created terraform/ (${Object.keys(files).length} files)`);
152
449
  }
153
- async function writeDockerFiles(outDir, docker) {
154
- if (!docker) return;
450
+ /** Write the AWS Terraform reference stack (`terraform/*.tf` + README). */
451
+ async function writeTerraformFiles(outDir, answers) {
452
+ const files = generateAwsTerraformFiles({
453
+ project: answers.name,
454
+ region: answers.awsRegion ?? "us-east-1",
455
+ dbEngine: answers.dbEngine ?? "rds",
456
+ ...answers.domainName ? { domainName: answers.domainName } : {},
457
+ ...answers.samlMetadataUrl ? { samlMetadataUrl: answers.samlMetadataUrl } : {}
458
+ });
459
+ await mkdir(join(outDir, "terraform"), { recursive: true });
460
+ for (const [relative, contents] of Object.entries(files)) await writeFile(join(outDir, relative), contents);
461
+ printLine(`Created terraform/ (${Object.keys(files).length} files)`);
462
+ }
463
+ async function writeDockerFiles(outDir, answers) {
464
+ 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);
477
+ 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;
482
+ }
155
483
  await writeFile(join(outDir, "Dockerfile"), generateDockerfile());
156
484
  await writeFile(join(outDir, ".dockerignore"), generateDockerignore());
157
- await writeFile(join(outDir, "compose.yaml"), generateComposeYaml());
485
+ await writeFile(join(outDir, "compose.yaml"), generateComposeYaml(answers.database));
158
486
  printLine(`Created Dockerfile, .dockerignore, compose.yaml`);
159
487
  }
160
- function printNextSteps(answers) {
488
+ function printNextSteps(answers, runner) {
161
489
  printLine(`\nScaffolded ${answers.name} in ${resolve(answers.dir)}`);
162
490
  printLine(`\nNext steps:`);
163
491
  printLine(` cd ${answers.dir}`);
164
- if (answers.docker) printLine(` docker compose up`);
165
- else {
492
+ const target = resolveDeployTarget(answers);
493
+ if (target === "aws") {
494
+ printLine(` terraform -chdir=terraform init`);
495
+ printLine(` terraform -chdir=terraform plan -out=tfplan # review before applying`);
496
+ printLine(` terraform -chdir=terraform apply tfplan # or: npm run infra:apply`);
497
+ printLine(` # wire outputs into env (terraform output -json / npm run infra:outputs):`);
498
+ printLine(` # S3_BUCKET / AWS_REGION, QUEUE_URL, DATABASE_URL, COGNITO_* credentials`);
499
+ printLine(` npm install`);
500
+ printLine(` npm start`);
501
+ return;
502
+ }
503
+ if (target === "azure") {
504
+ printLine(` az login`);
505
+ printLine(` terraform -chdir=terraform init`);
506
+ printLine(` terraform -chdir=terraform plan -out=tfplan # review before applying`);
507
+ printLine(` terraform -chdir=terraform apply tfplan # or: npm run infra:apply`);
508
+ printLine(` # wire outputs into env (terraform output -json / npm run infra:outputs):`);
509
+ printLine(` # AZURE_STORAGE_CONNECTION (+ AZURE_SERVICE_BUS_CONNECTION), DATABASE_URL, OIDC_* credentials`);
166
510
  printLine(` npm install`);
167
511
  printLine(` npm start`);
512
+ return;
513
+ }
514
+ if (target === "gcp") {
515
+ printLine(` gcloud auth login`);
516
+ printLine(` terraform -chdir=terraform init`);
517
+ printLine(` terraform -chdir=terraform plan -out=tfplan # review before applying`);
518
+ printLine(` terraform -chdir=terraform apply tfplan # or: npm run infra:apply`);
519
+ printLine(` # wire outputs into env (terraform output -json / npm run infra:outputs):`);
520
+ printLine(` # GCS_BUCKET + GOOGLE_CLOUD_PROJECT, DATABASE_URL, OIDC_* credentials`);
521
+ printLine(` npm install`);
522
+ printLine(` npm start`);
523
+ return;
524
+ }
525
+ 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`);
532
+ }
533
+ return;
534
+ }
535
+ printLine(` ${installCommand(runner)}`);
536
+ printLine(` ${startCommand(runner)}`);
537
+ if (answers.includeWorker) {
538
+ printLine(` # in another terminal:`);
539
+ printLine(` npx storyshelf worker serve --dir .`);
168
540
  }
169
541
  }
542
+ function promptInitial(choices, value) {
543
+ if (value === void 0 || value === "") return;
544
+ const idx = choices.findIndex((c) => c.value === value);
545
+ if (idx === -1) return void 0;
546
+ return idx;
547
+ }
170
548
  /**
171
- * Scaffold a new StoryShelf server project.
549
+ * Scaffold a new StoryShelf server project via interactive prompts.
550
+ *
551
+ * Asks for project name, database, storage, auth, and git provider choices,
552
+ * then generates `server.ts`, `package.json`, and optional Docker files in
553
+ * the chosen directory.
172
554
  *
173
- * @param _options - Server init command options.
555
+ * @param _options - Reserved for future CLI flags; currently prompts for all choices interactively
174
556
  */
175
557
  async function runServerInit(_options) {
176
- const responses = await prompts([...PROJECT_PROMPTS, ...INFRA_PROMPTS]);
177
- if (!responses["name"] || !responses["dir"]) {
558
+ const cwdPkg = detectInstalledAdapters(process.cwd());
559
+ const phase1 = await prompts([...PROJECT_PROMPTS, DEPLOY_TARGET_PROMPT]) ?? {};
560
+ if (!phase1["name"] || !phase1["dir"]) {
178
561
  printError("Cancelled.");
179
562
  return;
180
563
  }
181
- const answers = responses;
564
+ const answers = { ...phase1 };
565
+ const target = resolveDeployTarget(answers);
566
+ if (target === "local" || target === "docker") {
567
+ const advPhase = await prompts(ADVANCED_ADAPTERS_PROMPT) ?? {};
568
+ const advanced = answers.advancedAdapters ?? advPhase["advancedAdapters"] ?? false;
569
+ answers.advancedAdapters = advanced;
570
+ const localPrompts = localInfraPrompts(advanced).map((prompt) => {
571
+ if (prompt.type !== "select") return prompt;
572
+ const legacy = answers[prompt.name];
573
+ const detected = cwdPkg[prompt.name];
574
+ const initial = promptInitial(prompt.choices, legacy ?? detected);
575
+ if (initial !== void 0) return {
576
+ ...prompt,
577
+ initial
578
+ };
579
+ return prompt;
580
+ });
581
+ const phase2 = await prompts(localPrompts) ?? {};
582
+ Object.assign(answers, phase2);
583
+ answers.advancedAdapters = advanced;
584
+ answers.deployTarget ??= target;
585
+ } else {
586
+ const legacyInfra = { ...phase1 };
587
+ Object.assign(answers, legacyInfra);
588
+ }
589
+ if (!answers.queue) answers.queue = "memory";
590
+ if (resolveDeployTarget(answers) === "aws") {
591
+ answers.database = "postgres";
592
+ answers.storage = "s3";
593
+ answers.queue = "sqs";
594
+ answers.includeWorker = true;
595
+ answers.docker = true;
596
+ const awsAnswers = await prompts(AWS_INFRA_PROMPTS) ?? {};
597
+ answers.awsRegion = awsAnswers["awsRegion"] ?? "us-east-1";
598
+ answers.dbEngine = awsAnswers["dbEngine"] ?? "rds";
599
+ answers.domainName = awsAnswers["domainName"] ?? void 0;
600
+ answers.samlMetadataUrl = awsAnswers["samlMetadataUrl"] ?? void 0;
601
+ }
602
+ if (resolveDeployTarget(answers) === "azure") {
603
+ answers.database = "postgres";
604
+ answers.storage = "azure";
605
+ answers.includeWorker = true;
606
+ answers.docker = true;
607
+ const azureAnswers = await prompts(AZURE_INFRA_PROMPTS) ?? {};
608
+ answers.azureLocation = azureAnswers["azureLocation"] ?? "eastus";
609
+ answers.azureQueueBackend = azureAnswers["azureQueueBackend"] ?? "storage-queues";
610
+ answers.queue = answers.azureQueueBackend === "service-bus" ? "azure-service-bus" : "azure-storage-queues";
611
+ answers.domainName = azureAnswers["domainName"] ?? void 0;
612
+ answers.entraTenantId = azureAnswers["entraTenantId"] ?? void 0;
613
+ }
614
+ if (resolveDeployTarget(answers) === "gcp") {
615
+ answers.database = "postgres";
616
+ answers.storage = "gcs";
617
+ answers.queue = "gcp-pubsub";
618
+ answers.includeWorker = true;
619
+ answers.docker = true;
620
+ const gcpAnswers = await prompts(GCP_INFRA_PROMPTS) ?? {};
621
+ answers.gcpProjectId = gcpAnswers["gcpProjectId"] ?? void 0;
622
+ answers.gcpLocation = gcpAnswers["gcpLocation"] ?? "us-central1";
623
+ answers.domainName = gcpAnswers["domainName"] ?? void 0;
624
+ answers.identityTenant = gcpAnswers["identityTenant"] ?? void 0;
625
+ }
626
+ if (resolveDeployTarget(answers) !== "aws" && resolveDeployTarget(answers) !== "azure" && resolveDeployTarget(answers) !== "gcp" && isRemoteQueue(answers.queue)) answers.includeWorker = (await prompts({
627
+ type: "confirm",
628
+ name: "includeWorker",
629
+ message: "Generate worker service alongside server?",
630
+ initial: true
631
+ }) ?? {})["includeWorker"] ?? true;
182
632
  const outDir = resolve(answers.dir);
183
633
  await mkdir(outDir, { recursive: true });
184
634
  await writeFiles(outDir, answers);
185
- printNextSteps(answers);
635
+ printNextSteps(answers, await detectPackageRunner(process.cwd()));
186
636
  }
187
637
  //#endregion
188
638
  export { runServerInit };