@alfredmouelle/create-stack 0.1.2 → 0.2.0

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 (64) hide show
  1. package/README.md +42 -14
  2. package/_stack/packages/analytics/capability.json +26 -0
  3. package/_stack/packages/analytics/package.json +26 -0
  4. package/_stack/packages/analytics/src/adapters/noop/index.ts +12 -0
  5. package/_stack/packages/analytics/src/adapters/plausible/config.ts +10 -0
  6. package/_stack/packages/analytics/src/adapters/plausible/index.ts +94 -0
  7. package/_stack/packages/analytics/src/adapters/posthog/config.ts +7 -0
  8. package/_stack/packages/analytics/src/adapters/posthog/index.ts +50 -0
  9. package/_stack/packages/analytics/src/core/port.ts +30 -0
  10. package/_stack/packages/analytics/src/index.ts +17 -0
  11. package/_stack/packages/cache/capability.json +21 -0
  12. package/_stack/packages/cache/package.json +25 -0
  13. package/_stack/packages/cache/src/adapters/memory/index.ts +51 -0
  14. package/_stack/packages/cache/src/adapters/redis/config.ts +8 -0
  15. package/_stack/packages/cache/src/adapters/redis/index.ts +73 -0
  16. package/_stack/packages/cache/src/core/port.ts +29 -0
  17. package/_stack/packages/cache/src/core/wrap.ts +20 -0
  18. package/_stack/packages/cache/src/index.ts +12 -0
  19. package/_stack/packages/error-tracking/capability.json +21 -0
  20. package/_stack/packages/error-tracking/package.json +25 -0
  21. package/_stack/packages/error-tracking/src/adapters/console/index.ts +43 -0
  22. package/_stack/packages/error-tracking/src/adapters/sentry/config.ts +8 -0
  23. package/_stack/packages/error-tracking/src/adapters/sentry/index.ts +72 -0
  24. package/_stack/packages/error-tracking/src/core/port.ts +39 -0
  25. package/_stack/packages/error-tracking/src/index.ts +14 -0
  26. package/_stack/packages/http/package.json +20 -0
  27. package/_stack/packages/http/src/api.ts +373 -0
  28. package/_stack/packages/http/src/index.ts +14 -0
  29. package/_stack/packages/http/src/responses.ts +25 -0
  30. package/_stack/packages/http/src/types.ts +9 -0
  31. package/_stack/packages/jobs/capability.json +26 -0
  32. package/_stack/packages/jobs/package.json +27 -0
  33. package/_stack/packages/jobs/src/adapters/inngest/config.ts +8 -0
  34. package/_stack/packages/jobs/src/adapters/inngest/index.ts +93 -0
  35. package/_stack/packages/jobs/src/adapters/memory/index.ts +31 -0
  36. package/_stack/packages/jobs/src/adapters/trigger/config.ts +8 -0
  37. package/_stack/packages/jobs/src/adapters/trigger/index.ts +85 -0
  38. package/_stack/packages/jobs/src/core/port.ts +37 -0
  39. package/_stack/packages/jobs/src/index.ts +23 -0
  40. package/_stack/packages/logger/capability.json +21 -0
  41. package/_stack/packages/logger/package.json +25 -0
  42. package/_stack/packages/logger/src/adapters/console/config.ts +7 -0
  43. package/_stack/packages/logger/src/adapters/console/index.ts +69 -0
  44. package/_stack/packages/logger/src/adapters/pino/index.ts +54 -0
  45. package/_stack/packages/logger/src/core/port.ts +21 -0
  46. package/_stack/packages/logger/src/index.ts +12 -0
  47. package/_stack/packages/storage/capability.json +32 -0
  48. package/_stack/packages/storage/package.json +27 -0
  49. package/_stack/packages/storage/src/adapters/gcs/config.ts +8 -0
  50. package/_stack/packages/storage/src/adapters/gcs/index.ts +111 -0
  51. package/_stack/packages/storage/src/adapters/local/config.ts +8 -0
  52. package/_stack/packages/storage/src/adapters/local/index.ts +78 -0
  53. package/_stack/packages/storage/src/adapters/r2/config.ts +8 -0
  54. package/_stack/packages/storage/src/adapters/r2/index.ts +39 -0
  55. package/_stack/packages/storage/src/adapters/s3/config.ts +11 -0
  56. package/_stack/packages/storage/src/adapters/s3/index.ts +143 -0
  57. package/_stack/packages/storage/src/core/port.ts +41 -0
  58. package/_stack/packages/storage/src/index.ts +21 -0
  59. package/index.mjs +69 -18
  60. package/lib/build.mjs +23 -5
  61. package/lib/capabilities.mjs +375 -0
  62. package/lib/env.mjs +21 -0
  63. package/lib/scaffold.mjs +1 -0
  64. package/package.json +1 -1
@@ -0,0 +1,111 @@
1
+ import { Storage } from '@google-cloud/storage'
2
+ import * as v from 'valibot'
3
+ import {
4
+ type PutOptions,
5
+ type SignedUrlOptions,
6
+ StorageError,
7
+ type StoragePort,
8
+ } from '../../core/port.js'
9
+ import { GcsConfigSchema } from './config.js'
10
+
11
+ /** Structural view of a GCS file handle (eases testing). */
12
+ export interface GcsFileLike {
13
+ save(data: Buffer | string, options?: { contentType?: string }): Promise<void>
14
+ download(): Promise<[Buffer]>
15
+ delete(): Promise<unknown>
16
+ exists(): Promise<[boolean]>
17
+ getSignedUrl(options: {
18
+ version: 'v4'
19
+ action: 'read' | 'write'
20
+ expires: number
21
+ contentType?: string
22
+ }): Promise<[string]>
23
+ }
24
+
25
+ /** Structural view of a GCS bucket handle. */
26
+ export interface GcsBucketLike {
27
+ file(key: string): GcsFileLike
28
+ }
29
+
30
+ /** Structural view of the GCS Storage client. */
31
+ export interface GcsStorageLike {
32
+ bucket(name: string): GcsBucketLike
33
+ }
34
+
35
+ export interface GcsAdapterOptions {
36
+ bucket: string
37
+ projectId?: string
38
+ /** Inject custom/mock client. Defaults to real `Storage`. */
39
+ client?: GcsStorageLike
40
+ }
41
+
42
+ const DEFAULT_EXPIRES_IN = 900
43
+
44
+ function isNotFound(error: unknown): boolean {
45
+ if (typeof error !== 'object' || error === null) return false
46
+ return (error as { code?: number }).code === 404
47
+ }
48
+
49
+ export function gcsAdapter(options: GcsAdapterOptions): StoragePort {
50
+ // Validate early: missing config fails at construction, not use.
51
+ const config = v.parse(GcsConfigSchema, {
52
+ bucket: options.bucket,
53
+ projectId: options.projectId,
54
+ })
55
+
56
+ const client: GcsStorageLike =
57
+ options.client ?? (new Storage({ projectId: config.projectId }) as unknown as GcsStorageLike)
58
+
59
+ const bucket = client.bucket(config.bucket)
60
+
61
+ return {
62
+ name: 'gcs',
63
+ async put(key: string, data: Uint8Array | string, putOptions?: PutOptions) {
64
+ const body = typeof data === 'string' ? data : Buffer.from(data)
65
+ try {
66
+ await bucket.file(key).save(body, { contentType: putOptions?.contentType })
67
+ } catch (cause) {
68
+ throw new StorageError('GCS put failed', { adapter: 'gcs', cause })
69
+ }
70
+ },
71
+ async get(key: string) {
72
+ try {
73
+ const [contents] = await bucket.file(key).download()
74
+ return new Uint8Array(contents)
75
+ } catch (cause) {
76
+ if (isNotFound(cause)) return null
77
+ throw new StorageError('GCS get failed', { adapter: 'gcs', cause })
78
+ }
79
+ },
80
+ async delete(key: string) {
81
+ try {
82
+ await bucket.file(key).delete()
83
+ } catch (cause) {
84
+ if (isNotFound(cause)) return
85
+ throw new StorageError('GCS delete failed', { adapter: 'gcs', cause })
86
+ }
87
+ },
88
+ async exists(key: string) {
89
+ try {
90
+ const [found] = await bucket.file(key).exists()
91
+ return found
92
+ } catch (cause) {
93
+ throw new StorageError('GCS exists failed', { adapter: 'gcs', cause })
94
+ }
95
+ },
96
+ async getSignedUrl(key: string, urlOptions: SignedUrlOptions) {
97
+ const expiresInSeconds = urlOptions.expiresInSeconds ?? DEFAULT_EXPIRES_IN
98
+ try {
99
+ const [url] = await bucket.file(key).getSignedUrl({
100
+ version: 'v4',
101
+ action: urlOptions.operation === 'put' ? 'write' : 'read',
102
+ expires: Date.now() + expiresInSeconds * 1000,
103
+ contentType: urlOptions.contentType,
104
+ })
105
+ return url
106
+ } catch (cause) {
107
+ throw new StorageError('GCS getSignedUrl failed', { adapter: 'gcs', cause })
108
+ }
109
+ },
110
+ }
111
+ }
@@ -0,0 +1,8 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const LocalConfigSchema = v.object({
4
+ baseDir: v.pipe(v.string(), v.minLength(1, 'STORAGE_LOCAL_DIR is required')),
5
+ publicBaseUrl: v.optional(v.string()),
6
+ })
7
+
8
+ export type LocalConfig = v.InferOutput<typeof LocalConfigSchema>
@@ -0,0 +1,78 @@
1
+ import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import * as v from 'valibot'
4
+ import {
5
+ type PutOptions,
6
+ type SignedUrlOptions,
7
+ StorageError,
8
+ type StoragePort,
9
+ } from '../../core/port.js'
10
+ import { LocalConfigSchema } from './config.js'
11
+
12
+ export interface LocalAdapterOptions {
13
+ /** Root directory for stored objects. */
14
+ baseDir: string
15
+ /**
16
+ * Base URL prefixed to keys by {@link StoragePort.getSignedUrl}. NOT signed —
17
+ * returns `${publicBaseUrl}/${key}`. Dev/tests only.
18
+ */
19
+ publicBaseUrl?: string
20
+ }
21
+
22
+ function isNotFound(error: unknown): boolean {
23
+ if (typeof error !== 'object' || error === null) return false
24
+ return (error as { code?: string }).code === 'ENOENT'
25
+ }
26
+
27
+ export function localAdapter(options: LocalAdapterOptions): StoragePort {
28
+ const config = v.parse(LocalConfigSchema, {
29
+ baseDir: options.baseDir,
30
+ publicBaseUrl: options.publicBaseUrl,
31
+ })
32
+
33
+ const resolve = (key: string): string => join(config.baseDir, key)
34
+
35
+ return {
36
+ name: 'local',
37
+ async put(key: string, data: Uint8Array | string, _options?: PutOptions) {
38
+ const path = resolve(key)
39
+ try {
40
+ await mkdir(dirname(path), { recursive: true })
41
+ await writeFile(path, typeof data === 'string' ? data : Buffer.from(data))
42
+ } catch (cause) {
43
+ throw new StorageError('Local put failed', { adapter: 'local', cause })
44
+ }
45
+ },
46
+ async get(key: string) {
47
+ try {
48
+ const buffer = await readFile(resolve(key))
49
+ return new Uint8Array(buffer)
50
+ } catch (cause) {
51
+ if (isNotFound(cause)) return null
52
+ throw new StorageError('Local get failed', { adapter: 'local', cause })
53
+ }
54
+ },
55
+ async delete(key: string) {
56
+ try {
57
+ await unlink(resolve(key))
58
+ } catch (cause) {
59
+ if (isNotFound(cause)) return
60
+ throw new StorageError('Local delete failed', { adapter: 'local', cause })
61
+ }
62
+ },
63
+ async exists(key: string) {
64
+ try {
65
+ await stat(resolve(key))
66
+ return true
67
+ } catch (cause) {
68
+ if (isNotFound(cause)) return false
69
+ throw new StorageError('Local exists failed', { adapter: 'local', cause })
70
+ }
71
+ },
72
+ async getSignedUrl(key: string, _options: SignedUrlOptions) {
73
+ // No real signing; dev/test only.
74
+ const base = config.publicBaseUrl ?? ''
75
+ return `${base}/${key}`
76
+ },
77
+ }
78
+ }
@@ -0,0 +1,8 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const R2ConfigSchema = v.object({
4
+ bucket: v.pipe(v.string(), v.minLength(1, 'R2_BUCKET is required')),
5
+ accountId: v.pipe(v.string(), v.minLength(1, 'R2_ACCOUNT_ID is required')),
6
+ })
7
+
8
+ export type R2Config = v.InferOutput<typeof R2ConfigSchema>
@@ -0,0 +1,39 @@
1
+ import * as v from 'valibot'
2
+ import type { StoragePort } from '../../core/port.js'
3
+ import { type S3ClientLike, type S3Presigner, s3Adapter } from '../s3/index.js'
4
+ import { R2ConfigSchema } from './config.js'
5
+
6
+ /**
7
+ * R2 is S3-compatible: {@link s3Adapter} with R2's endpoint
8
+ * (`https://<accountId>.r2.cloudflarestorage.com`) and `auto` region. All behavior inherited.
9
+ */
10
+ export interface R2AdapterOptions {
11
+ bucket: string
12
+ /** Cloudflare account id (forms the R2 endpoint). */
13
+ accountId: string
14
+ accessKeyId?: string
15
+ secretAccessKey?: string
16
+ /** Inject custom/mock client. Defaults to real `S3Client`. */
17
+ client?: S3ClientLike
18
+ /** Inject custom/mock presigner. */
19
+ presign?: S3Presigner
20
+ }
21
+
22
+ export function r2Adapter(options: R2AdapterOptions): StoragePort {
23
+ const config = v.parse(R2ConfigSchema, {
24
+ bucket: options.bucket,
25
+ accountId: options.accountId,
26
+ })
27
+
28
+ const s3 = s3Adapter({
29
+ bucket: config.bucket,
30
+ region: 'auto',
31
+ endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
32
+ accessKeyId: options.accessKeyId,
33
+ secretAccessKey: options.secretAccessKey,
34
+ client: options.client,
35
+ presign: options.presign,
36
+ })
37
+
38
+ return { ...s3, name: 'r2' }
39
+ }
@@ -0,0 +1,11 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const S3ConfigSchema = v.object({
4
+ bucket: v.pipe(v.string(), v.minLength(1, 'S3_BUCKET is required')),
5
+ region: v.pipe(v.string(), v.minLength(1, 'S3_REGION is required')),
6
+ accessKeyId: v.optional(v.string()),
7
+ secretAccessKey: v.optional(v.string()),
8
+ endpoint: v.optional(v.string()),
9
+ })
10
+
11
+ export type S3Config = v.InferOutput<typeof S3ConfigSchema>
@@ -0,0 +1,143 @@
1
+ import {
2
+ DeleteObjectCommand,
3
+ GetObjectCommand,
4
+ HeadObjectCommand,
5
+ PutObjectCommand,
6
+ S3Client,
7
+ } from '@aws-sdk/client-s3'
8
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
9
+ import * as v from 'valibot'
10
+ import {
11
+ type PutOptions,
12
+ type SignedUrlOptions,
13
+ StorageError,
14
+ type StoragePort,
15
+ } from '../../core/port.js'
16
+ import { S3ConfigSchema } from './config.js'
17
+
18
+ /** Structural view of the S3 client (eases testing). */
19
+ export interface S3ClientLike {
20
+ send(command: unknown): Promise<unknown>
21
+ }
22
+
23
+ /** Presigner shape, injectable so tests never sign for real. */
24
+ export type S3Presigner = (
25
+ client: S3ClientLike,
26
+ command: unknown,
27
+ options: { expiresIn: number },
28
+ ) => Promise<string>
29
+
30
+ export interface S3AdapterOptions {
31
+ bucket: string
32
+ region: string
33
+ accessKeyId?: string
34
+ secretAccessKey?: string
35
+ endpoint?: string
36
+ /** Inject custom/mock client. Defaults to real `S3Client`. */
37
+ client?: S3ClientLike
38
+ /** Inject custom/mock presigner. Defaults to `@aws-sdk/s3-request-presigner`. */
39
+ presign?: S3Presigner
40
+ }
41
+
42
+ const DEFAULT_EXPIRES_IN = 900
43
+
44
+ interface S3GetBody {
45
+ transformToByteArray(): Promise<Uint8Array>
46
+ }
47
+
48
+ interface S3GetResult {
49
+ // biome-ignore lint/style/useNamingConvention: mirrors the S3 SDK response shape (GetObjectCommandOutput.Body)
50
+ Body?: S3GetBody
51
+ }
52
+
53
+ function isNotFound(error: unknown): boolean {
54
+ if (typeof error !== 'object' || error === null) return false
55
+ const e = error as { name?: string; $metadata?: { httpStatusCode?: number } }
56
+ return e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404
57
+ }
58
+
59
+ export function s3Adapter(options: S3AdapterOptions): StoragePort {
60
+ // Validate early: missing config fails at construction, not use.
61
+ const config = v.parse(S3ConfigSchema, {
62
+ bucket: options.bucket,
63
+ region: options.region,
64
+ accessKeyId: options.accessKeyId,
65
+ secretAccessKey: options.secretAccessKey,
66
+ endpoint: options.endpoint,
67
+ })
68
+
69
+ const client: S3ClientLike =
70
+ options.client ??
71
+ (new S3Client({
72
+ region: config.region,
73
+ endpoint: config.endpoint,
74
+ credentials:
75
+ config.accessKeyId && config.secretAccessKey
76
+ ? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
77
+ : undefined,
78
+ }) as unknown as S3ClientLike)
79
+
80
+ const presign: S3Presigner = options.presign ?? (getSignedUrl as unknown as S3Presigner)
81
+
82
+ return {
83
+ name: 's3',
84
+ async put(key: string, data: Uint8Array | string, putOptions?: PutOptions) {
85
+ try {
86
+ await client.send(
87
+ new PutObjectCommand({
88
+ Bucket: config.bucket,
89
+ Key: key,
90
+ Body: data,
91
+ ContentType: putOptions?.contentType,
92
+ }),
93
+ )
94
+ } catch (cause) {
95
+ throw new StorageError('S3 put failed', { adapter: 's3', cause })
96
+ }
97
+ },
98
+ async get(key: string) {
99
+ try {
100
+ const result = (await client.send(
101
+ new GetObjectCommand({ Bucket: config.bucket, Key: key }),
102
+ )) as S3GetResult
103
+ if (!result.Body) return null
104
+ return await result.Body.transformToByteArray()
105
+ } catch (cause) {
106
+ if (isNotFound(cause)) return null
107
+ throw new StorageError('S3 get failed', { adapter: 's3', cause })
108
+ }
109
+ },
110
+ async delete(key: string) {
111
+ try {
112
+ await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key }))
113
+ } catch (cause) {
114
+ throw new StorageError('S3 delete failed', { adapter: 's3', cause })
115
+ }
116
+ },
117
+ async exists(key: string) {
118
+ try {
119
+ await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: key }))
120
+ return true
121
+ } catch (cause) {
122
+ if (isNotFound(cause)) return false
123
+ throw new StorageError('S3 exists failed', { adapter: 's3', cause })
124
+ }
125
+ },
126
+ async getSignedUrl(key: string, urlOptions: SignedUrlOptions) {
127
+ const expiresIn = urlOptions.expiresInSeconds ?? DEFAULT_EXPIRES_IN
128
+ const command =
129
+ urlOptions.operation === 'put'
130
+ ? new PutObjectCommand({
131
+ Bucket: config.bucket,
132
+ Key: key,
133
+ ContentType: urlOptions.contentType,
134
+ })
135
+ : new GetObjectCommand({ Bucket: config.bucket, Key: key })
136
+ try {
137
+ return await presign(client, command, { expiresIn })
138
+ } catch (cause) {
139
+ throw new StorageError('S3 getSignedUrl failed', { adapter: 's3', cause })
140
+ }
141
+ },
142
+ }
143
+ }
@@ -0,0 +1,41 @@
1
+ /** Write options. */
2
+ export interface PutOptions {
3
+ /** MIME type stored with the object (e.g. `image/png`). */
4
+ contentType?: string
5
+ }
6
+
7
+ /** Signed-URL options. */
8
+ export interface SignedUrlOptions {
9
+ /** Download (`get`) or upload (`put`). */
10
+ operation: 'get' | 'put'
11
+ /** Validity window. Adapters apply a default. */
12
+ expiresInSeconds?: number
13
+ /** Constrain content type for `put` URLs. */
14
+ contentType?: string
15
+ }
16
+
17
+ /** The port the app depends on. Swap provider = swap adapter; this never changes. */
18
+ export interface StoragePort {
19
+ readonly name: string
20
+ /** Write `data` at `key`, creating or overwriting. */
21
+ put(key: string, data: Uint8Array | string, options?: PutOptions): Promise<void>
22
+ /** Read bytes at `key`, or `null` if absent. */
23
+ get(key: string): Promise<Uint8Array | null>
24
+ /** Remove `key`. No-op if missing. */
25
+ delete(key: string): Promise<void>
26
+ /** Whether `key` exists. */
27
+ exists(key: string): Promise<boolean>
28
+ /** Mint a time-limited URL for `key`. */
29
+ getSignedUrl(key: string, options: SignedUrlOptions): Promise<string>
30
+ }
31
+
32
+ /** Normalized adapter error so callers never catch provider types. */
33
+ export class StorageError extends Error {
34
+ readonly adapter: string
35
+
36
+ constructor(message: string, options: { adapter: string; cause?: unknown }) {
37
+ super(message, { cause: options.cause })
38
+ this.name = 'StorageError'
39
+ this.adapter = options.adapter
40
+ }
41
+ }
@@ -0,0 +1,21 @@
1
+ export { type GcsConfig, GcsConfigSchema } from './adapters/gcs/config.js'
2
+ export {
3
+ type GcsAdapterOptions,
4
+ type GcsBucketLike,
5
+ type GcsFileLike,
6
+ type GcsStorageLike,
7
+ gcsAdapter,
8
+ } from './adapters/gcs/index.js'
9
+ export { type LocalConfig, LocalConfigSchema } from './adapters/local/config.js'
10
+ export { type LocalAdapterOptions, localAdapter } from './adapters/local/index.js'
11
+ export { type R2Config, R2ConfigSchema } from './adapters/r2/config.js'
12
+ export { type R2AdapterOptions, r2Adapter } from './adapters/r2/index.js'
13
+ export { type S3Config, S3ConfigSchema } from './adapters/s3/config.js'
14
+ export {
15
+ type S3AdapterOptions,
16
+ type S3ClientLike,
17
+ type S3Presigner,
18
+ s3Adapter,
19
+ } from './adapters/s3/index.js'
20
+ export type { PutOptions, SignedUrlOptions, StoragePort } from './core/port.js'
21
+ export { StorageError } from './core/port.js'
package/index.mjs CHANGED
@@ -6,6 +6,12 @@
6
6
  import { resolve } from 'node:path'
7
7
  import * as p from '@clack/prompts'
8
8
  import { buildProject } from './lib/build.mjs'
9
+ import {
10
+ adapterChoices,
11
+ CAPABILITIES,
12
+ capabilityChoices,
13
+ resolveAdapter,
14
+ } from './lib/capabilities.mjs'
9
15
  import { isDirEmpty, run } from './lib/util.mjs'
10
16
 
11
17
  const ALL_FOUNDATIONS = ['drizzle', 'trpc', 'better-auth', 'data-table']
@@ -56,14 +62,24 @@ function normalize(picked, mailer) {
56
62
  return { kept, mailerProvider }
57
63
  }
58
64
 
65
+ /** Read --<capability> flags into { capability: adapter } (default adapter if bare). */
66
+ function collectCapabilityFlags(flags) {
67
+ const out = {}
68
+ for (const cap of CAPABILITIES) {
69
+ if (cap in flags) out[cap] = resolveAdapter(cap, flags[cap])
70
+ }
71
+ return out
72
+ }
73
+
59
74
  function collectFromFlags(args) {
60
75
  const argDir = args._[0]
61
76
  if (!argDir) throw new Error('Project name is required (positional) in non-interactive mode')
62
77
  const framework = args.flags.framework === 'next' ? 'next' : 'tanstack'
63
78
  const picked = args.flags.foundations ? csv(args.flags.foundations) : [...ALL_FOUNDATIONS]
64
79
  const { kept, mailerProvider } = normalize(picked, args.flags.mailer)
80
+ const capabilities = collectCapabilityFlags(args.flags)
65
81
  const doInstall = !args.flags['no-install']
66
- return { argDir, projectName: argDir, framework, kept, mailerProvider, doInstall }
82
+ return { argDir, projectName: argDir, framework, kept, mailerProvider, capabilities, doInstall }
67
83
  }
68
84
 
69
85
  async function collectFromPrompts(argDir) {
@@ -118,12 +134,56 @@ async function collectFromPrompts(argDir) {
118
134
  }),
119
135
  )
120
136
 
137
+ const capPicked = cancelled(
138
+ await p.multiselect({
139
+ message: 'Capabilities (space to toggle, swappable behind a port)',
140
+ required: false,
141
+ initialValues: [],
142
+ options: capabilityChoices(),
143
+ }),
144
+ )
145
+
146
+ const capabilities = {}
147
+ for (const cap of capPicked) {
148
+ const { defaultAdapter, options } = adapterChoices(cap)
149
+ capabilities[cap] = cancelled(
150
+ await p.select({
151
+ message: `${cap} adapter`,
152
+ options,
153
+ initialValue: defaultAdapter,
154
+ }),
155
+ )
156
+ }
157
+
121
158
  const doInstall = cancelled(
122
159
  await p.confirm({ message: 'Install dependencies and verify now?', initialValue: true }),
123
160
  )
124
161
 
125
162
  const { kept, mailerProvider } = normalize(picked, mailer)
126
- return { argDir, projectName, framework, kept, mailerProvider, doInstall }
163
+ return { argDir, projectName, framework, kept, mailerProvider, capabilities, doInstall }
164
+ }
165
+
166
+ const pnpmRun = (script, projectDir, opts = {}) =>
167
+ run('pnpm', ['--config.verify-deps-before-run=false', 'run', script], {
168
+ cwd: projectDir,
169
+ ...opts,
170
+ })
171
+
172
+ /** Install deps, normalize vendored imports, then report typecheck + biome status. */
173
+ function installAndVerify(projectDir, capabilities) {
174
+ p.log.step('pnpm install')
175
+ run('pnpm', ['install'], { cwd: projectDir })
176
+ // vendored capabilities rewrite cross-package imports (~/lib/http); let biome
177
+ // re-sort/normalize them so the initial commit is lint-clean.
178
+ if (Object.keys(capabilities ?? {}).length) {
179
+ pnpmRun('check:write', projectDir, { stdio: 'ignore' })
180
+ }
181
+ p.log.step('Verifying (typecheck + biome)')
182
+ const tc = pnpmRun('typecheck', projectDir)
183
+ const lint = pnpmRun('check', projectDir)
184
+ p.log[tc && lint ? 'success' : 'warn'](
185
+ tc && lint ? 'typecheck + biome clean' : 'verify reported issues (see output above)',
186
+ )
127
187
  }
128
188
 
129
189
  function execute(a) {
@@ -138,20 +198,7 @@ function execute(a) {
138
198
  buildProject({ ...a, projectDir })
139
199
  s.stop('Project scaffolded')
140
200
 
141
- if (a.doInstall) {
142
- p.log.step('pnpm install')
143
- run('pnpm', ['install'], { cwd: projectDir })
144
- p.log.step('Verifying (typecheck + biome)')
145
- const tc = run('pnpm', ['--config.verify-deps-before-run=false', 'run', 'typecheck'], {
146
- cwd: projectDir,
147
- })
148
- const lint = run('pnpm', ['--config.verify-deps-before-run=false', 'run', 'check'], {
149
- cwd: projectDir,
150
- })
151
- p.log[tc && lint ? 'success' : 'warn'](
152
- tc && lint ? 'typecheck + biome clean' : 'verify reported issues (see output above)',
153
- )
154
- }
201
+ if (a.doInstall) installAndVerify(projectDir, a.capabilities)
155
202
 
156
203
  // fresh repo + initial commit (also satisfies Biome vcs.useIgnoreFile).
157
204
  // commit is best-effort: skipped if git identity unset, staged tree left in place.
@@ -170,12 +217,14 @@ function execute(a) {
170
217
  }
171
218
 
172
219
  const keptMailer = a.mailerProvider !== 'none'
220
+ const capEntries = Object.entries(a.capabilities ?? {})
173
221
  const lines = [
174
222
  `Framework: ${a.framework === 'next' ? 'Next.js' : 'TanStack Start'}`,
175
223
  `Foundations: ${[...a.kept].sort().join(', ') || '(none)'}`,
176
224
  `Mailer: ${keptMailer ? a.mailerProvider : '(none)'}`,
225
+ `Capabilities: ${capEntries.map(([c, ad]) => `${c} (${ad})`).join(', ') || '(none)'}`,
177
226
  '',
178
- 'Add more tools (storage, jobs, cache, analytics, …) with the add-capability skill.',
227
+ 'Add more tools later with the add-capability skill.',
179
228
  '',
180
229
  'Next:',
181
230
  ` cd ${a.argDir ?? a.projectName}`,
@@ -192,7 +241,9 @@ async function main() {
192
241
  const nonInteractive =
193
242
  args.flags.yes ||
194
243
  args.flags.y ||
195
- ['framework', 'foundations', 'mailer', 'no-install'].some((k) => k in args.flags)
244
+ ['framework', 'foundations', 'mailer', 'no-install', ...CAPABILITIES].some(
245
+ (k) => k in args.flags,
246
+ )
196
247
 
197
248
  const answers = nonInteractive ? collectFromFlags(args) : await collectFromPrompts(args._[0])
198
249
 
package/lib/build.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // Pure build phase (no prompts/install): fork → strip → mailer → env → identity.
2
2
  // Shared by index.mjs (post-wizard) and the test harness.
3
3
 
4
+ import { vendorCapability } from './capabilities.mjs'
4
5
  import { writeEnv } from './env.mjs'
5
6
  import { stampIdentity } from './identity.mjs'
6
7
  import { swapMailer } from './mailer.mjs'
@@ -15,9 +16,17 @@ import { join, pkgAddDeps, pkgRemoveDeps, pkgRemoveScripts, readJSON, writeJSON
15
16
  * @param {'next'|'tanstack'} o.framework
16
17
  * @param {Set<string>} o.kept foundations to keep (deps pre-resolved)
17
18
  * @param {'resend'|'brevo'|'ses'|'none'} o.mailerProvider
18
- * @returns {{ kept: string[], keptMailer: boolean, mailerProvider: string, envKeys: string[] }}
19
+ * @param {Record<string,string>} [o.capabilities] capability adapter (e.g. { storage: 's3' })
20
+ * @returns {{ kept: string[], keptMailer: boolean, mailerProvider: string, capabilities: Record<string,string>, envKeys: string[] }}
19
21
  */
20
- export function buildProject({ projectDir, projectName, framework, kept, mailerProvider }) {
22
+ export function buildProject({
23
+ projectDir,
24
+ projectName,
25
+ framework,
26
+ kept,
27
+ mailerProvider,
28
+ capabilities = {},
29
+ }) {
21
30
  const authKept = kept.has('better-auth')
22
31
  const keptMailer = mailerProvider !== 'none'
23
32
 
@@ -29,12 +38,21 @@ export function buildProject({ projectDir, projectName, framework, kept, mailerP
29
38
  ? swapMailer(projectDir, mailerProvider)
30
39
  : { addDeps: {}, removeDeps: [], envKeys: [] }
31
40
 
41
+ // vendor each selected capability (core + adapter + composition root) into the fork.
42
+ const capAddDeps = {}
43
+ const capEnvKeys = []
44
+ for (const [cap, adapter] of Object.entries(capabilities)) {
45
+ const r = vendorCapability({ projectDir, framework, projectName, cap, adapter })
46
+ Object.assign(capAddDeps, r.addDeps)
47
+ capEnvKeys.push(...r.envKeys)
48
+ }
49
+
32
50
  const pkgPath = join(projectDir, 'package.json')
33
51
  const pkg = readJSON(pkgPath)
34
52
  pkg.description = `${projectName} — scaffolded from the personal reference stack.`
35
53
  pkgRemoveDeps(pkg, [...strip.removeDeps, ...mailer.removeDeps])
36
54
  pkgRemoveScripts(pkg, strip.removeScripts)
37
- pkgAddDeps(pkg, mailer.addDeps)
55
+ pkgAddDeps(pkg, { ...mailer.addDeps, ...capAddDeps })
38
56
  writeJSON(pkgPath, pkg)
39
57
 
40
58
  const envKeys = []
@@ -47,10 +65,10 @@ export function buildProject({ projectDir, projectName, framework, kept, mailerP
47
65
  'BETTER_AUTH_GOOGLE_CLIENT_SECRET',
48
66
  )
49
67
  }
50
- envKeys.push(...mailer.envKeys)
68
+ envKeys.push(...mailer.envKeys, ...capEnvKeys)
51
69
  writeEnv(projectDir, envKeys)
52
70
 
53
71
  stampIdentity(projectDir, projectName, framework)
54
72
 
55
- return { kept: [...kept], keptMailer, mailerProvider, envKeys }
73
+ return { kept: [...kept], keptMailer, mailerProvider, capabilities, envKeys }
56
74
  }