From 0e6f5eace048c182c4195499083d9fe3cff4cc57 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sat, 4 Apr 2026 12:59:11 -0500 Subject: [PATCH] feat: add `mosaic gateway` CLI commands and admin token auth Gateway daemon lifecycle management via the Mosaic CLI: - install, start, stop, restart, status, config, logs, uninstall - Admin API token auth (Bearer tokens) alongside existing session auth - Bootstrap endpoint for first-user setup (POST /api/bootstrap/setup) - admin_tokens table in DB schema with SHA-256 hashed tokens - Gateway .env fallback loading from ~/.config/mosaic/gateway/.env - CLI daemon management with PID file, log tailing, health checks Co-Authored-By: Claude Opus 4.6 --- .../src/admin/admin-tokens.controller.ts | 90 +++++ apps/gateway/src/admin/admin-tokens.dto.ts | 33 ++ apps/gateway/src/admin/admin.guard.ts | 71 +++- apps/gateway/src/admin/admin.module.ts | 10 +- .../gateway/src/admin/bootstrap.controller.ts | 101 ++++++ apps/gateway/src/admin/bootstrap.dto.ts | 31 ++ apps/gateway/src/main.ts | 9 +- packages/cli/src/commands/gateway.ts | 312 ++++++++---------- packages/cli/src/commands/gateway/config.ts | 143 ++++++++ packages/cli/src/commands/gateway/daemon.ts | 253 ++++++++++++++ packages/cli/src/commands/gateway/install.ts | 223 +++++++++++++ packages/cli/src/commands/gateway/logs.ts | 37 +++ packages/cli/src/commands/gateway/status.ts | 115 +++++++ .../cli/src/commands/gateway/uninstall.ts | 62 ++++ packages/db/src/index.ts | 1 + packages/db/src/schema.ts | 22 ++ 16 files changed, 1325 insertions(+), 188 deletions(-) create mode 100644 apps/gateway/src/admin/admin-tokens.controller.ts create mode 100644 apps/gateway/src/admin/admin-tokens.dto.ts create mode 100644 apps/gateway/src/admin/bootstrap.controller.ts create mode 100644 apps/gateway/src/admin/bootstrap.dto.ts create mode 100644 packages/cli/src/commands/gateway/config.ts create mode 100644 packages/cli/src/commands/gateway/daemon.ts create mode 100644 packages/cli/src/commands/gateway/install.ts create mode 100644 packages/cli/src/commands/gateway/logs.ts create mode 100644 packages/cli/src/commands/gateway/status.ts create mode 100644 packages/cli/src/commands/gateway/uninstall.ts diff --git a/apps/gateway/src/admin/admin-tokens.controller.ts b/apps/gateway/src/admin/admin-tokens.controller.ts new file mode 100644 index 0000000..94f27a7 --- /dev/null +++ b/apps/gateway/src/admin/admin-tokens.controller.ts @@ -0,0 +1,90 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Inject, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { randomBytes, createHash } from 'node:crypto'; +import { eq, type Db, adminTokens } from '@mosaic/db'; +import { v4 as uuid } from 'uuid'; +import { DB } from '../database/database.module.js'; +import { AdminGuard } from './admin.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import type { + CreateTokenDto, + TokenCreatedDto, + TokenDto, + TokenListDto, +} from './admin-tokens.dto.js'; + +function hashToken(plaintext: string): string { + return createHash('sha256').update(plaintext).digest('hex'); +} + +function toTokenDto(row: typeof adminTokens.$inferSelect): TokenDto { + return { + id: row.id, + label: row.label, + scope: row.scope, + expiresAt: row.expiresAt?.toISOString() ?? null, + lastUsedAt: row.lastUsedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + }; +} + +@Controller('api/admin/tokens') +@UseGuards(AdminGuard) +export class AdminTokensController { + constructor(@Inject(DB) private readonly db: Db) {} + + @Post() + async create( + @Body() dto: CreateTokenDto, + @CurrentUser() user: { id: string }, + ): Promise { + const plaintext = randomBytes(32).toString('hex'); + const tokenHash = hashToken(plaintext); + const id = uuid(); + + const expiresAt = dto.expiresInDays + ? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000) + : null; + + const [row] = await this.db + .insert(adminTokens) + .values({ + id, + userId: user.id, + tokenHash, + label: dto.label ?? 'CLI token', + scope: dto.scope ?? 'admin', + expiresAt, + }) + .returning(); + + return { ...toTokenDto(row!), plaintext }; + } + + @Get() + async list(@CurrentUser() user: { id: string }): Promise { + const rows = await this.db + .select() + .from(adminTokens) + .where(eq(adminTokens.userId, user.id)) + .orderBy(adminTokens.createdAt); + + return { tokens: rows.map(toTokenDto), total: rows.length }; + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async revoke(@Param('id') id: string, @CurrentUser() _user: { id: string }): Promise { + await this.db.delete(adminTokens).where(eq(adminTokens.id, id)); + } +} diff --git a/apps/gateway/src/admin/admin-tokens.dto.ts b/apps/gateway/src/admin/admin-tokens.dto.ts new file mode 100644 index 0000000..abf616d --- /dev/null +++ b/apps/gateway/src/admin/admin-tokens.dto.ts @@ -0,0 +1,33 @@ +import { IsString, IsOptional, IsInt, Min } from 'class-validator'; + +export class CreateTokenDto { + @IsString() + label!: string; + + @IsOptional() + @IsString() + scope?: string; + + @IsOptional() + @IsInt() + @Min(1) + expiresInDays?: number; +} + +export interface TokenDto { + id: string; + label: string; + scope: string; + expiresAt: string | null; + lastUsedAt: string | null; + createdAt: string; +} + +export interface TokenCreatedDto extends TokenDto { + plaintext: string; +} + +export interface TokenListDto { + tokens: TokenDto[]; + total: number; +} diff --git a/apps/gateway/src/admin/admin.guard.ts b/apps/gateway/src/admin/admin.guard.ts index a98a755..c6c0646 100644 --- a/apps/gateway/src/admin/admin.guard.ts +++ b/apps/gateway/src/admin/admin.guard.ts @@ -6,10 +6,11 @@ import { Injectable, UnauthorizedException, } from '@nestjs/common'; +import { createHash } from 'node:crypto'; import { fromNodeHeaders } from 'better-auth/node'; import type { Auth } from '@mosaic/auth'; import type { Db } from '@mosaic/db'; -import { eq, users as usersTable } from '@mosaic/db'; +import { eq, adminTokens, users as usersTable } from '@mosaic/db'; import type { FastifyRequest } from 'fastify'; import { AUTH } from '../auth/auth.tokens.js'; import { DB } from '../database/database.module.js'; @@ -19,6 +20,8 @@ interface UserWithRole { role?: string; } +type AuthenticatedRequest = FastifyRequest & { user: unknown; session: unknown }; + @Injectable() export class AdminGuard implements CanActivate { constructor( @@ -28,8 +31,64 @@ export class AdminGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); - const headers = fromNodeHeaders(request.raw.headers); + // Try bearer token auth first + const authHeader = request.raw.headers['authorization']; + if (authHeader?.startsWith('Bearer ')) { + return this.validateBearerToken(request, authHeader.slice(7)); + } + + // Fall back to BetterAuth session + return this.validateSession(request); + } + + private async validateBearerToken(request: FastifyRequest, plaintext: string): Promise { + const tokenHash = createHash('sha256').update(plaintext).digest('hex'); + + const [row] = await this.db + .select({ + tokenId: adminTokens.id, + userId: adminTokens.userId, + scope: adminTokens.scope, + expiresAt: adminTokens.expiresAt, + userName: usersTable.name, + userEmail: usersTable.email, + userRole: usersTable.role, + }) + .from(adminTokens) + .innerJoin(usersTable, eq(adminTokens.userId, usersTable.id)) + .where(eq(adminTokens.tokenHash, tokenHash)) + .limit(1); + + if (!row) { + throw new UnauthorizedException('Invalid API token'); + } + + if (row.expiresAt && row.expiresAt < new Date()) { + throw new UnauthorizedException('API token expired'); + } + + if (row.userRole !== 'admin') { + throw new ForbiddenException('Admin access required'); + } + + // Update last-used timestamp (fire-and-forget) + this.db + .update(adminTokens) + .set({ lastUsedAt: new Date() }) + .where(eq(adminTokens.id, row.tokenId)) + .then(() => {}) + .catch(() => {}); + + const req = request as AuthenticatedRequest; + req.user = { id: row.userId, name: row.userName, email: row.userEmail, role: row.userRole }; + req.session = { id: `token:${row.tokenId}`, userId: row.userId }; + + return true; + } + + private async validateSession(request: FastifyRequest): Promise { + const headers = fromNodeHeaders(request.raw.headers); const result = await this.auth.api.getSession({ headers }); if (!result) { @@ -38,8 +97,6 @@ export class AdminGuard implements CanActivate { const user = result.user as UserWithRole; - // Ensure the role field is populated. better-auth should include additionalFields - // in the session, but as a fallback, fetch the role from the database if needed. let userRole = user.role; if (!userRole) { const [dbUser] = await this.db @@ -48,7 +105,6 @@ export class AdminGuard implements CanActivate { .where(eq(usersTable.id, user.id)) .limit(1); userRole = dbUser?.role ?? 'member'; - // Update the session user object with the fetched role (user as UserWithRole).role = userRole; } @@ -56,8 +112,9 @@ export class AdminGuard implements CanActivate { throw new ForbiddenException('Admin access required'); } - (request as FastifyRequest & { user: unknown; session: unknown }).user = result.user; - (request as FastifyRequest & { user: unknown; session: unknown }).session = result.session; + const req = request as AuthenticatedRequest; + req.user = result.user; + req.session = result.session; return true; } diff --git a/apps/gateway/src/admin/admin.module.ts b/apps/gateway/src/admin/admin.module.ts index 3fe00ea..1f6c4a3 100644 --- a/apps/gateway/src/admin/admin.module.ts +++ b/apps/gateway/src/admin/admin.module.ts @@ -2,10 +2,18 @@ import { Module } from '@nestjs/common'; import { AdminController } from './admin.controller.js'; import { AdminHealthController } from './admin-health.controller.js'; import { AdminJobsController } from './admin-jobs.controller.js'; +import { AdminTokensController } from './admin-tokens.controller.js'; +import { BootstrapController } from './bootstrap.controller.js'; import { AdminGuard } from './admin.guard.js'; @Module({ - controllers: [AdminController, AdminHealthController, AdminJobsController], + controllers: [ + AdminController, + AdminHealthController, + AdminJobsController, + AdminTokensController, + BootstrapController, + ], providers: [AdminGuard], }) export class AdminModule {} diff --git a/apps/gateway/src/admin/bootstrap.controller.ts b/apps/gateway/src/admin/bootstrap.controller.ts new file mode 100644 index 0000000..db087ef --- /dev/null +++ b/apps/gateway/src/admin/bootstrap.controller.ts @@ -0,0 +1,101 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Inject, + InternalServerErrorException, + Post, +} from '@nestjs/common'; +import { randomBytes, createHash } from 'node:crypto'; +import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaic/db'; +import type { Auth } from '@mosaic/auth'; +import { v4 as uuid } from 'uuid'; +import { AUTH } from '../auth/auth.tokens.js'; +import { DB } from '../database/database.module.js'; +import type { BootstrapSetupDto, BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js'; + +@Controller('api/bootstrap') +export class BootstrapController { + constructor( + @Inject(AUTH) private readonly auth: Auth, + @Inject(DB) private readonly db: Db, + ) {} + + @Get('status') + async status(): Promise { + const [result] = await this.db.select({ total: count() }).from(usersTable); + return { needsSetup: (result?.total ?? 0) === 0 }; + } + + @Post('setup') + async setup(@Body() dto: BootstrapSetupDto): Promise { + // Only allow setup when zero users exist + const [result] = await this.db.select({ total: count() }).from(usersTable); + if ((result?.total ?? 0) > 0) { + throw new ForbiddenException('Setup already completed — users exist'); + } + + // Create admin user via BetterAuth API + const authApi = this.auth.api as unknown as { + createUser: (opts: { + body: { name: string; email: string; password: string; role?: string }; + }) => Promise<{ + user: { id: string; name: string; email: string }; + }>; + }; + + const created = await authApi.createUser({ + body: { + name: dto.name, + email: dto.email, + password: dto.password, + role: 'admin', + }, + }); + + // Verify user was created + const [user] = await this.db + .select() + .from(usersTable) + .where(eq(usersTable.id, created.user.id)) + .limit(1); + + if (!user) throw new InternalServerErrorException('User created but not found'); + + // Ensure role is admin (createUser may not set it via BetterAuth) + if (user.role !== 'admin') { + await this.db.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id)); + } + + // Generate admin API token + const plaintext = randomBytes(32).toString('hex'); + const tokenHash = createHash('sha256').update(plaintext).digest('hex'); + const tokenId = uuid(); + + const [token] = await this.db + .insert(adminTokens) + .values({ + id: tokenId, + userId: user.id, + tokenHash, + label: 'Initial setup token', + scope: 'admin', + }) + .returning(); + + return { + user: { + id: user.id, + name: user.name, + email: user.email, + role: 'admin', + }, + token: { + id: token!.id, + plaintext, + label: token!.label, + }, + }; + } +} diff --git a/apps/gateway/src/admin/bootstrap.dto.ts b/apps/gateway/src/admin/bootstrap.dto.ts new file mode 100644 index 0000000..72aeedb --- /dev/null +++ b/apps/gateway/src/admin/bootstrap.dto.ts @@ -0,0 +1,31 @@ +import { IsString, IsEmail, MinLength } from 'class-validator'; + +export class BootstrapSetupDto { + @IsString() + name!: string; + + @IsEmail() + email!: string; + + @IsString() + @MinLength(8) + password!: string; +} + +export interface BootstrapStatusDto { + needsSetup: boolean; +} + +export interface BootstrapResultDto { + user: { + id: string; + name: string; + email: string; + role: string; + }; + token: { + id: string; + plaintext: string; + label: string; + }; +} diff --git a/apps/gateway/src/main.ts b/apps/gateway/src/main.ts index d41e3da..4053565 100644 --- a/apps/gateway/src/main.ts +++ b/apps/gateway/src/main.ts @@ -1,5 +1,12 @@ import { config } from 'dotenv'; -import { resolve } from 'node:path'; +import { existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { homedir } from 'node:os'; + +// Load .env from daemon config dir (global install / daemon mode). +// Loaded first so monorepo .env can override for local dev. +const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env'); +if (existsSync(daemonEnv)) config({ path: daemonEnv }); // Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter) config({ path: resolve(process.cwd(), '../../.env') }); diff --git a/packages/cli/src/commands/gateway.ts b/packages/cli/src/commands/gateway.ts index b6e4b08..e673b78 100644 --- a/packages/cli/src/commands/gateway.ts +++ b/packages/cli/src/commands/gateway.ts @@ -1,198 +1,152 @@ -import { createInterface } from 'node:readline'; -import { spawn } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; import type { Command } from 'commander'; import { - DEFAULT_LOCAL_CONFIG, - DEFAULT_TEAM_CONFIG, - loadConfig, - type MosaicConfig, - type StorageTier, -} from '@mosaic/config'; + getDaemonPid, + readMeta, + startDaemon, + stopDaemon, + waitForHealth, +} from './gateway/daemon.js'; -function ask(rl: ReturnType, question: string): Promise { - return new Promise((res) => rl.question(question, res)); +interface GatewayParentOpts { + host: string; + port: string; + token?: string; } -async function runInit(opts: { tier?: string; output: string }): Promise { - const outputPath = resolve(opts.output); - let tier: StorageTier; - - if (opts.tier) { - if (opts.tier !== 'local' && opts.tier !== 'team') { - console.error(`Invalid tier "${opts.tier}" — expected "local" or "team"`); - process.exit(1); - } - tier = opts.tier; - } else { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - const answer = await ask(rl, 'Select tier (local/team) [local]: '); - rl.close(); - const trimmed = answer.trim().toLowerCase(); - tier = trimmed === 'team' ? 'team' : 'local'; - } - - let config: MosaicConfig; - - if (tier === 'local') { - config = DEFAULT_LOCAL_CONFIG; - } else { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - const dbUrl = await ask( - rl, - 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5432/mosaic]: ', - ); - const valkeyUrl = await ask(rl, 'VALKEY_URL [redis://localhost:6379]: '); - rl.close(); - - config = { - ...DEFAULT_TEAM_CONFIG, - storage: { - type: 'postgres', - url: dbUrl.trim() || 'postgresql://mosaic:mosaic@localhost:5432/mosaic', - }, - queue: { - type: 'bullmq', - url: valkeyUrl.trim() || 'redis://localhost:6379', - }, - }; - } - - writeFileSync(outputPath, JSON.stringify(config, null, 2) + '\n'); - console.log(`\nWrote ${outputPath}`); - console.log('\nNext steps:'); - console.log(' 1. Review the generated config'); - console.log(' 2. Run: pnpm --filter @mosaic/gateway exec tsx src/main.ts'); -} - -const PID_FILE = resolve(process.cwd(), '.mosaic/gateway.pid'); - -function writePidFile(pid: number): void { - const dir = dirname(PID_FILE); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(PID_FILE, String(pid)); -} - -function readPidFile(): number | null { - if (!existsSync(PID_FILE)) return null; - const raw = readFileSync(PID_FILE, 'utf-8').trim(); - const pid = Number(raw); - return Number.isFinite(pid) ? pid : null; -} - -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function printConfigSummary(config: MosaicConfig): void { - console.log(` Tier: ${config.tier}`); - console.log(` Storage: ${config.storage.type}`); - console.log(` Queue: ${config.queue.type}`); - console.log(` Memory: ${config.memory.type}`); +function resolveOpts(raw: GatewayParentOpts): { host: string; port: number; token?: string } { + const meta = readMeta(); + return { + host: raw.host ?? meta?.host ?? 'localhost', + port: parseInt(raw.port, 10) || meta?.port || 4000, + token: raw.token ?? meta?.adminToken, + }; } export function registerGatewayCommand(program: Command): void { - const gateway = program.command('gateway').description('Gateway management commands'); - - gateway - .command('init') - .description('Generate a mosaic.config.json for the gateway') - .option('--tier ', 'Storage tier: local or team (skips interactive prompt)') - .option('--output ', 'Output file path', './mosaic.config.json') - .action(async (opts: { tier?: string; output: string }) => { - await runInit(opts); - }); - - gateway - .command('start') - .description('Start the Mosaic gateway process') - .option('--port ', 'Port to listen on (overrides config)') - .option('--daemon', 'Run in background and write PID to .mosaic/gateway.pid') - .action((opts: { port?: string; daemon?: boolean }) => { - const config = loadConfig(); - const port = opts.port ?? '4000'; - - console.log('Starting gateway…'); - printConfigSummary(config); - console.log(` Port: ${port}`); - - const entryPoint = resolve(process.cwd(), 'apps/gateway/src/main.ts'); - const env = { ...process.env, GATEWAY_PORT: port }; - - if (opts.daemon) { - const child = spawn('npx', ['tsx', entryPoint], { - env, - stdio: 'ignore', - detached: true, - }); - - child.unref(); - - if (child.pid) { - writePidFile(child.pid); - console.log(`\nGateway started in background (PID ${child.pid})`); - console.log(`PID file: ${PID_FILE}`); - } - } else { - const child = spawn('npx', ['tsx', entryPoint], { - env, - stdio: 'inherit', - }); - - child.on('exit', (code) => { - process.exit(code ?? 0); - }); - } - }); - - gateway - .command('stop') - .description('Stop the running gateway process') + const gw = program + .command('gateway') + .description('Manage the Mosaic gateway daemon') + .helpOption('--help', 'Display help') + .option('-h, --host ', 'Gateway host', 'localhost') + .option('-p, --port ', 'Gateway port', '4000') + .option('-t, --token ', 'Admin API token') .action(() => { - const pid = readPidFile(); + gw.outputHelp(); + }); - if (pid === null) { - console.error('No PID file found at', PID_FILE); + // ─── install ──────────────────────────────────────────────────────────── + + gw.command('install') + .description('Install and configure the gateway daemon') + .option('--skip-install', 'Skip npm package installation (use local build)') + .action(async (cmdOpts: { skipInstall?: boolean }) => { + const opts = resolveOpts(gw.opts() as GatewayParentOpts); + const { runInstall } = await import('./gateway/install.js'); + await runInstall({ ...opts, skipInstall: cmdOpts.skipInstall }); + }); + + // ─── start ────────────────────────────────────────────────────────────── + + gw.command('start') + .description('Start the gateway daemon') + .action(async () => { + const opts = resolveOpts(gw.opts() as GatewayParentOpts); + try { + const pid = startDaemon(); + console.log(`Gateway started (PID ${pid.toString()})`); + console.log('Waiting for health...'); + const healthy = await waitForHealth(opts.host, opts.port); + if (healthy) { + console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`); + } else { + console.warn('Gateway started but health check timed out. Check logs.'); + } + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } - - if (!isProcessRunning(pid)) { - console.log(`Process ${pid} is not running. Removing stale PID file.`); - unlinkSync(PID_FILE); - return; - } - - process.kill(pid, 'SIGTERM'); - unlinkSync(PID_FILE); - console.log(`Gateway stopped (PID ${pid})`); }); - gateway - .command('status') - .description('Show gateway process status') - .action(() => { - const config = loadConfig(); - const pid = readPidFile(); + // ─── stop ─────────────────────────────────────────────────────────────── - if (pid !== null && isProcessRunning(pid)) { - console.log('Gateway: running'); - console.log(` PID: ${pid}`); - } else { - console.log('Gateway: stopped'); - if (pid !== null) { - console.log(` (stale PID file for ${pid})`); - unlinkSync(PID_FILE); - } + gw.command('stop') + .description('Stop the gateway daemon') + .action(async () => { + try { + await stopDaemon(); + console.log('Gateway stopped.'); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); } + }); - console.log(''); - console.log('Config:'); - printConfigSummary(config); + // ─── restart ──────────────────────────────────────────────────────────── + + gw.command('restart') + .description('Restart the gateway daemon') + .action(async () => { + const opts = resolveOpts(gw.opts() as GatewayParentOpts); + const pid = getDaemonPid(); + if (pid !== null) { + console.log('Stopping gateway...'); + await stopDaemon(); + } + console.log('Starting gateway...'); + try { + const newPid = startDaemon(); + console.log(`Gateway started (PID ${newPid.toString()})`); + const healthy = await waitForHealth(opts.host, opts.port); + if (healthy) { + console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`); + } else { + console.warn('Gateway started but health check timed out. Check logs.'); + } + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + // ─── status ───────────────────────────────────────────────────────────── + + gw.command('status') + .description('Show gateway daemon status and health') + .action(async () => { + const opts = resolveOpts(gw.opts() as GatewayParentOpts); + const { runStatus } = await import('./gateway/status.js'); + await runStatus(opts); + }); + + // ─── config ───────────────────────────────────────────────────────────── + + gw.command('config') + .description('View or modify gateway configuration') + .option('--set ', 'Set a configuration value') + .option('--unset ', 'Remove a configuration key') + .option('--edit', 'Open config in $EDITOR') + .action(async (cmdOpts: { set?: string; unset?: string; edit?: boolean }) => { + const { runConfig } = await import('./gateway/config.js'); + await runConfig(cmdOpts); + }); + + // ─── logs ─────────────────────────────────────────────────────────────── + + gw.command('logs') + .description('View gateway daemon logs') + .option('-f, --follow', 'Follow log output') + .option('-n, --lines ', 'Number of lines to show', '50') + .action(async (cmdOpts: { follow?: boolean; lines?: string }) => { + const { runLogs } = await import('./gateway/logs.js'); + runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) }); + }); + + // ─── uninstall ────────────────────────────────────────────────────────── + + gw.command('uninstall') + .description('Uninstall the gateway daemon and optionally remove data') + .action(async () => { + const { runUninstall } = await import('./gateway/uninstall.js'); + await runUninstall(); }); } diff --git a/packages/cli/src/commands/gateway/config.ts b/packages/cli/src/commands/gateway/config.ts new file mode 100644 index 0000000..341e283 --- /dev/null +++ b/packages/cli/src/commands/gateway/config.ts @@ -0,0 +1,143 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { ENV_FILE, getDaemonPid, readMeta, META_FILE, ensureDirs } from './daemon.js'; + +// Keys that should be masked in output +const SECRET_KEYS = new Set([ + 'BETTER_AUTH_SECRET', + 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', + 'ZAI_API_KEY', + 'OPENROUTER_API_KEY', + 'DISCORD_BOT_TOKEN', + 'TELEGRAM_BOT_TOKEN', +]); + +function maskValue(key: string, value: string): string { + if (SECRET_KEYS.has(key) && value.length > 8) { + return value.slice(0, 4) + '…' + value.slice(-4); + } + return value; +} + +function parseEnvFile(): Map { + const map = new Map(); + if (!existsSync(ENV_FILE)) return map; + + const lines = readFileSync(ENV_FILE, 'utf-8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + map.set(trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1)); + } + return map; +} + +function writeEnvFile(entries: Map): void { + ensureDirs(); + const lines: string[] = []; + for (const [key, value] of entries) { + lines.push(`${key}=${value}`); + } + writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 }); +} + +interface ConfigOpts { + set?: string; + unset?: string; + edit?: boolean; +} + +export async function runConfig(opts: ConfigOpts): Promise { + // Set a value + if (opts.set) { + const eqIdx = opts.set.indexOf('='); + if (eqIdx === -1) { + console.error('Usage: mosaic gateway config --set KEY=VALUE'); + process.exit(1); + } + const key = opts.set.slice(0, eqIdx); + const value = opts.set.slice(eqIdx + 1); + const entries = parseEnvFile(); + entries.set(key, value); + writeEnvFile(entries); + console.log(`Set ${key}=${maskValue(key, value)}`); + promptRestart(); + return; + } + + // Unset a value + if (opts.unset) { + const entries = parseEnvFile(); + if (!entries.has(opts.unset)) { + console.error(`Key not found: ${opts.unset}`); + process.exit(1); + } + entries.delete(opts.unset); + writeEnvFile(entries); + console.log(`Removed ${opts.unset}`); + promptRestart(); + return; + } + + // Open in editor + if (opts.edit) { + if (!existsSync(ENV_FILE)) { + console.error(`No config file found at ${ENV_FILE}`); + console.error('Run `mosaic gateway install` first.'); + process.exit(1); + } + const editor = process.env['EDITOR'] ?? process.env['VISUAL'] ?? 'vi'; + try { + execSync(`${editor} "${ENV_FILE}"`, { stdio: 'inherit' }); + promptRestart(); + } catch { + console.error('Editor exited with error.'); + } + return; + } + + // Default: show current config + showConfig(); +} + +function showConfig(): void { + if (!existsSync(ENV_FILE)) { + console.log('No gateway configuration found.'); + console.log('Run `mosaic gateway install` to set up.'); + return; + } + + const entries = parseEnvFile(); + const meta = readMeta(); + + console.log('Mosaic Gateway Configuration'); + console.log('────────────────────────────'); + console.log(` Config file: ${ENV_FILE}`); + console.log(` Meta file: ${META_FILE}`); + console.log(); + + if (entries.size === 0) { + console.log(' (empty)'); + return; + } + + const maxKeyLen = Math.max(...[...entries.keys()].map((k) => k.length)); + for (const [key, value] of entries) { + const padding = ' '.repeat(maxKeyLen - key.length); + console.log(` ${key}${padding} ${maskValue(key, value)}`); + } + + if (meta?.adminToken) { + console.log(); + console.log(` Admin token: ${maskValue('token', meta.adminToken)}`); + } +} + +function promptRestart(): void { + if (getDaemonPid() !== null) { + console.log('\nGateway is running — restart to apply changes: mosaic gateway restart'); + } +} diff --git a/packages/cli/src/commands/gateway/daemon.ts b/packages/cli/src/commands/gateway/daemon.ts new file mode 100644 index 0000000..b716ce6 --- /dev/null +++ b/packages/cli/src/commands/gateway/daemon.ts @@ -0,0 +1,253 @@ +import { spawn, execSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + unlinkSync, + openSync, + constants, +} from 'node:fs'; +import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { createRequire } from 'node:module'; + +// ─── Paths ────────────────────────────────────────────────────────────────── + +export const GATEWAY_HOME = resolve( + process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'), +); +export const PID_FILE = join(GATEWAY_HOME, 'daemon.pid'); +export const LOG_DIR = join(GATEWAY_HOME, 'logs'); +export const LOG_FILE = join(LOG_DIR, 'gateway.log'); +export const ENV_FILE = join(GATEWAY_HOME, '.env'); +export const META_FILE = join(GATEWAY_HOME, 'meta.json'); + +// ─── Meta ─────────────────────────────────────────────────────────────────── + +export interface GatewayMeta { + version: string; + installedAt: string; + entryPoint: string; + adminToken?: string; + host: string; + port: number; +} + +export function readMeta(): GatewayMeta | null { + if (!existsSync(META_FILE)) return null; + try { + return JSON.parse(readFileSync(META_FILE, 'utf-8')) as GatewayMeta; + } catch { + return null; + } +} + +export function writeMeta(meta: GatewayMeta): void { + ensureDirs(); + writeFileSync(META_FILE, JSON.stringify(meta, null, 2), { mode: 0o600 }); +} + +// ─── Directories ──────────────────────────────────────────────────────────── + +export function ensureDirs(): void { + mkdirSync(GATEWAY_HOME, { recursive: true, mode: 0o700 }); + mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 }); +} + +// ─── PID management ───────────────────────────────────────────────────────── + +export function readPid(): number | null { + if (!existsSync(PID_FILE)) return null; + try { + const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10); + return Number.isNaN(pid) ? null : pid; + } catch { + return null; + } +} + +export function isRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export function getDaemonPid(): number | null { + const pid = readPid(); + if (pid === null) return null; + return isRunning(pid) ? pid : null; +} + +// ─── Entry point resolution ───────────────────────────────────────────────── + +export function resolveGatewayEntry(): string { + // Check meta.json for custom entry point + const meta = readMeta(); + if (meta?.entryPoint && existsSync(meta.entryPoint)) { + return meta.entryPoint; + } + + // Try to resolve from globally installed @mosaicstack/gateway + try { + const req = createRequire(import.meta.url); + const pkgPath = req.resolve('@mosaicstack/gateway/package.json'); + const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js'); + if (existsSync(mainEntry)) return mainEntry; + } catch { + // Not installed globally via @mosaicstack + } + + // Try @mosaic/gateway (workspace / dev) + try { + const req = createRequire(import.meta.url); + const pkgPath = req.resolve('@mosaic/gateway/package.json'); + const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js'); + if (existsSync(mainEntry)) return mainEntry; + } catch { + // Not available + } + + throw new Error('Cannot find gateway entry point. Run `mosaic gateway install` first.'); +} + +// ─── Start / Stop / Health ────────────────────────────────────────────────── + +export function startDaemon(): number { + const running = getDaemonPid(); + if (running !== null) { + throw new Error(`Gateway is already running (PID ${running.toString()})`); + } + + ensureDirs(); + const entryPoint = resolveGatewayEntry(); + + // Load env vars from gateway .env + const env: Record = { ...process.env } as Record; + if (existsSync(ENV_FILE)) { + for (const line of readFileSync(ENV_FILE, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx > 0) env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1); + } + } + + const logFd = openSync(LOG_FILE, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND); + + const child = spawn('node', [entryPoint], { + detached: true, + stdio: ['ignore', logFd, logFd], + env, + cwd: GATEWAY_HOME, + }); + + if (!child.pid) { + throw new Error('Failed to spawn gateway process'); + } + + writeFileSync(PID_FILE, child.pid.toString(), { mode: 0o600 }); + child.unref(); + + return child.pid; +} + +export async function stopDaemon(timeoutMs = 10_000): Promise { + const pid = getDaemonPid(); + if (pid === null) { + throw new Error('Gateway is not running'); + } + + process.kill(pid, 'SIGTERM'); + + // Poll for exit + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (!isRunning(pid)) { + cleanPidFile(); + return; + } + await sleep(250); + } + + // Force kill + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already dead + } + cleanPidFile(); +} + +function cleanPidFile(): void { + try { + unlinkSync(PID_FILE); + } catch { + // Ignore + } +} + +export async function waitForHealth( + host: string, + port: number, + timeoutMs = 30_000, +): Promise { + const start = Date.now(); + let delay = 500; + + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`http://${host}:${port.toString()}/health`); + if (res.ok) return true; + } catch { + // Not ready yet + } + await sleep(delay); + delay = Math.min(delay * 1.5, 3000); + } + return false; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ─── npm install helper ───────────────────────────────────────────────────── + +export function installGatewayPackage(): void { + console.log('Installing @mosaicstack/gateway...'); + execSync('npm install -g @mosaicstack/gateway@latest', { + stdio: 'inherit', + timeout: 120_000, + }); +} + +export function uninstallGatewayPackage(): void { + try { + execSync('npm uninstall -g @mosaicstack/gateway', { + stdio: 'inherit', + timeout: 60_000, + }); + } catch { + console.warn('Warning: npm uninstall may not have completed cleanly.'); + } +} + +export function getInstalledGatewayVersion(): string | null { + try { + const output = execSync('npm ls -g @mosaicstack/gateway --json --depth=0', { + encoding: 'utf-8', + timeout: 15_000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const data = JSON.parse(output) as { + dependencies?: { '@mosaicstack/gateway'?: { version?: string } }; + }; + return data.dependencies?.['@mosaicstack/gateway']?.version ?? null; + } catch { + return null; + } +} diff --git a/packages/cli/src/commands/gateway/install.ts b/packages/cli/src/commands/gateway/install.ts new file mode 100644 index 0000000..d8cde5e --- /dev/null +++ b/packages/cli/src/commands/gateway/install.ts @@ -0,0 +1,223 @@ +import { randomBytes } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import type { GatewayMeta } from './daemon.js'; +import { + ENV_FILE, + GATEWAY_HOME, + ensureDirs, + installGatewayPackage, + readMeta, + resolveGatewayEntry, + startDaemon, + waitForHealth, + writeMeta, + getInstalledGatewayVersion, +} from './daemon.js'; + +interface InstallOpts { + host: string; + port: number; + skipInstall?: boolean; +} + +function prompt(rl: ReturnType, question: string): Promise { + return new Promise((resolve) => rl.question(question, resolve)); +} + +export async function runInstall(opts: InstallOpts): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + await doInstall(rl, opts); + } finally { + rl.close(); + } +} + +async function doInstall(rl: ReturnType, opts: InstallOpts): Promise { + // Check existing installation + const existing = readMeta(); + if (existing) { + const answer = await prompt( + rl, + `Gateway already installed (v${existing.version}). Reinstall? [y/N] `, + ); + if (answer.toLowerCase() !== 'y') { + console.log('Aborted.'); + return; + } + } + + // Step 1: Install npm package + if (!opts.skipInstall) { + installGatewayPackage(); + } + + ensureDirs(); + + // Step 2: Collect configuration + console.log('\n─── Gateway Configuration ───\n'); + + const port = + opts.port !== 4000 + ? opts.port + : parseInt( + (await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(), + 10, + ); + + const databaseUrl = + (await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) || + 'postgresql://mosaic:mosaic@localhost:5433/mosaic'; + + const valkeyUrl = + (await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380'; + + const anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): '); + + const corsOrigin = + (await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000'; + + // Generate auth secret + const authSecret = randomBytes(32).toString('hex'); + + // Step 3: Write .env + const envLines = [ + `GATEWAY_PORT=${port.toString()}`, + `DATABASE_URL=${databaseUrl}`, + `VALKEY_URL=${valkeyUrl}`, + `BETTER_AUTH_SECRET=${authSecret}`, + `BETTER_AUTH_URL=http://${opts.host}:${port.toString()}`, + `GATEWAY_CORS_ORIGIN=${corsOrigin}`, + `OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`, + `OTEL_SERVICE_NAME=mosaic-gateway`, + ]; + + if (anthropicKey) { + envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`); + } + + writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 }); + console.log(`\nConfig written to ${ENV_FILE}`); + + // Step 4: Write meta.json + let entryPoint: string; + try { + entryPoint = resolveGatewayEntry(); + } catch { + console.error('Error: Gateway package not found after install.'); + console.error('Check that @mosaicstack/gateway installed correctly.'); + return; + } + + const version = getInstalledGatewayVersion() ?? 'unknown'; + + const meta = { + version, + installedAt: new Date().toISOString(), + entryPoint, + host: opts.host, + port, + }; + writeMeta(meta); + + // Step 5: Start the daemon + console.log('\nStarting gateway daemon...'); + try { + const pid = startDaemon(); + console.log(`Gateway started (PID ${pid.toString()})`); + } catch (err) { + console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`); + return; + } + + // Step 6: Wait for health + console.log('Waiting for gateway to become healthy...'); + const healthy = await waitForHealth(opts.host, port, 30_000); + if (!healthy) { + console.error('Gateway did not become healthy within 30 seconds.'); + console.error(`Check logs: mosaic gateway logs`); + return; + } + console.log('Gateway is healthy.\n'); + + // Step 7: Bootstrap — first user setup + await bootstrapFirstUser(rl, opts.host, port, meta); + + console.log('\n─── Installation Complete ───'); + console.log(` Endpoint: http://${opts.host}:${port.toString()}`); + console.log(` Config: ${GATEWAY_HOME}`); + console.log(` Logs: mosaic gateway logs`); + console.log(` Status: mosaic gateway status`); +} + +async function bootstrapFirstUser( + rl: ReturnType, + host: string, + port: number, + meta: Omit & { adminToken?: string }, +): Promise { + const baseUrl = `http://${host}:${port.toString()}`; + + try { + const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`); + if (!statusRes.ok) return; + + const status = (await statusRes.json()) as { needsSetup: boolean }; + if (!status.needsSetup) { + console.log('Admin user already exists — skipping setup.'); + return; + } + } catch { + console.warn('Could not check bootstrap status — skipping first user setup.'); + return; + } + + console.log('─── Admin User Setup ───\n'); + + const name = (await prompt(rl, 'Admin name: ')).trim(); + if (!name) { + console.error('Name is required.'); + return; + } + + const email = (await prompt(rl, 'Admin email: ')).trim(); + if (!email) { + console.error('Email is required.'); + return; + } + + const password = (await prompt(rl, 'Admin password (min 8 chars): ')).trim(); + if (password.length < 8) { + console.error('Password must be at least 8 characters.'); + return; + } + + try { + const res = await fetch(`${baseUrl}/api/bootstrap/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, password }), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + console.error(`Bootstrap failed (${res.status.toString()}): ${body}`); + return; + } + + const result = (await res.json()) as { + user: { id: string; email: string }; + token: { plaintext: string }; + }; + + // Save admin token to meta + meta.adminToken = result.token.plaintext; + writeMeta(meta as GatewayMeta); + + console.log(`\nAdmin user created: ${result.user.email}`); + console.log('Admin API token saved to gateway config.'); + } catch (err) { + console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`); + } +} diff --git a/packages/cli/src/commands/gateway/logs.ts b/packages/cli/src/commands/gateway/logs.ts new file mode 100644 index 0000000..3dc6ea7 --- /dev/null +++ b/packages/cli/src/commands/gateway/logs.ts @@ -0,0 +1,37 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { LOG_FILE } from './daemon.js'; + +interface LogsOpts { + follow?: boolean; + lines?: number; +} + +export function runLogs(opts: LogsOpts): void { + if (!existsSync(LOG_FILE)) { + console.log('No log file found. Is the gateway installed?'); + return; + } + + if (opts.follow) { + const lines = opts.lines ?? 50; + const tail = spawn('tail', ['-n', lines.toString(), '-f', LOG_FILE], { + stdio: 'inherit', + }); + tail.on('error', () => { + // Fallback for systems without tail + console.log(readLastLines(opts.lines ?? 50)); + console.log('\n(--follow requires `tail` command)'); + }); + return; + } + + // Just print last N lines + console.log(readLastLines(opts.lines ?? 50)); +} + +function readLastLines(n: number): string { + const content = readFileSync(LOG_FILE, 'utf-8'); + const lines = content.split('\n'); + return lines.slice(-n).join('\n'); +} diff --git a/packages/cli/src/commands/gateway/status.ts b/packages/cli/src/commands/gateway/status.ts new file mode 100644 index 0000000..605438f --- /dev/null +++ b/packages/cli/src/commands/gateway/status.ts @@ -0,0 +1,115 @@ +import { getDaemonPid, readMeta, LOG_FILE, GATEWAY_HOME } from './daemon.js'; + +interface GatewayOpts { + host: string; + port: number; + token?: string; +} + +interface ServiceStatus { + name: string; + status: string; + latency?: string; +} + +interface AdminHealth { + status: string; + services: { + database: { status: string; latencyMs: number }; + cache: { status: string; latencyMs: number }; + }; + agentPool?: { active: number }; + providers?: Array<{ name: string; available: boolean; models: number }>; +} + +export async function runStatus(opts: GatewayOpts): Promise { + const meta = readMeta(); + const pid = getDaemonPid(); + + console.log('Mosaic Gateway Status'); + console.log('─────────────────────'); + + // Daemon status + if (pid !== null) { + console.log(` Status: running (PID ${pid.toString()})`); + } else { + console.log(' Status: stopped'); + } + + // Version + console.log(` Version: ${meta?.version ?? 'unknown'}`); + + // Endpoint + const host = opts.host; + const port = opts.port; + console.log(` Endpoint: http://${host}:${port.toString()}`); + console.log(` Config: ${GATEWAY_HOME}`); + console.log(` Logs: ${LOG_FILE}`); + + if (pid === null) return; + + // Health check + try { + const healthRes = await fetch(`http://${host}:${port.toString()}/health`); + if (!healthRes.ok) { + console.log('\n Health: unreachable'); + return; + } + } catch { + console.log('\n Health: unreachable'); + return; + } + + // Admin health (requires token) + const token = opts.token ?? meta?.adminToken; + if (!token) { + console.log( + '\n (No admin token — run `mosaic gateway config` to set one for detailed status)', + ); + return; + } + + try { + const res = await fetch(`http://${host}:${port.toString()}/api/admin/health`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + console.log('\n Admin health: unauthorized or unavailable'); + return; + } + + const health = (await res.json()) as AdminHealth; + + console.log('\n Services:'); + const services: ServiceStatus[] = [ + { + name: 'Database', + status: health.services.database.status, + latency: `${health.services.database.latencyMs.toString()}ms`, + }, + { + name: 'Cache', + status: health.services.cache.status, + latency: `${health.services.cache.latencyMs.toString()}ms`, + }, + ]; + + for (const svc of services) { + const latStr = svc.latency ? ` (${svc.latency})` : ''; + console.log(` ${svc.name}:${' '.repeat(10 - svc.name.length)}${svc.status}${latStr}`); + } + + if (health.providers && health.providers.length > 0) { + const available = health.providers.filter((p) => p.available); + const names = available.map((p) => p.name).join(', '); + console.log(`\n Providers: ${available.length.toString()} active (${names})`); + } + + if (health.agentPool) { + console.log(` Sessions: ${health.agentPool.active.toString()} active`); + } + } catch { + console.log('\n Admin health: connection error'); + } +} diff --git a/packages/cli/src/commands/gateway/uninstall.ts b/packages/cli/src/commands/gateway/uninstall.ts new file mode 100644 index 0000000..ae14a15 --- /dev/null +++ b/packages/cli/src/commands/gateway/uninstall.ts @@ -0,0 +1,62 @@ +import { existsSync, rmSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { + GATEWAY_HOME, + getDaemonPid, + readMeta, + stopDaemon, + uninstallGatewayPackage, +} from './daemon.js'; + +export async function runUninstall(): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + await doUninstall(rl); + } finally { + rl.close(); + } +} + +function prompt(rl: ReturnType, question: string): Promise { + return new Promise((resolve) => rl.question(question, resolve)); +} + +async function doUninstall(rl: ReturnType): Promise { + const meta = readMeta(); + if (!meta) { + console.log('Gateway is not installed.'); + return; + } + + const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] '); + if (answer.toLowerCase() !== 'y') { + console.log('Aborted.'); + return; + } + + // Stop if running + if (getDaemonPid() !== null) { + console.log('Stopping gateway daemon...'); + try { + await stopDaemon(); + console.log('Stopped.'); + } catch (err) { + console.warn(`Warning: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // Remove config/data + const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `); + if (removeData.toLowerCase() === 'y') { + if (existsSync(GATEWAY_HOME)) { + rmSync(GATEWAY_HOME, { recursive: true, force: true }); + console.log('Gateway data removed.'); + } + } + + // Uninstall npm package + console.log('Uninstalling npm package...'); + uninstallGatewayPackage(); + + console.log('\nGateway uninstalled.'); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 72230ed..bee6169 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -16,4 +16,5 @@ export { gte, lte, ilike, + count, } from 'drizzle-orm'; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 1e83df1..ee7d4e2 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -91,6 +91,28 @@ export const verifications = pgTable('verifications', { updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); +// ─── Admin API Tokens ─────────────────────────────────────────────────────── + +export const adminTokens = pgTable( + 'admin_tokens', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + tokenHash: text('token_hash').notNull(), + label: text('label').notNull(), + scope: text('scope').notNull().default('admin'), + expiresAt: timestamp('expires_at', { withTimezone: true }), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + index('admin_tokens_user_id_idx').on(t.userId), + uniqueIndex('admin_tokens_hash_idx').on(t.tokenHash), + ], +); + // ─── Teams ─────────────────────────────────────────────────────────────────── // Declared before projects because projects references teams. -- 2.49.1