Files
Moheeb Zara c0d5ffd8fc audit: implement --force admin password reset + update HANDOFF
Quick audit before merge found the docs promise 'pnpm setup create-admin --force'
resets an existing admin's password, but the CLI only reused the user without
resetting (an admin-lockout footgun). Implement it via auth.admin.updateUserById,
guarded by --force and only when a password is supplied. Update docs/HANDOFF.md
with the single-tenant + security-fix entry (newest on top).
2026-08-05 13:15:27 -07:00

706 lines
26 KiB
JavaScript
Executable File

// -----------------------------------------------------------------------------
// scripts/setup.mjs — one-command setup CLI for a single-tenant deployment.
//
// A hackerspace deployer runs this once to provision their one space AND create
// the first admin login. It talks to Supabase with the service role key, so run
// it from a trusted machine only — never expose the service role key.
//
// USAGE
// pnpm setup Provision the space, then create the first admin,
// then print a success summary + login URL.
// pnpm setup doctor Validate env + DB connectivity (no writes).
// pnpm setup provision Create the space only (idempotent).
// pnpm setup create-admin Create the first admin only (idempotent).
// pnpm setup help Print help.
//
// node scripts/setup.mjs create-admin --admin-email [email protected]
// node scripts/setup.mjs provision --space-name "My Space" --space-slug my-space
//
// CONFIG PRECEDENCE (earlier wins):
// explicit CLI flag > process.env > .env.local file
//
// FLAGS
// --space-name, --space-slug, --space-city
// --admin-email, --admin-password, --admin-name
// --yes non-interactive (never prompt; error out on missing required value)
// --force allow provision even if a DIFFERENT space already exists
//
// REQUIRED RUNTIME ENV (see .env.example / .env.local):
// NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,
// SUPABASE_SERVICE_ROLE_KEY, NEXT_PUBLIC_APP_URL
//
// SETUP ENV (used only here, never at runtime):
// SETUP_SPACE_NAME, SETUP_SPACE_SLUG, SETUP_SPACE_CITY,
// SETUP_ADMIN_EMAIL, SETUP_ADMIN_PASSWORD, SETUP_ADMIN_NAME
//
// EXIT CODES: 0 ok · 1 usage/env error · 2 runtime failure
// -----------------------------------------------------------------------------
import { createClient } from '@supabase/supabase-js'
import { randomBytes, randomInt } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
import { stdin, stdout } from 'node:process'
// --- Exit codes -------------------------------------------------------------
const EXIT_OK = 0
const EXIT_USAGE = 1
const EXIT_RUNTIME = 2
// A usage/env error we can throw and map to exit code 1. Any other thrown error
// is treated as a runtime failure (exit code 2).
class UsageError extends Error {}
// --- Small pretty-print helpers ---------------------------------------------
const c = {
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', cyan: '\x1b[36m',
}
const useColor = stdout.isTTY
const paint = (color, s) => (useColor ? color + s + c.reset : s)
const ok = (s) => paint(c.green, s)
const bad = (s) => paint(c.red, s)
const warn = (s) => paint(c.yellow, s)
const info = (s) => console.log(s)
const pass = (label) => console.log(` ${ok('PASS')} ${label}`)
const fail = (label) => console.log(` ${bad('FAIL')} ${label}`)
// -----------------------------------------------------------------------------
// .env.local parsing. Tiny KEY=VALUE parser: ignore blank lines and #-comments,
// strip surrounding single/double quotes. No new deps.
// -----------------------------------------------------------------------------
function parseEnvFile(path) {
let text
try {
text = readFileSync(path, 'utf8')
} catch {
return {} // No .env.local is fine — env / flags may cover everything.
}
const out = {}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const eq = line.indexOf('=')
if (eq === -1) continue
const key = line.slice(0, eq).trim()
if (!key) continue
let value = line.slice(eq + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
out[key] = value
}
return out
}
// -----------------------------------------------------------------------------
// Arg parsing. Supports "--flag value" and "--flag=value" and boolean "--flag".
// -----------------------------------------------------------------------------
const BOOL_FLAGS = new Set(['yes', 'force', 'help'])
function parseArgs(argv) {
const flags = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (!a.startsWith('--')) continue
const body = a.slice(2)
const eq = body.indexOf('=')
if (eq !== -1) {
flags[body.slice(0, eq)] = body.slice(eq + 1)
continue
}
if (BOOL_FLAGS.has(body)) {
flags[body] = true
continue
}
// "--flag value": consume the next token if it isn't itself a flag.
const next = argv[i + 1]
if (next !== undefined && !next.startsWith('--')) {
flags[body] = next
i++
} else {
flags[body] = true
}
}
return flags
}
// -----------------------------------------------------------------------------
// Config resolution. Precedence: CLI flag > process.env > .env.local.
// process.env is never overridden by the file (mirrors dotenv semantics).
// -----------------------------------------------------------------------------
function buildConfig(flags) {
const fileEnv = parseEnvFile(resolve(process.cwd(), '.env.local'))
// env: process.env wins over the file.
const env = { ...fileEnv }
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) env[k] = v
}
// Resolve a value: CLI flag first, then env (process.env + file).
const pick = (flagName, envName) => {
const f = flags[flagName]
if (typeof f === 'string' && f.trim() !== '') return f
const e = env[envName]
if (typeof e === 'string' && e.trim() !== '') return e
return undefined
}
return { env, flags, pick }
}
// -----------------------------------------------------------------------------
// Validation / normalization helpers.
// -----------------------------------------------------------------------------
function slugify(raw) {
return String(raw)
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function isValidEmail(email) {
// Deliberately conservative: one @, non-empty local + domain with a dot.
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
function generateInviteCode() {
// Mirror lib/auth-actions.ts::generateInviteCode exactly.
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const prefix = 'HSL'
const year = new Date().getFullYear()
const bytes = randomBytes(8)
let code = ''
for (let i = 0; i < 8; i++) code += chars[bytes[i] % chars.length]
return `${prefix}-${year}-${code}`
}
function generatePassword() {
// Strong, readable-ish random password. 24 chars from a wide alphabet using
// rejection-free randomInt so there is no modulo bias.
const chars =
'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*-_'
let out = ''
for (let i = 0; i < 24; i++) out += chars[randomInt(chars.length)]
return out
}
// -----------------------------------------------------------------------------
// Interactive prompting (only when TTY and not --yes).
// -----------------------------------------------------------------------------
async function promptFor(label, { secret = false } = {}) {
const rl = createInterface({ input: stdin, output: stdout })
try {
// node:readline/promises has no built-in masking; for a secret we write the
// label ourselves, then install a masking output writer BEFORE awaiting the
// (empty-query) question so each typed char echoes as '*'. Using the awaited
// promises form is essential: rl.question(query, cb) treats the 2nd arg as
// options (not a callback) in the promises API, so a callback never fires.
if (secret) {
const write = stdout.write.bind(stdout)
stdout.write(`${label}: `)
rl._writeToOutput = (str) => {
if (str.includes('\n') || str.includes('\r')) write(str)
else write('*')
}
const answer = await rl.question('')
stdout.write('\n')
return answer.trim()
}
const answer = await rl.question(`${label}: `)
return answer.trim()
} finally {
rl.close()
}
}
const canPrompt = (flags) => Boolean(stdin.isTTY) && !flags.yes
// Resolve a required value: flag/env, else prompt (interactive), else error.
async function requireValue(cfg, { flagName, envName, label, secret = false, optional = false }) {
const found = cfg.pick(flagName, envName)
if (found !== undefined) return found
if (optional) {
if (canPrompt(cfg.flags)) {
const v = await promptFor(`${label} (optional, blank to skip)`, { secret })
return v === '' ? undefined : v
}
return undefined
}
if (canPrompt(cfg.flags)) {
let v = ''
while (v === '') {
v = await promptFor(label, { secret })
if (v === '') console.log(warn(' This value is required.'))
}
return v
}
throw new UsageError(
`Missing required value: ${label}. Provide --${flagName} or set ${envName} (in env or .env.local), or run interactively without --yes.`,
)
}
// -----------------------------------------------------------------------------
// Supabase client (service role). Validates the 4 required runtime vars.
// -----------------------------------------------------------------------------
const REQUIRED_RUNTIME = [
'NEXT_PUBLIC_SUPABASE_URL',
'NEXT_PUBLIC_SUPABASE_ANON_KEY',
'SUPABASE_SERVICE_ROLE_KEY',
'NEXT_PUBLIC_APP_URL',
]
function missingRuntimeVars(env) {
return REQUIRED_RUNTIME.filter((k) => {
const v = env[k]
return typeof v !== 'string' || v.trim() === ''
})
}
function makeAdminClient(env) {
return createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
})
}
// appBaseUrl equivalent: NEXT_PUBLIC_APP_URL, trailing slash stripped.
function appBaseUrl(env) {
const raw = (env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000').trim()
return raw.replace(/\/+$/, '')
}
// -----------------------------------------------------------------------------
// Subcommand: doctor
// -----------------------------------------------------------------------------
async function cmdDoctor(cfg) {
console.log(c.bold + 'Setup doctor' + c.reset)
console.log(paint(c.dim, 'Validating environment and database connectivity...\n'))
let failed = false
// 1. Required runtime vars present.
const missing = missingRuntimeVars(cfg.env)
for (const k of REQUIRED_RUNTIME) {
if (missing.includes(k)) {
fail(`${k} is set`)
failed = true
} else {
pass(`${k} is set`)
}
}
if (missing.length > 0) {
console.log('\n' + bad('Cannot connect: required variables are missing (see above).'))
console.log('Set them in your environment or .env.local, then re-run `pnpm setup doctor`.')
return EXIT_USAGE
}
// 2. Connect + verify the spaces table exists via a trivial select.
const admin = makeAdminClient(cfg.env)
const { error } = await admin.from('spaces').select('id').limit(1)
if (error) {
fail('Connect with service role and query the "spaces" table')
console.log(paint(c.dim, ` ${error.message}`))
// 42P01 = undefined_table.
if (error.code === '42P01' || /relation .* does not exist/i.test(error.message)) {
console.log(
'\n' + warn('The "spaces" table does not exist. Run the schema first:') +
'\n psql "$DATABASE_URL" -f scripts/schema.sql (then apply scripts/0NN_*.sql)',
)
}
failed = true
} else {
pass('Connect with service role and query the "spaces" table')
}
console.log('')
if (failed) {
console.log(bad('doctor: FAIL'))
return EXIT_RUNTIME
}
console.log(ok('doctor: PASS') + ' — environment looks good.')
return EXIT_OK
}
// -----------------------------------------------------------------------------
// Subcommand: provision (create the space). Idempotent.
// -----------------------------------------------------------------------------
async function cmdProvision(cfg) {
const missing = missingRuntimeVars(cfg.env)
if (missing.length > 0) {
throw new UsageError(`Missing required runtime env: ${missing.join(', ')}. Run \`pnpm setup doctor\`.`)
}
const admin = makeAdminClient(cfg.env)
const rawName = await requireValue(cfg, {
flagName: 'space-name', envName: 'SETUP_SPACE_NAME', label: 'Space name',
})
const rawSlug = await requireValue(cfg, {
flagName: 'space-slug', envName: 'SETUP_SPACE_SLUG', label: 'Space slug (letters, numbers, hyphens)',
})
const rawCity = await requireValue(cfg, {
flagName: 'space-city', envName: 'SETUP_SPACE_CITY', label: 'Space city', optional: true,
})
const name = rawName.trim()
const slug = slugify(rawSlug)
const city = rawCity && rawCity.trim() ? rawCity.trim() : null
if (!name) throw new UsageError('Space name cannot be empty.')
if (!slug) throw new UsageError(`Space slug "${rawSlug}" slugifies to empty. Use letters, numbers, or hyphens.`)
// Idempotency: if a space with this slug already exists, reuse it.
const { data: existing, error: exErr } = await admin
.from('spaces')
.select('id, name, slug, invite_code')
.eq('slug', slug)
.maybeSingle()
if (exErr) throw new Error(`Failed to look up existing space: ${exErr.message}`)
if (existing) {
console.log(ok('Space already exists') + ' (idempotent, nothing to do):')
console.log(` id: ${existing.id}`)
console.log(` name: ${existing.name}`)
console.log(` slug: ${existing.slug}`)
console.log(` invite_code: ${existing.invite_code}`)
return { id: existing.id, slug: existing.slug, invite_code: existing.invite_code, created: false }
}
// Safety: refuse to add a SECOND space to a single-tenant DB unless --force.
const { data: others, error: othersErr } = await admin
.from('spaces')
.select('id, name, slug')
.limit(5)
if (othersErr) throw new Error(`Failed to check existing spaces: ${othersErr.message}`)
if (others && others.length > 0 && !cfg.flags.force) {
console.log(bad('Refusing to create a second space.'))
console.log('This database already contains a different space:')
for (const s of others) console.log(` - ${s.name} (${s.slug})`)
console.log(
'\nA single-tenant deployment hosts exactly one space. If you really intend to\n' +
'add another, re-run with --force. Otherwise check NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG.',
)
throw new UsageError('Provision aborted: a different space already exists (use --force to override).')
}
const invite_code = generateInviteCode()
const { data: space, error: insErr } = await admin
.from('spaces')
.insert({ name, slug, city, invite_code })
.select('id, slug, invite_code')
.single()
if (insErr || !space) {
if (insErr?.code === '23505') {
throw new Error(`A space with slug "${slug}" already exists (race). Re-run to reuse it.`)
}
throw new Error(`Failed to create space: ${insErr?.message ?? 'unknown error'}`)
}
console.log(ok('Created space:'))
console.log(` id: ${space.id}`)
console.log(` name: ${name}`)
console.log(` slug: ${space.slug}`)
console.log(` invite_code: ${space.invite_code}`)
console.log(paint(c.dim, ' (default channels, areas, and tiers were created automatically.)'))
return { id: space.id, slug: space.slug, invite_code: space.invite_code, created: true }
}
// -----------------------------------------------------------------------------
// Resolve the target space for create-admin: by --space-slug/SETUP_SPACE_SLUG,
// else the sole space in the DB.
// -----------------------------------------------------------------------------
async function resolveTargetSpace(cfg, admin) {
const slugRaw = cfg.pick('space-slug', 'SETUP_SPACE_SLUG')
if (slugRaw && slugRaw.trim()) {
const slug = slugify(slugRaw)
const { data, error } = await admin
.from('spaces')
.select('id, name, slug')
.eq('slug', slug)
.maybeSingle()
if (error) throw new Error(`Failed to look up space "${slug}": ${error.message}`)
if (!data) throw new UsageError(`No space with slug "${slug}". Run \`pnpm setup provision\` first.`)
return data
}
// No slug given: require exactly one space.
const { data: spaces, error } = await admin
.from('spaces')
.select('id, name, slug')
.order('created_at', { ascending: true })
.limit(2)
if (error) throw new Error(`Failed to list spaces: ${error.message}`)
if (!spaces || spaces.length === 0) {
throw new UsageError('No space exists yet. Run `pnpm setup provision` first.')
}
if (spaces.length > 1) {
throw new UsageError('Multiple spaces exist; specify which with --space-slug or SETUP_SPACE_SLUG.')
}
return spaces[0]
}
// Find an existing auth user by email (paginates the admin list). Supabase-js
// has no getUserByEmail, so we scan pages of listUsers.
async function findAuthUserByEmail(admin, email) {
const target = email.toLowerCase()
const perPage = 200
for (let page = 1; page <= 50; page++) {
const { data, error } = await admin.auth.admin.listUsers({ page, perPage })
if (error) throw new Error(`Failed to list auth users: ${error.message}`)
const users = data?.users ?? []
const hit = users.find((u) => (u.email ?? '').toLowerCase() === target)
if (hit) return hit
if (users.length < perPage) break // last page
}
return null
}
// -----------------------------------------------------------------------------
// Subcommand: create-admin. Idempotent.
// -----------------------------------------------------------------------------
async function cmdCreateAdmin(cfg) {
const missing = missingRuntimeVars(cfg.env)
if (missing.length > 0) {
throw new UsageError(`Missing required runtime env: ${missing.join(', ')}. Run \`pnpm setup doctor\`.`)
}
const admin = makeAdminClient(cfg.env)
const space = await resolveTargetSpace(cfg, admin)
const rawEmail = await requireValue(cfg, {
flagName: 'admin-email', envName: 'SETUP_ADMIN_EMAIL', label: 'Admin email',
})
const email = rawEmail.trim().toLowerCase()
if (!isValidEmail(email)) throw new UsageError(`"${rawEmail}" is not a valid email address.`)
const nameVal = await requireValue(cfg, {
flagName: 'admin-name', envName: 'SETUP_ADMIN_NAME', label: 'Admin display name', optional: true,
})
const displayName = (nameVal && nameVal.trim()) || email
// Find or create the auth user.
let authUser = await findAuthUserByEmail(admin, email)
let generatedPassword = null
if (authUser) {
console.log(ok('Auth user already exists') + ` (${email}) — reusing it.`)
// --force lets you reset the password on an existing admin account (recover a
// lost login). Only acts when a password is actually supplied; without --force
// an existing user's password is never touched.
if (cfg.flags.force) {
let newPw = cfg.pick('admin-password', 'SETUP_ADMIN_PASSWORD')
if ((!newPw || newPw.trim() === '') && canPrompt(cfg.flags)) {
const typed = await promptFor('New admin password (blank to skip reset)', { secret: true })
newPw = typed === '' ? null : typed
}
if (newPw && newPw.trim() !== '') {
const { error: updErr } = await admin.auth.admin.updateUserById(authUser.id, { password: newPw })
if (updErr) throw new Error(`Failed to reset password: ${updErr.message}`)
console.log(ok('Reset admin password') + ` for ${email}.`)
}
}
} else {
// Resolve password: flag/env, else generate (and print once).
let password = cfg.pick('admin-password', 'SETUP_ADMIN_PASSWORD')
if (!password || password.trim() === '') {
if (canPrompt(cfg.flags)) {
const typed = await promptFor('Admin password (blank to auto-generate a strong one)', { secret: true })
password = typed === '' ? null : typed
}
}
if (!password) {
password = generatePassword()
generatedPassword = password
}
const { data: created, error: createErr } = await admin.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { full_name: displayName },
})
if (createErr || !created?.user) {
throw new Error(`Failed to create auth user: ${createErr?.message ?? 'unknown error'}`)
}
authUser = created.user
console.log(ok('Created auth user:') + ` ${email}`)
}
// Find or create the space_members admin row.
const { data: existingMember, error: memLookupErr } = await admin
.from('space_members')
.select('id, role, status')
.eq('space_id', space.id)
.eq('user_id', authUser.id)
.maybeSingle()
if (memLookupErr) throw new Error(`Failed to look up membership: ${memLookupErr.message}`)
if (existingMember) {
console.log(ok('Admin membership already exists') + ` (role: ${existingMember.role}, status: ${existingMember.status}) — nothing to do.`)
} else {
const { error: memErr } = await admin.from('space_members').insert({
space_id: space.id,
user_id: authUser.id,
display_name: displayName,
email,
role: 'admin',
tier: 'plus',
status: 'current',
approved: true,
onboarding_completed_at: new Date().toISOString(),
})
if (memErr) {
if (memErr.code === '23505') {
console.log(warn('Membership already exists (unique constraint) — treating as done.'))
} else {
throw new Error(`Failed to create admin membership: ${memErr.message}`)
}
} else {
console.log(ok('Created admin membership') + ` in "${space.name}" (${space.slug}).`)
}
}
if (generatedPassword) {
console.log('')
console.log(warn('A password was generated for this admin. Save it now — it is shown only once:'))
console.log(' ' + c.bold + generatedPassword + c.reset)
}
return { email, spaceId: space.id, spaceSlug: space.slug, generatedPassword }
}
// -----------------------------------------------------------------------------
// Default / setup: provision THEN create-admin, then a success summary.
// -----------------------------------------------------------------------------
async function cmdSetup(cfg) {
console.log(c.bold + 'hackerspace setup' + c.reset)
console.log(paint(c.dim, 'Provisioning your space and first admin login...\n'))
console.log(c.bold + 'Step 1/2 — provision space' + c.reset)
const space = await cmdProvision(cfg)
console.log('\n' + c.bold + 'Step 2/2 — create first admin' + c.reset)
const adminResult = await cmdCreateAdmin(cfg)
const base = appBaseUrl(cfg.env)
console.log('')
console.log(ok('Setup complete.'))
console.log('')
console.log(c.bold + 'Summary' + c.reset)
console.log(` Space: ${space.slug}`)
console.log(` Space id: ${space.id}`)
console.log(` Invite code: ${space.invite_code}`)
console.log(` Admin email: ${adminResult.email}`)
console.log(` Login URL: ${base}/login`)
console.log('')
console.log(c.bold + 'Next steps' + c.reset)
console.log(` 1. Open ${base}/login and sign in as ${adminResult.email}.`)
if (adminResult.generatedPassword) {
console.log(' 2. Use the generated password printed above (shown only once).')
console.log(' 3. Set NEXT_PUBLIC_SINGLE_TENANT=true and NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG=' + space.slug + ' if running single-tenant.')
console.log(' 4. You may now clear SETUP_ADMIN_PASSWORD from your env.')
} else {
console.log(' 2. Sign in with the password you configured.')
console.log(' 3. Set NEXT_PUBLIC_SINGLE_TENANT=true and NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG=' + space.slug + ' if running single-tenant.')
console.log(' 4. You may now clear SETUP_ADMIN_PASSWORD from your env.')
}
return EXIT_OK
}
// -----------------------------------------------------------------------------
// Help.
// -----------------------------------------------------------------------------
function printHelp() {
console.log(`${c.bold}hackerspace setup CLI${c.reset}
${c.bold}Usage${c.reset}
pnpm setup [command] [flags]
node scripts/setup.mjs [command] [flags]
${c.bold}Commands${c.reset}
setup (default) provision the space, then create the first admin
doctor validate env + DB connectivity (no writes)
provision create the space only (idempotent)
create-admin create the first admin only (idempotent)
help show this help
${c.bold}Flags${c.reset}
--space-name <name> space display name (env SETUP_SPACE_NAME)
--space-slug <slug> url slug, [a-z0-9-] (env SETUP_SPACE_SLUG)
--space-city <city> optional city (env SETUP_SPACE_CITY)
--admin-email <email> first admin email (env SETUP_ADMIN_EMAIL)
--admin-password <pw> first admin password (env SETUP_ADMIN_PASSWORD)
--admin-name <name> first admin display name (env SETUP_ADMIN_NAME)
--yes non-interactive; never prompt
--force allow provision when a different space already exists
${c.bold}Config precedence${c.reset} (earlier wins)
CLI flag > process.env > .env.local
${c.bold}Required runtime env${c.reset}
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,
SUPABASE_SERVICE_ROLE_KEY, NEXT_PUBLIC_APP_URL
${c.bold}Examples${c.reset}
pnpm setup
pnpm setup doctor
node scripts/setup.mjs create-admin --admin-email [email protected]
Exit codes: 0 ok · 1 usage/env · 2 runtime failure
`)
}
// -----------------------------------------------------------------------------
// Entry point.
// -----------------------------------------------------------------------------
async function main() {
const argv = process.argv.slice(2)
const command = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'setup'
const flags = parseArgs(argv)
if (command === 'help' || command === '--help' || flags.help) {
printHelp()
return EXIT_OK
}
const known = new Set(['setup', 'doctor', 'provision', 'create-admin'])
if (!known.has(command)) {
console.log(bad(`Unknown command: ${command}`))
console.log('')
printHelp()
return EXIT_USAGE
}
const cfg = buildConfig(flags)
switch (command) {
case 'doctor':
return await cmdDoctor(cfg)
case 'provision':
await cmdProvision(cfg)
return EXIT_OK
case 'create-admin':
await cmdCreateAdmin(cfg)
return EXIT_OK
case 'setup':
default:
return await cmdSetup(cfg)
}
}
main()
.then((code) => process.exit(typeof code === 'number' ? code : EXIT_OK))
.catch((err) => {
if (err instanceof UsageError) {
console.error('\n' + bad('Error: ') + err.message)
process.exit(EXIT_USAGE)
}
console.error('\n' + bad('Error: ') + (err?.message ?? String(err)))
process.exit(EXIT_RUNTIME)
})