@alfredmouelle/create-stack 0.1.0 → 0.1.2
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 +101 -40
- 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 +4 -2
- package/_stack/apps/next-base/src/emails/components/context.tsx +3 -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 +12 -5
- 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/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/index.mjs +33 -44
- package/lib/build.mjs +7 -15
- package/lib/env.mjs +5 -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 +65 -10
- package/lib/strip.mjs +9 -24
- package/lib/util.mjs +8 -9
- package/package.json +1 -1
- package/_stack/packages/analytics/capability.json +0 -26
- package/_stack/packages/cache/capability.json +0 -21
- package/_stack/packages/error-tracking/capability.json +0 -21
- package/_stack/packages/jobs/capability.json +0 -26
- package/_stack/packages/logger/capability.json +0 -21
- package/_stack/packages/mailer/capability.json +0 -28
- package/_stack/packages/storage/capability.json +0 -32
- 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
package/index.mjs
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// create-stack —
|
|
3
|
-
//
|
|
4
|
-
// app, strip it to the selection, stamp identity, verify.
|
|
5
|
-
//
|
|
6
|
-
// Interactive by default. Non-interactive when any selection flag (or --yes) is
|
|
7
|
-
// passed — useful for scripts/CI and for headless end-to-end testing:
|
|
2
|
+
// create-stack — fork a base app, strip to selection, stamp identity, verify.
|
|
3
|
+
// Interactive by default; non-interactive when any selection flag (or --yes) is passed:
|
|
8
4
|
// create-stack my-app --framework next --foundations drizzle,trpc --mailer ses --no-install
|
|
9
5
|
|
|
10
6
|
import { resolve } from 'node:path'
|
|
11
7
|
import * as p from '@clack/prompts'
|
|
12
8
|
import { buildProject } from './lib/build.mjs'
|
|
13
|
-
import { loadCapabilities, loadPatterns } from './lib/manifests.mjs'
|
|
14
9
|
import { isDirEmpty, run } from './lib/util.mjs'
|
|
15
10
|
|
|
16
|
-
// Capabilities baked into the base apps vs. the ones add-capability supplies.
|
|
17
|
-
const BAKED_CAPS = new Set(['mailer', 'email-kit'])
|
|
18
11
|
const ALL_FOUNDATIONS = ['drizzle', 'trpc', 'better-auth', 'data-table']
|
|
19
12
|
|
|
20
13
|
const cancelled = (v) => {
|
|
@@ -54,7 +47,7 @@ const csv = (v) =>
|
|
|
54
47
|
.filter(Boolean)
|
|
55
48
|
: []
|
|
56
49
|
|
|
57
|
-
/** Resolve hard deps +
|
|
50
|
+
/** Resolve hard deps + mailer's better-auth requirement. */
|
|
58
51
|
function normalize(picked, mailer) {
|
|
59
52
|
const kept = new Set(picked.filter((f) => ALL_FOUNDATIONS.includes(f)))
|
|
60
53
|
if (kept.has('trpc') || kept.has('better-auth')) kept.add('drizzle')
|
|
@@ -63,19 +56,17 @@ function normalize(picked, mailer) {
|
|
|
63
56
|
return { kept, mailerProvider }
|
|
64
57
|
}
|
|
65
58
|
|
|
66
|
-
function collectFromFlags(args
|
|
59
|
+
function collectFromFlags(args) {
|
|
67
60
|
const argDir = args._[0]
|
|
68
61
|
if (!argDir) throw new Error('Project name is required (positional) in non-interactive mode')
|
|
69
62
|
const framework = args.flags.framework === 'next' ? 'next' : 'tanstack'
|
|
70
63
|
const picked = args.flags.foundations ? csv(args.flags.foundations) : [...ALL_FOUNDATIONS]
|
|
71
64
|
const { kept, mailerProvider } = normalize(picked, args.flags.mailer)
|
|
72
|
-
const validCaps = new Set(Object.keys(capabilities).filter((c) => !BAKED_CAPS.has(c)))
|
|
73
|
-
const extraCaps = csv(args.flags.caps).filter((c) => validCaps.has(c))
|
|
74
65
|
const doInstall = !args.flags['no-install']
|
|
75
|
-
return { argDir, projectName: argDir, framework, kept, mailerProvider,
|
|
66
|
+
return { argDir, projectName: argDir, framework, kept, mailerProvider, doInstall }
|
|
76
67
|
}
|
|
77
68
|
|
|
78
|
-
async function collectFromPrompts(argDir
|
|
69
|
+
async function collectFromPrompts(argDir) {
|
|
79
70
|
p.intro('create-stack — fork a base app, strip it to your selection')
|
|
80
71
|
|
|
81
72
|
const name = cancelled(
|
|
@@ -127,26 +118,15 @@ async function collectFromPrompts(argDir, capabilities) {
|
|
|
127
118
|
}),
|
|
128
119
|
)
|
|
129
120
|
|
|
130
|
-
const extraCaps = cancelled(
|
|
131
|
-
await p.multiselect({
|
|
132
|
-
message: 'Extra capabilities (optional — added via add-capability)',
|
|
133
|
-
required: false,
|
|
134
|
-
initialValues: [],
|
|
135
|
-
options: Object.keys(capabilities)
|
|
136
|
-
.filter((c) => !BAKED_CAPS.has(c))
|
|
137
|
-
.map((c) => ({ value: c, label: c, hint: capabilities[c].description?.slice(0, 40) })),
|
|
138
|
-
}),
|
|
139
|
-
)
|
|
140
|
-
|
|
141
121
|
const doInstall = cancelled(
|
|
142
122
|
await p.confirm({ message: 'Install dependencies and verify now?', initialValue: true }),
|
|
143
123
|
)
|
|
144
124
|
|
|
145
125
|
const { kept, mailerProvider } = normalize(picked, mailer)
|
|
146
|
-
return { argDir, projectName, framework, kept, mailerProvider,
|
|
126
|
+
return { argDir, projectName, framework, kept, mailerProvider, doInstall }
|
|
147
127
|
}
|
|
148
128
|
|
|
149
|
-
function execute(a
|
|
129
|
+
function execute(a) {
|
|
150
130
|
const projectDir = resolve(process.cwd(), a.argDir ?? a.projectName)
|
|
151
131
|
if (!isDirEmpty(projectDir)) {
|
|
152
132
|
p.cancel(`Target directory is not empty: ${projectDir}`)
|
|
@@ -155,7 +135,7 @@ function execute(a, patterns) {
|
|
|
155
135
|
|
|
156
136
|
const s = p.spinner()
|
|
157
137
|
s.start('Forking + stripping the base app')
|
|
158
|
-
buildProject({ ...a, projectDir
|
|
138
|
+
buildProject({ ...a, projectDir })
|
|
159
139
|
s.stop('Project scaffolded')
|
|
160
140
|
|
|
161
141
|
if (a.doInstall) {
|
|
@@ -173,20 +153,33 @@ function execute(a, patterns) {
|
|
|
173
153
|
)
|
|
174
154
|
}
|
|
175
155
|
|
|
156
|
+
// fresh repo + initial commit (also satisfies Biome vcs.useIgnoreFile).
|
|
157
|
+
// commit is best-effort: skipped if git identity unset, staged tree left in place.
|
|
158
|
+
if (run('git', ['-C', projectDir, 'init', '-q'])) {
|
|
159
|
+
run('git', ['-C', projectDir, 'add', '-A'])
|
|
160
|
+
const committed = run(
|
|
161
|
+
'git',
|
|
162
|
+
['-C', projectDir, 'commit', '-q', '-m', 'chore: initial commit from create-stack'],
|
|
163
|
+
{ stdio: 'ignore' },
|
|
164
|
+
)
|
|
165
|
+
p.log.step(
|
|
166
|
+
committed
|
|
167
|
+
? 'git repository initialized (initial commit created)'
|
|
168
|
+
: 'git repository initialized — set git user.name/email, then commit',
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
176
172
|
const keptMailer = a.mailerProvider !== 'none'
|
|
177
173
|
const lines = [
|
|
178
174
|
`Framework: ${a.framework === 'next' ? 'Next.js' : 'TanStack Start'}`,
|
|
179
175
|
`Foundations: ${[...a.kept].sort().join(', ') || '(none)'}`,
|
|
180
176
|
`Mailer: ${keptMailer ? a.mailerProvider : '(none)'}`,
|
|
177
|
+
'',
|
|
178
|
+
'Add more tools (storage, jobs, cache, analytics, …) with the add-capability skill.',
|
|
179
|
+
'',
|
|
180
|
+
'Next:',
|
|
181
|
+
` cd ${a.argDir ?? a.projectName}`,
|
|
181
182
|
]
|
|
182
|
-
if (a.extraCaps.length) {
|
|
183
|
-
lines.push(
|
|
184
|
-
'',
|
|
185
|
-
'Add the extra capabilities with the add-capability skill:',
|
|
186
|
-
...a.extraCaps.map((c) => ` • ${c}`),
|
|
187
|
-
)
|
|
188
|
-
}
|
|
189
|
-
lines.push('', 'Next:', ` cd ${a.argDir ?? a.projectName}`)
|
|
190
183
|
if (!a.doInstall) lines.push(' pnpm install')
|
|
191
184
|
lines.push(' cp .env.example .env # fill in the values', ' pnpm dev')
|
|
192
185
|
p.note(lines.join('\n'), 'Done')
|
|
@@ -194,20 +187,16 @@ function execute(a, patterns) {
|
|
|
194
187
|
}
|
|
195
188
|
|
|
196
189
|
async function main() {
|
|
197
|
-
const patterns = loadPatterns()
|
|
198
|
-
const capabilities = loadCapabilities()
|
|
199
190
|
const args = parseArgs(process.argv.slice(2))
|
|
200
191
|
|
|
201
192
|
const nonInteractive =
|
|
202
193
|
args.flags.yes ||
|
|
203
194
|
args.flags.y ||
|
|
204
|
-
['framework', 'foundations', 'mailer', '
|
|
195
|
+
['framework', 'foundations', 'mailer', 'no-install'].some((k) => k in args.flags)
|
|
205
196
|
|
|
206
|
-
const answers = nonInteractive
|
|
207
|
-
? collectFromFlags(args, capabilities)
|
|
208
|
-
: await collectFromPrompts(args._[0], capabilities)
|
|
197
|
+
const answers = nonInteractive ? collectFromFlags(args) : await collectFromPrompts(args._[0])
|
|
209
198
|
|
|
210
|
-
execute(answers
|
|
199
|
+
execute(answers)
|
|
211
200
|
}
|
|
212
201
|
|
|
213
202
|
main().catch((err) => {
|
package/lib/build.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Pure build phase (no prompts
|
|
2
|
-
//
|
|
1
|
+
// Pure build phase (no prompts/install): fork → strip → mailer → env → identity.
|
|
2
|
+
// Shared by index.mjs (post-wizard) and the test harness.
|
|
3
3
|
|
|
4
4
|
import { writeEnv } from './env.mjs'
|
|
5
5
|
import { stampIdentity } from './identity.mjs'
|
|
@@ -13,33 +13,25 @@ import { join, pkgAddDeps, pkgRemoveDeps, pkgRemoveScripts, readJSON, writeJSON
|
|
|
13
13
|
* @param {string} o.projectDir absolute target dir (must be empty)
|
|
14
14
|
* @param {string} o.projectName
|
|
15
15
|
* @param {'next'|'tanstack'} o.framework
|
|
16
|
-
* @param {Set<string>} o.kept
|
|
16
|
+
* @param {Set<string>} o.kept foundations to keep (deps pre-resolved)
|
|
17
17
|
* @param {'resend'|'brevo'|'ses'|'none'} o.mailerProvider
|
|
18
|
-
* @param {object} o.patterns loadPatterns()
|
|
19
18
|
* @returns {{ kept: string[], keptMailer: boolean, mailerProvider: string, envKeys: string[] }}
|
|
20
19
|
*/
|
|
21
|
-
export function buildProject({
|
|
22
|
-
projectDir,
|
|
23
|
-
projectName,
|
|
24
|
-
framework,
|
|
25
|
-
kept,
|
|
26
|
-
mailerProvider,
|
|
27
|
-
patterns,
|
|
28
|
-
}) {
|
|
20
|
+
export function buildProject({ projectDir, projectName, framework, kept, mailerProvider }) {
|
|
29
21
|
const authKept = kept.has('better-auth')
|
|
30
22
|
const keptMailer = mailerProvider !== 'none'
|
|
31
23
|
|
|
32
24
|
forkBase(framework, projectDir)
|
|
33
|
-
makeStandalone(projectDir, projectName)
|
|
25
|
+
makeStandalone(projectDir, projectName, framework)
|
|
34
26
|
|
|
35
|
-
const strip = stripFoundations({ projectDir, framework, kept, keptMailer
|
|
27
|
+
const strip = stripFoundations({ projectDir, framework, kept, keptMailer })
|
|
36
28
|
const mailer = keptMailer
|
|
37
29
|
? swapMailer(projectDir, mailerProvider)
|
|
38
30
|
: { addDeps: {}, removeDeps: [], envKeys: [] }
|
|
39
31
|
|
|
40
32
|
const pkgPath = join(projectDir, 'package.json')
|
|
41
33
|
const pkg = readJSON(pkgPath)
|
|
42
|
-
pkg.description = `${projectName} —
|
|
34
|
+
pkg.description = `${projectName} — scaffolded from the personal reference stack.`
|
|
43
35
|
pkgRemoveDeps(pkg, [...strip.removeDeps, ...mailer.removeDeps])
|
|
44
36
|
pkgRemoveScripts(pkg, strip.removeScripts)
|
|
45
37
|
pkgAddDeps(pkg, mailer.addDeps)
|
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())',
|
|
@@ -19,7 +18,7 @@ const SCHEMAS = {
|
|
|
19
18
|
AWS_SECRET_ACCESS_KEY: 'v.optional(v.pipe(v.string(), v.minLength(1)))',
|
|
20
19
|
}
|
|
21
20
|
|
|
22
|
-
/** Placeholder values for
|
|
21
|
+
/** Placeholder values for generated .env files. */
|
|
23
22
|
const PLACEHOLDERS = {
|
|
24
23
|
DATABASE_URL: 'postgres://postgres:postgres@localhost:5432/app',
|
|
25
24
|
BETTER_AUTH_URL: 'http://localhost:3000',
|
|
@@ -29,7 +28,7 @@ const PLACEHOLDERS = {
|
|
|
29
28
|
|
|
30
29
|
const indent = (s) => ` ${s}`
|
|
31
30
|
|
|
32
|
-
/** Write
|
|
31
|
+
/** Write final env.ts + .env files. `keys`: ordered string[]. */
|
|
33
32
|
export function writeEnv(projectDir, keys) {
|
|
34
33
|
const seen = new Set()
|
|
35
34
|
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 = [
|
|
@@ -22,7 +22,59 @@ const PNPM_WORKSPACE = `allowBuilds:
|
|
|
22
22
|
lightningcss: true
|
|
23
23
|
`
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// Generated explicitly: npm strips `.gitignore` from published tarballs, so the
|
|
26
|
+
// bundled base app's copy won't survive. Both keep `.env.example`.
|
|
27
|
+
const GITIGNORE = {
|
|
28
|
+
tanstack: `node_modules
|
|
29
|
+
.DS_Store
|
|
30
|
+
dist
|
|
31
|
+
dist-ssr
|
|
32
|
+
*.local
|
|
33
|
+
.env
|
|
34
|
+
.nitro
|
|
35
|
+
.tanstack
|
|
36
|
+
.wrangler
|
|
37
|
+
.output
|
|
38
|
+
.vinxi
|
|
39
|
+
__unconfig*
|
|
40
|
+
`,
|
|
41
|
+
next: `# dependencies
|
|
42
|
+
/node_modules
|
|
43
|
+
/.pnp
|
|
44
|
+
.pnp.*
|
|
45
|
+
|
|
46
|
+
# testing
|
|
47
|
+
/coverage
|
|
48
|
+
|
|
49
|
+
# next.js
|
|
50
|
+
/.next/
|
|
51
|
+
/out/
|
|
52
|
+
|
|
53
|
+
# production
|
|
54
|
+
/build
|
|
55
|
+
|
|
56
|
+
# misc
|
|
57
|
+
.DS_Store
|
|
58
|
+
*.pem
|
|
59
|
+
|
|
60
|
+
# debug
|
|
61
|
+
npm-debug.log*
|
|
62
|
+
.pnpm-debug.log*
|
|
63
|
+
|
|
64
|
+
# env files (keep .env.example committed)
|
|
65
|
+
.env
|
|
66
|
+
.env*.local
|
|
67
|
+
|
|
68
|
+
# vercel
|
|
69
|
+
.vercel
|
|
70
|
+
|
|
71
|
+
# typescript
|
|
72
|
+
*.tsbuildinfo
|
|
73
|
+
next-env.d.ts
|
|
74
|
+
`,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Copy base app into projectDir, minus build output & generated files. */
|
|
26
78
|
export function forkBase(framework, projectDir) {
|
|
27
79
|
const base = join(STACK_ROOT, 'apps', framework === 'next' ? 'next-base' : 'tanstack-base')
|
|
28
80
|
if (!exists(base)) throw new Error(`Base app not found: ${base}`)
|
|
@@ -32,18 +84,21 @@ export function forkBase(framework, projectDir) {
|
|
|
32
84
|
if (!run('rsync', args)) throw new Error('rsync failed while forking the base app')
|
|
33
85
|
}
|
|
34
86
|
|
|
35
|
-
/** Make the fork standalone (Biome, pnpm workspace,
|
|
36
|
-
export function makeStandalone(projectDir, projectName) {
|
|
37
|
-
//
|
|
38
|
-
copy(join(
|
|
87
|
+
/** Make the fork standalone (Biome, pnpm workspace, .gitignore, identity). */
|
|
88
|
+
export function makeStandalone(projectDir, projectName, framework) {
|
|
89
|
+
// fork needs its own Biome config (base inherits the monorepo root's)
|
|
90
|
+
copy(join(TEMPLATES, 'biome.jsonc'), join(projectDir, 'biome.jsonc'))
|
|
91
|
+
|
|
92
|
+
// Biome vcs.useIgnoreFile needs a .gitignore
|
|
93
|
+
write(join(projectDir, '.gitignore'), GITIGNORE[framework === 'next' ? 'next' : 'tanstack'])
|
|
39
94
|
|
|
40
|
-
//
|
|
95
|
+
// avoid ERR_PNPM_IGNORED_BUILDS on fresh install (native build scripts)
|
|
41
96
|
write(join(projectDir, 'pnpm-workspace.yaml'), PNPM_WORKSPACE)
|
|
42
97
|
|
|
43
98
|
const pkgPath = join(projectDir, 'package.json')
|
|
44
99
|
const pkg = readJSON(pkgPath)
|
|
45
100
|
pkg.name = projectName
|
|
46
|
-
delete pkg.private //
|
|
101
|
+
delete pkg.private // leaf project
|
|
47
102
|
pkg.private = true
|
|
48
103
|
writeJSON(pkgPath, pkg)
|
|
49
104
|
}
|
package/lib/strip.mjs
CHANGED
|
@@ -1,43 +1,28 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Whole-directory deletes (robust against orphans) + the few code "seams"
|
|
3
|
-
// that need surgery (trpc/auth wiring) via shipped reduced variants.
|
|
1
|
+
// Strip unselected foundations: whole-dir deletes + code-seam variants (trpc/auth).
|
|
4
2
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { foundationManifest } from './manifests.mjs'
|
|
3
|
+
import { FOUNDATIONS, foundationDeps, foundationScripts } from './foundations.mjs'
|
|
4
|
+
import { TEMPLATES } from './paths.mjs'
|
|
8
5
|
import { copy, editFile, join, remove } from './util.mjs'
|
|
9
6
|
|
|
10
|
-
const
|
|
11
|
-
const tpl = (rel) => join(here, '..', 'templates', rel)
|
|
7
|
+
const tpl = (rel) => join(TEMPLATES, rel)
|
|
12
8
|
|
|
13
9
|
const ALWAYS_KEEP = new Set(['valibot'])
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/** Logical foundations always present in a base app. */
|
|
18
|
-
export const FOUNDATIONS = ['drizzle', 'trpc', 'better-auth', 'data-table']
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Strip the unselected foundations from the fork.
|
|
22
|
-
* @returns {{ removeDeps: string[], removeScripts: string[] }}
|
|
23
|
-
*/
|
|
24
|
-
export function stripFoundations({ projectDir, framework, kept, keptMailer, patterns }) {
|
|
11
|
+
/** @returns {{ removeDeps: string[], removeScripts: string[] }} */
|
|
12
|
+
export function stripFoundations({ projectDir, framework, kept, keptMailer }) {
|
|
25
13
|
const next = framework === 'next'
|
|
26
14
|
const src = (p) => join(projectDir, 'src', p)
|
|
27
15
|
const dropped = FOUNDATIONS.filter((f) => !kept.has(f))
|
|
28
16
|
|
|
29
17
|
// Dep diff: remove a dropped foundation's deps unless a kept one still needs it.
|
|
30
|
-
const keptDeps = new Set(
|
|
31
|
-
[...kept].flatMap((f) => manifestDeps(patterns[foundationManifest(f, framework)])),
|
|
32
|
-
)
|
|
18
|
+
const keptDeps = new Set([...kept].flatMap((f) => foundationDeps(f, framework)))
|
|
33
19
|
const removeDeps = new Set()
|
|
34
20
|
const removeScripts = new Set()
|
|
35
21
|
for (const f of dropped) {
|
|
36
|
-
const
|
|
37
|
-
for (const d of manifestDeps(m)) {
|
|
22
|
+
for (const d of foundationDeps(f, framework)) {
|
|
38
23
|
if (!keptDeps.has(d) && !ALWAYS_KEEP.has(d)) removeDeps.add(d)
|
|
39
24
|
}
|
|
40
|
-
for (const s of
|
|
25
|
+
for (const s of foundationScripts(f)) removeScripts.add(s)
|
|
41
26
|
}
|
|
42
27
|
|
|
43
28
|
// --- data-table ---
|
package/lib/util.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// No external deps — keep the CLI lean and instantly runnable.
|
|
1
|
+
// fs / exec / package.json helpers shared by the CLI. No external deps.
|
|
3
2
|
|
|
4
3
|
import { spawnSync } from 'node:child_process'
|
|
5
4
|
import {
|
|
@@ -22,18 +21,18 @@ export const exists = (p) => existsSync(p)
|
|
|
22
21
|
export const readJSON = (p) => JSON.parse(read(p))
|
|
23
22
|
export const writeJSON = (p, obj) => write(p, `${JSON.stringify(obj, null, 2)}\n`)
|
|
24
23
|
|
|
25
|
-
/** Remove
|
|
24
|
+
/** Remove file/dir if present (recursive, never throws on absent). */
|
|
26
25
|
export const remove = (p) => {
|
|
27
26
|
if (existsSync(p)) rmSync(p, { recursive: true, force: true })
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
/** Copy a file
|
|
29
|
+
/** Copy a file/dir tree. */
|
|
31
30
|
export const copy = (from, to) => {
|
|
32
31
|
mkdirSync(dirname(to), { recursive: true })
|
|
33
32
|
cpSync(from, to, { recursive: true })
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
/** Edit a file in place via
|
|
35
|
+
/** Edit a file in place via (content) => content. No-op if absent. */
|
|
37
36
|
export const editFile = (p, fn) => {
|
|
38
37
|
if (!existsSync(p)) return false
|
|
39
38
|
const next = fn(read(p))
|
|
@@ -41,20 +40,20 @@ export const editFile = (p, fn) => {
|
|
|
41
40
|
return true
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
/**
|
|
43
|
+
/** Dir empty (or absent)? Ignores noise files. */
|
|
45
44
|
export const isDirEmpty = (p) => {
|
|
46
45
|
if (!existsSync(p)) return true
|
|
47
46
|
const noise = new Set(['.git', '.DS_Store'])
|
|
48
47
|
return readdirSync(p).every((f) => noise.has(f))
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
/** Run a command
|
|
50
|
+
/** Run a command (inherits stdio). True on exit 0. */
|
|
52
51
|
export const run = (cmd, args, opts = {}) => {
|
|
53
52
|
const res = spawnSync(cmd, args, { stdio: 'inherit', ...opts })
|
|
54
53
|
return res.status === 0
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
/** Run a command capturing stdout
|
|
56
|
+
/** Run a command, capturing trimmed stdout. '' on failure. */
|
|
58
57
|
export const runCapture = (cmd, args, opts = {}) => {
|
|
59
58
|
const res = spawnSync(cmd, args, { encoding: 'utf8', ...opts })
|
|
60
59
|
return res.status === 0 ? (res.stdout || '').trim() : ''
|
|
@@ -62,7 +61,7 @@ export const runCapture = (cmd, args, opts = {}) => {
|
|
|
62
61
|
|
|
63
62
|
export { join }
|
|
64
63
|
|
|
65
|
-
// --- package.json helpers (
|
|
64
|
+
// --- package.json helpers (mutate parsed object in place) ---
|
|
66
65
|
|
|
67
66
|
export const pkgRemoveDeps = (pkg, names) => {
|
|
68
67
|
for (const field of ['dependencies', 'devDependencies']) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfredmouelle/create-stack",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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": {
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "../../capability.schema.json",
|
|
3
|
-
"name": "analytics",
|
|
4
|
-
"description": "Product analytics behind a swappable port. Capture events and identify users; flush/shutdown to drain pending events.",
|
|
5
|
-
"port": "src/core/port.ts",
|
|
6
|
-
"defaultAdapter": "posthog",
|
|
7
|
-
"adapters": {
|
|
8
|
-
"posthog": {
|
|
9
|
-
"deps": ["posthog-node"],
|
|
10
|
-
"env": ["POSTHOG_API_KEY", "POSTHOG_HOST"],
|
|
11
|
-
"files": ["src/adapters/posthog"]
|
|
12
|
-
},
|
|
13
|
-
"plausible": {
|
|
14
|
-
"deps": ["@alfredmouelle/http"],
|
|
15
|
-
"env": ["PLAUSIBLE_DOMAIN", "PLAUSIBLE_API_HOST"],
|
|
16
|
-
"files": ["src/adapters/plausible"]
|
|
17
|
-
},
|
|
18
|
-
"noop": {
|
|
19
|
-
"deps": [],
|
|
20
|
-
"env": [],
|
|
21
|
-
"files": ["src/adapters/noop"]
|
|
22
|
-
}
|
|
23
|
-
},
|
|
24
|
-
"sharedDeps": ["valibot"],
|
|
25
|
-
"sharedFiles": ["src/core", "src/index.ts"]
|
|
26
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "../../capability.schema.json",
|
|
3
|
-
"name": "cache",
|
|
4
|
-
"description": "Key/value cache behind a swappable port. Values are serialized as JSON for remote stores.",
|
|
5
|
-
"port": "src/core/port.ts",
|
|
6
|
-
"defaultAdapter": "redis",
|
|
7
|
-
"adapters": {
|
|
8
|
-
"redis": {
|
|
9
|
-
"deps": ["ioredis"],
|
|
10
|
-
"env": ["REDIS_URL"],
|
|
11
|
-
"files": ["src/adapters/redis"]
|
|
12
|
-
},
|
|
13
|
-
"memory": {
|
|
14
|
-
"deps": [],
|
|
15
|
-
"env": [],
|
|
16
|
-
"files": ["src/adapters/memory"]
|
|
17
|
-
}
|
|
18
|
-
},
|
|
19
|
-
"sharedDeps": ["valibot"],
|
|
20
|
-
"sharedFiles": ["src/core", "src/index.ts"]
|
|
21
|
-
}
|