nucleus-core-ts 0.9.745 → 0.9.746

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 (35) hide show
  1. package/bin/cli.ts +86 -65
  2. package/bin/lib/commands/add.ts +235 -0
  3. package/bin/lib/commands/dev.ts +174 -0
  4. package/bin/lib/commands/doctor.ts +78 -0
  5. package/bin/lib/commands/init.ts +128 -0
  6. package/bin/lib/commands/validate.ts +113 -0
  7. package/bin/lib/config-builder.ts +66 -0
  8. package/bin/lib/env.ts +77 -0
  9. package/bin/lib/presets.ts +120 -0
  10. package/bin/lib/schema.ts +119 -0
  11. package/bin/lib/templates.ts +145 -0
  12. package/bin/lib/ui.ts +123 -0
  13. package/dist/bin/cli.d.ts +7 -5
  14. package/dist/bin/lib/commands/add.d.ts +1 -0
  15. package/dist/bin/lib/commands/dev.d.ts +1 -0
  16. package/dist/bin/lib/commands/doctor.d.ts +1 -0
  17. package/dist/bin/lib/commands/init.d.ts +1 -0
  18. package/dist/bin/lib/commands/validate.d.ts +8 -0
  19. package/dist/bin/lib/config-builder.d.ts +7 -0
  20. package/dist/bin/lib/env.d.ts +16 -0
  21. package/dist/bin/lib/presets.d.ts +12 -0
  22. package/dist/bin/lib/schema.d.ts +13 -0
  23. package/dist/bin/lib/templates.d.ts +3 -0
  24. package/dist/bin/lib/ui.d.ts +36 -0
  25. package/dist/index.js +6 -6
  26. package/dist/src/ElysiaPlugin/authRouteClassifier.d.ts +34 -0
  27. package/dist/src/ElysiaPlugin/authRouteClassifier.test.d.ts +1 -0
  28. package/dist/src/ElysiaPlugin/routes/auth/oauth/index.d.ts +7 -0
  29. package/dist/src/ElysiaPlugin/routes/auth/oauth/resolveSafeOAuthRedirect.test.d.ts +1 -0
  30. package/dist/src/ElysiaPlugin/routes/entity/createScope.d.ts +8 -0
  31. package/dist/src/ElysiaPlugin/routes/entity/createScope.test.d.ts +1 -0
  32. package/dist/src/ElysiaPlugin/routes/shared/sensitiveTables.d.ts +8 -0
  33. package/dist/src/ElysiaPlugin/routes/shared/sensitiveTables.test.d.ts +1 -0
  34. package/dist/src/Services/Payment/Marketplace/MarketplaceService.d.ts +12 -0
  35. package/package.json +1 -1
package/bin/cli.ts CHANGED
@@ -1,110 +1,131 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * Nucleus CLI — unified entry point for all nucleus-core-ts commands.
4
+ * Nucleus CLI — the single front door to nucleus-core-ts.
5
5
  *
6
- * Usage:
7
- * npx nucleus-core-ts scaffold — Interactive project scaffolding
8
- * npx nucleus-core-ts generate — Generate schema from config.json
9
- * npx nucleus-core-ts help — Show available commands
6
+ * nucleus init scaffold a new project (interactive)
7
+ * nucleus dev … run locally (up | down | run)
8
+ * nucleus add … extend an existing project (entity | feature | oauth)
9
+ * nucleus validate validate config.json against the schema
10
+ * nucleus doctor check the local environment (bun, postgres, redis, …)
11
+ * nucleus generate generate the Drizzle schema from config.json
10
12
  */
11
13
 
12
14
  import { spawn } from 'node:child_process'
13
15
  import { dirname, join } from 'node:path'
14
16
  import { fileURLToPath } from 'node:url'
17
+ import { banner, c, log } from './lib/ui'
18
+ import { doctorCommand } from './lib/commands/doctor'
19
+ import { validateCommand } from './lib/commands/validate'
20
+ import { initCommand } from './lib/commands/init'
21
+ import { devCommand } from './lib/commands/dev'
22
+ import { addCommand } from './lib/commands/add'
23
+
24
+ const here = dirname(fileURLToPath(import.meta.url))
25
+ const rootDir = join(here, '..')
26
+
27
+ const argv = process.argv.slice(2)
28
+ const firstArg = argv[0] ?? ''
29
+ const looksLikeFile = firstArg.endsWith('.json') || firstArg.startsWith('./') || firstArg.startsWith('/')
30
+ const command = looksLikeFile ? 'generate' : firstArg
15
31
 
16
- const BOLD = '\x1b[1m'
17
- const CYAN = '\x1b[36m'
18
- const DIM = '\x1b[2m'
19
- const RED = '\x1b[31m'
20
- const RESET = '\x1b[0m'
21
-
22
- const __filename = fileURLToPath(import.meta.url)
23
- const __dirname = dirname(__filename)
24
- const rootDir = join(__dirname, '..')
32
+ async function pkgVersion(): Promise<string> {
33
+ try {
34
+ return (JSON.parse(await Bun.file(join(rootDir, 'package.json')).text()) as { version: string }).version
35
+ } catch {
36
+ return '?'
37
+ }
38
+ }
25
39
 
26
- const firstArg = process.argv[2] ?? ''
27
- const looksLikeFile =
28
- firstArg.endsWith('.json') || firstArg.startsWith('./') || firstArg.startsWith('/')
29
- const command = looksLikeFile ? 'generate' : firstArg
40
+ function showHelp(version: string) {
41
+ banner(`Nucleus CLI v${version}`, 'nucleus-core-ts — config-driven backend framework')
42
+ log(`
43
+ ${c.bold('Usage')} ${c.cyan('nucleus')} ${c.dim('<command> [options]')}
30
44
 
31
- function showHelp() {
32
- console.log(`
33
- ${CYAN}${BOLD}Nucleus CLI${RESET} ${DIM}(nucleus-core-ts)${RESET}
45
+ ${c.bold('Create & evolve')}
46
+ ${c.cyan('init')} ${c.dim('[--preset <p>] [-o <dir>] [-y]')} build a config + scaffold a runnable backend (zero-to-running)
47
+ ${c.cyan('add')} ${c.dim('entity | feature <n> | oauth <p>')} extend this project's config in place (+ regenerate)
48
+ ${c.cyan('scaffold')} full-project scaffold (backend+frontend+k8s+pipelines) from a config.json
49
+ ${c.cyan('generate')} ${c.dim('<config.json> <outDir>')} generate the Drizzle schema + relations from config
50
+ ${c.cyan('validate')} ${c.dim('[config.json]')} validate a config against the JSON-Schema + cross-field rules
34
51
 
35
- ${BOLD}Usage:${RESET}
36
- npx nucleus-core-ts ${CYAN}<command>${RESET}
52
+ ${c.bold('Local dev')}
53
+ ${c.cyan('dev')} ${c.dim('[up | down]')} run the app; ${c.dim('up')} starts Postgres+Redis, ${c.dim('down')} stops them
54
+ ${c.cyan('doctor')} check the local environment can run a Nucleus project
37
55
 
38
- ${BOLD}Commands:${RESET}
39
- ${CYAN}scaffold${RESET} Interactive project scaffolding (API + frontend + k8s + pipelines)
40
- ${CYAN}generate${RESET} Generate Drizzle schema from config.json
41
- ${CYAN}audit:purge-noise${RESET} Delete historical low-signal audit_logs rows (dry-run by default)
42
- ${CYAN}help${RESET} Show this help message
56
+ ${c.bold('Maintenance')}
57
+ ${c.cyan('audit:purge-noise')} ${c.dim('[--execute]')} delete historical low-signal audit_logs rows (dry-run by default)
58
+ ${c.cyan('help')}${c.dim(', ')}${c.cyan('--version')} this help / the installed version
43
59
 
44
- ${BOLD}Examples:${RESET}
45
- ${DIM}npx nucleus-core-ts scaffold${RESET}
46
- ${DIM}npx nucleus-core-ts generate src/config.json src/drizzle${RESET}
47
- ${DIM}npx nucleus-core-ts audit:purge-noise # dry run, all schemas${RESET}
48
- ${DIM}npx nucleus-core-ts audit:purge-noise --execute # actually delete${RESET}
60
+ ${c.dim('init = preset → runnable backend · scaffold (= new) = full project · Aliases: generate = gen, validate = check')}
49
61
  `)
50
62
  }
51
63
 
52
- async function runScript(scriptRelPath: string, args: string[]) {
53
- return new Promise<void>(() => {
54
- const proc = spawn('bun', ['run', join(rootDir, scriptRelPath), ...args], {
55
- cwd: process.cwd(),
56
- stdio: 'inherit',
57
- })
58
- proc.on('close', (code) => {
59
- process.exit(code ?? 1)
60
- })
61
- })
62
- }
63
-
64
- async function runGenerate(args: string[]) {
65
- return new Promise<void>(() => {
66
- const proc = spawn('bun', ['run', join(rootDir, 'scripts', 'generate-schema.ts'), ...args], {
67
- cwd: process.cwd(),
68
- stdio: 'inherit',
69
- })
70
- proc.on('close', (code) => {
71
- process.exit(code ?? 1)
72
- })
64
+ function runScript(scriptRelPath: string, args: string[]): Promise<never> {
65
+ return new Promise<never>(() => {
66
+ const proc = spawn('bun', ['run', join(rootDir, scriptRelPath), ...args], { cwd: process.cwd(), stdio: 'inherit' })
67
+ proc.on('close', (code) => process.exit(code ?? 1))
73
68
  })
74
69
  }
75
70
 
76
71
  switch (command) {
77
- case 'scaffold':
78
72
  case 'init':
73
+ process.exit(await initCommand(argv.slice(1)))
74
+ break
75
+
76
+ case 'dev':
77
+ process.exit(await devCommand(argv.slice(1)))
78
+ break
79
+
80
+ case 'add':
81
+ process.exit(await addCommand(argv.slice(1)))
82
+ break
83
+
84
+ case 'scaffold':
79
85
  case 'new': {
86
+ // Full-project scaffold (backend + frontend + k8s + pipelines) from an
87
+ // existing config.json. `init` is the leaner, config-building local-dev path.
80
88
  const { scaffold } = await import(join(rootDir, 'infra', 'scripts', 'generate-project.ts'))
81
- const infraDir = join(rootDir, 'infra')
82
- await scaffold(infraDir)
89
+ await scaffold(join(rootDir, 'infra'))
83
90
  break
84
91
  }
85
92
 
86
93
  case 'generate':
87
94
  case 'gen': {
88
- const genArgs = looksLikeFile ? process.argv.slice(2) : process.argv.slice(3)
89
- await runGenerate(genArgs)
95
+ const genArgs = looksLikeFile ? argv : argv.slice(1)
96
+ await runScript(join('scripts', 'generate-schema.ts'), genArgs)
90
97
  break
91
98
  }
92
99
 
100
+ case 'validate':
101
+ case 'check':
102
+ process.exit(await validateCommand(argv.slice(1)))
103
+ break
104
+
105
+ case 'doctor':
106
+ process.exit(await doctorCommand())
107
+ break
108
+
93
109
  case 'audit:purge-noise':
94
- case 'audit-purge-noise': {
95
- await runScript(join('scripts', 'audit-purge-noise.ts'), process.argv.slice(3))
110
+ case 'audit-purge-noise':
111
+ await runScript(join('scripts', 'audit-purge-noise.ts'), argv.slice(1))
112
+ break
113
+
114
+ case '--version':
115
+ case '-v':
116
+ log(await pkgVersion())
96
117
  break
97
- }
98
118
 
99
119
  case 'help':
100
120
  case '--help':
101
121
  case '-h':
122
+ case '':
102
123
  case undefined:
103
- showHelp()
124
+ showHelp(await pkgVersion())
104
125
  break
105
126
 
106
127
  default:
107
- console.error(`${RED}Unknown command: ${command}${RESET}`)
108
- showHelp()
128
+ log(`${c.red('Unknown command:')} ${command}\n`)
129
+ showHelp(await pkgVersion())
109
130
  process.exit(1)
110
131
  }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * `nucleus add <thing>` — extend an existing project's config.json in place.
3
+ *
4
+ * nucleus add entity interactively add one or more entities
5
+ * nucleus add feature <name> enable a feature block (storage, chat,
6
+ * payment, notification, verification, audit,
7
+ * captcha, pubsub) with schema-valid defaults
8
+ * nucleus add oauth <provider> add an OAuth provider to authentication
9
+ *
10
+ * After editing, it re-validates and (for schema-affecting changes) regenerates
11
+ * the Drizzle schema so `nucleus dev` picks the change up.
12
+ */
13
+ import { dirname, join, relative } from 'node:path'
14
+ import { spawnSync } from 'node:child_process'
15
+ import { c, fail, info, log, ok, step, warn } from '../ui'
16
+ import { prompts } from '../ui'
17
+ import { buildEntity, slug } from '../config-builder'
18
+ import { findConfigPath, crossFieldChecks } from './validate'
19
+ import { loadConfigSchema, validateAgainstSchema } from '../schema'
20
+
21
+ type Cfg = Record<string, unknown>
22
+
23
+ /** Feature blocks keyed by name — each returns a schema-valid default config fragment. */
24
+ const FEATURES: Record<string, { summary: string; apply: (cfg: Cfg) => void | Promise<void> }> = {
25
+ storage: {
26
+ summary: 'file storage + CDN (upload/serve, ownership-scoped)',
27
+ apply: (cfg) => {
28
+ cfg.storage = {
29
+ enabled: true,
30
+ provider: 'local',
31
+ local: { basePath: './.storage' },
32
+ cdn: { enabled: true },
33
+ }
34
+ },
35
+ },
36
+ chat: {
37
+ summary: 'realtime chat channels + messages',
38
+ apply: (cfg) => {
39
+ cfg.chat = { enabled: true, attachments: { enabled: false } }
40
+ },
41
+ },
42
+ notification: {
43
+ summary: 'notification channels (email/telegram/webhook)',
44
+ apply: (cfg) => {
45
+ cfg.notification = { enabled: true, channels: { email: false } }
46
+ },
47
+ },
48
+ verification: {
49
+ summary: 'signed verification / approval flows',
50
+ apply: (cfg) => {
51
+ cfg.verification = { enabled: true }
52
+ },
53
+ },
54
+ audit: {
55
+ summary: 'audit logging of mutations',
56
+ apply: (cfg) => {
57
+ cfg.audit = { enabled: true }
58
+ },
59
+ },
60
+ captcha: {
61
+ summary: 'CAPTCHA challenge on sensitive endpoints',
62
+ apply: (cfg) => {
63
+ cfg.captcha = { enabled: true, provider: 'turnstile', secretKey: 'CAPTCHA_SECRET_KEY' }
64
+ },
65
+ },
66
+ pubsub: {
67
+ summary: 'Dapr pub/sub event fan-out',
68
+ apply: (cfg) => {
69
+ cfg.pubsub = { enabled: true }
70
+ },
71
+ },
72
+ payment: {
73
+ summary: 'payment provider (iyzico | stripe)',
74
+ apply: async (cfg) => {
75
+ const p = prompts()
76
+ try {
77
+ const provider = await p.choose(' Provider', ['iyzico', 'stripe'], 0)
78
+ const block: Cfg = { enabled: true, provider }
79
+ if (provider === 'iyzico')
80
+ block.iyzico = { apiKey: 'IYZICO_API_KEY', secretKey: 'IYZICO_SECRET_KEY', baseUrl: 'IYZICO_BASE_URL' }
81
+ else block.stripe = { secretKey: 'STRIPE_SECRET_KEY', webhookSecret: 'STRIPE_WEBHOOK_SECRET' }
82
+ cfg.payment = block
83
+ } finally {
84
+ p.close()
85
+ }
86
+ },
87
+ },
88
+ }
89
+
90
+ const OAUTH_PROVIDERS = ['google', 'microsoft', 'github', 'apple']
91
+
92
+ async function readConfig(): Promise<{ path: string; cfg: Cfg } | null> {
93
+ const path = findConfigPath()
94
+ if (!path) {
95
+ fail('No config.json found here — run this from a Nucleus project (or `nucleus init` first)')
96
+ return null
97
+ }
98
+ try {
99
+ return { path, cfg: JSON.parse(await Bun.file(path).text()) as Cfg }
100
+ } catch {
101
+ fail(`Config ${path} is not valid JSON`)
102
+ return null
103
+ }
104
+ }
105
+
106
+ /** Persist, validate, and (if schema-affecting) regenerate Drizzle. */
107
+ async function persist(path: string, cfg: Cfg, regen: boolean): Promise<number> {
108
+ const schema = await loadConfigSchema()
109
+ const res = validateAgainstSchema(cfg, schema)
110
+ const cross = crossFieldChecks(cfg)
111
+ if (res.errors.length + cross.length > 0) {
112
+ for (const e of [...res.errors, ...cross]) fail(`${c.bold(e.path || '(root)')} — ${e.message}`)
113
+ fail('Change would make the config invalid — nothing written')
114
+ return 1
115
+ }
116
+ await Bun.write(path, JSON.stringify(cfg, null, 2) + '\n')
117
+ ok(`Updated ${c.cyan(relative(process.cwd(), path))}`)
118
+
119
+ if (regen) {
120
+ const outDir = join(dirname(path), 'drizzle')
121
+ step(`Regenerating Drizzle schema → ${c.dim(relative(process.cwd(), outDir))}`)
122
+ const r = spawnSync('bunx', ['nucleus-core-ts', 'generate', path, outDir], { stdio: 'inherit' })
123
+ if (r.status !== 0) {
124
+ warn('Schema regeneration failed — run `bunx nucleus-core-ts generate` manually')
125
+ return 1
126
+ }
127
+ ok('Schema regenerated')
128
+ }
129
+ return 0
130
+ }
131
+
132
+ async function addEntity(): Promise<number> {
133
+ const loaded = await readConfig()
134
+ if (!loaded) return 1
135
+ const { path, cfg } = loaded
136
+ const entities = Array.isArray(cfg.entities) ? (cfg.entities as Cfg[]) : []
137
+ const existing = new Set(entities.map((e) => (e as { table_name?: string }).table_name))
138
+
139
+ step('Add entities (blank table name to finish)')
140
+ const p = prompts()
141
+ let added = 0
142
+ try {
143
+ let e = await buildEntity(p)
144
+ while (e) {
145
+ const tn = (e as { table_name: string }).table_name
146
+ if (existing.has(tn)) warn(`Entity "${tn}" already exists — skipped`)
147
+ else {
148
+ entities.push(e)
149
+ existing.add(tn)
150
+ ok(`Queued entity "${tn}"`)
151
+ added++
152
+ }
153
+ e = await buildEntity(p)
154
+ }
155
+ } finally {
156
+ p.close()
157
+ }
158
+ if (added === 0) {
159
+ info('No entities added')
160
+ return 0
161
+ }
162
+ cfg.entities = entities
163
+ return persist(path, cfg, true)
164
+ }
165
+
166
+ async function addFeature(name?: string): Promise<number> {
167
+ const key = (name || '').toLowerCase()
168
+ if (!key || !FEATURES[key]) {
169
+ fail(`Unknown feature "${name ?? ''}"`)
170
+ log(` Available features:`)
171
+ for (const [k, v] of Object.entries(FEATURES)) log(` ${c.cyan(k.padEnd(14))} ${c.dim(v.summary)}`)
172
+ return 1
173
+ }
174
+ const loaded = await readConfig()
175
+ if (!loaded) return 1
176
+ const { path, cfg } = loaded
177
+ if ((cfg[key] as Cfg | undefined)?.enabled) {
178
+ info(`Feature "${key}" is already enabled — leaving it as-is`)
179
+ return 0
180
+ }
181
+ step(`Enabling feature: ${c.cyan(key)} ${c.dim(`— ${FEATURES[key].summary}`)}`)
182
+ await FEATURES[key].apply(cfg)
183
+ const rc = await persist(path, cfg, false)
184
+ if (rc === 0) info('Secrets are referenced by ENV-VAR NAME — set them in .env before running.')
185
+ return rc
186
+ }
187
+
188
+ async function addOauth(provider?: string): Promise<number> {
189
+ const prov = (provider || '').toLowerCase()
190
+ if (!prov || !OAUTH_PROVIDERS.includes(prov)) {
191
+ fail(`Unknown OAuth provider "${provider ?? ''}" — expected one of ${OAUTH_PROVIDERS.join(', ')}`)
192
+ return 1
193
+ }
194
+ const loaded = await readConfig()
195
+ if (!loaded) return 1
196
+ const { path, cfg } = loaded
197
+ const auth = (cfg.authentication as Cfg | undefined) ?? {}
198
+ if (!auth.enabled) {
199
+ fail('authentication is not enabled — enable it first (an `api` or `saas` preset has it on)')
200
+ return 1
201
+ }
202
+ const oauth = (auth.oauth as Cfg | undefined) ?? {}
203
+ if (oauth[prov]) {
204
+ info(`OAuth provider "${prov}" is already configured`)
205
+ return 0
206
+ }
207
+ const up = prov.toUpperCase()
208
+ oauth[prov] = { clientId: `${up}_CLIENT_ID`, clientSecret: `${up}_CLIENT_SECRET` }
209
+ auth.oauth = oauth
210
+ cfg.authentication = auth
211
+ step(`Adding OAuth provider: ${c.cyan(prov)}`)
212
+ const rc = await persist(path, cfg, false)
213
+ if (rc === 0) info(`Set ${up}_CLIENT_ID and ${up}_CLIENT_SECRET in .env, plus the provider redirect URI.`)
214
+ return rc
215
+ }
216
+
217
+ export async function addCommand(args: string[]): Promise<number> {
218
+ const thing = args[0]
219
+ switch (thing) {
220
+ case 'entity':
221
+ case 'entities':
222
+ return addEntity()
223
+ case 'feature':
224
+ return addFeature(args[1])
225
+ case 'oauth':
226
+ return addOauth(args[1])
227
+ default:
228
+ fail(`Unknown: nucleus add ${thing ?? ''}`)
229
+ log(` Usage:`)
230
+ log(` ${c.cyan('nucleus add entity')} ${c.dim('add one or more entities (interactive)')}`)
231
+ log(` ${c.cyan('nucleus add feature <name>')} ${c.dim('enable a feature block')}`)
232
+ log(` ${c.cyan('nucleus add oauth <provider>')} ${c.dim(`add OAuth (${OAUTH_PROVIDERS.join('|')})`)}`)
233
+ return 1
234
+ }
235
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `nucleus dev [up|down]` — the local run loop.
3
+ *
4
+ * nucleus dev up ensure Postgres + Redis are reachable; if not, start
5
+ * throwaway Docker containers for the missing one(s).
6
+ * nucleus dev pre-flight the datastores, then run the backend
7
+ * (`bun run dev`), streaming its output.
8
+ * nucleus dev down stop + remove the Docker containers `dev up` started.
9
+ *
10
+ * If your Postgres/Redis are already running (the common local case) `dev up`
11
+ * detects them and starts nothing — Docker is only a fallback.
12
+ */
13
+ import { existsSync } from 'node:fs'
14
+ import { spawnSync } from 'node:child_process'
15
+ import { c, fail, info, log, ok, step, warn } from '../ui'
16
+ import { pgConn, readEnv, redisConn, tcpReachable } from '../env'
17
+
18
+ const PG_CONTAINER = 'nucleus-dev-postgres'
19
+ const REDIS_CONTAINER = 'nucleus-dev-redis'
20
+
21
+ type Svc = { name: string; container: string; host: string; port: number; run: (port: number) => string[] }
22
+
23
+ function services(env: Record<string, string>): Svc[] {
24
+ const pg = pgConn(env)
25
+ const r = redisConn(env)
26
+ return [
27
+ {
28
+ name: 'Postgres',
29
+ container: PG_CONTAINER,
30
+ host: pg.host,
31
+ port: pg.port,
32
+ run: (port) => [
33
+ 'run', '-d', '--name', PG_CONTAINER,
34
+ '-e', 'POSTGRES_PASSWORD=postgres',
35
+ '-e', 'POSTGRES_USER=postgres',
36
+ '-e', 'POSTGRES_DB=nucleus',
37
+ '-p', `${port}:5432`,
38
+ 'postgres:16-alpine',
39
+ ],
40
+ },
41
+ {
42
+ name: 'Redis',
43
+ container: REDIS_CONTAINER,
44
+ host: r.host,
45
+ port: r.port,
46
+ run: (port) => ['run', '-d', '--name', REDIS_CONTAINER, '-p', `${port}:6379`, 'redis:7-alpine'],
47
+ },
48
+ ]
49
+ }
50
+
51
+ const isLocal = (host: string) => host === 'localhost' || host === '127.0.0.1' || host === '::1'
52
+
53
+ async function waitReachable(host: string, port: number, tries = 30): Promise<boolean> {
54
+ for (let i = 0; i < tries; i++) {
55
+ if (await tcpReachable(host, port, 1000)) return true
56
+ await new Promise((r) => setTimeout(r, 1000))
57
+ }
58
+ return false
59
+ }
60
+
61
+ async function devUp(): Promise<number> {
62
+ step('nucleus dev up — ensuring datastores')
63
+ const env = readEnv()
64
+ const svcs = services(env)
65
+ const need: Svc[] = []
66
+
67
+ for (const s of svcs) {
68
+ if (await tcpReachable(s.host, s.port)) {
69
+ ok(`${s.name} already reachable at ${c.dim(`${s.host}:${s.port}`)}`)
70
+ } else if (!isLocal(s.host)) {
71
+ fail(`${s.name} not reachable at ${s.host}:${s.port} (remote host — can't start a container for it)`)
72
+ return 1
73
+ } else {
74
+ need.push(s)
75
+ }
76
+ }
77
+
78
+ if (need.length === 0) {
79
+ log()
80
+ ok(c.green('All datastores up.') + c.dim(' Run `nucleus dev` to start the app.'))
81
+ return 0
82
+ }
83
+
84
+ if (!Bun.which('docker')) {
85
+ fail(`docker not found — install Docker, or start ${need.map((s) => s.name).join(' + ')} yourself`)
86
+ return 1
87
+ }
88
+
89
+ for (const s of need) {
90
+ // Reuse a stopped container of the same name if present, else create one.
91
+ const existing = spawnSync('docker', ['ps', '-aq', '-f', `name=^${s.container}$`], { encoding: 'utf-8' })
92
+ const id = (existing.stdout || '').trim()
93
+ if (id) {
94
+ info(`Starting existing container ${c.dim(s.container)}`)
95
+ spawnSync('docker', ['start', s.container], { stdio: 'ignore' })
96
+ } else {
97
+ info(`Starting ${s.name} container ${c.dim(s.container)} (${s.host}:${s.port})`)
98
+ const res = spawnSync('docker', s.run(s.port), { encoding: 'utf-8' })
99
+ if (res.status !== 0) {
100
+ fail(`Failed to start ${s.name}: ${(res.stderr || '').trim()}`)
101
+ return 1
102
+ }
103
+ }
104
+ }
105
+
106
+ step('Waiting for datastores to accept connections')
107
+ for (const s of need) {
108
+ if (await waitReachable(s.host, s.port)) ok(`${s.name} ready at ${c.dim(`${s.host}:${s.port}`)}`)
109
+ else {
110
+ fail(`${s.name} did not become reachable — check \`docker logs ${s.container}\``)
111
+ return 1
112
+ }
113
+ }
114
+ log()
115
+ ok(c.green('Datastores up.') + c.dim(' Run `nucleus dev` to start the app, `nucleus dev down` to stop them.'))
116
+ return 0
117
+ }
118
+
119
+ function devDown(): number {
120
+ step('nucleus dev down — stopping dev containers')
121
+ if (!Bun.which('docker')) {
122
+ warn('docker not found — nothing to stop')
123
+ return 0
124
+ }
125
+ let stopped = 0
126
+ for (const container of [PG_CONTAINER, REDIS_CONTAINER]) {
127
+ const found = spawnSync('docker', ['ps', '-aq', '-f', `name=^${container}$`], { encoding: 'utf-8' })
128
+ if ((found.stdout || '').trim()) {
129
+ spawnSync('docker', ['rm', '-f', container], { stdio: 'ignore' })
130
+ ok(`Removed ${c.dim(container)}`)
131
+ stopped++
132
+ }
133
+ }
134
+ if (stopped === 0) info('No nucleus dev containers were running')
135
+ return 0
136
+ }
137
+
138
+ async function devRun(): Promise<number> {
139
+ if (!existsSync('package.json')) {
140
+ fail('No package.json here — run this from a Nucleus project (or `nucleus init` first)')
141
+ return 1
142
+ }
143
+ // Pre-flight the datastores so failures are obvious before the app boots.
144
+ const env = readEnv()
145
+ for (const s of services(env)) {
146
+ if (!(await tcpReachable(s.host, s.port)))
147
+ warn(`${s.name} not reachable at ${s.host}:${s.port} — run \`nucleus dev up\` first (the app may fail to boot)`)
148
+ }
149
+
150
+ // Prefer the project's own dev script; fall back to running the entrypoint.
151
+ let cmd = ['bun', 'run', 'dev']
152
+ try {
153
+ const pkg = JSON.parse(await Bun.file('package.json').text()) as { scripts?: Record<string, string> }
154
+ if (!pkg.scripts?.dev) cmd = ['bun', '--watch', existsSync('src/index.ts') ? 'src/index.ts' : 'index.ts']
155
+ } catch {
156
+ /* use default */
157
+ }
158
+
159
+ step(`Running ${c.cyan(cmd.join(' '))} ${c.dim('(Ctrl-C to stop)')}`)
160
+ const [bin = 'bun', ...rest] = cmd
161
+ const res = spawnSync(bin, rest, { stdio: 'inherit' })
162
+ return res.status ?? 0
163
+ }
164
+
165
+ export async function devCommand(args: string[]): Promise<number> {
166
+ const sub = args[0]
167
+ if (sub === 'up') return devUp()
168
+ if (sub === 'down') return devDown()
169
+ if (sub && sub !== 'run') {
170
+ fail(`Unknown: nucleus dev ${sub} (expected up | down | run)`)
171
+ return 1
172
+ }
173
+ return devRun()
174
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `nucleus doctor` — checks the local environment can run a Nucleus project:
3
+ * runtime (bun), reachable Postgres + Redis, optional Docker/Dapr, and a valid
4
+ * config. Prints a green/red checklist with fix hints. Exit 1 if a REQUIRED
5
+ * check fails.
6
+ */
7
+ import { c, fail, info, log, ok, step, warn } from '../ui'
8
+ import { pgConn, readEnv, redisConn, tcpReachable } from '../env'
9
+ import { collectEnvRefs, crossFieldChecks, findConfigPath } from './validate'
10
+ import { loadConfigSchema, validateAgainstSchema } from '../schema'
11
+
12
+ export async function doctorCommand(): Promise<number> {
13
+ step('Nucleus doctor — local environment check')
14
+ const env = readEnv()
15
+ let requiredFailures = 0
16
+
17
+ // Runtime
18
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') ok(`bun ${c.dim(Bun.version)}`)
19
+ else {
20
+ fail('bun runtime not detected — install from https://bun.sh')
21
+ requiredFailures++
22
+ }
23
+ if (process.versions.node) info(`node ${c.dim(process.versions.node)} (optional)`)
24
+
25
+ // Postgres
26
+ const pg = pgConn(env)
27
+ if (await tcpReachable(pg.host, pg.port)) ok(`Postgres reachable at ${c.dim(`${pg.host}:${pg.port}`)}`)
28
+ else {
29
+ fail(`Postgres NOT reachable at ${pg.host}:${pg.port} — start it (\`nucleus dev up\`) or set DATABASE_URL`)
30
+ requiredFailures++
31
+ }
32
+
33
+ // Redis
34
+ const r = redisConn(env)
35
+ if (await tcpReachable(r.host, r.port)) ok(`Redis reachable at ${c.dim(`${r.host}:${r.port}`)}`)
36
+ else {
37
+ fail(`Redis NOT reachable at ${r.host}:${r.port} — start it (\`nucleus dev up\`) or set REDIS_URL / REDIS_HOST`)
38
+ requiredFailures++
39
+ }
40
+
41
+ // Optional tooling
42
+ Bun.which('docker') ? ok(`docker ${c.dim('(for nucleus dev up)')}`) : warn('docker not found — needed only for `nucleus dev up`')
43
+ Bun.which('dapr') ? ok(`dapr ${c.dim('(optional pub/sub sidecar)')}`) : info('dapr not found (optional — only for Dapr pub/sub)')
44
+ Bun.which('createdb')
45
+ ? ok(`createdb ${c.dim('(for nucleus db create)')}`)
46
+ : info('createdb not found (optional — psql client tools)')
47
+
48
+ // Config
49
+ const cfgPath = findConfigPath()
50
+ if (cfgPath) {
51
+ try {
52
+ const cfg = JSON.parse(await Bun.file(cfgPath).text()) as Record<string, unknown>
53
+ const res = validateAgainstSchema(cfg, await loadConfigSchema())
54
+ const cross = crossFieldChecks(cfg)
55
+ const errCount = res.errors.length + cross.length
56
+ if (errCount === 0) ok(`Config ${c.dim(cfgPath)} is valid`)
57
+ else {
58
+ fail(`Config ${cfgPath} has ${errCount} error(s) — run \`nucleus validate\``)
59
+ requiredFailures++
60
+ }
61
+ const missing = [...collectEnvRefs(cfg)].filter((e) => !env[e])
62
+ if (missing.length) warn(`${missing.length} referenced env var(s) unset: ${c.dim(missing.join(', '))}`)
63
+ } catch {
64
+ fail(`Config ${cfgPath} is not valid JSON`)
65
+ requiredFailures++
66
+ }
67
+ } else {
68
+ info('No config.json here yet — run `nucleus init` to create a project')
69
+ }
70
+
71
+ log()
72
+ if (requiredFailures === 0) {
73
+ ok(c.green(c.bold('Environment ready ✔')))
74
+ return 0
75
+ }
76
+ fail(c.red(c.bold(`${requiredFailures} blocking issue(s) — fix the above before running the app`)))
77
+ return 1
78
+ }