@beignet/cli 0.0.26 → 0.0.27

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +29 -10
  3. package/dist/choices.d.ts +8 -0
  4. package/dist/choices.d.ts.map +1 -1
  5. package/dist/choices.js +14 -0
  6. package/dist/choices.js.map +1 -1
  7. package/dist/db.js +2 -2
  8. package/dist/db.js.map +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +109 -13
  11. package/dist/index.js.map +1 -1
  12. package/dist/inspect.js +51 -3
  13. package/dist/inspect.js.map +1 -1
  14. package/dist/make/inbox.d.ts +63 -0
  15. package/dist/make/inbox.d.ts.map +1 -0
  16. package/dist/make/inbox.js +1747 -0
  17. package/dist/make/inbox.js.map +1 -0
  18. package/dist/make/payments.d.ts +61 -0
  19. package/dist/make/payments.d.ts.map +1 -0
  20. package/dist/make/payments.js +1900 -0
  21. package/dist/make/payments.js.map +1 -0
  22. package/dist/make/shared.d.ts +575 -0
  23. package/dist/make/shared.d.ts.map +1 -0
  24. package/dist/make/shared.js +2166 -0
  25. package/dist/make/shared.js.map +1 -0
  26. package/dist/make/tenancy.d.ts +101 -0
  27. package/dist/make/tenancy.d.ts.map +1 -0
  28. package/dist/make/tenancy.js +4409 -0
  29. package/dist/make/tenancy.js.map +1 -0
  30. package/dist/make.d.ts +8 -44
  31. package/dist/make.d.ts.map +1 -1
  32. package/dist/make.js +71 -3659
  33. package/dist/make.js.map +1 -1
  34. package/dist/mcp.d.ts.map +1 -1
  35. package/dist/mcp.js +44 -3
  36. package/dist/mcp.js.map +1 -1
  37. package/dist/provider-add.d.ts +19 -0
  38. package/dist/provider-add.d.ts.map +1 -0
  39. package/dist/provider-add.js +691 -0
  40. package/dist/provider-add.js.map +1 -0
  41. package/dist/registry-edits.d.ts +9 -0
  42. package/dist/registry-edits.d.ts.map +1 -1
  43. package/dist/registry-edits.js +32 -0
  44. package/dist/registry-edits.js.map +1 -1
  45. package/dist/task.d.ts +2 -0
  46. package/dist/task.d.ts.map +1 -1
  47. package/dist/task.js +2 -0
  48. package/dist/task.js.map +1 -1
  49. package/dist/templates/agents.js +2 -2
  50. package/dist/templates/server.d.ts.map +1 -1
  51. package/dist/templates/server.js +44 -37
  52. package/dist/templates/server.js.map +1 -1
  53. package/dist/templates/shared.d.ts +5 -0
  54. package/dist/templates/shared.d.ts.map +1 -1
  55. package/dist/templates/shared.js +5 -0
  56. package/dist/templates/shared.js.map +1 -1
  57. package/package.json +2 -2
  58. package/skills/app-structure/SKILL.md +1 -1
  59. package/src/choices.ts +28 -0
  60. package/src/db.ts +2 -2
  61. package/src/index.ts +170 -13
  62. package/src/inspect.ts +68 -2
  63. package/src/make/inbox.ts +2015 -0
  64. package/src/make/payments.ts +2182 -0
  65. package/src/make/shared.ts +3550 -0
  66. package/src/make/tenancy.ts +4809 -0
  67. package/src/make.ts +380 -5289
  68. package/src/mcp.ts +65 -2
  69. package/src/provider-add.ts +926 -0
  70. package/src/registry-edits.ts +47 -0
  71. package/src/task.ts +8 -10
  72. package/src/templates/agents.ts +2 -2
  73. package/src/templates/server.ts +44 -37
  74. package/src/templates/shared.ts +5 -0
  75. package/src/test-helpers/generated-app.ts +168 -0
@@ -0,0 +1,926 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { type ProviderPresetName, providerPresetChoices } from "./choices.js";
4
+ import {
5
+ directoryPath,
6
+ loadBeignetConfig,
7
+ type ResolvedBeignetConfig,
8
+ } from "./config.js";
9
+ import {
10
+ appendToArrayExpression,
11
+ appendToNamedArray,
12
+ arrayInitializerInfo,
13
+ identifiersFromArrayExpression,
14
+ insertAfterImports,
15
+ matchingDelimiterIndex,
16
+ } from "./registry-edits.js";
17
+ import { externalVersions, json } from "./templates/shared.js";
18
+
19
+ export type AddProviderPresetOptions = {
20
+ name: ProviderPresetName | string;
21
+ cwd?: string;
22
+ dryRun?: boolean;
23
+ };
24
+
25
+ export type AddProviderPresetResult = {
26
+ schemaVersion: 1;
27
+ name: ProviderPresetName;
28
+ targetDir: string;
29
+ dryRun: boolean;
30
+ files: string[];
31
+ createdFiles: string[];
32
+ updatedFiles: string[];
33
+ skippedFiles: string[];
34
+ nextSteps: string[];
35
+ };
36
+
37
+ type PackageJson = {
38
+ dependencies?: Record<string, string>;
39
+ devDependencies?: Record<string, string>;
40
+ [key: string]: unknown;
41
+ };
42
+
43
+ type AppPortUpdate = {
44
+ key: string;
45
+ typeName: string;
46
+ importName: string;
47
+ importFrom: string;
48
+ };
49
+
50
+ type ProviderPreset = {
51
+ name: ProviderPresetName;
52
+ displayName: string;
53
+ importName: string;
54
+ importFrom: string;
55
+ providerEntry: string;
56
+ dependencies: (packageJson: PackageJson) => Record<string, string>;
57
+ appPort: AppPortUpdate;
58
+ envVars: string[];
59
+ envExample: string;
60
+ docs: string;
61
+ nextSteps: string[];
62
+ conflictingProviderEntries?: string[];
63
+ };
64
+
65
+ type PlannedFile = {
66
+ path: string;
67
+ content: string;
68
+ result: "created" | "updated" | "skipped";
69
+ };
70
+
71
+ const presets: Record<ProviderPresetName, ProviderPreset> = {
72
+ "flags-openfeature": {
73
+ name: "flags-openfeature",
74
+ displayName: "OpenFeature flags",
75
+ importName: "openFeatureFlagsProvider",
76
+ importFrom: "@beignet/provider-flags-openfeature",
77
+ providerEntry: "openFeatureFlagsProvider",
78
+ dependencies: (packageJson) => ({
79
+ "@beignet/provider-flags-openfeature":
80
+ beignetDependencyVersion(packageJson),
81
+ "@openfeature/server-sdk": externalVersions.openFeatureServerSdk,
82
+ }),
83
+ appPort: {
84
+ key: "flags",
85
+ typeName: "FlagsPort",
86
+ importName: "FlagsPort",
87
+ importFrom: "@beignet/core/flags",
88
+ },
89
+ envVars: [],
90
+ envExample: "",
91
+ docs: [
92
+ "## OpenFeature flags",
93
+ "",
94
+ "- Package: `@beignet/provider-flags-openfeature`",
95
+ "- Peer dependency: `@openfeature/server-sdk`",
96
+ "- The preset wires `openFeatureFlagsProvider` in `server/providers.ts` and defers `flags` in `infra/app-ports.ts`.",
97
+ "- Configure the OpenFeature provider globally or replace this preset with `createOpenFeatureFlagsProvider(...)` when the app needs provider-specific setup.",
98
+ "- Use `ctx.ports.flags` in app code; `ctx.ports.openFeature` is available as an escape hatch.",
99
+ "",
100
+ ].join("\n"),
101
+ nextSteps: [
102
+ "Run your package manager install command.",
103
+ "Configure an OpenFeature provider before relying on non-default flag values.",
104
+ "Run beignet providers audit and beignet doctor --strict.",
105
+ ],
106
+ },
107
+ "mail-resend": {
108
+ name: "mail-resend",
109
+ displayName: "Resend mail",
110
+ importName: "mailResendProvider",
111
+ importFrom: "@beignet/provider-mail-resend",
112
+ providerEntry: "mailResendProvider",
113
+ dependencies: (packageJson) => ({
114
+ "@beignet/provider-mail-resend": beignetDependencyVersion(packageJson),
115
+ resend: externalVersions.resend,
116
+ }),
117
+ appPort: {
118
+ key: "mailer",
119
+ typeName: "MailerPort",
120
+ importName: "MailerPort",
121
+ importFrom: "@beignet/core/mail",
122
+ },
123
+ envVars: ["RESEND_API_KEY", "RESEND_FROM"],
124
+ envExample: [
125
+ "# Resend mail provider",
126
+ "RESEND_API_KEY=dev-placeholder-change-me",
127
+ "RESEND_FROM=Beignet App <noreply@example.com>",
128
+ "",
129
+ ].join("\n"),
130
+ docs: [
131
+ "## Resend mail",
132
+ "",
133
+ "- Package: `@beignet/provider-mail-resend`",
134
+ "- Peer dependency: `resend`",
135
+ "- The preset wires `mailResendProvider` in `server/providers.ts` and defers `mailer` in `infra/app-ports.ts`.",
136
+ "- `.env.example` ships dev placeholders so the app boots; set real `RESEND_API_KEY` and `RESEND_FROM` values before sending mail.",
137
+ "- Use `ctx.ports.mailer` in app code; `ctx.ports.resend` is available as an escape hatch.",
138
+ "",
139
+ ].join("\n"),
140
+ nextSteps: [
141
+ "Run your package manager install command.",
142
+ "Set RESEND_API_KEY and RESEND_FROM in .env.local or your deployment environment.",
143
+ "Run beignet providers audit and beignet doctor --strict.",
144
+ ],
145
+ conflictingProviderEntries: [
146
+ "mailSmtpProvider",
147
+ "createMemoryMailerProvider",
148
+ ],
149
+ },
150
+ "mail-smtp": {
151
+ name: "mail-smtp",
152
+ displayName: "SMTP mail",
153
+ importName: "mailSmtpProvider",
154
+ importFrom: "@beignet/provider-mail-smtp",
155
+ providerEntry: "mailSmtpProvider",
156
+ dependencies: (packageJson) => ({
157
+ "@beignet/provider-mail-smtp": beignetDependencyVersion(packageJson),
158
+ nodemailer: externalVersions.nodemailer,
159
+ }),
160
+ appPort: {
161
+ key: "mailer",
162
+ typeName: "MailerPort",
163
+ importName: "MailerPort",
164
+ importFrom: "@beignet/core/mail",
165
+ },
166
+ envVars: ["MAIL_HOST", "MAIL_PORT", "MAIL_USER", "MAIL_PASS", "MAIL_FROM"],
167
+ envExample: [
168
+ "# SMTP mail provider",
169
+ "MAIL_HOST=smtp.example.com",
170
+ "MAIL_PORT=587",
171
+ "MAIL_USER=dev-placeholder-change-me",
172
+ "MAIL_PASS=dev-placeholder-change-me",
173
+ "MAIL_FROM=Beignet App <noreply@example.com>",
174
+ "",
175
+ ].join("\n"),
176
+ docs: [
177
+ "## SMTP mail",
178
+ "",
179
+ "- Package: `@beignet/provider-mail-smtp`",
180
+ "- Peer dependency: `nodemailer`",
181
+ "- The preset wires `mailSmtpProvider` in `server/providers.ts` and defers `mailer` in `infra/app-ports.ts`.",
182
+ "- `.env.example` ships dev placeholders so the app boots; set real `MAIL_*` values before sending mail.",
183
+ "- Use `ctx.ports.mailer` in app code; `ctx.ports.smtp` is available as an escape hatch.",
184
+ "",
185
+ ].join("\n"),
186
+ nextSteps: [
187
+ "Run your package manager install command.",
188
+ "Set MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PASS, and MAIL_FROM in .env.local or your deployment environment.",
189
+ "Run beignet providers audit and beignet doctor --strict.",
190
+ ],
191
+ conflictingProviderEntries: [
192
+ "mailResendProvider",
193
+ "createMemoryMailerProvider",
194
+ ],
195
+ },
196
+ "search-meilisearch": {
197
+ name: "search-meilisearch",
198
+ displayName: "Meilisearch search",
199
+ importName: "meilisearchSearchProvider",
200
+ importFrom: "@beignet/provider-search-meilisearch",
201
+ providerEntry: "meilisearchSearchProvider",
202
+ dependencies: (packageJson) => ({
203
+ "@beignet/provider-search-meilisearch":
204
+ beignetDependencyVersion(packageJson),
205
+ }),
206
+ appPort: {
207
+ key: "search",
208
+ typeName: "SearchPort",
209
+ importName: "SearchPort",
210
+ importFrom: "@beignet/core/search",
211
+ },
212
+ envVars: ["MEILISEARCH_HOST"],
213
+ envExample: [
214
+ "# Meilisearch search provider",
215
+ "MEILISEARCH_HOST=http://localhost:7700",
216
+ "# MEILISEARCH_API_KEY=",
217
+ "# MEILISEARCH_INDEX_PREFIX=beignet",
218
+ "# MEILISEARCH_TIMEOUT_MS=5000",
219
+ "",
220
+ ].join("\n"),
221
+ docs: [
222
+ "## Meilisearch search",
223
+ "",
224
+ "- Package: `@beignet/provider-search-meilisearch`",
225
+ "- The preset wires `meilisearchSearchProvider` in `server/providers.ts` and defers `search` in `infra/app-ports.ts`.",
226
+ "- Set `MEILISEARCH_HOST` before booting the app; set `MEILISEARCH_API_KEY` when your Meilisearch server requires one.",
227
+ "- Use `ctx.ports.search` in app code; `ctx.ports.meilisearch` is available as an escape hatch.",
228
+ "",
229
+ ].join("\n"),
230
+ nextSteps: [
231
+ "Run your package manager install command.",
232
+ "Set MEILISEARCH_HOST and any needed MEILISEARCH_* values in .env.local or your deployment environment.",
233
+ "Run beignet providers audit and beignet doctor --strict.",
234
+ ],
235
+ },
236
+ sentry: {
237
+ name: "sentry",
238
+ displayName: "Sentry error reporting",
239
+ importName: "sentryErrorReportingProvider",
240
+ importFrom: "@beignet/provider-error-reporting-sentry",
241
+ providerEntry: "sentryErrorReportingProvider",
242
+ dependencies: (packageJson) => ({
243
+ "@beignet/provider-error-reporting-sentry":
244
+ beignetDependencyVersion(packageJson),
245
+ "@sentry/node": externalVersions.sentryNode,
246
+ }),
247
+ appPort: {
248
+ key: "errorReporter",
249
+ typeName: "ErrorReporterPort",
250
+ importName: "ErrorReporterPort",
251
+ importFrom: "@beignet/core/error-reporting",
252
+ },
253
+ envVars: ["SENTRY_DSN"],
254
+ envExample: ["# Sentry error reporting", "# SENTRY_DSN=", ""].join("\n"),
255
+ docs: [
256
+ "## Sentry error reporting",
257
+ "",
258
+ "- Package: `@beignet/provider-error-reporting-sentry`",
259
+ "- Peer dependency: `@sentry/node`",
260
+ "- The preset wires `sentryErrorReportingProvider` in `server/providers.ts`.",
261
+ "- Set `SENTRY_DSN` before deploying when captures should be sent to Sentry.",
262
+ "- Use `ctx.ports.errorReporter` in app code; `ctx.ports.sentry` is available as an escape hatch.",
263
+ "",
264
+ ].join("\n"),
265
+ nextSteps: [
266
+ "Run your package manager install command.",
267
+ "Set SENTRY_DSN in .env.local or your deployment environment.",
268
+ "Run beignet providers audit and beignet doctor --strict.",
269
+ ],
270
+ },
271
+ "redis-cache": {
272
+ name: "redis-cache",
273
+ displayName: "Redis cache",
274
+ importName: "redisProvider",
275
+ importFrom: "@beignet/provider-redis",
276
+ providerEntry: "redisProvider",
277
+ dependencies: (packageJson) => ({
278
+ "@beignet/provider-redis": beignetDependencyVersion(packageJson),
279
+ ioredis: externalVersions.ioredis,
280
+ }),
281
+ appPort: {
282
+ key: "cache",
283
+ typeName: "CachePort",
284
+ importName: "CachePort",
285
+ importFrom: "@beignet/core/ports",
286
+ },
287
+ envVars: ["REDIS_URL"],
288
+ envExample: [
289
+ "# Redis cache provider",
290
+ "REDIS_URL=redis://localhost:6379",
291
+ "# REDIS_DB=0",
292
+ "",
293
+ ].join("\n"),
294
+ docs: [
295
+ "## Redis cache",
296
+ "",
297
+ "- Package: `@beignet/provider-redis`",
298
+ "- Peer dependency: `ioredis`",
299
+ "- The preset wires `redisProvider` in `server/providers.ts` and defers `cache` in `infra/app-ports.ts`.",
300
+ "- Set `REDIS_URL` before booting the app.",
301
+ "- Use `ctx.ports.cache` in app code; `ctx.ports.redis` is available as an escape hatch.",
302
+ "",
303
+ ].join("\n"),
304
+ nextSteps: [
305
+ "Run your package manager install command.",
306
+ "Set REDIS_URL in .env.local or your deployment environment.",
307
+ "Run beignet providers audit and beignet doctor --strict.",
308
+ ],
309
+ },
310
+ "redis-locks": {
311
+ name: "redis-locks",
312
+ displayName: "Redis locks",
313
+ importName: "redisLocksProvider",
314
+ importFrom: "@beignet/provider-locks-redis",
315
+ providerEntry: "redisLocksProvider",
316
+ dependencies: (packageJson) => ({
317
+ "@beignet/provider-locks-redis": beignetDependencyVersion(packageJson),
318
+ ioredis: externalVersions.ioredis,
319
+ }),
320
+ appPort: {
321
+ key: "locks",
322
+ typeName: "LocksPort",
323
+ importName: "LocksPort",
324
+ importFrom: "@beignet/core/locks",
325
+ },
326
+ envVars: ["REDIS_LOCKS_URL"],
327
+ envExample: [
328
+ "# Redis locks provider",
329
+ "REDIS_LOCKS_URL=redis://localhost:6379",
330
+ "# REDIS_LOCKS_DB=0",
331
+ "# REDIS_LOCKS_PREFIX=beignet:locks",
332
+ "",
333
+ ].join("\n"),
334
+ docs: [
335
+ "## Redis locks",
336
+ "",
337
+ "- Package: `@beignet/provider-locks-redis`",
338
+ "- Peer dependency: `ioredis`",
339
+ "- The preset wires `redisLocksProvider` in `server/providers.ts` and defers `locks` in `infra/app-ports.ts`.",
340
+ "- Set `REDIS_LOCKS_URL` before booting the app.",
341
+ "- Use `ctx.ports.locks` in app code; `ctx.ports.redisLocks` is available as an escape hatch.",
342
+ "",
343
+ ].join("\n"),
344
+ nextSteps: [
345
+ "Run your package manager install command.",
346
+ "Set REDIS_LOCKS_URL in .env.local or your deployment environment.",
347
+ "Run beignet providers audit and beignet doctor --strict.",
348
+ ],
349
+ },
350
+ "s3-storage": {
351
+ name: "s3-storage",
352
+ displayName: "S3-compatible storage",
353
+ importName: "s3StorageProvider",
354
+ importFrom: "@beignet/provider-storage-s3",
355
+ providerEntry: "s3StorageProvider",
356
+ dependencies: (packageJson) => ({
357
+ "@beignet/provider-storage-s3": beignetDependencyVersion(packageJson),
358
+ "@aws-sdk/client-s3": externalVersions.awsSdkS3,
359
+ "@aws-sdk/s3-request-presigner": externalVersions.awsSdkS3,
360
+ }),
361
+ appPort: {
362
+ key: "storage",
363
+ typeName: "StoragePort",
364
+ importName: "StoragePort",
365
+ importFrom: "@beignet/core/storage",
366
+ },
367
+ envVars: ["STORAGE_S3_BUCKET"],
368
+ envExample: [
369
+ "# S3-compatible storage provider",
370
+ "STORAGE_S3_BUCKET=beignet-assets",
371
+ "STORAGE_S3_REGION=us-east-1",
372
+ "# STORAGE_S3_ENDPOINT=",
373
+ "# STORAGE_S3_PUBLIC_BASE_URL=",
374
+ "# STORAGE_S3_FORCE_PATH_STYLE=false",
375
+ "# STORAGE_S3_KEY_PREFIX=",
376
+ "# STORAGE_S3_ACCESS_KEY_ID=",
377
+ "# STORAGE_S3_SECRET_ACCESS_KEY=",
378
+ "# STORAGE_S3_SESSION_TOKEN=",
379
+ "",
380
+ ].join("\n"),
381
+ docs: [
382
+ "## S3-compatible storage",
383
+ "",
384
+ "- Package: `@beignet/provider-storage-s3`",
385
+ "- Peer dependencies: `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner`",
386
+ "- The preset wires `s3StorageProvider` in `server/providers.ts` and defers `storage` in `infra/app-ports.ts`.",
387
+ "- Set `STORAGE_S3_BUCKET` before booting the app; set endpoint and credentials when using an S3-compatible host outside AWS defaults.",
388
+ "- Use `ctx.ports.storage` in app code; `ctx.ports.s3Storage` is available as an escape hatch.",
389
+ "",
390
+ ].join("\n"),
391
+ nextSteps: [
392
+ "Run your package manager install command.",
393
+ "Set STORAGE_S3_BUCKET and any needed STORAGE_S3_* credentials in .env.local or your deployment environment.",
394
+ "Run beignet providers audit and beignet doctor --strict.",
395
+ ],
396
+ conflictingProviderEntries: [
397
+ "localStorageProvider",
398
+ "createLocalStorageProvider",
399
+ ],
400
+ },
401
+ "upstash-rate-limit": {
402
+ name: "upstash-rate-limit",
403
+ displayName: "Upstash rate limit",
404
+ importName: "upstashRateLimitProvider",
405
+ importFrom: "@beignet/provider-rate-limit-upstash",
406
+ providerEntry: "upstashRateLimitProvider",
407
+ dependencies: (packageJson) => ({
408
+ "@beignet/provider-rate-limit-upstash":
409
+ beignetDependencyVersion(packageJson),
410
+ "@upstash/ratelimit": externalVersions.upstashRateLimit,
411
+ "@upstash/redis": externalVersions.upstashRedis,
412
+ }),
413
+ appPort: {
414
+ key: "rateLimit",
415
+ typeName: "RateLimitPort",
416
+ importName: "RateLimitPort",
417
+ importFrom: "@beignet/core/ports",
418
+ },
419
+ envVars: ["UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN"],
420
+ envExample: [
421
+ "# Upstash rate limit provider",
422
+ "UPSTASH_REDIS_REST_URL=https://dev-placeholder.upstash.io",
423
+ "UPSTASH_REDIS_REST_TOKEN=dev-placeholder-change-me",
424
+ "UPSTASH_PREFIX=beignet:ratelimit",
425
+ "# UPSTASH_ALGORITHM=fixed-window",
426
+ "",
427
+ ].join("\n"),
428
+ docs: [
429
+ "## Upstash rate limit",
430
+ "",
431
+ "- Package: `@beignet/provider-rate-limit-upstash`",
432
+ "- Peer dependencies: `@upstash/ratelimit` and `@upstash/redis`",
433
+ "- The preset wires `upstashRateLimitProvider` in `server/providers.ts` and defers `rateLimit` in `infra/app-ports.ts`.",
434
+ "- `.env.example` ships dev placeholders so the app boots; set real `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` values before rate limiting requests.",
435
+ "- Use `ctx.ports.rateLimit` in app code; `ctx.ports.upstash` is available as an escape hatch.",
436
+ "",
437
+ ].join("\n"),
438
+ nextSteps: [
439
+ "Run your package manager install command.",
440
+ "Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN in .env.local or your deployment environment.",
441
+ "Run beignet providers audit and beignet doctor --strict.",
442
+ ],
443
+ },
444
+ };
445
+
446
+ const providerPresetSet = new Set<string>(providerPresetChoices);
447
+
448
+ export async function addProviderPreset(
449
+ options: AddProviderPresetOptions,
450
+ ): Promise<AddProviderPresetResult> {
451
+ if (!providerPresetSet.has(options.name)) {
452
+ throw new Error(
453
+ `Unknown provider preset "${options.name}". Available presets: ${providerPresetChoices.join(
454
+ ", ",
455
+ )}.`,
456
+ );
457
+ }
458
+
459
+ const preset = presets[options.name as ProviderPresetName];
460
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
461
+ const config = await loadBeignetConfig(targetDir);
462
+ const packageJsonPath = "package.json";
463
+ const providersPath = serverProvidersPath(config);
464
+ const integrationsPath = "docs/integrations.md";
465
+
466
+ const packageJsonSource = await readRequiredAppFile(
467
+ targetDir,
468
+ packageJsonPath,
469
+ );
470
+ const packageJson = parsePackageJson(packageJsonSource);
471
+ const envExampleSource = await readOptionalAppFile(targetDir, ".env.example");
472
+ const envExampleContent = updateEnvExample(envExampleSource, preset);
473
+ const filesToPlan = [
474
+ {
475
+ path: packageJsonPath,
476
+ content: updatePackageJson(packageJsonSource, packageJson, preset),
477
+ },
478
+ {
479
+ path: providersPath,
480
+ content: updateServerProviders(
481
+ await readRequiredAppFile(targetDir, providersPath),
482
+ preset,
483
+ ),
484
+ },
485
+ {
486
+ path: config.paths.ports,
487
+ content: updatePortsFile(
488
+ await readRequiredAppFile(targetDir, config.paths.ports),
489
+ preset.appPort,
490
+ ),
491
+ },
492
+ {
493
+ path: config.paths.infrastructurePorts,
494
+ content: updateInfrastructurePortsFile(
495
+ await readRequiredAppFile(targetDir, config.paths.infrastructurePorts),
496
+ preset.appPort.key,
497
+ ),
498
+ },
499
+ {
500
+ path: integrationsPath,
501
+ content: updateIntegrationsDoc(
502
+ await readOptionalAppFile(targetDir, integrationsPath),
503
+ preset,
504
+ ),
505
+ },
506
+ ];
507
+
508
+ if (envExampleContent !== undefined) {
509
+ filesToPlan.splice(filesToPlan.length - 1, 0, {
510
+ path: ".env.example",
511
+ content: envExampleContent,
512
+ });
513
+ }
514
+
515
+ const plannedFiles = await planFiles(targetDir, filesToPlan);
516
+
517
+ if (!options.dryRun) {
518
+ for (const file of plannedFiles) {
519
+ await writePlannedFile(targetDir, file);
520
+ }
521
+ }
522
+
523
+ return {
524
+ schemaVersion: 1,
525
+ name: preset.name,
526
+ targetDir,
527
+ dryRun: Boolean(options.dryRun),
528
+ files: plannedFiles.map((file) => file.path),
529
+ createdFiles: plannedFiles
530
+ .filter((file) => file.result === "created")
531
+ .map((file) => file.path),
532
+ updatedFiles: plannedFiles
533
+ .filter((file) => file.result === "updated")
534
+ .map((file) => file.path),
535
+ skippedFiles: plannedFiles
536
+ .filter((file) => file.result === "skipped")
537
+ .map((file) => file.path),
538
+ nextSteps: preset.nextSteps,
539
+ };
540
+ }
541
+
542
+ function beignetDependencyVersion(packageJson: PackageJson): string {
543
+ return (
544
+ packageJson.dependencies?.["@beignet/core"] ??
545
+ packageJson.dependencies?.["@beignet/next"] ??
546
+ packageJson.dependencies?.["@beignet/devtools"] ??
547
+ packageJson.devDependencies?.["@beignet/cli"] ??
548
+ "*"
549
+ );
550
+ }
551
+
552
+ function serverProvidersPath(config: ResolvedBeignetConfig): string {
553
+ return path.posix.join(
554
+ directoryPath(path.posix.dirname(config.paths.server)),
555
+ "providers.ts",
556
+ );
557
+ }
558
+
559
+ function parsePackageJson(source: string): PackageJson {
560
+ try {
561
+ const packageJson = JSON.parse(source) as PackageJson;
562
+ if (typeof packageJson !== "object" || packageJson === null) {
563
+ throw new Error("package.json must contain a JSON object.");
564
+ }
565
+ return packageJson;
566
+ } catch (error) {
567
+ if (error instanceof SyntaxError) {
568
+ throw new Error(`Invalid package.json: ${error.message}`);
569
+ }
570
+ throw error;
571
+ }
572
+ }
573
+
574
+ function updatePackageJson(
575
+ source: string,
576
+ packageJson: PackageJson,
577
+ preset: ProviderPreset,
578
+ ): string {
579
+ const dependencies = {
580
+ ...(packageJson.dependencies ?? {}),
581
+ };
582
+ let changed = false;
583
+
584
+ for (const [name, version] of Object.entries(
585
+ preset.dependencies(packageJson),
586
+ )) {
587
+ if (dependencies[name] === undefined) {
588
+ dependencies[name] = version;
589
+ changed = true;
590
+ }
591
+ }
592
+
593
+ if (!changed) return source;
594
+
595
+ return json({
596
+ ...packageJson,
597
+ dependencies: sortRecord(dependencies),
598
+ });
599
+ }
600
+
601
+ function updateServerProviders(source: string, preset: ProviderPreset): string {
602
+ assertNoConflictingProviders(source, preset);
603
+
604
+ const importLine = `import { ${preset.importName} } from "${preset.importFrom}";`;
605
+ const result = appendToNamedArray(
606
+ source,
607
+ "providers",
608
+ preset.providerEntry,
609
+ hasNamedImport(source, preset.importName, preset.importFrom)
610
+ ? undefined
611
+ : importLine,
612
+ );
613
+
614
+ if (result.kind === "missing") {
615
+ throw new Error(
616
+ "Could not find `export const providers = [...]` in server/providers.ts.",
617
+ );
618
+ }
619
+
620
+ return result.kind === "updated" ? result.source : source;
621
+ }
622
+
623
+ function assertNoConflictingProviders(
624
+ source: string,
625
+ preset: ProviderPreset,
626
+ ): void {
627
+ const registry = arrayInitializerInfo(source, "providers");
628
+ if (!registry) return;
629
+
630
+ const identifiers = identifiersFromArrayExpression(registry.text);
631
+ for (const conflict of preset.conflictingProviderEntries ?? []) {
632
+ if (identifiers.has(conflict)) {
633
+ throw new Error(
634
+ `Provider preset "${preset.name}" conflicts with existing provider ${conflict} in server/providers.ts. Remove or replace that provider before adding this preset.`,
635
+ );
636
+ }
637
+ }
638
+ }
639
+
640
+ function updatePortsFile(source: string, appPort: AppPortUpdate): string {
641
+ let next = ensureNamedTypeImport(
642
+ source,
643
+ appPort.importName,
644
+ appPort.importFrom,
645
+ );
646
+ next = ensureTypeProperty(next, "AppPorts", appPort.key, appPort.typeName);
647
+ return next;
648
+ }
649
+
650
+ function updateInfrastructurePortsFile(
651
+ source: string,
652
+ portKey: string,
653
+ ): string {
654
+ if (hasBoundPort(source, portKey)) return source;
655
+ return ensureDeferredPort(source, portKey);
656
+ }
657
+
658
+ function updateEnvExample(
659
+ source: string | undefined,
660
+ preset: ProviderPreset,
661
+ ): string | undefined {
662
+ if (preset.envVars.length === 0) {
663
+ return source;
664
+ }
665
+
666
+ const current = source ?? "";
667
+ if (preset.envVars.every((name) => hasEnvVar(current, name))) {
668
+ return current;
669
+ }
670
+
671
+ return appendSection(current, preset.envExample);
672
+ }
673
+
674
+ function updateIntegrationsDoc(
675
+ source: string | undefined,
676
+ preset: ProviderPreset,
677
+ ): string {
678
+ const current =
679
+ source ??
680
+ [
681
+ "# Integrations",
682
+ "",
683
+ "This file tracks app-level provider setup added after the initial scaffold.",
684
+ "",
685
+ ].join("\n");
686
+
687
+ if (current.includes(`## ${preset.displayName}`)) {
688
+ return current;
689
+ }
690
+
691
+ return appendSection(current, preset.docs);
692
+ }
693
+
694
+ function ensureNamedTypeImport(
695
+ source: string,
696
+ importName: string,
697
+ importFrom: string,
698
+ ): string {
699
+ if (hasNamedImport(source, importName, importFrom)) return source;
700
+
701
+ const importPattern = new RegExp(
702
+ `import\\s+type\\s+\\{([^}]*)\\}\\s+from\\s+["']${escapeRegExp(
703
+ importFrom,
704
+ )}["'];`,
705
+ );
706
+ const match = importPattern.exec(source);
707
+
708
+ if (!match) {
709
+ return insertAfterImports(
710
+ source,
711
+ `import type { ${importName} } from "${importFrom}";`,
712
+ );
713
+ }
714
+
715
+ const fullImport = match[0];
716
+ const imports = parseNamedImports(match[1]);
717
+ if (!imports.includes(importName)) imports.push(importName);
718
+ imports.sort((a, b) => a.localeCompare(b));
719
+
720
+ const replacement = fullImport.includes("\n")
721
+ ? `import type {\n${imports.map((name) => `\t${name},`).join("\n")}\n} from "${importFrom}";`
722
+ : `import type { ${imports.join(", ")} } from "${importFrom}";`;
723
+
724
+ return `${source.slice(0, match.index)}${replacement}${source.slice(
725
+ match.index + fullImport.length,
726
+ )}`;
727
+ }
728
+
729
+ function ensureTypeProperty(
730
+ source: string,
731
+ typeName: string,
732
+ key: string,
733
+ propertyType: string,
734
+ ): string {
735
+ const typeMatch = new RegExp(`export\\s+type\\s+${typeName}\\s*=`).exec(
736
+ source,
737
+ );
738
+ if (!typeMatch) {
739
+ throw new Error(
740
+ `Could not find \`export type ${typeName}\` in ports file.`,
741
+ );
742
+ }
743
+
744
+ const openBrace = source.indexOf("{", typeMatch.index);
745
+ if (openBrace === -1) {
746
+ throw new Error(
747
+ `Could not find object body for \`export type ${typeName}\`.`,
748
+ );
749
+ }
750
+
751
+ const closeBrace = matchingDelimiterIndex(source, openBrace, "{", "}");
752
+ if (closeBrace === -1) {
753
+ throw new Error(
754
+ `Could not parse object body for \`export type ${typeName}\`.`,
755
+ );
756
+ }
757
+
758
+ const body = source.slice(openBrace + 1, closeBrace);
759
+ if (new RegExp(`\\b${escapeRegExp(key)}\\s*:`).test(body)) {
760
+ return source;
761
+ }
762
+
763
+ const indentation = body.match(/\n([\t ]*)[A-Za-z_$]/)?.[1] ?? "\t";
764
+ const propertyLine = `${indentation}${key}: ${propertyType};\n`;
765
+ return `${source.slice(0, closeBrace)}${propertyLine}${source.slice(closeBrace)}`;
766
+ }
767
+
768
+ function ensureDeferredPort(source: string, portKey: string): string {
769
+ const match = /\bdeferred\s*:\s*\[/.exec(source);
770
+ if (!match) {
771
+ throw new Error("Could not find `deferred: [...]` in infra/app-ports.ts.");
772
+ }
773
+
774
+ const openBracket = source.indexOf("[", match.index);
775
+ const closeBracket = matchingDelimiterIndex(source, openBracket, "[", "]");
776
+ if (closeBracket === -1) {
777
+ throw new Error("Could not parse `deferred` array in infra/app-ports.ts.");
778
+ }
779
+
780
+ const arrayText = source.slice(openBracket, closeBracket + 1);
781
+ if (new RegExp(`["']${escapeRegExp(portKey)}["']`).test(arrayText)) {
782
+ return source;
783
+ }
784
+
785
+ const nextArray = appendToArrayExpression(arrayText, [`"${portKey}"`]);
786
+ return `${source.slice(0, openBracket)}${nextArray}${source.slice(
787
+ closeBracket + 1,
788
+ )}`;
789
+ }
790
+
791
+ function hasBoundPort(source: string, portKey: string): boolean {
792
+ const match = /\bbound\s*:\s*\{/.exec(source);
793
+ if (!match) return false;
794
+
795
+ const openBrace = source.indexOf("{", match.index);
796
+ const closeBrace = matchingDelimiterIndex(source, openBrace, "{", "}");
797
+ if (closeBrace === -1) return false;
798
+
799
+ const body = source.slice(openBrace + 1, closeBrace);
800
+ return new RegExp(`\\b${escapeRegExp(portKey)}\\s*:`).test(body);
801
+ }
802
+
803
+ function hasNamedImport(
804
+ source: string,
805
+ importName: string,
806
+ importFrom: string,
807
+ ): boolean {
808
+ const importPattern = new RegExp(
809
+ `import\\s+(?:type\\s+)?\\{([^}]*)\\}\\s+from\\s+["']${escapeRegExp(
810
+ importFrom,
811
+ )}["'];`,
812
+ "g",
813
+ );
814
+
815
+ for (const match of source.matchAll(importPattern)) {
816
+ if (parseNamedImports(match[1]).includes(importName)) return true;
817
+ }
818
+
819
+ return false;
820
+ }
821
+
822
+ function parseNamedImports(source: string): string[] {
823
+ return source
824
+ .split(",")
825
+ .map((entry) => entry.trim())
826
+ .filter(Boolean)
827
+ .map((entry) =>
828
+ entry
829
+ .replace(/^type\s+/, "")
830
+ .split(/\s+as\s+/)[0]
831
+ .trim(),
832
+ )
833
+ .filter(Boolean);
834
+ }
835
+
836
+ function hasEnvVar(source: string, name: string): boolean {
837
+ return new RegExp(`^#?\\s*${escapeRegExp(name)}\\s*=`, "m").test(source);
838
+ }
839
+
840
+ function appendSection(source: string, section: string): string {
841
+ const trimmedSource = source.replace(/\s*$/, "");
842
+ const trimmedSection = section.replace(/\s*$/, "");
843
+
844
+ if (!trimmedSource) return `${trimmedSection}\n`;
845
+ return `${trimmedSource}\n\n${trimmedSection}\n`;
846
+ }
847
+
848
+ function sortRecord(record: Record<string, string>): Record<string, string> {
849
+ return Object.fromEntries(
850
+ Object.entries(record).sort(([left], [right]) => left.localeCompare(right)),
851
+ );
852
+ }
853
+
854
+ async function planFiles(
855
+ targetDir: string,
856
+ files: Array<{ path: string; content: string }>,
857
+ ): Promise<PlannedFile[]> {
858
+ const planned: PlannedFile[] = [];
859
+
860
+ for (const file of files) {
861
+ const existing = await readOptionalAppFile(targetDir, file.path);
862
+ planned.push({
863
+ ...file,
864
+ result:
865
+ existing === file.content
866
+ ? "skipped"
867
+ : existing === undefined
868
+ ? "created"
869
+ : "updated",
870
+ });
871
+ }
872
+
873
+ return planned;
874
+ }
875
+
876
+ async function writePlannedFile(
877
+ targetDir: string,
878
+ file: PlannedFile,
879
+ ): Promise<void> {
880
+ if (file.result === "skipped") return;
881
+
882
+ const destination = path.join(targetDir, file.path);
883
+ await mkdir(path.dirname(destination), { recursive: true });
884
+ await writeFile(destination, file.content);
885
+ }
886
+
887
+ async function readRequiredAppFile(
888
+ targetDir: string,
889
+ filePath: string,
890
+ ): Promise<string> {
891
+ const content = await readOptionalAppFile(targetDir, filePath);
892
+ if (content === undefined) {
893
+ throw new Error(
894
+ `Could not find ${filePath}. Run this command in a Beignet app.`,
895
+ );
896
+ }
897
+ return content;
898
+ }
899
+
900
+ async function readOptionalAppFile(
901
+ targetDir: string,
902
+ filePath: string,
903
+ ): Promise<string | undefined> {
904
+ const absolutePath = path.join(targetDir, filePath);
905
+
906
+ try {
907
+ await stat(absolutePath);
908
+ return await readFile(absolutePath, "utf8");
909
+ } catch (error) {
910
+ if (isNotFoundError(error)) return undefined;
911
+ throw error;
912
+ }
913
+ }
914
+
915
+ function isNotFoundError(error: unknown): boolean {
916
+ return (
917
+ typeof error === "object" &&
918
+ error !== null &&
919
+ "code" in error &&
920
+ error.code === "ENOENT"
921
+ );
922
+ }
923
+
924
+ function escapeRegExp(value: string): string {
925
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
926
+ }