@alfredmouelle/create-stack 0.1.1 → 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 (127) hide show
  1. package/README.md +41 -18
  2. package/_stack/apps/next-base/.turbo/turbo-typecheck.log +1 -0
  3. package/_stack/apps/next-base/.vscode/settings.json +35 -0
  4. package/_stack/apps/next-base/.zed/settings.json +45 -0
  5. package/_stack/apps/next-base/src/components/ui/spinner.tsx +1 -1
  6. package/_stack/apps/next-base/src/emails/components/components.tsx +2 -2
  7. package/_stack/apps/next-base/src/emails/components/context.tsx +1 -1
  8. package/_stack/apps/next-base/src/emails/components/theme.ts +6 -7
  9. package/_stack/apps/next-base/src/env.ts +2 -3
  10. package/_stack/apps/next-base/src/lib/date.ts +1 -1
  11. package/_stack/apps/next-base/src/server/auth/guards.ts +1 -1
  12. package/_stack/apps/next-base/src/server/better-auth/config.ts +1 -1
  13. package/_stack/apps/next-base/src/server/better-auth/server.ts +2 -2
  14. package/_stack/apps/next-base/src/server/db/schemas/index.ts +1 -1
  15. package/_stack/apps/next-base/src/server/db/seed.ts +2 -2
  16. package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +3 -3
  17. package/_stack/apps/next-base/src/server/email/core/address.ts +3 -3
  18. package/_stack/apps/next-base/src/server/email/core/port.ts +13 -20
  19. package/_stack/apps/next-base/src/server/email/core/render.ts +2 -2
  20. package/_stack/apps/next-base/src/server/email/factory.ts +7 -9
  21. package/_stack/apps/next-base/src/server/email/index.ts +1 -1
  22. package/_stack/apps/next-base/src/trpc/react.tsx +2 -2
  23. package/_stack/apps/next-base/src/trpc/server.ts +1 -1
  24. package/_stack/apps/tanstack-base/.turbo/turbo-typecheck.log +1 -0
  25. package/_stack/apps/tanstack-base/.vscode/settings.json +35 -0
  26. package/_stack/apps/tanstack-base/.zed/settings.json +45 -0
  27. package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +1 -1
  28. package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +1 -1
  29. package/_stack/apps/tanstack-base/src/emails/components/components.tsx +2 -2
  30. package/_stack/apps/tanstack-base/src/emails/components/context.tsx +1 -1
  31. package/_stack/apps/tanstack-base/src/emails/components/theme.ts +6 -7
  32. package/_stack/apps/tanstack-base/src/env.ts +2 -6
  33. package/_stack/apps/tanstack-base/src/lib/date.ts +1 -1
  34. package/_stack/apps/tanstack-base/src/routes/__root.tsx +1 -1
  35. package/_stack/apps/tanstack-base/src/routes/_authed.tsx +1 -4
  36. package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +1 -1
  37. package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +1 -2
  38. package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +1 -1
  39. package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +6 -7
  40. package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +1 -1
  41. package/_stack/apps/tanstack-base/src/server/db/seed.ts +2 -2
  42. package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +3 -3
  43. package/_stack/apps/tanstack-base/src/server/email/core/address.ts +3 -3
  44. package/_stack/apps/tanstack-base/src/server/email/core/port.ts +12 -22
  45. package/_stack/apps/tanstack-base/src/server/email/core/render.ts +1 -1
  46. package/_stack/apps/tanstack-base/src/server/email/factory.ts +7 -8
  47. package/_stack/apps/tanstack-base/src/server/email/index.ts +1 -1
  48. package/_stack/packages/analytics/package.json +26 -0
  49. package/_stack/packages/analytics/src/adapters/noop/index.ts +12 -0
  50. package/_stack/packages/analytics/src/adapters/plausible/config.ts +10 -0
  51. package/_stack/packages/analytics/src/adapters/plausible/index.ts +94 -0
  52. package/_stack/packages/analytics/src/adapters/posthog/config.ts +7 -0
  53. package/_stack/packages/analytics/src/adapters/posthog/index.ts +50 -0
  54. package/_stack/packages/analytics/src/core/port.ts +30 -0
  55. package/_stack/packages/analytics/src/index.ts +17 -0
  56. package/_stack/packages/cache/package.json +25 -0
  57. package/_stack/packages/cache/src/adapters/memory/index.ts +51 -0
  58. package/_stack/packages/cache/src/adapters/redis/config.ts +8 -0
  59. package/_stack/packages/cache/src/adapters/redis/index.ts +73 -0
  60. package/_stack/packages/cache/src/core/port.ts +29 -0
  61. package/_stack/packages/cache/src/core/wrap.ts +20 -0
  62. package/_stack/packages/cache/src/index.ts +12 -0
  63. package/_stack/packages/error-tracking/package.json +25 -0
  64. package/_stack/packages/error-tracking/src/adapters/console/index.ts +43 -0
  65. package/_stack/packages/error-tracking/src/adapters/sentry/config.ts +8 -0
  66. package/_stack/packages/error-tracking/src/adapters/sentry/index.ts +72 -0
  67. package/_stack/packages/error-tracking/src/core/port.ts +39 -0
  68. package/_stack/packages/error-tracking/src/index.ts +14 -0
  69. package/_stack/packages/http/package.json +20 -0
  70. package/_stack/packages/http/src/api.ts +373 -0
  71. package/_stack/packages/http/src/index.ts +14 -0
  72. package/_stack/packages/http/src/responses.ts +25 -0
  73. package/_stack/packages/http/src/types.ts +9 -0
  74. package/_stack/packages/jobs/package.json +27 -0
  75. package/_stack/packages/jobs/src/adapters/inngest/config.ts +8 -0
  76. package/_stack/packages/jobs/src/adapters/inngest/index.ts +93 -0
  77. package/_stack/packages/jobs/src/adapters/memory/index.ts +31 -0
  78. package/_stack/packages/jobs/src/adapters/trigger/config.ts +8 -0
  79. package/_stack/packages/jobs/src/adapters/trigger/index.ts +85 -0
  80. package/_stack/packages/jobs/src/core/port.ts +37 -0
  81. package/_stack/packages/jobs/src/index.ts +23 -0
  82. package/_stack/packages/logger/package.json +25 -0
  83. package/_stack/packages/logger/src/adapters/console/config.ts +7 -0
  84. package/_stack/packages/logger/src/adapters/console/index.ts +69 -0
  85. package/_stack/packages/logger/src/adapters/pino/index.ts +54 -0
  86. package/_stack/packages/logger/src/core/port.ts +21 -0
  87. package/_stack/packages/logger/src/index.ts +12 -0
  88. package/_stack/packages/mailer/src/adapters/brevo/index.ts +3 -3
  89. package/_stack/packages/mailer/src/adapters/resend/index.ts +3 -3
  90. package/_stack/packages/mailer/src/adapters/ses/config.ts +3 -3
  91. package/_stack/packages/mailer/src/adapters/ses/index.ts +4 -5
  92. package/_stack/packages/storage/package.json +27 -0
  93. package/_stack/packages/storage/src/adapters/gcs/config.ts +8 -0
  94. package/_stack/packages/storage/src/adapters/gcs/index.ts +111 -0
  95. package/_stack/packages/storage/src/adapters/local/config.ts +8 -0
  96. package/_stack/packages/storage/src/adapters/local/index.ts +78 -0
  97. package/_stack/packages/storage/src/adapters/r2/config.ts +8 -0
  98. package/_stack/packages/storage/src/adapters/r2/index.ts +39 -0
  99. package/_stack/packages/storage/src/adapters/s3/config.ts +11 -0
  100. package/_stack/packages/storage/src/adapters/s3/index.ts +143 -0
  101. package/_stack/packages/storage/src/core/port.ts +41 -0
  102. package/_stack/packages/storage/src/index.ts +21 -0
  103. package/index.mjs +89 -55
  104. package/lib/build.mjs +21 -11
  105. package/lib/capabilities.mjs +375 -0
  106. package/lib/env.mjs +26 -6
  107. package/lib/foundations.mjs +35 -0
  108. package/lib/identity.mjs +4 -5
  109. package/lib/mailer.mjs +9 -13
  110. package/lib/paths.mjs +15 -0
  111. package/lib/scaffold.mjs +12 -11
  112. package/lib/strip.mjs +9 -24
  113. package/lib/util.mjs +8 -9
  114. package/package.json +1 -1
  115. package/_stack/packages/mailer/capability.json +0 -28
  116. package/_stack/patterns/README.md +0 -58
  117. package/_stack/patterns/_baseline/env.ts +0 -31
  118. package/_stack/patterns/_baseline/tsconfig.json +0 -27
  119. package/_stack/patterns/better-auth/pattern.json +0 -73
  120. package/_stack/patterns/better-auth-next/pattern.json +0 -76
  121. package/_stack/patterns/data-table/pattern.json +0 -43
  122. package/_stack/patterns/drizzle/pattern.json +0 -61
  123. package/_stack/patterns/trpc/pattern.json +0 -61
  124. package/_stack/patterns/trpc-next/pattern.json +0 -64
  125. package/lib/manifests.mjs +0 -61
  126. /package/{_stack/patterns/_baseline → templates}/README-author.md +0 -0
  127. /package/{_stack/patterns/_baseline → templates}/biome.jsonc +0 -0
@@ -0,0 +1,375 @@
1
+ // Swappable capabilities (storage, cache, jobs, logger, analytics, error-tracking).
2
+ // Generalizes mailer.mjs: vendor a capability's core + chosen adapter into the fork,
3
+ // generate a composition root reading typed env, and return dep/env deltas.
4
+ // Manifest (capability.json) is the source of truth for files/deps/env; this registry
5
+ // adds only what a manifest can't carry — the env → constructor-arg mapping.
6
+
7
+ import { readdirSync, statSync } from 'node:fs'
8
+ import { STACK_ROOT } from './paths.mjs'
9
+ import { copy, exists, join, read, readJSON, write } from './util.mjs'
10
+
11
+ const PKG = (cap) => join(STACK_ROOT, 'packages', cap)
12
+
13
+ /**
14
+ * Per-capability wiring. `dir` = vendored destination, `entry` = exported accessor
15
+ * stem, `portType` = the interface returned. Each adapter maps constructor args to
16
+ * env keys: [argName, ENV_KEY, required?]. A required arg is narrowed via required().
17
+ */
18
+ const CAPS = {
19
+ storage: {
20
+ label: 'Storage',
21
+ dir: 'src/server/storage',
22
+ entry: 'storage',
23
+ portType: 'StoragePort',
24
+ defaultAdapter: 's3',
25
+ adapters: {
26
+ s3: {
27
+ fn: 's3Adapter',
28
+ args: [
29
+ ['bucket', 'S3_BUCKET', true],
30
+ ['region', 'S3_REGION', true],
31
+ ['accessKeyId', 'AWS_ACCESS_KEY_ID', false],
32
+ ['secretAccessKey', 'AWS_SECRET_ACCESS_KEY', false],
33
+ ],
34
+ },
35
+ r2: {
36
+ fn: 'r2Adapter',
37
+ args: [
38
+ ['bucket', 'R2_BUCKET', true],
39
+ ['accountId', 'R2_ACCOUNT_ID', true],
40
+ ['accessKeyId', 'R2_ACCESS_KEY_ID', false],
41
+ ['secretAccessKey', 'R2_SECRET_ACCESS_KEY', false],
42
+ ],
43
+ },
44
+ gcs: {
45
+ fn: 'gcsAdapter',
46
+ args: [
47
+ ['bucket', 'GCS_BUCKET', true],
48
+ ['projectId', 'GOOGLE_CLOUD_PROJECT', false],
49
+ ],
50
+ },
51
+ local: { fn: 'localAdapter', args: [['baseDir', 'STORAGE_LOCAL_DIR', true]] },
52
+ },
53
+ },
54
+
55
+ cache: {
56
+ label: 'Cache',
57
+ dir: 'src/server/cache',
58
+ entry: 'cache',
59
+ portType: 'CachePort',
60
+ defaultAdapter: 'redis',
61
+ adapters: {
62
+ redis: { fn: 'redisAdapter', args: [['url', 'REDIS_URL', false]] },
63
+ memory: { fn: 'memoryAdapter', args: [] },
64
+ },
65
+ },
66
+
67
+ logger: {
68
+ label: 'Logger',
69
+ dir: 'src/server/logger',
70
+ entry: 'logger',
71
+ portType: 'Logger',
72
+ defaultAdapter: 'pino',
73
+ adapters: {
74
+ pino: { fn: 'pinoAdapter', args: [] },
75
+ console: { fn: 'consoleAdapter', args: [] },
76
+ },
77
+ },
78
+
79
+ analytics: {
80
+ label: 'Analytics',
81
+ dir: 'src/server/analytics',
82
+ entry: 'analytics',
83
+ portType: 'AnalyticsPort',
84
+ defaultAdapter: 'posthog',
85
+ adapters: {
86
+ posthog: {
87
+ fn: 'posthogAdapter',
88
+ args: [
89
+ ['apiKey', 'POSTHOG_API_KEY', true],
90
+ ['host', 'POSTHOG_HOST', false],
91
+ ],
92
+ },
93
+ plausible: {
94
+ fn: 'plausibleAdapter',
95
+ args: [
96
+ ['domain', 'PLAUSIBLE_DOMAIN', true],
97
+ ['apiHost', 'PLAUSIBLE_API_HOST', false],
98
+ ],
99
+ },
100
+ noop: { fn: 'noopAdapter', args: [] },
101
+ },
102
+ },
103
+
104
+ 'error-tracking': {
105
+ label: 'Error tracking',
106
+ dir: 'src/server/error-tracking',
107
+ entry: 'errorTracking',
108
+ portType: 'ErrorTrackingPort',
109
+ defaultAdapter: 'sentry',
110
+ adapters: {
111
+ sentry: {
112
+ fn: 'sentryAdapter',
113
+ args: [
114
+ ['dsn', 'SENTRY_DSN', true],
115
+ ['environment', 'SENTRY_ENVIRONMENT', false],
116
+ ],
117
+ },
118
+ console: { fn: 'consoleAdapter', args: [] },
119
+ },
120
+ },
121
+
122
+ jobs: {
123
+ label: 'Background jobs',
124
+ dir: 'src/server/jobs',
125
+ entry: 'jobs',
126
+ kind: 'jobs', // bespoke: concrete adapter (serving) + HTTP surface
127
+ defaultAdapter: 'inngest',
128
+ adapters: {
129
+ inngest: { fn: 'inngestAdapter' },
130
+ trigger: { fn: 'triggerDevAdapter' },
131
+ memory: { fn: 'memoryAdapter' },
132
+ },
133
+ },
134
+ }
135
+
136
+ export const CAPABILITIES = Object.keys(CAPS)
137
+
138
+ /** Prompt options for the capability multiselect. */
139
+ export const capabilityChoices = () =>
140
+ CAPABILITIES.map((name) => ({
141
+ value: name,
142
+ label: CAPS[name].label,
143
+ hint: Object.keys(CAPS[name].adapters).join(' / '),
144
+ }))
145
+
146
+ /** Adapter options + default for one capability (drives the per-capability select). */
147
+ export const adapterChoices = (cap) => ({
148
+ defaultAdapter: CAPS[cap].defaultAdapter,
149
+ options: Object.keys(CAPS[cap].adapters).map((value) => ({ value, label: value })),
150
+ })
151
+
152
+ /** Resolve a possibly-empty flag value to a valid adapter, or throw. */
153
+ export function resolveAdapter(cap, value) {
154
+ const spec = CAPS[cap]
155
+ if (!spec) throw new Error(`Unknown capability: ${cap}`)
156
+ if (value === true || value == null || value === '') return spec.defaultAdapter
157
+ if (!spec.adapters[value]) {
158
+ throw new Error(
159
+ `Unknown ${cap} adapter: ${value} (have ${Object.keys(spec.adapters).join(', ')})`,
160
+ )
161
+ }
162
+ return value
163
+ }
164
+
165
+ const REQUIRED_HELPER = `
166
+ function required(value: string | undefined, name: string): string {
167
+ if (!value) throw new Error(\`\${name} is required\`)
168
+ return value
169
+ }
170
+ `
171
+
172
+ /** Object-literal body mapping constructor args to env (required ones narrowed). */
173
+ const ctorArgs = (args) =>
174
+ args
175
+ .map(([name, key, req]) =>
176
+ req ? ` ${name}: required(env.${key}, '${key}'),` : ` ${name}: env.${key},`,
177
+ )
178
+ .join('\n')
179
+
180
+ /** Lazy-singleton composition root: boots without env, constructs on first use. */
181
+ function standardRoot({ entry, portType, adapterKey, fn, args }) {
182
+ const getter = `get${entry[0].toUpperCase()}${entry.slice(1)}`
183
+ const hasRequired = args.some((a) => a[2])
184
+ const ctor = args.length ? `${fn}({\n${ctorArgs(args)}\n })` : `${fn}()`
185
+ const envImport = args.length ? "import { env } from '~/env'\n" : ''
186
+ return `${envImport}import { ${fn} } from './adapters/${adapterKey}/index'
187
+ import type { ${portType} } from './core/port'
188
+ ${hasRequired ? REQUIRED_HELPER : ''}
189
+ let instance: ${portType} | null = null
190
+ export function ${getter}(): ${portType} {
191
+ if (!instance) {
192
+ instance = ${ctor}
193
+ }
194
+ return instance
195
+ }
196
+ `
197
+ }
198
+
199
+ /** Slug usable as an Inngest app id (no slashes, lowercase). */
200
+ const slug = (name) =>
201
+ name
202
+ .split('/')
203
+ .pop()
204
+ .replace(/[^a-z0-9]+/gi, '-')
205
+ .replace(/^-+|-+$/g, '')
206
+ .toLowerCase() || 'app'
207
+
208
+ /** Jobs needs the concrete adapter object (for serving), so it exports an eager const. */
209
+ function jobsRoot(adapterKey, projectName) {
210
+ if (adapterKey === 'inngest') {
211
+ return `import { env } from '~/env'
212
+ import { inngestAdapter } from './adapters/inngest/index'
213
+
214
+ // Composition root — register jobs with jobs.defineJob(), trigger with jobs.trigger().
215
+ export const jobs = inngestAdapter({
216
+ id: '${slug(projectName)}',
217
+ eventKey: env.INNGEST_EVENT_KEY,
218
+ })
219
+ `
220
+ }
221
+ if (adapterKey === 'trigger') {
222
+ return `import { env } from '~/env'
223
+ import { triggerDevAdapter } from './adapters/trigger/index'
224
+
225
+ // Composition root — register jobs with jobs.defineJob(), trigger with jobs.trigger().
226
+ export const jobs = triggerDevAdapter({
227
+ secretKey: env.TRIGGER_SECRET_KEY,
228
+ })
229
+ `
230
+ }
231
+ return `import { memoryAdapter } from './adapters/memory/index'
232
+
233
+ // In-process composition root (dev/tests) — runs handlers inline on trigger().
234
+ export const jobs = memoryAdapter()
235
+ `
236
+ }
237
+
238
+ const JOBS_SERVE = `import { serve } from 'inngest/edge'
239
+ import { env } from '~/env'
240
+ import { inngestServeHandler } from './adapters/inngest/index'
241
+ import { jobs } from './index'
242
+
243
+ // Web-standard FetchHandler serving the registered functions; mounted by the route shim.
244
+ export const handler = inngestServeHandler(jobs, serve, {
245
+ signingKey: env.INNGEST_SIGNING_KEY,
246
+ })
247
+ `
248
+
249
+ const NEXT_INNGEST_ROUTE = `import { handler } from '~/server/jobs/serve'
250
+
251
+ export { handler as GET, handler as POST, handler as PUT }
252
+ `
253
+
254
+ const TANSTACK_INNGEST_ROUTE = `import { createFileRoute } from '@tanstack/react-router'
255
+ import type {} from '@tanstack/react-start'
256
+ import { handler } from '~/server/jobs/serve'
257
+
258
+ export const Route = createFileRoute('/api/inngest')({
259
+ server: {
260
+ handlers: {
261
+ GET: ({ request }) => handler(request),
262
+ POST: ({ request }) => handler(request),
263
+ PUT: ({ request }) => handler(request),
264
+ },
265
+ },
266
+ })
267
+ `
268
+
269
+ /** Copy a manifest path (rooted at the package) under destDir, stripping `src/`. */
270
+ function copyPath(cap, relSrc, destDir) {
271
+ copy(join(PKG(cap), relSrc), join(destDir, relSrc.replace(/^src\//, '')))
272
+ }
273
+
274
+ /** True if any vendored .ts file under dir still imports a cross-package. */
275
+ function references(dir, mod) {
276
+ for (const name of readdirSync(dir, { recursive: true })) {
277
+ const p = join(dir, name)
278
+ if (statSync(p).isFile() && /\.tsx?$/.test(p) && read(p).includes(mod)) return true
279
+ }
280
+ return false
281
+ }
282
+
283
+ /** Run fn(path, source) over every vendored .ts/.tsx file under dir. */
284
+ function walkTs(dir, fn) {
285
+ for (const name of readdirSync(dir, { recursive: true })) {
286
+ const p = join(dir, name)
287
+ if (statSync(p).isFile() && /\.tsx?$/.test(p)) fn(p, read(p))
288
+ }
289
+ }
290
+
291
+ /** Rewrite every `from`-module import to `to` across vendored .ts files. */
292
+ function rewriteImports(dir, from, to) {
293
+ walkTs(dir, (p, src) => {
294
+ if (src.includes(from)) write(p, src.split(from).join(to))
295
+ })
296
+ }
297
+
298
+ // Package source uses NodeNext `.js` extensions on relative imports (for tsdown);
299
+ // the base app + app bundlers (Next/Vite) expect extensionless. Strip them on vendor.
300
+ const JS_EXT = /(from\s+['"]\.\.?\/[^'"]*?)\.js(['"])/g
301
+
302
+ /** Drop `.js` from relative import specifiers across vendored .ts files. */
303
+ function stripJsExtensions(dir) {
304
+ walkTs(dir, (p, src) => {
305
+ const next = src.replace(JS_EXT, '$1$2')
306
+ if (next !== src) write(p, next)
307
+ })
308
+ }
309
+
310
+ /** Vendor @alfredmouelle/http into src/lib/http (once) and point imports at it. */
311
+ function vendorHttp(projectDir, destDir) {
312
+ if (!references(destDir, '@alfredmouelle/http')) return
313
+ const httpDest = join(projectDir, 'src/lib/http')
314
+ if (!exists(httpDest)) {
315
+ copy(join(STACK_ROOT, 'packages/http/src'), httpDest)
316
+ stripJsExtensions(httpDest)
317
+ }
318
+ rewriteImports(destDir, '@alfredmouelle/http', '~/lib/http')
319
+ }
320
+
321
+ /** Dep ranges from the capability's manifest; skips vendored @alfredmouelle/* deps. */
322
+ function resolveDeps(cap, names) {
323
+ const pkg = readJSON(join(PKG(cap), 'package.json'))
324
+ const out = {}
325
+ for (const d of names) {
326
+ if (d.startsWith('@alfredmouelle/')) continue
327
+ out[d] = pkg.dependencies?.[d] ?? 'latest'
328
+ }
329
+ return out
330
+ }
331
+
332
+ /**
333
+ * Vendor one capability into the fork.
334
+ * @returns {{ addDeps: Record<string,string>, envKeys: string[] }}
335
+ */
336
+ export function vendorCapability({ projectDir, framework, projectName, cap, adapter }) {
337
+ const spec = CAPS[cap]
338
+ if (!spec) throw new Error(`Unknown capability: ${cap}`)
339
+ const aSpec = spec.adapters[adapter]
340
+ if (!aSpec) throw new Error(`Unknown ${cap} adapter: ${adapter}`)
341
+
342
+ const manifest = readJSON(join(PKG(cap), 'capability.json'))
343
+ const adManifest = manifest.adapters[adapter]
344
+ const destDir = join(projectDir, spec.dir)
345
+
346
+ // core (+ shared) and the chosen adapter; the barrel index.ts is replaced below.
347
+ const files = [...manifest.sharedFiles.filter((f) => f !== 'src/index.ts'), ...adManifest.files]
348
+ for (const f of files) copyPath(cap, f, destDir)
349
+ stripJsExtensions(destDir) // NodeNext `.js` specifiers → extensionless (app bundler)
350
+
351
+ // composition root
352
+ write(
353
+ join(destDir, 'index.ts'),
354
+ spec.kind === 'jobs'
355
+ ? jobsRoot(adapter, projectName)
356
+ : standardRoot({ ...spec, adapterKey: adapter, fn: aSpec.fn, args: aSpec.args }),
357
+ )
358
+
359
+ // jobs/inngest has an HTTP surface: serve handler + per-framework route shim.
360
+ if (spec.kind === 'jobs' && adapter === 'inngest') {
361
+ write(join(destDir, 'serve.ts'), JOBS_SERVE)
362
+ if (framework === 'next') {
363
+ write(join(projectDir, 'src/app/api/inngest/route.ts'), NEXT_INNGEST_ROUTE)
364
+ } else {
365
+ write(join(projectDir, 'src/routes/api/inngest.ts'), TANSTACK_INNGEST_ROUTE)
366
+ }
367
+ }
368
+
369
+ vendorHttp(projectDir, destDir)
370
+
371
+ return {
372
+ addDeps: resolveDeps(cap, [...adManifest.deps, ...(manifest.sharedDeps ?? [])]),
373
+ envKeys: adManifest.env,
374
+ }
375
+ }
package/lib/env.mjs CHANGED
@@ -1,10 +1,9 @@
1
- // Rebuilds src/env.ts `server` + `runtimeEnv` blocks from the final key set,
2
- // and generates .env.example + .env. Adds keys (e.g. a swapped mailer provider)
3
- // and prunes keys of stripped foundations/capabilities — deterministically.
1
+ // Rebuilds src/env.ts `server` + `runtimeEnv` from the final key set; generates
2
+ // .env.example + .env. Adds swapped-mailer keys, prunes stripped ones, deterministically.
4
3
 
5
4
  import { editFile, join, write } from './util.mjs'
6
5
 
7
- /** valibot schema text per known env key. */
6
+ /** valibot schema text per env key. */
8
7
  const SCHEMAS = {
9
8
  DATABASE_URL: 'v.pipe(v.string(), v.url())',
10
9
  BETTER_AUTH_URL: 'v.pipe(v.string(), v.url())',
@@ -17,9 +16,30 @@ const SCHEMAS = {
17
16
  AWS_REGION: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
18
17
  AWS_ACCESS_KEY_ID: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
19
18
  AWS_SECRET_ACCESS_KEY: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
19
+ // capabilities — all optional; adapters validate required-ness at first use.
20
+ // emptyStringAsUndefined (env.ts) turns blank placeholders into undefined.
21
+ S3_BUCKET: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
22
+ S3_REGION: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
23
+ R2_BUCKET: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
24
+ R2_ACCOUNT_ID: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
25
+ R2_ACCESS_KEY_ID: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
26
+ R2_SECRET_ACCESS_KEY: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
27
+ GCS_BUCKET: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
28
+ GOOGLE_CLOUD_PROJECT: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
29
+ STORAGE_LOCAL_DIR: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
30
+ REDIS_URL: 'v.optional(v.pipe(v.string(), v.url()))',
31
+ POSTHOG_API_KEY: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
32
+ POSTHOG_HOST: 'v.optional(v.pipe(v.string(), v.url()))',
33
+ PLAUSIBLE_DOMAIN: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
34
+ PLAUSIBLE_API_HOST: 'v.optional(v.pipe(v.string(), v.url()))',
35
+ SENTRY_DSN: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
36
+ SENTRY_ENVIRONMENT: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
37
+ INNGEST_EVENT_KEY: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
38
+ INNGEST_SIGNING_KEY: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
39
+ TRIGGER_SECRET_KEY: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
20
40
  }
21
41
 
22
- /** Placeholder values for the generated .env files. */
42
+ /** Placeholder values for generated .env files. */
23
43
  const PLACEHOLDERS = {
24
44
  DATABASE_URL: 'postgres://postgres:postgres@localhost:5432/app',
25
45
  BETTER_AUTH_URL: 'http://localhost:3000',
@@ -29,7 +49,7 @@ const PLACEHOLDERS = {
29
49
 
30
50
  const indent = (s) => ` ${s}`
31
51
 
32
- /** Write the final env.ts and .env files. `keys` is an ordered string[]. */
52
+ /** Write final env.ts + .env files. `keys`: ordered string[]. */
33
53
  export function writeEnv(projectDir, keys) {
34
54
  const seen = new Set()
35
55
  const ordered = keys.filter((k) => SCHEMAS[k] && !seen.has(k) && seen.add(k))
@@ -0,0 +1,35 @@
1
+ // Foundations the CLI can strip + the npm footprint each adds (deps/devDeps/scripts only).
2
+ // File deletes, env keys, code seams live in strip.mjs/build.mjs (need framework-specific surgery).
3
+
4
+ export const FOUNDATIONS = ['drizzle', 'trpc', 'better-auth', 'data-table']
5
+
6
+ const DATA = {
7
+ drizzle: {
8
+ deps: ['drizzle-orm', 'pg'],
9
+ devDeps: ['drizzle-kit', 'dotenv', 'tsx', '@types/pg', '@faker-js/faker'],
10
+ scripts: ['db:generate', 'db:migrate', 'db:push', 'db:studio', 'db:seed'],
11
+ },
12
+ trpc: {
13
+ deps: ['@trpc/server', '@trpc/client', '@tanstack/react-query', 'superjson', 'valibot'],
14
+ // React Query bridge differs per framework
15
+ perFramework: { tanstack: ['@trpc/tanstack-react-query'], next: ['@trpc/react-query'] },
16
+ },
17
+ 'better-auth': {
18
+ deps: ['better-auth'],
19
+ },
20
+ 'data-table': {
21
+ deps: ['@tanstack/react-table'],
22
+ },
23
+ }
24
+
25
+ /** Every npm dep (prod + dev) a foundation contributes, for `framework`. */
26
+ export function foundationDeps(name, framework) {
27
+ const d = DATA[name]
28
+ if (!d) return []
29
+ return [...(d.deps ?? []), ...(d.devDeps ?? []), ...(d.perFramework?.[framework] ?? [])]
30
+ }
31
+
32
+ /** package.json script names a foundation adds. */
33
+ export function foundationScripts(name) {
34
+ return DATA[name]?.scripts ?? []
35
+ }
package/lib/identity.mjs CHANGED
@@ -1,7 +1,6 @@
1
- // Step A4 — stamp the project identity: title/meta + a README that ends with
2
- // the stack's `# Author` footer verbatim.
1
+ // Stamp project identity: title/meta + README ending with the `# Author` footer verbatim.
3
2
 
4
- import { STACK_ROOT } from './manifests.mjs'
3
+ import { TEMPLATES } from './paths.mjs'
5
4
  import { editFile, join, read, write } from './util.mjs'
6
5
 
7
6
  const titleFiles = {
@@ -10,12 +9,12 @@ const titleFiles = {
10
9
  }
11
10
 
12
11
  export function stampIdentity(projectDir, projectName, framework) {
13
- // Swap the placeholder title 'App' in the root document/metadata.
12
+ // swap placeholder title 'App' in root document/metadata
14
13
  for (const rel of titleFiles[framework === 'next' ? 'next' : 'tanstack']) {
15
14
  editFile(join(projectDir, rel), (c) => c.replaceAll("title: 'App'", `title: '${projectName}'`))
16
15
  }
17
16
 
18
- const footer = read(join(STACK_ROOT, 'patterns/_baseline/README-author.md'))
17
+ const footer = read(join(TEMPLATES, 'README-author.md'))
19
18
  const readme = `# ${projectName}
20
19
 
21
20
  Bootstrapped from the personal reference stack.
package/lib/mailer.mjs CHANGED
@@ -1,13 +1,12 @@
1
- // Mailer provider swap. The base inlines the Resend adapter; if the user picks
2
- // another provider we swap the adapter files + the composition root (email/index.ts)
3
- // and return the dep/env deltas. Mirrors the mailer capability manifest.
1
+ // Mailer provider swap. Base inlines Resend; other providers swap adapter files +
2
+ // composition root (email/index.ts) and return dep/env deltas. Mirrors the mailer manifest.
4
3
 
5
- import { STACK_ROOT } from './manifests.mjs'
4
+ import { STACK_ROOT } from './paths.mjs'
6
5
  import { copy, join, readJSON, remove, write } from './util.mjs'
7
6
 
8
7
  const EMAIL_DIR = 'src/server/email'
9
8
 
10
- /** getMailer() body per provider (composition root in email/index.ts). */
9
+ /** getMailer() body per provider (composition root). */
11
10
  const FACTORY = {
12
11
  brevo: {
13
12
  import: "import { brevoAdapter } from './adapters/brevo/index'",
@@ -17,7 +16,7 @@ const FACTORY = {
17
16
  },
18
17
  ses: {
19
18
  import: "import { sesAdapter } from './adapters/ses/index'",
20
- // SESv2 SDK reads AWS_REGION / AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY from env.
19
+ // SESv2 SDK reads AWS_REGION / AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY from env
21
20
  adapter: 'sesAdapter()',
22
21
  envKeys: ['EMAIL_FROM', 'AWS_REGION', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'],
23
22
  pkgDep: '@aws-sdk/client-sesv2',
@@ -63,10 +62,7 @@ export async function sendEmail(params: {
63
62
  }
64
63
  `
65
64
 
66
- /**
67
- * Swap the inlined mailer to `provider`. Returns { addDeps, removeDeps, envKeys }.
68
- * provider === 'resend' is a no-op (the base default).
69
- */
65
+ /** Swap inlined mailer to `provider` → { addDeps, removeDeps, envKeys }. 'resend' is a no-op (base default). */
70
66
  export function swapMailer(projectDir, provider) {
71
67
  if (provider === 'resend') {
72
68
  return { addDeps: {}, removeDeps: [], envKeys: ['EMAIL_FROM', 'RESEND_API_KEY'] }
@@ -74,17 +70,17 @@ export function swapMailer(projectDir, provider) {
74
70
  const cfg = FACTORY[provider]
75
71
  if (!cfg) throw new Error(`Unknown mailer provider: ${provider}`)
76
72
 
77
- // Swap adapter files: drop resend, copy the chosen adapter from the package.
73
+ // swap adapter files: drop resend, copy chosen adapter from package
78
74
  remove(join(projectDir, EMAIL_DIR, 'adapters/resend'))
79
75
  copy(
80
76
  join(STACK_ROOT, 'packages/mailer/src/adapters', provider),
81
77
  join(projectDir, EMAIL_DIR, 'adapters', provider),
82
78
  )
83
79
 
84
- // Rewrite the composition root.
80
+ // rewrite composition root
85
81
  write(join(projectDir, EMAIL_DIR, 'index.ts'), INDEX_TS(cfg))
86
82
 
87
- // Dep delta — pull the provider's range from the mailer package manifest.
83
+ // dep delta — pull provider's range from mailer package manifest
88
84
  const mailerPkg = readJSON(join(STACK_ROOT, 'packages/mailer/package.json'))
89
85
  const range = mailerPkg.dependencies?.[cfg.pkgDep] ?? 'latest'
90
86
  return {
package/lib/paths.mjs ADDED
@@ -0,0 +1,15 @@
1
+ // Where the bundled stack assets live: cli/_stack when published (scripts/bundle.mjs), repo root in dev.
2
+
3
+ import { existsSync } from 'node:fs'
4
+ import { dirname, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url))
8
+ const bundled = resolve(here, '..', '_stack')
9
+
10
+ // Forkable source (base apps + mailer adapters): _stack when published, monorepo root in dev.
11
+ export const STACK_ROOT = existsSync(bundled) ? bundled : resolve(here, '..', '..')
12
+
13
+ // CLI-owned templates injected into the fork (root-wiring variants, biome.jsonc,
14
+ // # Author README footer). Always shipped in the package.
15
+ export const TEMPLATES = resolve(here, '..', 'templates')
package/lib/scaffold.mjs CHANGED
@@ -1,7 +1,7 @@
1
- // Step A2 — fork a base app into the target dir and make it standalone
2
- // (its own Biome config, pnpm workspace + build allowlist, project name).
1
+ // Fork a base app into the target dir + make it standalone
2
+ // (own Biome config, pnpm workspace + build allowlist, project name).
3
3
 
4
- import { STACK_ROOT } from './manifests.mjs'
4
+ import { STACK_ROOT, TEMPLATES } from './paths.mjs'
5
5
  import { copy, exists, join, readJSON, run, write, writeJSON } from './util.mjs'
6
6
 
7
7
  const RSYNC_EXCLUDES = [
@@ -20,10 +20,11 @@ const PNPM_WORKSPACE = `allowBuilds:
20
20
  esbuild: true
21
21
  sharp: true
22
22
  lightningcss: true
23
+ protobufjs: true
23
24
  `
24
25
 
25
- // Generated explicitly: npm strips `.gitignore` from published tarballs, so we
26
- // can't rely on the bundled base app's copy surviving. Both keep `.env.example`.
26
+ // Generated explicitly: npm strips `.gitignore` from published tarballs, so the
27
+ // bundled base app's copy won't survive. Both keep `.env.example`.
27
28
  const GITIGNORE = {
28
29
  tanstack: `node_modules
29
30
  .DS_Store
@@ -74,7 +75,7 @@ next-env.d.ts
74
75
  `,
75
76
  }
76
77
 
77
- /** Copy the base app into projectDir, minus build output & generated files. */
78
+ /** Copy base app into projectDir, minus build output & generated files. */
78
79
  export function forkBase(framework, projectDir) {
79
80
  const base = join(STACK_ROOT, 'apps', framework === 'next' ? 'next-base' : 'tanstack-base')
80
81
  if (!exists(base)) throw new Error(`Base app not found: ${base}`)
@@ -86,19 +87,19 @@ export function forkBase(framework, projectDir) {
86
87
 
87
88
  /** Make the fork standalone (Biome, pnpm workspace, .gitignore, identity). */
88
89
  export function makeStandalone(projectDir, projectName, framework) {
89
- // A fork needs its own Biome config (the base inherits the monorepo root's).
90
- copy(join(STACK_ROOT, 'patterns/_baseline/biome.jsonc'), join(projectDir, 'biome.jsonc'))
90
+ // fork needs its own Biome config (base inherits the monorepo root's)
91
+ copy(join(TEMPLATES, 'biome.jsonc'), join(projectDir, 'biome.jsonc'))
91
92
 
92
- // Biome's vcs.useIgnoreFile needs a .gitignore; also good project hygiene.
93
+ // Biome vcs.useIgnoreFile needs a .gitignore
93
94
  write(join(projectDir, '.gitignore'), GITIGNORE[framework === 'next' ? 'next' : 'tanstack'])
94
95
 
95
- // Avoid ERR_PNPM_IGNORED_BUILDS on a fresh install (native build scripts).
96
+ // avoid ERR_PNPM_IGNORED_BUILDS on fresh install (native build scripts)
96
97
  write(join(projectDir, 'pnpm-workspace.yaml'), PNPM_WORKSPACE)
97
98
 
98
99
  const pkgPath = join(projectDir, 'package.json')
99
100
  const pkg = readJSON(pkgPath)
100
101
  pkg.name = projectName
101
- delete pkg.private // a leaf project; let the user decide
102
+ delete pkg.private // leaf project
102
103
  pkg.private = true
103
104
  writeJSON(pkgPath, pkg)
104
105
  }