@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,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
@@ -16,6 +16,27 @@ const SCHEMAS = {
16
16
  AWS_REGION: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
17
17
  AWS_ACCESS_KEY_ID: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
18
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)))',
19
40
  }
20
41
 
21
42
  /** Placeholder values for generated .env files. */
package/lib/scaffold.mjs CHANGED
@@ -20,6 +20,7 @@ const PNPM_WORKSPACE = `allowBuilds:
20
20
  esbuild: true
21
21
  sharp: true
22
22
  lightningcss: true
23
+ protobufjs: true
23
24
  `
24
25
 
25
26
  // Generated explicitly: npm strips `.gitignore` from published tarballs, so the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfredmouelle/create-stack",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Interactive, deterministic installer for the personal reference stack — forks a base app (Next.js / TanStack Start) and strips it to your selection.",
6
6
  "author": {