@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.
- package/README.md +41 -18
- package/_stack/apps/next-base/.turbo/turbo-typecheck.log +1 -0
- package/_stack/apps/next-base/.vscode/settings.json +35 -0
- package/_stack/apps/next-base/.zed/settings.json +45 -0
- package/_stack/apps/next-base/src/components/ui/spinner.tsx +1 -1
- package/_stack/apps/next-base/src/emails/components/components.tsx +2 -2
- package/_stack/apps/next-base/src/emails/components/context.tsx +1 -1
- package/_stack/apps/next-base/src/emails/components/theme.ts +6 -7
- package/_stack/apps/next-base/src/env.ts +2 -3
- package/_stack/apps/next-base/src/lib/date.ts +1 -1
- package/_stack/apps/next-base/src/server/auth/guards.ts +1 -1
- package/_stack/apps/next-base/src/server/better-auth/config.ts +1 -1
- package/_stack/apps/next-base/src/server/better-auth/server.ts +2 -2
- package/_stack/apps/next-base/src/server/db/schemas/index.ts +1 -1
- package/_stack/apps/next-base/src/server/db/seed.ts +2 -2
- package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +3 -3
- package/_stack/apps/next-base/src/server/email/core/address.ts +3 -3
- package/_stack/apps/next-base/src/server/email/core/port.ts +13 -20
- package/_stack/apps/next-base/src/server/email/core/render.ts +2 -2
- package/_stack/apps/next-base/src/server/email/factory.ts +7 -9
- package/_stack/apps/next-base/src/server/email/index.ts +1 -1
- package/_stack/apps/next-base/src/trpc/react.tsx +2 -2
- package/_stack/apps/next-base/src/trpc/server.ts +1 -1
- package/_stack/apps/tanstack-base/.turbo/turbo-typecheck.log +1 -0
- package/_stack/apps/tanstack-base/.vscode/settings.json +35 -0
- package/_stack/apps/tanstack-base/.zed/settings.json +45 -0
- package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +1 -1
- package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +1 -1
- package/_stack/apps/tanstack-base/src/emails/components/components.tsx +2 -2
- package/_stack/apps/tanstack-base/src/emails/components/context.tsx +1 -1
- package/_stack/apps/tanstack-base/src/emails/components/theme.ts +6 -7
- package/_stack/apps/tanstack-base/src/env.ts +2 -6
- package/_stack/apps/tanstack-base/src/lib/date.ts +1 -1
- package/_stack/apps/tanstack-base/src/routes/__root.tsx +1 -1
- package/_stack/apps/tanstack-base/src/routes/_authed.tsx +1 -4
- package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +1 -1
- package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +1 -2
- package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +6 -7
- package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/db/seed.ts +2 -2
- package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +3 -3
- package/_stack/apps/tanstack-base/src/server/email/core/address.ts +3 -3
- package/_stack/apps/tanstack-base/src/server/email/core/port.ts +12 -22
- package/_stack/apps/tanstack-base/src/server/email/core/render.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/email/factory.ts +7 -8
- package/_stack/apps/tanstack-base/src/server/email/index.ts +1 -1
- package/_stack/packages/analytics/package.json +26 -0
- package/_stack/packages/analytics/src/adapters/noop/index.ts +12 -0
- package/_stack/packages/analytics/src/adapters/plausible/config.ts +10 -0
- package/_stack/packages/analytics/src/adapters/plausible/index.ts +94 -0
- package/_stack/packages/analytics/src/adapters/posthog/config.ts +7 -0
- package/_stack/packages/analytics/src/adapters/posthog/index.ts +50 -0
- package/_stack/packages/analytics/src/core/port.ts +30 -0
- package/_stack/packages/analytics/src/index.ts +17 -0
- package/_stack/packages/cache/package.json +25 -0
- package/_stack/packages/cache/src/adapters/memory/index.ts +51 -0
- package/_stack/packages/cache/src/adapters/redis/config.ts +8 -0
- package/_stack/packages/cache/src/adapters/redis/index.ts +73 -0
- package/_stack/packages/cache/src/core/port.ts +29 -0
- package/_stack/packages/cache/src/core/wrap.ts +20 -0
- package/_stack/packages/cache/src/index.ts +12 -0
- package/_stack/packages/error-tracking/package.json +25 -0
- package/_stack/packages/error-tracking/src/adapters/console/index.ts +43 -0
- package/_stack/packages/error-tracking/src/adapters/sentry/config.ts +8 -0
- package/_stack/packages/error-tracking/src/adapters/sentry/index.ts +72 -0
- package/_stack/packages/error-tracking/src/core/port.ts +39 -0
- package/_stack/packages/error-tracking/src/index.ts +14 -0
- package/_stack/packages/http/package.json +20 -0
- package/_stack/packages/http/src/api.ts +373 -0
- package/_stack/packages/http/src/index.ts +14 -0
- package/_stack/packages/http/src/responses.ts +25 -0
- package/_stack/packages/http/src/types.ts +9 -0
- package/_stack/packages/jobs/package.json +27 -0
- package/_stack/packages/jobs/src/adapters/inngest/config.ts +8 -0
- package/_stack/packages/jobs/src/adapters/inngest/index.ts +93 -0
- package/_stack/packages/jobs/src/adapters/memory/index.ts +31 -0
- package/_stack/packages/jobs/src/adapters/trigger/config.ts +8 -0
- package/_stack/packages/jobs/src/adapters/trigger/index.ts +85 -0
- package/_stack/packages/jobs/src/core/port.ts +37 -0
- package/_stack/packages/jobs/src/index.ts +23 -0
- package/_stack/packages/logger/package.json +25 -0
- package/_stack/packages/logger/src/adapters/console/config.ts +7 -0
- package/_stack/packages/logger/src/adapters/console/index.ts +69 -0
- package/_stack/packages/logger/src/adapters/pino/index.ts +54 -0
- package/_stack/packages/logger/src/core/port.ts +21 -0
- package/_stack/packages/logger/src/index.ts +12 -0
- package/_stack/packages/mailer/src/adapters/brevo/index.ts +3 -3
- package/_stack/packages/mailer/src/adapters/resend/index.ts +3 -3
- package/_stack/packages/mailer/src/adapters/ses/config.ts +3 -3
- package/_stack/packages/mailer/src/adapters/ses/index.ts +4 -5
- package/_stack/packages/storage/package.json +27 -0
- package/_stack/packages/storage/src/adapters/gcs/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/gcs/index.ts +111 -0
- package/_stack/packages/storage/src/adapters/local/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/local/index.ts +78 -0
- package/_stack/packages/storage/src/adapters/r2/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/r2/index.ts +39 -0
- package/_stack/packages/storage/src/adapters/s3/config.ts +11 -0
- package/_stack/packages/storage/src/adapters/s3/index.ts +143 -0
- package/_stack/packages/storage/src/core/port.ts +41 -0
- package/_stack/packages/storage/src/index.ts +21 -0
- package/index.mjs +89 -55
- package/lib/build.mjs +21 -11
- package/lib/capabilities.mjs +375 -0
- package/lib/env.mjs +26 -6
- package/lib/foundations.mjs +35 -0
- package/lib/identity.mjs +4 -5
- package/lib/mailer.mjs +9 -13
- package/lib/paths.mjs +15 -0
- package/lib/scaffold.mjs +12 -11
- package/lib/strip.mjs +9 -24
- package/lib/util.mjs +8 -9
- package/package.json +1 -1
- package/_stack/packages/mailer/capability.json +0 -28
- package/_stack/patterns/README.md +0 -58
- package/_stack/patterns/_baseline/env.ts +0 -31
- package/_stack/patterns/_baseline/tsconfig.json +0 -27
- package/_stack/patterns/better-auth/pattern.json +0 -73
- package/_stack/patterns/better-auth-next/pattern.json +0 -76
- package/_stack/patterns/data-table/pattern.json +0 -43
- package/_stack/patterns/drizzle/pattern.json +0 -61
- package/_stack/patterns/trpc/pattern.json +0 -61
- package/_stack/patterns/trpc-next/pattern.json +0 -64
- package/lib/manifests.mjs +0 -61
- /package/{_stack/patterns/_baseline → templates}/README-author.md +0 -0
- /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`
|
|
2
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2
|
-
// the stack's `# Author` footer verbatim.
|
|
1
|
+
// Stamp project identity: title/meta + README ending with the `# Author` footer verbatim.
|
|
3
2
|
|
|
4
|
-
import {
|
|
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
|
-
//
|
|
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(
|
|
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.
|
|
2
|
-
//
|
|
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 './
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
80
|
+
// rewrite composition root
|
|
85
81
|
write(join(projectDir, EMAIL_DIR, 'index.ts'), INDEX_TS(cfg))
|
|
86
82
|
|
|
87
|
-
//
|
|
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
|
-
//
|
|
2
|
-
// (
|
|
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 './
|
|
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
|
|
26
|
-
//
|
|
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
|
|
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
|
-
//
|
|
90
|
-
copy(join(
|
|
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
|
|
93
|
+
// Biome vcs.useIgnoreFile needs a .gitignore
|
|
93
94
|
write(join(projectDir, '.gitignore'), GITIGNORE[framework === 'next' ? 'next' : 'tanstack'])
|
|
94
95
|
|
|
95
|
-
//
|
|
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 //
|
|
102
|
+
delete pkg.private // leaf project
|
|
102
103
|
pkg.private = true
|
|
103
104
|
writeJSON(pkgPath, pkg)
|
|
104
105
|
}
|