nucleus-core-ts 0.9.745 → 0.9.747
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/bin/cli.ts +86 -65
- package/bin/lib/commands/add.ts +235 -0
- package/bin/lib/commands/dev.ts +174 -0
- package/bin/lib/commands/doctor.ts +78 -0
- package/bin/lib/commands/init.ts +128 -0
- package/bin/lib/commands/validate.ts +113 -0
- package/bin/lib/config-builder.ts +66 -0
- package/bin/lib/env.ts +77 -0
- package/bin/lib/presets.ts +120 -0
- package/bin/lib/schema.ts +119 -0
- package/bin/lib/templates.ts +145 -0
- package/bin/lib/ui.ts +123 -0
- package/dist/bin/cli.d.ts +7 -5
- package/dist/bin/lib/commands/add.d.ts +1 -0
- package/dist/bin/lib/commands/dev.d.ts +1 -0
- package/dist/bin/lib/commands/doctor.d.ts +1 -0
- package/dist/bin/lib/commands/init.d.ts +1 -0
- package/dist/bin/lib/commands/validate.d.ts +8 -0
- package/dist/bin/lib/config-builder.d.ts +7 -0
- package/dist/bin/lib/env.d.ts +16 -0
- package/dist/bin/lib/presets.d.ts +12 -0
- package/dist/bin/lib/schema.d.ts +13 -0
- package/dist/bin/lib/templates.d.ts +3 -0
- package/dist/bin/lib/ui.d.ts +36 -0
- package/dist/index.js +6 -6
- package/dist/src/ElysiaPlugin/authRouteClassifier.d.ts +34 -0
- package/dist/src/ElysiaPlugin/authRouteClassifier.test.d.ts +1 -0
- package/dist/src/ElysiaPlugin/routes/auth/oauth/index.d.ts +7 -0
- package/dist/src/ElysiaPlugin/routes/auth/oauth/resolveSafeOAuthRedirect.test.d.ts +1 -0
- package/dist/src/ElysiaPlugin/routes/entity/createScope.d.ts +8 -0
- package/dist/src/ElysiaPlugin/routes/entity/createScope.test.d.ts +1 -0
- package/dist/src/ElysiaPlugin/routes/shared/sensitiveTables.d.ts +8 -0
- package/dist/src/ElysiaPlugin/routes/shared/sensitiveTables.test.d.ts +1 -0
- package/dist/src/Services/Payment/Marketplace/MarketplaceService.d.ts +12 -0
- package/dist/src/Services/WebAuthn/webauthnChallenge.integration.test.d.ts +1 -0
- 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 —
|
|
4
|
+
* Nucleus CLI — the single front door to nucleus-core-ts.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
${
|
|
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
|
-
${
|
|
36
|
-
|
|
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
|
-
${
|
|
39
|
-
${
|
|
40
|
-
${
|
|
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
|
-
${
|
|
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
|
-
|
|
53
|
-
return new Promise<
|
|
54
|
-
const proc = spawn('bun', ['run', join(rootDir, scriptRelPath), ...args], {
|
|
55
|
-
|
|
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
|
-
|
|
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 ?
|
|
89
|
-
await
|
|
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'),
|
|
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
|
-
|
|
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
|
+
}
|