feat: mosaic gateway CLI daemon management + admin token auth (#369)
This commit was merged in pull request #369.
This commit is contained in:
90
apps/gateway/src/admin/admin-tokens.controller.ts
Normal file
90
apps/gateway/src/admin/admin-tokens.controller.ts
Normal file
@@ -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<TokenCreatedDto> {
|
||||||
|
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<TokenListDto> {
|
||||||
|
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<void> {
|
||||||
|
await this.db.delete(adminTokens).where(eq(adminTokens.id, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/gateway/src/admin/admin-tokens.dto.ts
Normal file
33
apps/gateway/src/admin/admin-tokens.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,10 +6,11 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { fromNodeHeaders } from 'better-auth/node';
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
import type { Auth } from '@mosaic/auth';
|
import type { Auth } from '@mosaic/auth';
|
||||||
import type { Db } from '@mosaic/db';
|
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 type { FastifyRequest } from 'fastify';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
@@ -19,6 +20,8 @@ interface UserWithRole {
|
|||||||
role?: string;
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthenticatedRequest = FastifyRequest & { user: unknown; session: unknown };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminGuard implements CanActivate {
|
export class AdminGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -28,8 +31,64 @@ export class AdminGuard implements CanActivate {
|
|||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
const headers = fromNodeHeaders(request.raw.headers);
|
||||||
const result = await this.auth.api.getSession({ headers });
|
const result = await this.auth.api.getSession({ headers });
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -38,8 +97,6 @@ export class AdminGuard implements CanActivate {
|
|||||||
|
|
||||||
const user = result.user as UserWithRole;
|
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;
|
let userRole = user.role;
|
||||||
if (!userRole) {
|
if (!userRole) {
|
||||||
const [dbUser] = await this.db
|
const [dbUser] = await this.db
|
||||||
@@ -48,7 +105,6 @@ export class AdminGuard implements CanActivate {
|
|||||||
.where(eq(usersTable.id, user.id))
|
.where(eq(usersTable.id, user.id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
userRole = dbUser?.role ?? 'member';
|
userRole = dbUser?.role ?? 'member';
|
||||||
// Update the session user object with the fetched role
|
|
||||||
(user as UserWithRole).role = userRole;
|
(user as UserWithRole).role = userRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +112,9 @@ export class AdminGuard implements CanActivate {
|
|||||||
throw new ForbiddenException('Admin access required');
|
throw new ForbiddenException('Admin access required');
|
||||||
}
|
}
|
||||||
|
|
||||||
(request as FastifyRequest & { user: unknown; session: unknown }).user = result.user;
|
const req = request as AuthenticatedRequest;
|
||||||
(request as FastifyRequest & { user: unknown; session: unknown }).session = result.session;
|
req.user = result.user;
|
||||||
|
req.session = result.session;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,18 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AdminController } from './admin.controller.js';
|
import { AdminController } from './admin.controller.js';
|
||||||
import { AdminHealthController } from './admin-health.controller.js';
|
import { AdminHealthController } from './admin-health.controller.js';
|
||||||
import { AdminJobsController } from './admin-jobs.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';
|
import { AdminGuard } from './admin.guard.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AdminController, AdminHealthController, AdminJobsController],
|
controllers: [
|
||||||
|
AdminController,
|
||||||
|
AdminHealthController,
|
||||||
|
AdminJobsController,
|
||||||
|
AdminTokensController,
|
||||||
|
BootstrapController,
|
||||||
|
],
|
||||||
providers: [AdminGuard],
|
providers: [AdminGuard],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
101
apps/gateway/src/admin/bootstrap.controller.ts
Normal file
101
apps/gateway/src/admin/bootstrap.controller.ts
Normal file
@@ -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<BootstrapStatusDto> {
|
||||||
|
const [result] = await this.db.select({ total: count() }).from(usersTable);
|
||||||
|
return { needsSetup: (result?.total ?? 0) === 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('setup')
|
||||||
|
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/gateway/src/admin/bootstrap.dto.ts
Normal file
31
apps/gateway/src/admin/bootstrap.dto.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { config } from 'dotenv';
|
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)
|
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
||||||
config({ path: resolve(process.cwd(), '../../.env') });
|
config({ path: resolve(process.cwd(), '../../.env') });
|
||||||
|
|||||||
@@ -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 type { Command } from 'commander';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCAL_CONFIG,
|
getDaemonPid,
|
||||||
DEFAULT_TEAM_CONFIG,
|
readMeta,
|
||||||
loadConfig,
|
startDaemon,
|
||||||
type MosaicConfig,
|
stopDaemon,
|
||||||
type StorageTier,
|
waitForHealth,
|
||||||
} from '@mosaic/config';
|
} from './gateway/daemon.js';
|
||||||
|
|
||||||
function ask(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
interface GatewayParentOpts {
|
||||||
return new Promise((res) => rl.question(question, res));
|
host: string;
|
||||||
|
port: string;
|
||||||
|
token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runInit(opts: { tier?: string; output: string }): Promise<void> {
|
function resolveOpts(raw: GatewayParentOpts): { host: string; port: number; token?: string } {
|
||||||
const outputPath = resolve(opts.output);
|
const meta = readMeta();
|
||||||
let tier: StorageTier;
|
return {
|
||||||
|
host: raw.host ?? meta?.host ?? 'localhost',
|
||||||
if (opts.tier) {
|
port: parseInt(raw.port, 10) || meta?.port || 4000,
|
||||||
if (opts.tier !== 'local' && opts.tier !== 'team') {
|
token: raw.token ?? meta?.adminToken,
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerGatewayCommand(program: Command): void {
|
export function registerGatewayCommand(program: Command): void {
|
||||||
const gateway = program.command('gateway').description('Gateway management commands');
|
const gw = program
|
||||||
|
.command('gateway')
|
||||||
gateway
|
.description('Manage the Mosaic gateway daemon')
|
||||||
.command('init')
|
.helpOption('--help', 'Display help')
|
||||||
.description('Generate a mosaic.config.json for the gateway')
|
.option('-h, --host <host>', 'Gateway host', 'localhost')
|
||||||
.option('--tier <tier>', 'Storage tier: local or team (skips interactive prompt)')
|
.option('-p, --port <port>', 'Gateway port', '4000')
|
||||||
.option('--output <path>', 'Output file path', './mosaic.config.json')
|
.option('-t, --token <token>', 'Admin API token')
|
||||||
.action(async (opts: { tier?: string; output: string }) => {
|
|
||||||
await runInit(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
gateway
|
|
||||||
.command('start')
|
|
||||||
.description('Start the Mosaic gateway process')
|
|
||||||
.option('--port <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')
|
|
||||||
.action(() => {
|
.action(() => {
|
||||||
const pid = readPidFile();
|
gw.outputHelp();
|
||||||
|
});
|
||||||
|
|
||||||
if (pid === null) {
|
// ─── install ────────────────────────────────────────────────────────────
|
||||||
console.error('No PID file found at', PID_FILE);
|
|
||||||
|
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);
|
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
|
// ─── stop ───────────────────────────────────────────────────────────────
|
||||||
.command('status')
|
|
||||||
.description('Show gateway process status')
|
|
||||||
.action(() => {
|
|
||||||
const config = loadConfig();
|
|
||||||
const pid = readPidFile();
|
|
||||||
|
|
||||||
if (pid !== null && isProcessRunning(pid)) {
|
gw.command('stop')
|
||||||
console.log('Gateway: running');
|
.description('Stop the gateway daemon')
|
||||||
console.log(` PID: ${pid}`);
|
.action(async () => {
|
||||||
} else {
|
try {
|
||||||
console.log('Gateway: stopped');
|
await stopDaemon();
|
||||||
if (pid !== null) {
|
console.log('Gateway stopped.');
|
||||||
console.log(` (stale PID file for ${pid})`);
|
} catch (err) {
|
||||||
unlinkSync(PID_FILE);
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
}
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
console.log('');
|
// ─── restart ────────────────────────────────────────────────────────────
|
||||||
console.log('Config:');
|
|
||||||
printConfigSummary(config);
|
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 <KEY=VALUE>', 'Set a configuration value')
|
||||||
|
.option('--unset <KEY>', '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 <count>', '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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
143
packages/cli/src/commands/gateway/config.ts
Normal file
143
packages/cli/src/commands/gateway/config.ts
Normal file
@@ -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<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
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<string, string>): 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<void> {
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
253
packages/cli/src/commands/gateway/daemon.ts
Normal file
253
packages/cli/src/commands/gateway/daemon.ts
Normal file
@@ -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<string, string> = { ...process.env } as Record<string, string>;
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
223
packages/cli/src/commands/gateway/install.ts
Normal file
223
packages/cli/src/commands/gateway/install.ts
Normal file
@@ -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<typeof createInterface>, question: string): Promise<string> {
|
||||||
|
return new Promise((resolve) => rl.question(question, resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runInstall(opts: InstallOpts): Promise<void> {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
try {
|
||||||
|
await doInstall(rl, opts);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
|
||||||
|
// 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<typeof createInterface>,
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
meta: Omit<GatewayMeta, 'adminToken'> & { adminToken?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
packages/cli/src/commands/gateway/logs.ts
Normal file
37
packages/cli/src/commands/gateway/logs.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
115
packages/cli/src/commands/gateway/status.ts
Normal file
115
packages/cli/src/commands/gateway/status.ts
Normal file
@@ -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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
62
packages/cli/src/commands/gateway/uninstall.ts
Normal file
62
packages/cli/src/commands/gateway/uninstall.ts
Normal file
@@ -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<void> {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
try {
|
||||||
|
await doUninstall(rl);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||||
|
return new Promise((resolve) => rl.question(question, resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||||
|
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.');
|
||||||
|
}
|
||||||
@@ -16,4 +16,5 @@ export {
|
|||||||
gte,
|
gte,
|
||||||
lte,
|
lte,
|
||||||
ilike,
|
ilike,
|
||||||
|
count,
|
||||||
} from 'drizzle-orm';
|
} from 'drizzle-orm';
|
||||||
|
|||||||
@@ -91,6 +91,28 @@ export const verifications = pgTable('verifications', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
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 ───────────────────────────────────────────────────────────────────
|
// ─── Teams ───────────────────────────────────────────────────────────────────
|
||||||
// Declared before projects because projects references teams.
|
// Declared before projects because projects references teams.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user