nucleus-core-ts 0.9.744 → 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.
- 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/entity/filterFields.test.d.ts +1 -0
- package/dist/src/ElysiaPlugin/routes/entity/index.d.ts +15 -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/package.json +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nucleus init` — the front door. Build a config (from a preset, an existing
|
|
3
|
+
* file, or the interactive builder), scaffold a runnable Bun/Elysia backend,
|
|
4
|
+
* install deps and generate the Drizzle schema — so a new service boots in one
|
|
5
|
+
* command.
|
|
6
|
+
*
|
|
7
|
+
* Flags:
|
|
8
|
+
* --preset <minimal|api|saas|marketplace> non-interactive: seed from a preset
|
|
9
|
+
* --config <path> use an existing config.json (no builder)
|
|
10
|
+
* --app-id <id> app id (else prompted / from config)
|
|
11
|
+
* --output, -o <dir> target dir (default ./<appId>)
|
|
12
|
+
* --yes, -y no prompts (defaults to the saas preset)
|
|
13
|
+
* --no-install skip bun install + generate
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
17
|
+
import { resolve } from 'node:path'
|
|
18
|
+
import { banner, c, fail, info, log, ok, prompts, step, warn } from '../ui'
|
|
19
|
+
import { buildPreset, PRESET_NAMES, type PresetName } from '../presets'
|
|
20
|
+
import { buildConfigInteractive } from '../config-builder'
|
|
21
|
+
import { writeBackend } from '../templates'
|
|
22
|
+
import { crossFieldChecks } from './validate'
|
|
23
|
+
import { loadConfigSchema, validateAgainstSchema } from '../schema'
|
|
24
|
+
|
|
25
|
+
type Flags = { preset?: string; config?: string; appId?: string; output?: string; yes: boolean; install: boolean }
|
|
26
|
+
|
|
27
|
+
function parseFlags(args: string[]): Flags {
|
|
28
|
+
const f: Flags = { yes: false, install: true }
|
|
29
|
+
for (let i = 0; i < args.length; i++) {
|
|
30
|
+
const a = args[i]
|
|
31
|
+
if (a === '--preset') f.preset = args[++i]
|
|
32
|
+
else if (a === '--config') f.config = args[++i]
|
|
33
|
+
else if (a === '--app-id') f.appId = args[++i]
|
|
34
|
+
else if (a === '--output' || a === '-o') f.output = args[++i]
|
|
35
|
+
else if (a === '--yes' || a === '-y') f.yes = true
|
|
36
|
+
else if (a === '--no-install') f.install = false
|
|
37
|
+
}
|
|
38
|
+
return f
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
|
42
|
+
|
|
43
|
+
export async function initCommand(args: string[]): Promise<number> {
|
|
44
|
+
const f = parseFlags(args)
|
|
45
|
+
const version = 'nucleus init'
|
|
46
|
+
banner(version, 'scaffold a runnable Nucleus backend')
|
|
47
|
+
|
|
48
|
+
let config: Record<string, unknown>
|
|
49
|
+
let appId: string
|
|
50
|
+
|
|
51
|
+
if (f.config) {
|
|
52
|
+
// Use an existing config (skip the builder).
|
|
53
|
+
const path = resolve(f.config)
|
|
54
|
+
if (!existsSync(path)) return fail(`Config not found: ${f.config}`), 1
|
|
55
|
+
config = JSON.parse(await Bun.file(path).text()) as Record<string, unknown>
|
|
56
|
+
appId = slug((config.appId as string) || f.appId || 'my-app')
|
|
57
|
+
} else if (f.preset || f.yes) {
|
|
58
|
+
const name = (f.preset || 'saas') as PresetName
|
|
59
|
+
if (!PRESET_NAMES.includes(name)) return fail(`Unknown preset "${name}" (choose ${PRESET_NAMES.join(', ')})`), 1
|
|
60
|
+
appId = slug(f.appId || 'my-app')
|
|
61
|
+
config = buildPreset(name, appId)
|
|
62
|
+
step(`Seeded a "${name}" config for ${c.cyan(appId)}`)
|
|
63
|
+
} else {
|
|
64
|
+
// Interactive.
|
|
65
|
+
const p = prompts()
|
|
66
|
+
try {
|
|
67
|
+
config = await buildConfigInteractive(p)
|
|
68
|
+
appId = slug((config.appId as string) || 'my-app')
|
|
69
|
+
} finally {
|
|
70
|
+
p.close()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Validate before writing anything.
|
|
75
|
+
step('Validating config')
|
|
76
|
+
const res = validateAgainstSchema(config, await loadConfigSchema())
|
|
77
|
+
const cross = crossFieldChecks(config)
|
|
78
|
+
for (const e of [...res.errors, ...cross]) fail(`${c.bold(e.path || '(root)')} — ${e.message}`)
|
|
79
|
+
if (res.errors.length + cross.length > 0) {
|
|
80
|
+
fail('Config is invalid — aborting.')
|
|
81
|
+
return 1
|
|
82
|
+
}
|
|
83
|
+
ok('Config is valid')
|
|
84
|
+
|
|
85
|
+
// Resolve + guard the output directory.
|
|
86
|
+
const outDir = resolve(f.output || appId)
|
|
87
|
+
if (existsSync(outDir) && readdirSync(outDir).length > 0) {
|
|
88
|
+
if (f.yes || f.preset || f.config) {
|
|
89
|
+
warn(`Output dir not empty: ${outDir} — writing into it anyway (--yes/non-interactive)`)
|
|
90
|
+
} else {
|
|
91
|
+
const p = prompts()
|
|
92
|
+
const cont = await p.confirm(`Directory ${outDir} is not empty. Continue?`, false)
|
|
93
|
+
p.close()
|
|
94
|
+
if (!cont) return log('\nAborted.'), 0
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
step(`Generating backend at ${c.cyan(outDir)}`)
|
|
99
|
+
const missing = writeBackend(outDir, appId, config)
|
|
100
|
+
ok('package.json, src/index.ts, src/config.json, tsconfig, .env written')
|
|
101
|
+
if (missing.length) warn(`.env has ${missing.length} secret(s) to fill: ${c.dim(missing.join(', '))}`)
|
|
102
|
+
|
|
103
|
+
if (f.install) {
|
|
104
|
+
step('Installing dependencies')
|
|
105
|
+
const inst = spawnSync('bun', ['install'], { cwd: outDir, stdio: 'inherit' })
|
|
106
|
+
if (inst.status !== 0) {
|
|
107
|
+
fail('bun install failed — fix and re-run `bun install` in the project')
|
|
108
|
+
} else {
|
|
109
|
+
ok('dependencies installed')
|
|
110
|
+
step('Generating Drizzle schema')
|
|
111
|
+
const gen = spawnSync('bunx', ['nucleus-core-ts', 'generate', 'src/config.json', 'src/drizzle'], {
|
|
112
|
+
cwd: outDir,
|
|
113
|
+
stdio: 'inherit',
|
|
114
|
+
})
|
|
115
|
+
if (gen.status === 0) ok('schema generated')
|
|
116
|
+
else warn('schema generation skipped/failed — run `bun run generate` after setting DATABASE_URL')
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
log()
|
|
121
|
+
ok(c.green(c.bold('Project ready')))
|
|
122
|
+
info(`Next:`)
|
|
123
|
+
log(` ${c.cyan(`cd ${f.output || appId}`)}`)
|
|
124
|
+
if (!f.install) log(` ${c.cyan('bun install && bun run generate')}`)
|
|
125
|
+
log(` ${c.cyan('nucleus doctor')} ${c.dim('# verify Postgres/Redis are up')}`)
|
|
126
|
+
log(` ${c.cyan('bun run dev')} ${c.dim('# http://localhost:4000/docs')}`)
|
|
127
|
+
return 0
|
|
128
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nucleus validate [config.json]` — validate a Nucleus config against the
|
|
3
|
+
* generated JSON-Schema, plus cross-field rules the schema can't express and an
|
|
4
|
+
* env-var reference report. Exit code 1 on any error (CI-friendly).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { resolve } from 'node:path'
|
|
8
|
+
import { c, fail, info, log, ok, step, warn } from '../ui'
|
|
9
|
+
import { loadConfigSchema, validateAgainstSchema, type ValidationIssue } from '../schema'
|
|
10
|
+
|
|
11
|
+
const CANDIDATES = ['src/config.json', 'config.json', 'src/config.nucleus.json']
|
|
12
|
+
|
|
13
|
+
export function findConfigPath(arg?: string): string | null {
|
|
14
|
+
if (arg) return existsSync(resolve(arg)) ? resolve(arg) : null
|
|
15
|
+
for (const cand of CANDIDATES) if (existsSync(resolve(cand))) return resolve(cand)
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type AnyRec = Record<string, unknown>
|
|
20
|
+
const isOn = (s: unknown): boolean => typeof s === 'object' && s !== null && (s as AnyRec).enabled === true
|
|
21
|
+
|
|
22
|
+
/** Rules the JSON-Schema can't express (conditional dependencies between keys). */
|
|
23
|
+
export function crossFieldChecks(cfg: AnyRec): ValidationIssue[] {
|
|
24
|
+
const issues: ValidationIssue[] = []
|
|
25
|
+
const add = (path: string, message: string) => issues.push({ path, message })
|
|
26
|
+
|
|
27
|
+
// Genuinely-required top-level keys (no runtime default).
|
|
28
|
+
if (!cfg.appId || typeof cfg.appId !== 'string') add('appId', 'is required (a unique, stable app id)')
|
|
29
|
+
const db = cfg.database as AnyRec | undefined
|
|
30
|
+
if (!db || !db.url) add('database.url', 'is required (connection string or env-var name)')
|
|
31
|
+
|
|
32
|
+
const auth = cfg.authentication as AnyRec | undefined
|
|
33
|
+
if (auth?.enabled) {
|
|
34
|
+
if (!auth.mode) add('authentication.mode', 'set "full" (IDP) or "consumer" (resource server)')
|
|
35
|
+
if (auth.mode === 'consumer' && isOn(cfg.authorization) && !auth.idpUrl)
|
|
36
|
+
add('authentication.idpUrl', 'consumer mode with authorization needs idpUrl for /auth/check')
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(cfg.entities) || (cfg.entities as unknown[]).length === 0)
|
|
39
|
+
add('entities', 'no entities — no CRUD endpoints will be generated')
|
|
40
|
+
|
|
41
|
+
const payment = cfg.payment as AnyRec | undefined
|
|
42
|
+
if (payment?.enabled) {
|
|
43
|
+
if (!payment.provider) add('payment.provider', 'required ("iyzico" or "stripe") when payment.enabled')
|
|
44
|
+
else if (!payment[payment.provider as string]) add(`payment.${payment.provider}`, 'provider config block is missing')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const notif = cfg.notification as AnyRec | undefined
|
|
48
|
+
const channels = notif?.channels as AnyRec | undefined
|
|
49
|
+
if (notif?.enabled && channels) {
|
|
50
|
+
const tg = channels.telegram as AnyRec | undefined
|
|
51
|
+
if (isOn(tg) && (!tg?.botToken || !tg?.chatId)) add('notification.channels.telegram', 'enabled but botToken/chatId missing')
|
|
52
|
+
const wh = channels.webhook as AnyRec | undefined
|
|
53
|
+
if (isOn(wh) && !wh?.url) add('notification.channels.webhook', 'enabled but url missing')
|
|
54
|
+
if (channels.email === true && !cfg.email) add('notification.channels.email', 'enabled but no config.email provider set')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const chat = cfg.chat as AnyRec | undefined
|
|
58
|
+
const storage = cfg.storage as AnyRec | undefined
|
|
59
|
+
if (isOn(chat) && isOn((chat as AnyRec)?.attachments) && !(isOn(storage) && isOn((storage as AnyRec)?.cdn)))
|
|
60
|
+
add('chat.attachments', 'requires storage.enabled and storage.cdn.enabled')
|
|
61
|
+
|
|
62
|
+
return issues
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ENV_RE = /^[A-Z][A-Z0-9_]{2,}$/
|
|
66
|
+
const ENV_SKIP = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'TOGGLE', 'VERIFICATION', 'OPTIONS', 'HEAD'])
|
|
67
|
+
const ENV_SKIP_KEYS = new Set(['entities', 'externalEntities', 'seed'])
|
|
68
|
+
|
|
69
|
+
export function collectEnvRefs(v: unknown, found = new Set<string>()): Set<string> {
|
|
70
|
+
if (typeof v === 'string') {
|
|
71
|
+
if (ENV_RE.test(v) && !ENV_SKIP.has(v)) found.add(v)
|
|
72
|
+
} else if (Array.isArray(v)) for (const i of v) collectEnvRefs(i, found)
|
|
73
|
+
else if (typeof v === 'object' && v !== null)
|
|
74
|
+
for (const [k, val] of Object.entries(v)) if (!ENV_SKIP_KEYS.has(k)) collectEnvRefs(val, found)
|
|
75
|
+
return found
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function validateCommand(args: string[]): Promise<number> {
|
|
79
|
+
const path = findConfigPath(args[0])
|
|
80
|
+
if (!path) {
|
|
81
|
+
fail(args[0] ? `Config not found: ${args[0]}` : `No config found (looked for ${CANDIDATES.join(', ')})`)
|
|
82
|
+
return 1
|
|
83
|
+
}
|
|
84
|
+
step(`Validating ${c.cyan(path)}`)
|
|
85
|
+
|
|
86
|
+
let cfg: AnyRec
|
|
87
|
+
try {
|
|
88
|
+
cfg = JSON.parse(await Bun.file(path).text()) as AnyRec
|
|
89
|
+
} catch (e) {
|
|
90
|
+
fail(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`)
|
|
91
|
+
return 1
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const schema = await loadConfigSchema()
|
|
95
|
+
const res = validateAgainstSchema(cfg, schema)
|
|
96
|
+
const cross = crossFieldChecks(cfg)
|
|
97
|
+
|
|
98
|
+
for (const e of res.errors) fail(`${c.bold(e.path || '(root)')} — ${e.message}`)
|
|
99
|
+
for (const e of cross) fail(`${c.bold(e.path)} — ${e.message}`)
|
|
100
|
+
for (const w of res.warnings) warn(`${c.bold(w.path)} — ${w.message}`)
|
|
101
|
+
|
|
102
|
+
const envRefs = [...collectEnvRefs(cfg)].sort()
|
|
103
|
+
if (envRefs.length) info(`${envRefs.length} env var(s) referenced: ${c.dim(envRefs.join(', '))}`)
|
|
104
|
+
|
|
105
|
+
const errCount = res.errors.length + cross.length
|
|
106
|
+
log()
|
|
107
|
+
if (errCount === 0) {
|
|
108
|
+
ok(`${c.green(c.bold('Valid'))} — ${res.warnings.length} warning(s)`)
|
|
109
|
+
return 0
|
|
110
|
+
}
|
|
111
|
+
fail(`${c.red(c.bold(`${errCount} error(s)`))}, ${res.warnings.length} warning(s)`)
|
|
112
|
+
return 1
|
|
113
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive config builder for `nucleus init` (TTY). Starts from a preset,
|
|
3
|
+
* then optionally layers on entities via a column-by-column entity builder.
|
|
4
|
+
* Returns a NucleusConfigOptions object ready to write to config.json.
|
|
5
|
+
*/
|
|
6
|
+
import { c, info, log, ok, step } from './ui'
|
|
7
|
+
import type { prompts as promptsFactory } from './ui'
|
|
8
|
+
import { buildPreset, PRESET_NAMES, PRESET_SUMMARY, type PresetName } from './presets'
|
|
9
|
+
|
|
10
|
+
type P = ReturnType<typeof promptsFactory>
|
|
11
|
+
type Cfg = Record<string, unknown>
|
|
12
|
+
|
|
13
|
+
const COLUMN_TYPES = ['varchar', 'text', 'integer', 'numeric', 'boolean', 'timestamp', 'uuid', 'json', 'jsonb']
|
|
14
|
+
|
|
15
|
+
export function slug(s: string): string {
|
|
16
|
+
return s.toLowerCase().replace(/[^a-z0-9_]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function buildEntity(p: P): Promise<Cfg | null> {
|
|
20
|
+
const name = slug(await p.ask(' Table name (snake_case, blank to finish)'))
|
|
21
|
+
if (!name) return null
|
|
22
|
+
const columns: Cfg[] = []
|
|
23
|
+
log(` ${c.dim('Add columns (blank name to finish):')}`)
|
|
24
|
+
while (true) {
|
|
25
|
+
const cname = slug(await p.ask(' · column name'))
|
|
26
|
+
if (!cname) break
|
|
27
|
+
const type = await p.choose(` type of "${cname}"`, COLUMN_TYPES, 0)
|
|
28
|
+
const notNull = await p.confirm(' required (NOT NULL)?', false)
|
|
29
|
+
const col: Cfg = { name: cname, type }
|
|
30
|
+
if (notNull) col.notNull = true
|
|
31
|
+
columns.push(col)
|
|
32
|
+
}
|
|
33
|
+
if (columns.length === 0) columns.push({ name: 'name', type: 'varchar', length: 200, notNull: true })
|
|
34
|
+
const isPublic = await p.confirm(' make GET public (readable without auth)?', false)
|
|
35
|
+
const bulk = await p.confirm(' enable bulk endpoints?', false)
|
|
36
|
+
const entity: Cfg = { table_name: name, add_base_columns: true, columns }
|
|
37
|
+
if (isPublic) entity.is_public = { GET: true }
|
|
38
|
+
if (bulk) entity.bulk_endpoints_enabled = true
|
|
39
|
+
return entity
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function buildConfigInteractive(p: P): Promise<Cfg> {
|
|
43
|
+
step('Build your config')
|
|
44
|
+
const appId = slug((await p.askRequired('App id (unique, stable — e.g. shop-api)')) || 'my-app')
|
|
45
|
+
|
|
46
|
+
log(`\n ${c.bold('Start from a preset:')}`)
|
|
47
|
+
for (const name of PRESET_NAMES) log(` ${c.cyan(name)} — ${c.dim(PRESET_SUMMARY[name])}`)
|
|
48
|
+
const preset = (await p.choose('\n Preset', [...PRESET_NAMES], 2)) as PresetName
|
|
49
|
+
|
|
50
|
+
const cfg = buildPreset(preset, appId)
|
|
51
|
+
ok(`Seeded a "${preset}" config`)
|
|
52
|
+
|
|
53
|
+
if (await p.confirm('\n Replace the example entity with your own?', false)) {
|
|
54
|
+
const entities: Cfg[] = []
|
|
55
|
+
let e = await buildEntity(p)
|
|
56
|
+
while (e) {
|
|
57
|
+
entities.push(e)
|
|
58
|
+
ok(`Added entity "${(e as { table_name: string }).table_name}"`)
|
|
59
|
+
e = await buildEntity(p)
|
|
60
|
+
}
|
|
61
|
+
if (entities.length > 0) cfg.entities = entities
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
info(`Deeper options: edit config.json after — the $schema ref gives editor autocomplete for every key.`)
|
|
65
|
+
return cfg
|
|
66
|
+
}
|
package/bin/lib/env.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared local-environment helpers for the CLI: read a project .env, parse a
|
|
3
|
+
* host:port, and TCP-probe reachability. Used by `doctor` and `dev`.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { resolve } from 'node:path'
|
|
7
|
+
|
|
8
|
+
/** Read a project .env (KEY=VALUE) merged over process.env. */
|
|
9
|
+
export function readEnv(cwd = process.cwd()): Record<string, string> {
|
|
10
|
+
const env: Record<string, string> = { ...(process.env as Record<string, string>) }
|
|
11
|
+
const envPath = resolve(cwd, '.env')
|
|
12
|
+
if (existsSync(envPath)) {
|
|
13
|
+
try {
|
|
14
|
+
for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
|
|
15
|
+
const m = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/)
|
|
16
|
+
if (m?.[1]) env[m[1]] = (m[2] ?? '').replace(/^["']|["']$/g, '')
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
/* ignore */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return env
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hostPort(url: string | undefined, defHost: string, defPort: number): { host: string; port: number } {
|
|
26
|
+
if (!url) return { host: defHost, port: defPort }
|
|
27
|
+
try {
|
|
28
|
+
const u = new URL(url)
|
|
29
|
+
return { host: u.hostname || defHost, port: u.port ? Number(u.port) : defPort }
|
|
30
|
+
} catch {
|
|
31
|
+
return { host: defHost, port: defPort }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function tcpReachable(hostname: string, port: number, timeoutMs = 1500): Promise<boolean> {
|
|
36
|
+
return new Promise((res) => {
|
|
37
|
+
let done = false
|
|
38
|
+
const finish = (v: boolean) => {
|
|
39
|
+
if (!done) {
|
|
40
|
+
done = true
|
|
41
|
+
res(v)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const t = setTimeout(() => finish(false), timeoutMs)
|
|
45
|
+
Bun.connect({
|
|
46
|
+
hostname,
|
|
47
|
+
port,
|
|
48
|
+
socket: {
|
|
49
|
+
data() {},
|
|
50
|
+
open(sock) {
|
|
51
|
+
clearTimeout(t)
|
|
52
|
+
sock.end()
|
|
53
|
+
finish(true)
|
|
54
|
+
},
|
|
55
|
+
error() {
|
|
56
|
+
clearTimeout(t)
|
|
57
|
+
finish(false)
|
|
58
|
+
},
|
|
59
|
+
connectError() {
|
|
60
|
+
clearTimeout(t)
|
|
61
|
+
finish(false)
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
}).catch(() => {
|
|
65
|
+
clearTimeout(t)
|
|
66
|
+
finish(false)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolve Postgres + Redis host/port from a project's env. */
|
|
72
|
+
export function pgConn(env: Record<string, string>) {
|
|
73
|
+
return hostPort(env.DATABASE_URL, 'localhost', 5432)
|
|
74
|
+
}
|
|
75
|
+
export function redisConn(env: Record<string, string>) {
|
|
76
|
+
return hostPort(env.REDIS_URL, env.REDIS_HOST || 'localhost', Number(env.REDIS_PORT) || 6379)
|
|
77
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starter config presets for `nucleus init --preset <name>`. Each returns a
|
|
3
|
+
* complete, schema-valid NucleusConfigOptions object (as a plain record) for a
|
|
4
|
+
* given appId — a sensible, runnable starting point the user then refines.
|
|
5
|
+
* Secrets are env-var NAMES (resolved at boot), never literals.
|
|
6
|
+
*/
|
|
7
|
+
export type PresetName = 'minimal' | 'api' | 'saas' | 'marketplace'
|
|
8
|
+
export const PRESET_NAMES: PresetName[] = ['minimal', 'api', 'saas', 'marketplace']
|
|
9
|
+
|
|
10
|
+
type Cfg = Record<string, unknown>
|
|
11
|
+
|
|
12
|
+
const exampleEntity = {
|
|
13
|
+
table_name: 'products',
|
|
14
|
+
add_base_columns: true,
|
|
15
|
+
bulk_endpoints_enabled: true,
|
|
16
|
+
is_public: { GET: true },
|
|
17
|
+
columns: [
|
|
18
|
+
{ name: 'title', type: 'varchar', length: 200, notNull: true },
|
|
19
|
+
{ name: 'description', type: 'text' },
|
|
20
|
+
{ name: 'price', type: 'numeric', precision: 10, scale: 2 },
|
|
21
|
+
{ name: 'in_stock', type: 'boolean', default: true },
|
|
22
|
+
],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function base(appId: string, mode: 'development' | 'production'): Cfg {
|
|
26
|
+
return {
|
|
27
|
+
$schema: './config.nucleus.json',
|
|
28
|
+
appId,
|
|
29
|
+
mode,
|
|
30
|
+
database: { url: 'DATABASE_URL' },
|
|
31
|
+
redis: { host: 'REDIS_HOST', port: 'REDIS_PORT', withDapr: false },
|
|
32
|
+
logging: { level: mode === 'development' ? 'debug' : 'info' },
|
|
33
|
+
entities: [exampleEntity],
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const fullAuth = () => ({
|
|
38
|
+
enabled: true,
|
|
39
|
+
mode: 'full',
|
|
40
|
+
defaultRole: 'basic',
|
|
41
|
+
accessToken: { secret: 'ACCESS_TOKEN_SECRET', expiresIn: '15m' },
|
|
42
|
+
refreshToken: { secret: 'REFRESH_TOKEN_SECRET', expiresIn: '30d' },
|
|
43
|
+
sessionToken: { secret: 'SESSION_TOKEN_SECRET' },
|
|
44
|
+
login: { enabled: true },
|
|
45
|
+
register: { enabled: true, createProfileOnRegister: true },
|
|
46
|
+
logout: { enabled: true },
|
|
47
|
+
refresh: { enabled: true },
|
|
48
|
+
passwordReset: { enabled: true },
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
export function buildPreset(name: PresetName, appId: string): Cfg {
|
|
52
|
+
switch (name) {
|
|
53
|
+
case 'minimal':
|
|
54
|
+
return { ...base(appId, 'development'), authentication: { enabled: false } }
|
|
55
|
+
|
|
56
|
+
case 'api':
|
|
57
|
+
return {
|
|
58
|
+
...base(appId, 'production'),
|
|
59
|
+
authentication: { enabled: true, mode: 'consumer', idpUrl: 'IDP_URL' },
|
|
60
|
+
authorization: { enabled: true, autoSeedClaims: true },
|
|
61
|
+
rateLimit: { enabled: true, strategy: 'sliding-window' },
|
|
62
|
+
monitoring: { enabled: true },
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case 'saas':
|
|
66
|
+
return {
|
|
67
|
+
...base(appId, 'production'),
|
|
68
|
+
database: { url: 'DATABASE_URL', isMultiTenant: true, tenantResolution: 'subdomain' },
|
|
69
|
+
authentication: {
|
|
70
|
+
...fullAuth(),
|
|
71
|
+
register: {
|
|
72
|
+
enabled: true,
|
|
73
|
+
createProfileOnRegister: true,
|
|
74
|
+
emailVerification: { enabled: true },
|
|
75
|
+
},
|
|
76
|
+
sessions: { maxActiveSessions: 5, notifyOnNewDevice: true },
|
|
77
|
+
},
|
|
78
|
+
authorization: { enabled: true, autoSeedClaims: true },
|
|
79
|
+
email: { provider: 'gmail', gmail: { json_file_path: 'GMAIL_SA_JSON', from_email: 'FROM_EMAIL', from_name: appId } },
|
|
80
|
+
storage: {
|
|
81
|
+
enabled: true,
|
|
82
|
+
maxFileSizeBytes: 10485760,
|
|
83
|
+
cdn: { enabled: true, cacheMaxAge: 86400 },
|
|
84
|
+
},
|
|
85
|
+
notification: { enabled: true, channels: { portal: true, email: true } },
|
|
86
|
+
monitoring: { enabled: true },
|
|
87
|
+
audit: { enabled: true },
|
|
88
|
+
rateLimit: { enabled: true, strategy: 'sliding-window' },
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case 'marketplace': {
|
|
92
|
+
const saas = buildPreset('saas', appId)
|
|
93
|
+
return {
|
|
94
|
+
...saas,
|
|
95
|
+
payment: {
|
|
96
|
+
enabled: true,
|
|
97
|
+
provider: 'stripe',
|
|
98
|
+
defaultCurrency: 'usd',
|
|
99
|
+
threeDSecureEnabled: true,
|
|
100
|
+
savedMethodsEnabled: true,
|
|
101
|
+
subMerchantsEnabled: true,
|
|
102
|
+
stripe: { secretKey: 'STRIPE_SECRET_KEY', webhookSecret: 'STRIPE_WEBHOOK_SECRET' },
|
|
103
|
+
marketplace: {
|
|
104
|
+
defaultSettlementDelayDays: 7,
|
|
105
|
+
reserveRate: 0.1,
|
|
106
|
+
minimumPayoutAmount: 1000,
|
|
107
|
+
defaultCurrency: 'usd',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const PRESET_SUMMARY: Record<PresetName, string> = {
|
|
116
|
+
minimal: 'auth off, 1 example entity — the smallest runnable API',
|
|
117
|
+
api: 'consumer-mode (resource server) + authorization + rate-limit + monitoring',
|
|
118
|
+
saas: 'full auth (login/register/email-verify) + multi-tenant + email + storage/CDN + notifications + audit',
|
|
119
|
+
marketplace: 'the SaaS stack + Stripe payments & the marketplace money layer (splits/reserves/payouts)',
|
|
120
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads the generated Nucleus config JSON-Schema (`schemas/config.nucleus.json`,
|
|
3
|
+
* produced from `NucleusConfigOptions`) and validates a config object against it.
|
|
4
|
+
*
|
|
5
|
+
* A deliberately small, dependency-free validator covering the subset the
|
|
6
|
+
* generated schema actually uses: type, enum, required, properties, items,
|
|
7
|
+
* $ref (to #/$defs|#/definitions), and oneOf/anyOf. Unknown properties are NOT
|
|
8
|
+
* treated as errors (the framework tolerates forward-compatible extra keys), but
|
|
9
|
+
* they are collected as warnings so typos surface.
|
|
10
|
+
*/
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
// bin/lib -> package root
|
|
16
|
+
export const SCHEMA_PATH = join(here, '..', '..', 'schemas', 'config.nucleus.json')
|
|
17
|
+
|
|
18
|
+
export type JsonSchema = Record<string, unknown>
|
|
19
|
+
export type ValidationIssue = { path: string; message: string }
|
|
20
|
+
export type ValidationResult = { valid: boolean; errors: ValidationIssue[]; warnings: ValidationIssue[] }
|
|
21
|
+
|
|
22
|
+
export async function loadConfigSchema(): Promise<JsonSchema> {
|
|
23
|
+
const raw = await Bun.file(SCHEMA_PATH).text()
|
|
24
|
+
return JSON.parse(raw) as JsonSchema
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const typeOf = (v: unknown): string => {
|
|
28
|
+
if (v === null) return 'null'
|
|
29
|
+
if (Array.isArray(v)) return 'array'
|
|
30
|
+
return typeof v // string | number | boolean | object
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const matchesType = (v: unknown, t: string): boolean => {
|
|
34
|
+
if (t === 'integer') return typeof v === 'number' && Number.isInteger(v)
|
|
35
|
+
if (t === 'number') return typeof v === 'number'
|
|
36
|
+
return typeOf(v) === t
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveRef(root: JsonSchema, ref: string): JsonSchema | null {
|
|
40
|
+
if (!ref.startsWith('#/')) return null
|
|
41
|
+
let node: unknown = root
|
|
42
|
+
for (const part of ref.slice(2).split('/')) {
|
|
43
|
+
node = (node as Record<string, unknown>)?.[decodeURIComponent(part)]
|
|
44
|
+
if (node == null) return null
|
|
45
|
+
}
|
|
46
|
+
return node as JsonSchema
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validateNode(
|
|
50
|
+
value: unknown,
|
|
51
|
+
schema: JsonSchema,
|
|
52
|
+
root: JsonSchema,
|
|
53
|
+
path: string,
|
|
54
|
+
out: ValidationResult
|
|
55
|
+
): void {
|
|
56
|
+
if (typeof schema.$ref === 'string') {
|
|
57
|
+
const resolved = resolveRef(root, schema.$ref)
|
|
58
|
+
if (resolved) return validateNode(value, resolved, root, path, out)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// oneOf / anyOf — pass if any branch validates cleanly.
|
|
63
|
+
for (const key of ['oneOf', 'anyOf'] as const) {
|
|
64
|
+
const branches = schema[key] as JsonSchema[] | undefined
|
|
65
|
+
if (Array.isArray(branches)) {
|
|
66
|
+
const anyValid = branches.some((b) => {
|
|
67
|
+
const probe: ValidationResult = { valid: true, errors: [], warnings: [] }
|
|
68
|
+
validateNode(value, b, root, path, probe)
|
|
69
|
+
return probe.errors.length === 0
|
|
70
|
+
})
|
|
71
|
+
if (!anyValid) out.errors.push({ path, message: `does not match any allowed ${key} variant` })
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const types = schema.type
|
|
77
|
+
if (types !== undefined) {
|
|
78
|
+
const list = Array.isArray(types) ? (types as string[]) : [types as string]
|
|
79
|
+
if (!list.some((t) => matchesType(value, t))) {
|
|
80
|
+
out.errors.push({ path, message: `expected ${list.join(' | ')}, got ${typeOf(value)}` })
|
|
81
|
+
return // wrong type — deeper checks would be noise
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((e) => e === value)) {
|
|
86
|
+
out.errors.push({ path, message: `must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(', ')}` })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeOf(value) === 'object') {
|
|
90
|
+
const obj = value as Record<string, unknown>
|
|
91
|
+
const props = (schema.properties as Record<string, JsonSchema>) ?? {}
|
|
92
|
+
for (const req of (schema.required as string[]) ?? []) {
|
|
93
|
+
// The generated schema marks every non-optional TS field `required`, but
|
|
94
|
+
// most nucleus config fields have RUNTIME defaults — so a missing "required"
|
|
95
|
+
// field is a hint (the runtime fills it), not an error. Genuinely-critical
|
|
96
|
+
// requireds (appId, database, entities, conditional feature keys) are
|
|
97
|
+
// enforced as errors by the cross-field checks instead.
|
|
98
|
+
if (!(req in obj)) out.warnings.push({ path: path ? `${path}.${req}` : req, message: 'not set (uses the runtime default)' })
|
|
99
|
+
}
|
|
100
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
101
|
+
const childPath = path ? `${path}.${k}` : k
|
|
102
|
+
if (props[k]) validateNode(v, props[k], root, childPath, out)
|
|
103
|
+
else if (schema.additionalProperties === false)
|
|
104
|
+
out.warnings.push({ path: childPath, message: 'unknown property (not in schema)' })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (typeOf(value) === 'array' && schema.items) {
|
|
109
|
+
const itemSchema = schema.items as JsonSchema
|
|
110
|
+
;(value as unknown[]).forEach((item, i) => validateNode(item, itemSchema, root, `${path}[${i}]`, out))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function validateAgainstSchema(config: unknown, schema: JsonSchema): ValidationResult {
|
|
115
|
+
const out: ValidationResult = { valid: true, errors: [], warnings: [] }
|
|
116
|
+
validateNode(config, schema, schema, '', out)
|
|
117
|
+
out.valid = out.errors.length === 0
|
|
118
|
+
return out
|
|
119
|
+
}
|