Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d04874f3c | |||
| 9f036242fa | |||
| c4e52085e3 | |||
| 84e1868028 | |||
| f94f9f672b | |||
| cd29fc8708 | |||
| 6e22c0fdeb | |||
| 1f4d54e474 | |||
| b7a39b45d7 | |||
| 1bfdc91f90 | |||
| 58a90ac9d7 | |||
| 684dbdc6a4 | |||
| e92de12cf9 | |||
| 1f784a6a04 | |||
| ab37c2e69f | |||
| c8f3e0db44 | |||
| 02772a3910 | |||
| 85a25fd995 | |||
| 20f302367c | |||
| 54c6bfded0 | |||
| ca5472bc31 | |||
| 55b5a31c3c | |||
| 01e9891243 | |||
| 446a424c1f | |||
| 02a0d515d9 | |||
| 2bf3816efc | |||
| 96902bab44 | |||
| 280c5351e2 |
14
.env.example
14
.env.example
@@ -18,3 +18,17 @@ BETTER_AUTH_URL=http://localhost:4000
|
||||
|
||||
# Gateway
|
||||
GATEWAY_PORT=4000
|
||||
|
||||
# Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable)
|
||||
# DISCORD_BOT_TOKEN=
|
||||
# DISCORD_GUILD_ID=
|
||||
# DISCORD_GATEWAY_URL=http://localhost:4000
|
||||
|
||||
# Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable)
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
||||
|
||||
# Authentik SSO (optional — set AUTHENTIK_CLIENT_ID to enable)
|
||||
# AUTHENTIK_ISSUER=https://auth.example.com
|
||||
# AUTHENTIK_CLIENT_ID=
|
||||
# AUTHENTIK_CLIENT_SECRET=
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
variables:
|
||||
- &node_image 'node:22-alpine'
|
||||
- &install_deps |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
- &enable_pnpm 'corepack enable'
|
||||
|
||||
when:
|
||||
- event: [push, pull_request, manual]
|
||||
|
||||
# Steps run sequentially to avoid OOM on the CI runner.
|
||||
# node_modules is installed once by the install step and shared across
|
||||
# all subsequent steps via Woodpecker's shared workspace volume.
|
||||
|
||||
steps:
|
||||
install:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
- *enable_pnpm
|
||||
- pnpm typecheck
|
||||
depends_on:
|
||||
- install
|
||||
@@ -24,34 +27,31 @@ steps:
|
||||
lint:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
- *enable_pnpm
|
||||
- pnpm lint
|
||||
depends_on:
|
||||
- install
|
||||
- typecheck
|
||||
|
||||
format:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
- *enable_pnpm
|
||||
- pnpm format:check
|
||||
depends_on:
|
||||
- install
|
||||
- lint
|
||||
|
||||
test:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
- *enable_pnpm
|
||||
- pnpm test
|
||||
depends_on:
|
||||
- install
|
||||
- format
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
depends_on:
|
||||
- typecheck
|
||||
- lint
|
||||
- format
|
||||
- test
|
||||
|
||||
@@ -8,16 +8,19 @@
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@mariozechner/pi-ai": "~0.57.1",
|
||||
"@mariozechner/pi-coding-agent": "~0.57.1",
|
||||
"@mosaic/auth": "workspace:^",
|
||||
"@mosaic/brain": "workspace:^",
|
||||
"@mosaic/coord": "workspace:^",
|
||||
"@mosaic/db": "workspace:^",
|
||||
"@mosaic/discord-plugin": "workspace:^",
|
||||
"@mosaic/telegram-plugin": "workspace:^",
|
||||
"@mosaic/log": "workspace:^",
|
||||
"@mosaic/memory": "workspace:^",
|
||||
"@mosaic/types": "workspace:^",
|
||||
@@ -25,6 +28,7 @@
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-fastify": "^11.0.0",
|
||||
"@nestjs/platform-socket.io": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.0.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
|
||||
@@ -35,6 +39,8 @@
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"better-auth": "^1.5.5",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"fastify": "^5.0.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
|
||||
137
apps/gateway/src/__tests__/resource-ownership.test.ts
Normal file
137
apps/gateway/src/__tests__/resource-ownership.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationsController } from '../conversations/conversations.controller.js';
|
||||
import { MissionsController } from '../missions/missions.controller.js';
|
||||
import { ProjectsController } from '../projects/projects.controller.js';
|
||||
import { TasksController } from '../tasks/tasks.controller.js';
|
||||
|
||||
function createBrain() {
|
||||
return {
|
||||
conversations: {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
findMessages: vi.fn(),
|
||||
addMessage: vi.fn(),
|
||||
},
|
||||
projects: {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
missions: {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByProject: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
tasks: {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByProject: vi.fn(),
|
||||
findByMission: vi.fn(),
|
||||
findByStatus: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Resource ownership checks', () => {
|
||||
it('forbids access to another user conversation', async () => {
|
||||
const brain = createBrain();
|
||||
brain.conversations.findById.mockResolvedValue({ id: 'conv-1', userId: 'user-2' });
|
||||
const controller = new ConversationsController(brain as never);
|
||||
|
||||
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids access to another user project', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new ProjectsController(brain as never);
|
||||
|
||||
await expect(controller.findOne('project-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids access to a mission owned by another project owner', async () => {
|
||||
const brain = createBrain();
|
||||
brain.missions.findById.mockResolvedValue({ id: 'mission-1', projectId: 'project-1' });
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new MissionsController(brain as never);
|
||||
|
||||
await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids access to a task owned by another project owner', async () => {
|
||||
const brain = createBrain();
|
||||
brain.tasks.findById.mockResolvedValue({ id: 'task-1', projectId: 'project-1' });
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new TasksController(brain as never);
|
||||
|
||||
await expect(controller.findOne('task-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids creating a task with an unowned project', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new TasksController(brain as never);
|
||||
|
||||
await expect(
|
||||
controller.create(
|
||||
{
|
||||
title: 'Task',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
{ id: 'user-1' },
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('forbids listing tasks for an unowned project', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new TasksController(brain as never);
|
||||
|
||||
await expect(
|
||||
controller.list({ id: 'user-1' }, 'project-1', undefined, undefined),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('lists only tasks for the current user owned projects when no filter is provided', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findAll.mockResolvedValue([
|
||||
{ id: 'project-1', ownerId: 'user-1' },
|
||||
{ id: 'project-2', ownerId: 'user-2' },
|
||||
]);
|
||||
brain.missions.findAll.mockResolvedValue([{ id: 'mission-1', projectId: 'project-1' }]);
|
||||
brain.tasks.findAll.mockResolvedValue([
|
||||
{ id: 'task-1', projectId: 'project-1' },
|
||||
{ id: 'task-2', missionId: 'mission-1' },
|
||||
{ id: 'task-3', projectId: 'project-2' },
|
||||
]);
|
||||
const controller = new TasksController(brain as never);
|
||||
|
||||
await expect(
|
||||
controller.list({ id: 'user-1' }, undefined, undefined, undefined),
|
||||
).resolves.toEqual([
|
||||
{ id: 'task-1', projectId: 'project-1' },
|
||||
{ id: 'task-2', missionId: 'mission-1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ export class ProviderService implements OnModuleInit {
|
||||
private registry!: ModelRegistry;
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const authStorage = AuthStorage.create();
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
this.registry = new ModelRegistry(authStorage);
|
||||
|
||||
this.registerOllamaProvider();
|
||||
@@ -89,7 +89,7 @@ export class ProviderService implements OnModuleInit {
|
||||
const modelsEnv = process.env['OLLAMA_MODELS'] ?? 'llama3.2,codellama,mistral';
|
||||
const modelIds = modelsEnv
|
||||
.split(',')
|
||||
.map((m) => m.trim())
|
||||
.map((modelId: string) => modelId.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
this.registerCustomProvider({
|
||||
|
||||
@@ -145,8 +145,11 @@ export class RoutingService {
|
||||
|
||||
private classifyTier(model: ModelInfo): CostTier {
|
||||
const cost = model.cost.input;
|
||||
if (cost <= COST_TIER_THRESHOLDS.cheap.maxInput) return 'cheap';
|
||||
if (cost <= COST_TIER_THRESHOLDS.standard.maxInput) return 'standard';
|
||||
const cheapThreshold = COST_TIER_THRESHOLDS['cheap'];
|
||||
const standardThreshold = COST_TIER_THRESHOLDS['standard'];
|
||||
|
||||
if (cost <= cheapThreshold.maxInput) return 'cheap';
|
||||
if (cost <= standardThreshold.maxInput) return 'standard';
|
||||
return 'premium';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { HealthController } from './health/health.controller.js';
|
||||
import { DatabaseModule } from './database/database.module.js';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
@@ -13,9 +14,12 @@ import { CoordModule } from './coord/coord.module.js';
|
||||
import { MemoryModule } from './memory/memory.module.js';
|
||||
import { LogModule } from './log/log.module.js';
|
||||
import { SkillsModule } from './skills/skills.module.js';
|
||||
import { PluginModule } from './plugin/plugin.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 60 }]),
|
||||
DatabaseModule,
|
||||
AuthModule,
|
||||
BrainModule,
|
||||
@@ -29,7 +33,14 @@ import { SkillsModule } from './skills/skills.module.js';
|
||||
MemoryModule,
|
||||
LogModule,
|
||||
SkillsModule,
|
||||
PluginModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
11
apps/gateway/src/auth/resource-ownership.ts
Normal file
11
apps/gateway/src/auth/resource-ownership.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
export function assertOwner(
|
||||
ownerId: string | null | undefined,
|
||||
userId: string,
|
||||
resourceName: string,
|
||||
): void {
|
||||
if (!ownerId || ownerId !== userId) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
}
|
||||
}
|
||||
80
apps/gateway/src/chat/__tests__/chat-security.test.ts
Normal file
80
apps/gateway/src/chat/__tests__/chat-security.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SendMessageDto } from '../../conversations/conversations.dto.js';
|
||||
import { ChatRequestDto } from '../chat.dto.js';
|
||||
import { validateSocketSession } from '../chat.gateway-auth.js';
|
||||
|
||||
describe('Chat controller source hardening', () => {
|
||||
it('applies AuthGuard and reads the current user', () => {
|
||||
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||
|
||||
expect(source).toContain('@UseGuards(AuthGuard)');
|
||||
expect(source).toContain('@CurrentUser() user: { id: string }');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebSocket session authentication', () => {
|
||||
it('returns null when the handshake does not resolve to a session', async () => {
|
||||
const result = await validateSocketSession(
|
||||
{},
|
||||
{
|
||||
api: {
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the resolved session when Better Auth accepts the headers', async () => {
|
||||
const session = { user: { id: 'user-1' }, session: { id: 'session-1' } };
|
||||
|
||||
const result = await validateSocketSession(
|
||||
{ cookie: 'session=abc' },
|
||||
{
|
||||
api: {
|
||||
getSession: vi.fn().mockResolvedValue(session),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chat DTO validation', () => {
|
||||
it('rejects unsupported message roles', () => {
|
||||
const dto = Object.assign(new SendMessageDto(), {
|
||||
content: 'hello',
|
||||
role: 'moderator',
|
||||
});
|
||||
|
||||
const errors = validateSync(dto);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects oversized conversation message content above 10000 characters', () => {
|
||||
const dto = Object.assign(new SendMessageDto(), {
|
||||
content: 'x'.repeat(10_001),
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
const errors = validateSync(dto);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects oversized chat content above 10000 characters', () => {
|
||||
const dto = Object.assign(new ChatRequestDto(), {
|
||||
content: 'x'.repeat(10_001),
|
||||
});
|
||||
|
||||
const errors = validateSync(dto);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,20 @@
|
||||
import { Controller, Post, Body, Logger, HttpException, HttpStatus, Inject } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Logger,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface ChatRequest {
|
||||
conversationId?: string;
|
||||
content: string;
|
||||
}
|
||||
import { ChatRequestDto } from './chat.dto.js';
|
||||
|
||||
interface ChatResponse {
|
||||
conversationId: string;
|
||||
@@ -14,13 +22,18 @@ interface ChatResponse {
|
||||
}
|
||||
|
||||
@Controller('api/chat')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ChatController {
|
||||
private readonly logger = new Logger(ChatController.name);
|
||||
|
||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||
|
||||
@Post()
|
||||
async chat(@Body() body: ChatRequest): Promise<ChatResponse> {
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
async chat(
|
||||
@Body() body: ChatRequestDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
): Promise<ChatResponse> {
|
||||
const conversationId = body.conversationId ?? uuid();
|
||||
|
||||
try {
|
||||
@@ -36,6 +49,8 @@ export class ChatController {
|
||||
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
|
||||
|
||||
let responseText = '';
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
|
||||
31
apps/gateway/src/chat/chat.dto.ts
Normal file
31
apps/gateway/src/chat/chat.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class ChatRequestDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
conversationId?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
content!: string;
|
||||
}
|
||||
|
||||
export class ChatSocketMessageDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
conversationId?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
content!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
provider?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
modelId?: string;
|
||||
}
|
||||
30
apps/gateway/src/chat/chat.gateway-auth.ts
Normal file
30
apps/gateway/src/chat/chat.gateway-auth.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
|
||||
export interface SocketSessionResult {
|
||||
session: unknown;
|
||||
user: { id: string };
|
||||
}
|
||||
|
||||
export interface SessionAuth {
|
||||
api: {
|
||||
getSession(context: { headers: Headers }): Promise<SocketSessionResult | null>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateSocketSession(
|
||||
headers: IncomingHttpHeaders,
|
||||
auth: SessionAuth,
|
||||
): Promise<SocketSessionResult | null> {
|
||||
const sessionHeaders = fromNodeHeaders(headers);
|
||||
const result = await auth.api.getSession({ headers: sessionHeaders });
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
session: result.session,
|
||||
user: { id: result.user.id },
|
||||
};
|
||||
}
|
||||
@@ -11,18 +11,17 @@ import {
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface ChatMessage {
|
||||
conversationId?: string;
|
||||
content: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
}
|
||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: { origin: '*' },
|
||||
cors: {
|
||||
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
||||
},
|
||||
namespace: '/chat',
|
||||
})
|
||||
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||
@@ -35,13 +34,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
{ conversationId: string; cleanup: () => void }
|
||||
>();
|
||||
|
||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||
constructor(
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(AUTH) private readonly auth: Auth,
|
||||
) {}
|
||||
|
||||
afterInit(): void {
|
||||
this.logger.log('Chat WebSocket gateway initialized');
|
||||
}
|
||||
|
||||
handleConnection(client: Socket): void {
|
||||
async handleConnection(client: Socket): Promise<void> {
|
||||
const session = await validateSocketSession(client.handshake.headers, this.auth);
|
||||
if (!session) {
|
||||
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
client.data.user = session.user;
|
||||
client.data.session = session.session;
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
}
|
||||
|
||||
@@ -58,7 +69,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
@SubscribeMessage('message')
|
||||
async handleMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: ChatMessage,
|
||||
@MessageBody() data: ChatSocketMessageDto,
|
||||
): Promise<void> {
|
||||
const conversationId = data.conversationId ?? uuid();
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import type {
|
||||
import { assertOwner } from '../auth/resource-ownership.js';
|
||||
import {
|
||||
CreateConversationDto,
|
||||
UpdateConversationDto,
|
||||
SendMessageDto,
|
||||
@@ -33,10 +34,8 @@ export class ConversationsController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
const conversation = await this.brain.conversations.findById(id);
|
||||
if (!conversation) throw new NotFoundException('Conversation not found');
|
||||
return conversation;
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
return this.getOwnedConversation(id, user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -49,7 +48,12 @@ export class ConversationsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateConversationDto) {
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateConversationDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
await this.getOwnedConversation(id, user.id);
|
||||
const conversation = await this.brain.conversations.update(id, dto);
|
||||
if (!conversation) throw new NotFoundException('Conversation not found');
|
||||
return conversation;
|
||||
@@ -57,22 +61,25 @@ export class ConversationsController {
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string) {
|
||||
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
await this.getOwnedConversation(id, user.id);
|
||||
const deleted = await this.brain.conversations.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Conversation not found');
|
||||
}
|
||||
|
||||
@Get(':id/messages')
|
||||
async listMessages(@Param('id') id: string) {
|
||||
const conversation = await this.brain.conversations.findById(id);
|
||||
if (!conversation) throw new NotFoundException('Conversation not found');
|
||||
async listMessages(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
await this.getOwnedConversation(id, user.id);
|
||||
return this.brain.conversations.findMessages(id);
|
||||
}
|
||||
|
||||
@Post(':id/messages')
|
||||
async addMessage(@Param('id') id: string, @Body() dto: SendMessageDto) {
|
||||
const conversation = await this.brain.conversations.findById(id);
|
||||
if (!conversation) throw new NotFoundException('Conversation not found');
|
||||
async addMessage(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: SendMessageDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
await this.getOwnedConversation(id, user.id);
|
||||
return this.brain.conversations.addMessage({
|
||||
conversationId: id,
|
||||
role: dto.role,
|
||||
@@ -80,4 +87,11 @@ export class ConversationsController {
|
||||
metadata: dto.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
private async getOwnedConversation(id: string, userId: string) {
|
||||
const conversation = await this.brain.conversations.findById(id);
|
||||
if (!conversation) throw new NotFoundException('Conversation not found');
|
||||
assertOwner(conversation.userId, userId, 'Conversation');
|
||||
return conversation;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
export interface CreateConversationDto {
|
||||
import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateConversationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateConversationDto {
|
||||
export class UpdateConversationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
export interface SendMessageDto {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
export class SendMessageDto {
|
||||
@IsIn(['user', 'assistant', 'system'])
|
||||
role!: 'user' | 'assistant' | 'system';
|
||||
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
content!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,45 @@
|
||||
import './tracing.js';
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import helmet from '@fastify/helmet';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { mountAuthHandler } from './auth/auth.controller.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required');
|
||||
}
|
||||
|
||||
if (
|
||||
process.env['AUTHENTIK_CLIENT_ID'] &&
|
||||
(!process.env['AUTHENTIK_CLIENT_SECRET'] || !process.env['AUTHENTIK_ISSUER'])
|
||||
) {
|
||||
console.warn(
|
||||
'[warn] AUTHENTIK_CLIENT_ID is set but AUTHENTIK_CLIENT_SECRET or AUTHENTIK_ISSUER is missing — Authentik SSO will not work',
|
||||
);
|
||||
}
|
||||
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter({ bodyLimit: 1_048_576 }),
|
||||
);
|
||||
|
||||
await app.register(helmet as never, { contentSecurityPolicy: false });
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
mountAuthHandler(app);
|
||||
|
||||
const port = process.env['GATEWAY_PORT'] ?? 4000;
|
||||
await app.listen(port as number, '0.0.0.0');
|
||||
const port = Number(process.env['GATEWAY_PORT'] ?? 4000);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
logger.log(`Gateway listening on port ${port}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
@@ -15,7 +16,9 @@ import {
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import type { CreateMissionDto, UpdateMissionDto } from './missions.dto.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { assertOwner } from '../auth/resource-ownership.js';
|
||||
import { CreateMissionDto, UpdateMissionDto } from './missions.dto.js';
|
||||
|
||||
@Controller('api/missions')
|
||||
@UseGuards(AuthGuard)
|
||||
@@ -28,14 +31,15 @@ export class MissionsController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
const mission = await this.brain.missions.findById(id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return mission;
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
return this.getOwnedMission(id, user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateMissionDto) {
|
||||
async create(@Body() dto: CreateMissionDto, @CurrentUser() user: { id: string }) {
|
||||
if (dto.projectId) {
|
||||
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
|
||||
}
|
||||
return this.brain.missions.create({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
@@ -45,7 +49,15 @@ export class MissionsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateMissionDto) {
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateMissionDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
await this.getOwnedMission(id, user.id);
|
||||
if (dto.projectId) {
|
||||
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
|
||||
}
|
||||
const mission = await this.brain.missions.update(id, dto);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return mission;
|
||||
@@ -53,8 +65,34 @@ export class MissionsController {
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string) {
|
||||
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
await this.getOwnedMission(id, user.id);
|
||||
const deleted = await this.brain.missions.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Mission not found');
|
||||
}
|
||||
|
||||
private async getOwnedMission(id: string, userId: string) {
|
||||
const mission = await this.brain.missions.findById(id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
await this.getOwnedProject(mission.projectId, userId, 'Mission');
|
||||
return mission;
|
||||
}
|
||||
|
||||
private async getOwnedProject(
|
||||
projectId: string | null | undefined,
|
||||
userId: string,
|
||||
resourceName: string,
|
||||
) {
|
||||
if (!projectId) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
}
|
||||
|
||||
const project = await this.brain.projects.findById(projectId);
|
||||
if (!project) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
}
|
||||
|
||||
assertOwner(project.ownerId, userId, resourceName);
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
export interface CreateMissionDto {
|
||||
name: string;
|
||||
import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
const missionStatuses = ['planning', 'active', 'paused', 'completed', 'failed'] as const;
|
||||
|
||||
export class CreateMissionDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(missionStatuses)
|
||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
export interface UpdateMissionDto {
|
||||
export class UpdateMissionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(missionStatuses)
|
||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
5
apps/gateway/src/plugin/plugin.interface.ts
Normal file
5
apps/gateway/src/plugin/plugin.interface.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface IChannelPlugin {
|
||||
readonly name: string;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
109
apps/gateway/src/plugin/plugin.module.ts
Normal file
109
apps/gateway/src/plugin/plugin.module.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Global,
|
||||
Inject,
|
||||
Logger,
|
||||
Module,
|
||||
type OnModuleDestroy,
|
||||
type OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { DiscordPlugin } from '@mosaic/discord-plugin';
|
||||
import { TelegramPlugin } from '@mosaic/telegram-plugin';
|
||||
import { PluginService } from './plugin.service.js';
|
||||
import type { IChannelPlugin } from './plugin.interface.js';
|
||||
import { PLUGIN_REGISTRY } from './plugin.tokens.js';
|
||||
|
||||
class DiscordChannelPluginAdapter implements IChannelPlugin {
|
||||
readonly name = 'discord';
|
||||
|
||||
constructor(private readonly plugin: DiscordPlugin) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.plugin.start();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.plugin.stop();
|
||||
}
|
||||
}
|
||||
|
||||
class TelegramChannelPluginAdapter implements IChannelPlugin {
|
||||
readonly name = 'telegram';
|
||||
|
||||
constructor(private readonly plugin: TelegramPlugin) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.plugin.start();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.plugin.stop();
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_GATEWAY_URL = 'http://localhost:4000';
|
||||
|
||||
function createPluginRegistry(): IChannelPlugin[] {
|
||||
const plugins: IChannelPlugin[] = [];
|
||||
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
||||
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
||||
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
||||
|
||||
if (discordToken) {
|
||||
plugins.push(
|
||||
new DiscordChannelPluginAdapter(
|
||||
new DiscordPlugin({
|
||||
token: discordToken,
|
||||
guildId: discordGuildId,
|
||||
gatewayUrl: discordGatewayUrl,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const telegramToken = process.env['TELEGRAM_BOT_TOKEN'];
|
||||
const telegramGatewayUrl = process.env['TELEGRAM_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
||||
|
||||
if (telegramToken) {
|
||||
plugins.push(
|
||||
new TelegramChannelPluginAdapter(
|
||||
new TelegramPlugin({
|
||||
token: telegramToken,
|
||||
gatewayUrl: telegramGatewayUrl,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: PLUGIN_REGISTRY,
|
||||
useFactory: (): IChannelPlugin[] => createPluginRegistry(),
|
||||
},
|
||||
PluginService,
|
||||
],
|
||||
exports: [PluginService, PLUGIN_REGISTRY],
|
||||
})
|
||||
export class PluginModule implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PluginModule.name);
|
||||
|
||||
constructor(@Inject(PLUGIN_REGISTRY) private readonly plugins: IChannelPlugin[]) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
for (const plugin of this.plugins) {
|
||||
this.logger.log(`Starting plugin: ${plugin.name}`);
|
||||
await plugin.start();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
for (const plugin of [...this.plugins].reverse()) {
|
||||
this.logger.log(`Stopping plugin: ${plugin.name}`);
|
||||
await plugin.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
16
apps/gateway/src/plugin/plugin.service.ts
Normal file
16
apps/gateway/src/plugin/plugin.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PLUGIN_REGISTRY } from './plugin.tokens.js';
|
||||
import type { IChannelPlugin } from './plugin.interface.js';
|
||||
|
||||
@Injectable()
|
||||
export class PluginService {
|
||||
constructor(@Inject(PLUGIN_REGISTRY) private readonly plugins: IChannelPlugin[]) {}
|
||||
|
||||
getPlugins(): IChannelPlugin[] {
|
||||
return this.plugins;
|
||||
}
|
||||
|
||||
getPlugin(name: string): IChannelPlugin | undefined {
|
||||
return this.plugins.find((plugin: IChannelPlugin) => plugin.name === name);
|
||||
}
|
||||
}
|
||||
1
apps/gateway/src/plugin/plugin.tokens.ts
Normal file
1
apps/gateway/src/plugin/plugin.tokens.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const PLUGIN_REGISTRY = Symbol('PLUGIN_REGISTRY');
|
||||
@@ -16,7 +16,8 @@ import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import type { CreateProjectDto, UpdateProjectDto } from './projects.dto.js';
|
||||
import { assertOwner } from '../auth/resource-ownership.js';
|
||||
import { CreateProjectDto, UpdateProjectDto } from './projects.dto.js';
|
||||
|
||||
@Controller('api/projects')
|
||||
@UseGuards(AuthGuard)
|
||||
@@ -29,10 +30,8 @@ export class ProjectsController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
const project = await this.brain.projects.findById(id);
|
||||
if (!project) throw new NotFoundException('Project not found');
|
||||
return project;
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
return this.getOwnedProject(id, user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -46,7 +45,12 @@ export class ProjectsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateProjectDto) {
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateProjectDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
await this.getOwnedProject(id, user.id);
|
||||
const project = await this.brain.projects.update(id, dto);
|
||||
if (!project) throw new NotFoundException('Project not found');
|
||||
return project;
|
||||
@@ -54,8 +58,16 @@ export class ProjectsController {
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string) {
|
||||
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
await this.getOwnedProject(id, user.id);
|
||||
const deleted = await this.brain.projects.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
private async getOwnedProject(id: string, userId: string) {
|
||||
const project = await this.brain.projects.findById(id);
|
||||
if (!project) throw new NotFoundException('Project not found');
|
||||
assertOwner(project.ownerId, userId, 'Project');
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
export interface CreateProjectDto {
|
||||
name: string;
|
||||
import { IsIn, IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
const projectStatuses = ['active', 'paused', 'completed', 'archived'] as const;
|
||||
|
||||
export class CreateProjectDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(projectStatuses)
|
||||
status?: 'active' | 'paused' | 'completed' | 'archived';
|
||||
}
|
||||
|
||||
export interface UpdateProjectDto {
|
||||
export class UpdateProjectDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(projectStatuses)
|
||||
status?: 'active' | 'paused' | 'completed' | 'archived';
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
@@ -16,7 +17,9 @@ import {
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import type { CreateTaskDto, UpdateTaskDto } from './tasks.dto.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { assertOwner } from '../auth/resource-ownership.js';
|
||||
import { CreateTaskDto, UpdateTaskDto } from './tasks.dto.js';
|
||||
|
||||
@Controller('api/tasks')
|
||||
@UseGuards(AuthGuard)
|
||||
@@ -25,28 +28,63 @@ export class TasksController {
|
||||
|
||||
@Get()
|
||||
async list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Query('projectId') projectId?: string,
|
||||
@Query('missionId') missionId?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
if (projectId) return this.brain.tasks.findByProject(projectId);
|
||||
if (missionId) return this.brain.tasks.findByMission(missionId);
|
||||
if (status)
|
||||
return this.brain.tasks.findByStatus(
|
||||
if (projectId) {
|
||||
await this.getOwnedProject(projectId, user.id, 'Task');
|
||||
return this.brain.tasks.findByProject(projectId);
|
||||
}
|
||||
if (missionId) {
|
||||
await this.getOwnedMission(missionId, user.id, 'Task');
|
||||
return this.brain.tasks.findByMission(missionId);
|
||||
}
|
||||
|
||||
const [projects, missions, tasks] = await Promise.all([
|
||||
this.brain.projects.findAll(),
|
||||
this.brain.missions.findAll(),
|
||||
status
|
||||
? this.brain.tasks.findByStatus(
|
||||
status as Parameters<typeof this.brain.tasks.findByStatus>[0],
|
||||
)
|
||||
: this.brain.tasks.findAll(),
|
||||
]);
|
||||
|
||||
const ownedProjectIds = new Set(
|
||||
projects.filter((project) => project.ownerId === user.id).map((project) => project.id),
|
||||
);
|
||||
const ownedMissionIds = new Set(
|
||||
missions
|
||||
.filter(
|
||||
(ownedMission) =>
|
||||
typeof ownedMission.projectId === 'string' &&
|
||||
ownedProjectIds.has(ownedMission.projectId),
|
||||
)
|
||||
.map((ownedMission) => ownedMission.id),
|
||||
);
|
||||
|
||||
return tasks.filter(
|
||||
(task) =>
|
||||
(task.projectId ? ownedProjectIds.has(task.projectId) : false) ||
|
||||
(task.missionId ? ownedMissionIds.has(task.missionId) : false),
|
||||
);
|
||||
return this.brain.tasks.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
const task = await this.brain.tasks.findById(id);
|
||||
if (!task) throw new NotFoundException('Task not found');
|
||||
return task;
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
return this.getOwnedTask(id, user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateTaskDto) {
|
||||
async create(@Body() dto: CreateTaskDto, @CurrentUser() user: { id: string }) {
|
||||
if (dto.projectId) {
|
||||
await this.getOwnedProject(dto.projectId, user.id, 'Task');
|
||||
}
|
||||
if (dto.missionId) {
|
||||
await this.getOwnedMission(dto.missionId, user.id, 'Task');
|
||||
}
|
||||
return this.brain.tasks.create({
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
@@ -61,7 +99,18 @@ export class TasksController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateTaskDto) {
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTaskDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
await this.getOwnedTask(id, user.id);
|
||||
if (dto.projectId) {
|
||||
await this.getOwnedProject(dto.projectId, user.id, 'Task');
|
||||
}
|
||||
if (dto.missionId) {
|
||||
await this.getOwnedMission(dto.missionId, user.id, 'Task');
|
||||
}
|
||||
const task = await this.brain.tasks.update(id, {
|
||||
...dto,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
|
||||
@@ -72,8 +121,46 @@ export class TasksController {
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string) {
|
||||
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
await this.getOwnedTask(id, user.id);
|
||||
const deleted = await this.brain.tasks.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Task not found');
|
||||
}
|
||||
|
||||
private async getOwnedTask(id: string, userId: string) {
|
||||
const task = await this.brain.tasks.findById(id);
|
||||
if (!task) throw new NotFoundException('Task not found');
|
||||
|
||||
if (task.projectId) {
|
||||
await this.getOwnedProject(task.projectId, userId, 'Task');
|
||||
return task;
|
||||
}
|
||||
|
||||
if (task.missionId) {
|
||||
await this.getOwnedMission(task.missionId, userId, 'Task');
|
||||
return task;
|
||||
}
|
||||
|
||||
throw new ForbiddenException('Task does not belong to the current user');
|
||||
}
|
||||
|
||||
private async getOwnedMission(missionId: string, userId: string, resourceName: string) {
|
||||
const mission = await this.brain.missions.findById(missionId);
|
||||
if (!mission?.projectId) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
}
|
||||
|
||||
await this.getOwnedProject(mission.projectId, userId, resourceName);
|
||||
return mission;
|
||||
}
|
||||
|
||||
private async getOwnedProject(projectId: string, userId: string, resourceName: string) {
|
||||
const project = await this.brain.projects.findById(projectId);
|
||||
if (!project) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
}
|
||||
|
||||
assertOwner(project.ownerId, userId, resourceName);
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,103 @@
|
||||
export interface CreateTaskDto {
|
||||
title: string;
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsISO8601,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
const taskStatuses = ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'] as const;
|
||||
const taskPriorities = ['critical', 'high', 'medium', 'low'] as const;
|
||||
|
||||
export class CreateTaskDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(taskPriorities)
|
||||
priority?: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
missionId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
assignee?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTaskDto {
|
||||
export class UpdateTaskDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(taskPriorities)
|
||||
priority?: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
missionId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
assignee?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
tags?: string[] | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
dueDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
18
apps/gateway/tsconfig.typecheck.json
Normal file
18
apps/gateway/tsconfig.typecheck.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../..",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@mosaic/auth": ["../../packages/auth/src/index.ts"],
|
||||
"@mosaic/brain": ["../../packages/brain/src/index.ts"],
|
||||
"@mosaic/coord": ["../../packages/coord/src/index.ts"],
|
||||
"@mosaic/db": ["../../packages/db/src/index.ts"],
|
||||
"@mosaic/log": ["../../packages/log/src/index.ts"],
|
||||
"@mosaic/memory": ["../../packages/memory/src/index.ts"],
|
||||
"@mosaic/types": ["../../packages/types/src/index.ts"],
|
||||
"@mosaic/discord-plugin": ["../../plugins/discord/src/index.ts"],
|
||||
"@mosaic/telegram-plugin": ["../../plugins/telegram/src/index.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@
|
||||
**ID:** mvp-20260312
|
||||
**Statement:** Build Mosaic Stack v0.1.0 — a self-hosted, multi-user AI agent platform with web dashboard, TUI, remote control, shared memory, mission orchestration, and extensible skill/plugin architecture. All TypeScript. Pi as agent harness. Brain as knowledge layer. Queue as coordination backbone.
|
||||
**Phase:** Execution
|
||||
**Current Milestone:** Phase 5: Remote Control (v0.0.6)
|
||||
**Progress:** 5 / 8 milestones
|
||||
**Current Milestone:** Phase 7: Polish & Beta (v0.1.0)
|
||||
**Progress:** 7 / 8 milestones
|
||||
**Status:** active
|
||||
**Last Updated:** 2026-03-13 UTC
|
||||
**Last Updated:** 2026-03-14 UTC
|
||||
|
||||
## Success Criteria
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
| 2 | ms-159 | Phase 2: Agent Layer (v0.0.3) | done | — | — | 2026-03-13 | 2026-03-12 |
|
||||
| 3 | ms-160 | Phase 3: Web Dashboard (v0.0.4) | done | — | — | 2026-03-12 | 2026-03-13 |
|
||||
| 4 | ms-161 | Phase 4: Memory & Intelligence (v0.0.5) | done | — | — | 2026-03-13 | 2026-03-13 |
|
||||
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | not-started | — | — | — | — |
|
||||
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | not-started | — | — | — | — |
|
||||
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | done | — | #99 | 2026-03-14 | 2026-03-14 |
|
||||
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | done | — | #104 | 2026-03-14 | 2026-03-14 |
|
||||
| 7 | ms-164 | Phase 7: Polish & Beta (v0.1.0) | not-started | — | — | — | — |
|
||||
|
||||
## Deployment
|
||||
@@ -68,7 +68,8 @@
|
||||
| 7 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-007 |
|
||||
| 8 | claude-opus-4-6 | 2026-03-12 | — | context limit | Phase 2 complete |
|
||||
| 9 | claude-opus-4-6 | 2026-03-12 | — | context limit | P3-007 |
|
||||
| 10 | claude-opus-4-6 | 2026-03-13 | — | active | P3-008 |
|
||||
| 10 | claude-opus-4-6 | 2026-03-13 | — | context limit | P3-008 |
|
||||
| 11 | claude-opus-4-6 | 2026-03-14 | — | active | P5-005 |
|
||||
|
||||
## Scratchpad
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
|
||||
| id | status | milestone | description | pr | notes |
|
||||
| ------ | ----------- | --------- | ------------------------------------------------------------- | --- | ----- |
|
||||
| ------ | ----------- | --------- | ------------------------------------------------------------- | ---- | ----- |
|
||||
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
||||
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
|
||||
| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
|
||||
@@ -44,17 +44,17 @@
|
||||
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
|
||||
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
|
||||
| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 |
|
||||
| P5-001 | not-started | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 |
|
||||
| P5-001 | done | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 |
|
||||
| P5-002 | done | Phase 5 | @mosaic/discord-plugin — Discord bot + channel plugin | #61 | #42 |
|
||||
| P5-003 | not-started | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
|
||||
| P5-004 | not-started | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
|
||||
| P5-005 | not-started | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | — | #45 |
|
||||
| P6-001 | not-started | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | — | #46 |
|
||||
| P6-002 | not-started | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | — | #47 |
|
||||
| P6-003 | not-started | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | — | #48 |
|
||||
| P6-004 | not-started | Phase 6 | @mosaic/mosaic — install wizard for v1 | — | #49 |
|
||||
| P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
|
||||
| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
|
||||
| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 |
|
||||
| P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 |
|
||||
| P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 |
|
||||
| P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 |
|
||||
| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 |
|
||||
| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
|
||||
| P6-006 | not-started | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
||||
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
||||
| P7-001 | not-started | Phase 7 | MCP endpoint hardening — streamable HTTP transport | — | #52 |
|
||||
| P7-002 | not-started | Phase 7 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
|
||||
| P7-003 | not-started | Phase 7 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
||||
|
||||
98
docs/plans/2026-03-13-gateway-security-hardening.md
Normal file
98
docs/plans/2026-03-13-gateway-security-hardening.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Gateway Security Hardening Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Finish the requested gateway security hardening fixes in the existing `fix/gateway-security` worktree and produce a PR-ready branch.
|
||||
|
||||
**Architecture:** Tighten NestJS gateway boundaries in-place by enforcing auth guards, session validation, ownership checks, DTO validation, and Fastify security defaults. Preserve the current module structure and existing ESM import conventions.
|
||||
|
||||
**Tech Stack:** NestJS 11, Fastify, Socket.IO, Better Auth, class-validator, Vitest, pnpm, TypeScript ESM
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Reconcile Security Tests
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/gateway/src/chat/__tests__/chat-security.test.ts`
|
||||
- Modify: `apps/gateway/src/__tests__/resource-ownership.test.ts`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
- Encode the requested DTO constraints and socket-auth contract exactly.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pnpm --filter @mosaic/gateway test -- src/chat/__tests__/chat-security.test.ts src/__tests__/resource-ownership.test.ts`
|
||||
|
||||
Expected: FAIL on current DTO/helper mismatch.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Update DTO/helper/controller code only where tests prove a gap.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run the same command and require green.
|
||||
|
||||
### Task 2: Align Gateway Runtime Hardening
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/gateway/src/conversations/conversations.dto.ts`
|
||||
- Modify: `apps/gateway/src/chat/chat.dto.ts`
|
||||
- Modify: `apps/gateway/src/chat/chat.gateway-auth.ts`
|
||||
- Modify: `apps/gateway/src/chat/chat.gateway.ts`
|
||||
- Modify: `apps/gateway/src/main.ts`
|
||||
- Modify: `apps/gateway/src/app.module.ts`
|
||||
|
||||
**Step 1: Verify remaining requested deltas**
|
||||
|
||||
- Confirm code matches requested guard, rate limit, helmet, body limit, env validation, and CORS settings.
|
||||
|
||||
**Step 2: Apply minimal patch**
|
||||
|
||||
- Keep changes scoped to requested behavior only.
|
||||
|
||||
**Step 3: Run targeted tests**
|
||||
|
||||
Run: `pnpm --filter @mosaic/gateway test -- src/chat/__tests__/chat-security.test.ts src/__tests__/resource-ownership.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Verification, Review, and Delivery
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `docs/reports/code-review/gateway-security-20260313.md`
|
||||
- Create: `docs/reports/qa/gateway-security-20260313.md`
|
||||
- Modify: `docs/scratchpads/gateway-security-20260313.md`
|
||||
|
||||
**Step 1: Run baseline gates**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
**Step 2: Perform manual code review**
|
||||
|
||||
- Record correctness/security/testing/doc findings.
|
||||
|
||||
**Step 3: Commit and publish**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting"
|
||||
git push origin fix/gateway-security
|
||||
```
|
||||
|
||||
**Step 4: Open PR and notify**
|
||||
|
||||
- Open PR titled `fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting`
|
||||
- Run `openclaw system event --text "PR ready: mosaic-mono-v1 fix/gateway-security — 7 security fixes" --mode now`
|
||||
- Remove worktree after PR is created.
|
||||
40
docs/plans/authentik-sso-setup.md
Normal file
40
docs/plans/authentik-sso-setup.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Authentik SSO Setup
|
||||
|
||||
## Create the Authentik application
|
||||
|
||||
1. In Authentik, create an OAuth2/OpenID Provider.
|
||||
2. Create an Application and link it to that provider.
|
||||
3. Copy the generated client ID and client secret.
|
||||
|
||||
## Required environment variables
|
||||
|
||||
Set these values for the gateway/auth runtime:
|
||||
|
||||
```bash
|
||||
AUTHENTIK_CLIENT_ID=your-client-id
|
||||
AUTHENTIK_CLIENT_SECRET=your-client-secret
|
||||
AUTHENTIK_ISSUER=https://authentik.example.com
|
||||
```
|
||||
|
||||
`AUTHENTIK_ISSUER` should be the Authentik base URL, for example `https://authentik.example.com`.
|
||||
|
||||
## Redirect URI
|
||||
|
||||
Configure this redirect URI in the Authentik provider/application:
|
||||
|
||||
```text
|
||||
{BETTER_AUTH_URL}/api/auth/callback/authentik
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
https://mosaic.example.com/api/auth/callback/authentik
|
||||
```
|
||||
|
||||
## Test the flow
|
||||
|
||||
1. Start the gateway with `BETTER_AUTH_URL` and the Authentik environment variables set.
|
||||
2. Open the Mosaic login flow and choose the Authentik provider.
|
||||
3. Complete the Authentik login.
|
||||
4. Confirm the browser returns to Mosaic and a session is created successfully.
|
||||
23
docs/reports/code-review/gateway-security-20260313.md
Normal file
23
docs/reports/code-review/gateway-security-20260313.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Code Review Report — Gateway Security Hardening
|
||||
|
||||
## Scope Reviewed
|
||||
|
||||
- `apps/gateway/src/chat/chat.gateway-auth.ts`
|
||||
- `apps/gateway/src/chat/chat.gateway.ts`
|
||||
- `apps/gateway/src/conversations/conversations.dto.ts`
|
||||
- `apps/gateway/src/chat/__tests__/chat-security.test.ts`
|
||||
|
||||
## Findings
|
||||
|
||||
- No blocker findings in the final changed surface.
|
||||
|
||||
## Review Summary
|
||||
|
||||
- Correctness: socket auth helper now returns Better Auth session data unchanged, and gateway disconnects clients whose handshake does not narrow to a valid session payload
|
||||
- Security: conversation role validation now rejects `system`; conversation content ceiling is 32k; chat request ceiling remains 10k
|
||||
- Testing: targeted auth, ownership, and DTO regression tests pass
|
||||
- Quality: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` all pass after the final edits
|
||||
|
||||
## Residual Risk
|
||||
|
||||
- `chat.gateway.ts` uses local narrowing around an `unknown` session result because the requested helper contract intentionally returns `unknown`.
|
||||
39
docs/reports/qa/gateway-security-20260313.md
Normal file
39
docs/reports/qa/gateway-security-20260313.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# QA Report — Gateway Security Hardening
|
||||
|
||||
## Scope
|
||||
|
||||
- Chat HTTP auth guard hardening
|
||||
- Chat WebSocket session validation
|
||||
- DTO validation rules for chat and conversation payloads
|
||||
- Ownership regression coverage for by-id routes
|
||||
|
||||
## TDD
|
||||
|
||||
- Required: yes
|
||||
- Applied: yes
|
||||
- Red step: targeted tests failed on socket session reshaping and DTO role/length mismatches
|
||||
- Green step: targeted tests passed after runtime and DTO alignment
|
||||
|
||||
## Baseline Verification
|
||||
|
||||
| Command | Result | Evidence |
|
||||
| --- | --- | --- |
|
||||
| `pnpm --filter @mosaic/gateway test -- src/chat/__tests__/chat-security.test.ts src/__tests__/resource-ownership.test.ts` | pass | 3 test files passed, 20 tests passed |
|
||||
| `pnpm typecheck` | pass | turbo completed 18/18 package typecheck tasks |
|
||||
| `pnpm lint` | pass | turbo completed 18/18 package lint tasks |
|
||||
| `pnpm format:check` | pass | `All matched files use Prettier code style!` |
|
||||
|
||||
## Situational Verification
|
||||
|
||||
| Acceptance Criterion | Verification Method | Evidence |
|
||||
| --- | --- | --- |
|
||||
| Chat controller requires auth and current-user context | source assertion test | `chat-security.test.ts` checks `@UseGuards(AuthGuard)` and `@CurrentUser() user: { id: string }` |
|
||||
| WebSocket handshake requires Better Auth session | unit tests for `validateSocketSession()` | null handshake returns `null`; valid handshake returns original session object |
|
||||
| Conversation messages reject non-user/assistant roles | class-validator test | `system` role fails validation |
|
||||
| Conversation messages enforce a 32k max length | class-validator test | `32_001` chars fail validation |
|
||||
| Chat request payload enforces a 10k max length | class-validator test | `10_001` chars fail validation |
|
||||
| By-id routes reject cross-user access | ownership regression tests | conversations, projects, missions, tasks each raise `ForbiddenException` for non-owner access |
|
||||
|
||||
## Residual Risk
|
||||
|
||||
- No live HTTP or WebSocket smoke test against a running gateway process was executed in this session.
|
||||
30
docs/scratchpads/41-plugin-host.md
Normal file
30
docs/scratchpads/41-plugin-host.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Scratchpad — P5-001 Plugin Host
|
||||
|
||||
- Task: P5-001 / issue #41
|
||||
- Branch: feat/p5-plugin-host
|
||||
- Objective: add global NestJS plugin host module, wire Discord import, register active plugins from env, and attach to AppModule.
|
||||
- TDD: skipped as optional for module wiring/integration work; relying on targeted typecheck/lint and module-level situational verification.
|
||||
- Constraints: ESM .js imports, explicit @Inject(), follow existing gateway patterns, do not touch TASKS.md.
|
||||
|
||||
## Progress Log
|
||||
|
||||
- 2026-03-13: session started in worktree; loading gateway/plugin package context.
|
||||
- 2026-03-13: implemented initial plugin module, service, interface, and AppModule wiring; pending verification.
|
||||
- 2026-03-13: added `@mosaic/discord-plugin` as a gateway workspace dependency and regenerated `pnpm-lock.yaml`.
|
||||
- 2026-03-13: built gateway dependency chain so workspace packages exported `dist/*` for clean TypeScript resolution in this fresh worktree.
|
||||
- 2026-03-13: verification complete.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm --filter @mosaic/gateway... build` ✅
|
||||
- `pnpm --filter @mosaic/gateway typecheck` ✅
|
||||
- `pnpm --filter @mosaic/gateway lint` ✅
|
||||
- `pnpm format:check` ✅
|
||||
- `pnpm typecheck` ✅
|
||||
- `pnpm lint` ✅
|
||||
|
||||
## Review
|
||||
|
||||
- Automated review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
|
||||
- Manual review: diff inspection of gateway plugin host changes
|
||||
- Result: no blocker findings
|
||||
68
docs/scratchpads/gateway-security-20260313.md
Normal file
68
docs/scratchpads/gateway-security-20260313.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Gateway Security Hardening Scratchpad
|
||||
|
||||
## Metadata
|
||||
|
||||
- Date: 2026-03-13
|
||||
- Worktree: `/home/jwoltje/src/mosaic-mono-v1-worktrees/sec-remediation`
|
||||
- Branch: `fix/gateway-security`
|
||||
- Scope: Finish 7 requested gateway security fixes without switching branches or worktrees
|
||||
- Related tracker: worker task only; `docs/TASKS.md` is orchestrator-owned and left unchanged
|
||||
- Budget assumption: no explicit token cap; keep scope limited to requested gateway/auth/validation hardening
|
||||
|
||||
## Objective
|
||||
|
||||
Complete the remaining gateway security hardening work:
|
||||
|
||||
1. Chat HTTP auth guard enforcement
|
||||
2. Chat WebSocket session validation
|
||||
3. Ownership checks on by-id CRUD routes
|
||||
4. Global validation pipe and DTO enforcement
|
||||
5. Rate limiting
|
||||
6. Helmet security headers
|
||||
7. Body limit and env validation
|
||||
|
||||
## Plan
|
||||
|
||||
1. Reconcile current worktree state against requested fixes.
|
||||
2. Patch or extend tests first for DTO/auth behavior mismatches.
|
||||
3. Implement minimal code changes to satisfy tests and requested behavior.
|
||||
4. Run targeted gateway tests.
|
||||
5. Run baseline gates: `pnpm typecheck`, `pnpm lint`.
|
||||
6. Perform manual code review and record findings.
|
||||
7. Commit, push branch, open PR, send OpenClaw event, remove worktree.
|
||||
|
||||
## Progress Log
|
||||
|
||||
### 2026-03-13T00:00 local
|
||||
|
||||
- Loaded required Mosaic/global/runtime instructions and applicable skills.
|
||||
- Confirmed active worktree is `sec-remediation` and branch is already dirty with prior session changes.
|
||||
- Identified remaining gaps: DTO validation mismatch and non-requested socket auth helper typing/behavior drift.
|
||||
|
||||
## TDD Notes
|
||||
|
||||
- Required: yes. This is security/auth/permission logic.
|
||||
- Approach: update targeted unit tests first, verify failure, then patch code minimally.
|
||||
|
||||
## Verification Log
|
||||
|
||||
- `pnpm --filter @mosaic/gateway test -- src/chat/__tests__/chat-security.test.ts src/__tests__/resource-ownership.test.ts`
|
||||
- Red: failed on socket session reshaping and DTO role/length mismatches.
|
||||
- Green: passed with 3 test files and 20 tests passing.
|
||||
- `pnpm typecheck`
|
||||
- Pass on 2026-03-13 with 18/18 package typecheck tasks successful.
|
||||
- `pnpm lint`
|
||||
- Pass on 2026-03-13 with 18/18 package lint tasks successful.
|
||||
- `pnpm format:check`
|
||||
- Pass on 2026-03-13 with `All matched files use Prettier code style!`
|
||||
|
||||
## Review Log
|
||||
|
||||
- Manual review completed against auth, authorization, validation, and runtime hardening requirements.
|
||||
- No blocker findings remained after remediation.
|
||||
|
||||
## Risks / Blockers
|
||||
|
||||
- Repository instructions conflict on PR merge behavior; user explicitly instructed PR-only, no merge. Follow user instruction.
|
||||
- Existing worktree contains prior-session modifications; do not revert unrelated changes.
|
||||
- `missions` and `tasks` currently depend on project ownership because the schema does not carry a direct user owner column.
|
||||
@@ -73,6 +73,34 @@ User confirmed: start the planning gate.
|
||||
| ------- | ---------- | --------- | ---------- | ----------------------------------------------------------------------------------- |
|
||||
| 7-8 | 2026-03-12 | Phase 2 | P2-007 | 19 unit tests (routing + coord). PR #79 merged, issue #25 closed. Phase 2 complete. |
|
||||
|
||||
### Session 11 — Phase 5 completion
|
||||
|
||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||
| ------- | ---------- | --------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 11 | 2026-03-14 | Phase 5 | P5-005 | Wired Telegram plugin into gateway (was stubbed). Updated .env.example with all P5 env vars. PR #99 merged, issue #45 closed. Phase 5 complete. |
|
||||
|
||||
**Findings during verification:**
|
||||
|
||||
- Telegram plugin was built but not wired into gateway (stub warning in plugin.module.ts)
|
||||
- Discord plugin was fully wired
|
||||
- SSO/Authentik OIDC adapter was fully wired
|
||||
- All three quality gates passing
|
||||
|
||||
### Session 11 (continued) — Phase 6 completion
|
||||
|
||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||
| ------- | ---------- | --------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| 11 | 2026-03-14 | Phase 6 | P6-002, P6-003, P6-004, P6-001, P6-006 | Full CLI & Tools migration. PRs #100-#104 merged. Also fixed 2 gateway startup bugs (PR #102). Phase 6 complete. |
|
||||
|
||||
**Phase 6 details:**
|
||||
|
||||
- P6-002: @mosaic/prdy migrated from v0 (~400 LOC). PR #101.
|
||||
- P6-003: @mosaic/quality-rails migrated from v0 (~500 LOC). PR #100.
|
||||
- P6-004: @mosaic/mosaic wizard migrated from v0 (2272 LOC, 28 files). PR #103.
|
||||
- P6-001: CLI subcommands wired — tui, prdy, quality-rails, wizard all working. PR #104.
|
||||
- BUG-1: PLUGIN_REGISTRY circular import fixed via plugin.tokens.ts. PR #102.
|
||||
- BUG-2: AuthStorage.create() → .inMemory() to prevent silent exit. PR #102.
|
||||
|
||||
## Open Questions
|
||||
|
||||
(none at this time)
|
||||
|
||||
50
docs/scratchpads/p5-003-telegram-plugin.md
Normal file
50
docs/scratchpads/p5-003-telegram-plugin.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Scratchpad — P5-003 Telegram Plugin
|
||||
|
||||
## Objective
|
||||
|
||||
Implement `@mosaic/telegram-plugin` by matching the established Discord plugin pattern with Telegraf + socket.io-client, add package docs, and pass package typecheck/lint.
|
||||
|
||||
## Requirements Source
|
||||
|
||||
- docs/PRD.md: Phase 5 remote control / Telegram plugin
|
||||
- docs/TASKS.md: P5-003
|
||||
- User task brief dated 2026-03-13
|
||||
|
||||
## Plan
|
||||
|
||||
1. Inspect Discord plugin behavior and package conventions
|
||||
2. Add Telegram runtime dependencies if missing
|
||||
3. Implement Telegram plugin with matching gateway event flow
|
||||
4. Add README usage documentation
|
||||
5. Run package typecheck and lint
|
||||
6. Run code review and remediate findings
|
||||
7. Commit, push, open PR, notify, remove worktree
|
||||
|
||||
## TDD Rationale
|
||||
|
||||
ASSUMPTION: No existing telegram package test harness or fixture coverage makes package-level TDD
|
||||
disproportionate for this plugin scaffold task. Validation will rely on typecheck, lint, and
|
||||
manual structural parity with the Discord plugin.
|
||||
|
||||
## Risks
|
||||
|
||||
- Telegram API typings may differ from Discord’s event shapes and require narrower guards.
|
||||
- Socket event payloads may already include `role` in shared gateway expectations.
|
||||
|
||||
## Progress Log
|
||||
|
||||
- 2026-03-13: Loaded Mosaic/global/repo guidance, mission files, Discord reference implementation, and Telegram package scaffold.
|
||||
- 2026-03-13: Added `telegraf` and `socket.io-client` to `@mosaic/telegram-plugin`.
|
||||
- 2026-03-13: Implemented Telegram message forwarding, gateway streaming accumulation, response chunking, and package README.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- `pnpm --filter @mosaic/telegram-plugin typecheck` → pass
|
||||
- `pnpm --filter @mosaic/telegram-plugin lint` → pass
|
||||
- `pnpm typecheck` → pass
|
||||
- `pnpm lint` → pass
|
||||
|
||||
## Review
|
||||
|
||||
- Automated uncommitted review wrapper was invoked for the current delta.
|
||||
- Manual review completed against Discord parity, gateway event contracts, and package docs; no additional blockers found.
|
||||
44
docs/scratchpads/p5-004-authentik-sso.md
Normal file
44
docs/scratchpads/p5-004-authentik-sso.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# P5-004 Scratchpad
|
||||
|
||||
- Objective: Add optional Authentik OIDC SSO adapter via Better Auth genericOAuth.
|
||||
- Task ref: P5-004
|
||||
- Issue ref: #96
|
||||
- Plan:
|
||||
1. Inspect auth/gateway surfaces and Better Auth plugin shape.
|
||||
2. Add failing coverage for auth config/startup validation where feasible.
|
||||
3. Implement adapter, docs, and warnings.
|
||||
4. Run targeted typechecks, lint, and review.
|
||||
|
||||
- TDD note: no low-friction auth plugin or bootstrap-env test seam exists for `packages/auth/src/auth.ts` or `apps/gateway/src/main.ts`. This change is configuration-oriented and does not alter an existing behavioral contract with a current test harness. I skipped new tests for this pass and relied on exact typecheck/lint/test commands plus manual review.
|
||||
|
||||
- Changes:
|
||||
1. Added conditional Better Auth `genericOAuth` plugin registration for the `authentik` provider in `packages/auth/src/auth.ts`.
|
||||
2. Added a soft startup warning in `apps/gateway/src/main.ts` for incomplete Authentik env configuration.
|
||||
3. Added `docs/plans/authentik-sso-setup.md` with env, redirect URI, and test-flow guidance.
|
||||
4. Confirmed `packages/auth/src/index.ts` already exports `AuthConfig`; no change required there.
|
||||
|
||||
- Verification:
|
||||
1. `pnpm --filter @mosaic/db build`
|
||||
2. `pnpm --filter @mosaic/auth typecheck`
|
||||
3. `pnpm --filter @mosaic/gateway typecheck`
|
||||
4. `pnpm lint`
|
||||
5. `pnpm format:check`
|
||||
6. `pnpm --filter @mosaic/auth test`
|
||||
7. `pnpm --filter @mosaic/gateway test`
|
||||
|
||||
- Results:
|
||||
1. `@mosaic/auth` typecheck passed after replacing the non-existent `enabled` field with conditional plugin registration.
|
||||
2. `@mosaic/gateway` typecheck passed.
|
||||
3. Repo lint passed.
|
||||
4. Prettier check passed after formatting `apps/gateway/src/main.ts`.
|
||||
5. `@mosaic/auth` tests reported `No test files found, exiting with code 0`.
|
||||
6. `@mosaic/gateway` tests passed: `3` files, `20` tests.
|
||||
|
||||
- Review:
|
||||
1. Manual review of the diff found no blocker issues.
|
||||
2. External `codex-code-review.sh --uncommitted` was attempted but did not return a usable verdict in-session; no automated review findings were available from that run.
|
||||
|
||||
- Situational evidence:
|
||||
1. Provider activation is env-gated by `AUTHENTIK_CLIENT_ID`.
|
||||
2. Misconfigured optional SSO surfaces a warning instead of crashing gateway startup.
|
||||
3. Setup doc records the expected redirect path: `{BETTER_AUTH_URL}/api/auth/callback/authentik`.
|
||||
58
docs/scratchpads/task-mission-ownership-20260313.md
Normal file
58
docs/scratchpads/task-mission-ownership-20260313.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Task Ownership Gap Fix Scratchpad
|
||||
|
||||
## Metadata
|
||||
|
||||
- Date: 2026-03-13
|
||||
- Worktree: `/home/jwoltje/src/mosaic-mono-v1-worktrees/fix-task-ownership`
|
||||
- Branch: `fix/task-mission-ownership`
|
||||
- Scope: Fix ownership checks in TasksController/MissionsController and extend gateway ownership tests
|
||||
- Related tracker: worker task only; `docs/TASKS.md` is orchestrator-owned and left unchanged
|
||||
- Budget assumption: no explicit token cap; keep scope limited to requested gateway permission fixes
|
||||
|
||||
## Objective
|
||||
|
||||
Close ownership gaps so task listing/creation and mission creation enforce project/mission ownership and reject cross-user access.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. TasksController `list()` enforces ownership for `projectId` and `missionId`, and does not return cross-user data when neither filter is provided.
|
||||
2. TasksController `create()` rejects unowned `projectId` and `missionId` references.
|
||||
3. MissionsController `create()` rejects unowned `projectId` references.
|
||||
4. Gateway ownership tests cover forbidden task creation and forbidden task listing by unowned project.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Inspect current controller and ownership test patterns.
|
||||
2. Add failing permission tests first.
|
||||
3. Patch controller methods with existing ownership helpers.
|
||||
4. Run targeted gateway tests, then gateway typecheck/lint/full test.
|
||||
5. Perform independent review, record evidence, then complete the requested git/PR workflow.
|
||||
|
||||
## TDD Notes
|
||||
|
||||
- Required: yes. This is auth/permission logic and a bugfix.
|
||||
- Strategy: add failing tests in `resource-ownership.test.ts`, verify red, then implement minimal controller changes.
|
||||
|
||||
## Verification Log
|
||||
|
||||
- `pnpm --filter @mosaic/gateway test -- src/__tests__/resource-ownership.test.ts`
|
||||
- Red: failed with 2 expected permission-path failures before controller changes.
|
||||
- Green: passed after wiring ownership checks and adding owned-task filtering coverage.
|
||||
- `pnpm --filter @mosaic/gateway typecheck`
|
||||
- Pass on 2026-03-13 after fixing parameter ordering and mission project nullability.
|
||||
- `pnpm --filter @mosaic/gateway lint`
|
||||
- Pass on 2026-03-13.
|
||||
- `pnpm --filter @mosaic/gateway test`
|
||||
- Pass on 2026-03-13 with 3 test files and 23 tests passing.
|
||||
- `pnpm format:check`
|
||||
- Pass on 2026-03-13.
|
||||
|
||||
## Review Log
|
||||
|
||||
- Manual review: checked for auth regressions, cross-user list leakage, and dashboard behavior impact; kept unfiltered task list functional by filtering to owned projects/missions instead of returning an empty list.
|
||||
- Automated review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` running/re-run for independent review evidence.
|
||||
|
||||
## Risks / Blockers
|
||||
|
||||
- Repository-wide Mosaic instructions require merge/issue closure, but the user explicitly instructed PR-only and no merge; follow the user instruction.
|
||||
- `docs/TASKS.md` is orchestrator-owned and will not be edited from this worker task.
|
||||
@@ -1,5 +1,6 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { genericOAuth } from 'better-auth/plugins';
|
||||
import type { Db } from '@mosaic/db';
|
||||
|
||||
export interface AuthConfig {
|
||||
@@ -10,6 +11,33 @@ export interface AuthConfig {
|
||||
|
||||
export function createAuth(config: AuthConfig) {
|
||||
const { db, baseURL, secret } = config;
|
||||
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
|
||||
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
||||
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||
const plugins = authentikClientId
|
||||
? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: 'authentik',
|
||||
clientId: authentikClientId,
|
||||
clientSecret: authentikClientSecret ?? '',
|
||||
discoveryUrl: authentikIssuer
|
||||
? `${authentikIssuer}/.well-known/openid-configuration`
|
||||
: undefined,
|
||||
authorizationUrl: authentikIssuer
|
||||
? `${authentikIssuer}/application/o/authorize/`
|
||||
: undefined,
|
||||
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
|
||||
userInfoUrl: authentikIssuer
|
||||
? `${authentikIssuer}/application/o/userinfo/`
|
||||
: undefined,
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: undefined;
|
||||
|
||||
return betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -36,6 +64,7 @@ export function createAuth(config: AuthConfig) {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // refresh daily
|
||||
},
|
||||
plugins,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaic/mosaic": "workspace:^",
|
||||
"@mosaic/prdy": "workspace:^",
|
||||
"@mosaic/quality-rails": "workspace:^",
|
||||
"ink": "^5.0.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
@@ -29,6 +32,7 @@
|
||||
"commander": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { buildPrdyCli } from '@mosaic/prdy';
|
||||
import { createQualityRailsCli } from '@mosaic/quality-rails';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
@@ -25,4 +27,85 @@ program
|
||||
);
|
||||
});
|
||||
|
||||
// prdy subcommand
|
||||
// buildPrdyCli() returns a wrapper Command; extract the 'prdy' subcommand from it.
|
||||
// Type cast is required because @mosaic/prdy uses commander@12 while @mosaic/cli uses commander@13.
|
||||
const prdyWrapper = buildPrdyCli();
|
||||
const prdyCmd = prdyWrapper.commands.find((c) => c.name() === 'prdy');
|
||||
if (prdyCmd !== undefined) {
|
||||
program.addCommand(prdyCmd as unknown as Command);
|
||||
}
|
||||
|
||||
// quality-rails subcommand
|
||||
// createQualityRailsCli() returns a wrapper Command; extract the 'quality-rails' subcommand.
|
||||
const qrWrapper = createQualityRailsCli();
|
||||
const qrCmd = qrWrapper.commands.find((c) => c.name() === 'quality-rails');
|
||||
if (qrCmd !== undefined) {
|
||||
program.addCommand(qrCmd as unknown as Command);
|
||||
}
|
||||
|
||||
// wizard subcommand — wraps @mosaic/mosaic installation wizard
|
||||
program
|
||||
.command('wizard')
|
||||
.description('Run the Mosaic installation wizard')
|
||||
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
|
||||
.option('--source-dir <path>', 'Source directory for framework files')
|
||||
.option('--mosaic-home <path>', 'Target config directory')
|
||||
.option('--name <name>', 'Agent name')
|
||||
.option('--role <description>', 'Agent role description')
|
||||
.option('--style <style>', 'Communication style: direct|friendly|formal')
|
||||
.option('--accessibility <prefs>', 'Accessibility preferences')
|
||||
.option('--guardrails <rules>', 'Custom guardrails')
|
||||
.option('--user-name <name>', 'Your name')
|
||||
.option('--pronouns <pronouns>', 'Your pronouns')
|
||||
.option('--timezone <tz>', 'Your timezone')
|
||||
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
||||
// Dynamic import to avoid loading wizard deps for other commands
|
||||
const {
|
||||
runWizard,
|
||||
ClackPrompter,
|
||||
HeadlessPrompter,
|
||||
createConfigService,
|
||||
WizardCancelledError,
|
||||
DEFAULT_MOSAIC_HOME,
|
||||
} = await import('@mosaic/mosaic');
|
||||
|
||||
try {
|
||||
const mosaicHome = (opts['mosaicHome'] as string | undefined) ?? DEFAULT_MOSAIC_HOME;
|
||||
const sourceDir = (opts['sourceDir'] as string | undefined) ?? mosaicHome;
|
||||
|
||||
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
|
||||
|
||||
const configService = createConfigService(mosaicHome, sourceDir);
|
||||
|
||||
await runWizard({
|
||||
mosaicHome,
|
||||
sourceDir,
|
||||
prompter,
|
||||
configService,
|
||||
cliOverrides: {
|
||||
soul: {
|
||||
agentName: opts['name'] as string | undefined,
|
||||
roleDescription: opts['role'] as string | undefined,
|
||||
communicationStyle: opts['style'] as 'direct' | 'friendly' | 'formal' | undefined,
|
||||
accessibility: opts['accessibility'] as string | undefined,
|
||||
customGuardrails: opts['guardrails'] as string | undefined,
|
||||
},
|
||||
user: {
|
||||
userName: opts['userName'] as string | undefined,
|
||||
pronouns: opts['pronouns'] as string | undefined,
|
||||
timezone: opts['timezone'] as string | undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof WizardCancelledError) {
|
||||
console.log('\nWizard cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('Wizard failed:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"name": "@mosaic/mosaic",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"description": "Mosaic installation wizard",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"mosaic-wizard": "dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -15,7 +20,15 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.9.1",
|
||||
"commander": "^12.1.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"yaml": "^2.6.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
|
||||
23
packages/mosaic/src/config/config-service.ts
Normal file
23
packages/mosaic/src/config/config-service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
||||
import { FileConfigAdapter } from './file-adapter.js';
|
||||
|
||||
/**
|
||||
* ConfigService interface — abstracts config read/write operations.
|
||||
* Currently backed by FileConfigAdapter (writes .md files from templates).
|
||||
* Designed for future swap to SqliteConfigAdapter or PostgresConfigAdapter.
|
||||
*/
|
||||
export interface ConfigService {
|
||||
readSoul(): Promise<SoulConfig>;
|
||||
readUser(): Promise<UserConfig>;
|
||||
readTools(): Promise<ToolsConfig>;
|
||||
|
||||
writeSoul(config: SoulConfig): Promise<void>;
|
||||
writeUser(config: UserConfig): Promise<void>;
|
||||
writeTools(config: ToolsConfig): Promise<void>;
|
||||
|
||||
syncFramework(action: InstallAction): Promise<void>;
|
||||
}
|
||||
|
||||
export function createConfigService(mosaicHome: string, sourceDir: string): ConfigService {
|
||||
return new FileConfigAdapter(mosaicHome, sourceDir);
|
||||
}
|
||||
158
packages/mosaic/src/config/file-adapter.ts
Normal file
158
packages/mosaic/src/config/file-adapter.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { ConfigService } from './config-service.js';
|
||||
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
||||
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
|
||||
import { renderTemplate } from '../template/engine.js';
|
||||
import {
|
||||
buildSoulTemplateVars,
|
||||
buildUserTemplateVars,
|
||||
buildToolsTemplateVars,
|
||||
} from '../template/builders.js';
|
||||
import { atomicWrite, backupFile, syncDirectory } from '../platform/file-ops.js';
|
||||
|
||||
/**
|
||||
* Parse a SoulConfig from an existing SOUL.md file.
|
||||
*/
|
||||
function parseSoulFromMarkdown(content: string): SoulConfig {
|
||||
const config: SoulConfig = {};
|
||||
|
||||
const nameMatch = content.match(/You are \*\*(.+?)\*\*/);
|
||||
if (nameMatch?.[1]) config.agentName = nameMatch[1];
|
||||
|
||||
const roleMatch = content.match(/Role identity: (.+)/);
|
||||
if (roleMatch?.[1]) config.roleDescription = roleMatch[1];
|
||||
|
||||
if (content.includes('Be direct, concise')) {
|
||||
config.communicationStyle = 'direct';
|
||||
} else if (content.includes('Be warm and conversational')) {
|
||||
config.communicationStyle = 'friendly';
|
||||
} else if (content.includes('Use professional, structured')) {
|
||||
config.communicationStyle = 'formal';
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a UserConfig from an existing USER.md file.
|
||||
*/
|
||||
function parseUserFromMarkdown(content: string): UserConfig {
|
||||
const config: UserConfig = {};
|
||||
|
||||
const nameMatch = content.match(/\*\*Name:\*\* (.+)/);
|
||||
if (nameMatch?.[1]) config.userName = nameMatch[1];
|
||||
|
||||
const pronounsMatch = content.match(/\*\*Pronouns:\*\* (.+)/);
|
||||
if (pronounsMatch?.[1]) config.pronouns = pronounsMatch[1];
|
||||
|
||||
const tzMatch = content.match(/\*\*Timezone:\*\* (.+)/);
|
||||
if (tzMatch?.[1]) config.timezone = tzMatch[1];
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ToolsConfig from an existing TOOLS.md file.
|
||||
*/
|
||||
function parseToolsFromMarkdown(content: string): ToolsConfig {
|
||||
const config: ToolsConfig = {};
|
||||
|
||||
const credsMatch = content.match(/\*\*Location:\*\* (.+)/);
|
||||
if (credsMatch?.[1]) config.credentialsLocation = credsMatch[1];
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export class FileConfigAdapter implements ConfigService {
|
||||
constructor(
|
||||
private mosaicHome: string,
|
||||
private sourceDir: string,
|
||||
) {}
|
||||
|
||||
async readSoul(): Promise<SoulConfig> {
|
||||
const path = join(this.mosaicHome, 'SOUL.md');
|
||||
if (!existsSync(path)) return {};
|
||||
return parseSoulFromMarkdown(readFileSync(path, 'utf-8'));
|
||||
}
|
||||
|
||||
async readUser(): Promise<UserConfig> {
|
||||
const path = join(this.mosaicHome, 'USER.md');
|
||||
if (!existsSync(path)) return {};
|
||||
return parseUserFromMarkdown(readFileSync(path, 'utf-8'));
|
||||
}
|
||||
|
||||
async readTools(): Promise<ToolsConfig> {
|
||||
const path = join(this.mosaicHome, 'TOOLS.md');
|
||||
if (!existsSync(path)) return {};
|
||||
return parseToolsFromMarkdown(readFileSync(path, 'utf-8'));
|
||||
}
|
||||
|
||||
async writeSoul(config: SoulConfig): Promise<void> {
|
||||
const validated = soulSchema.parse(config);
|
||||
const templatePath = this.findTemplate('SOUL.md.template');
|
||||
if (!templatePath) return;
|
||||
|
||||
const template = readFileSync(templatePath, 'utf-8');
|
||||
const vars = buildSoulTemplateVars(validated);
|
||||
const output = renderTemplate(template, vars);
|
||||
|
||||
const outPath = join(this.mosaicHome, 'SOUL.md');
|
||||
backupFile(outPath);
|
||||
atomicWrite(outPath, output);
|
||||
}
|
||||
|
||||
async writeUser(config: UserConfig): Promise<void> {
|
||||
const validated = userSchema.parse(config);
|
||||
const templatePath = this.findTemplate('USER.md.template');
|
||||
if (!templatePath) return;
|
||||
|
||||
const template = readFileSync(templatePath, 'utf-8');
|
||||
const vars = buildUserTemplateVars(validated);
|
||||
const output = renderTemplate(template, vars);
|
||||
|
||||
const outPath = join(this.mosaicHome, 'USER.md');
|
||||
backupFile(outPath);
|
||||
atomicWrite(outPath, output);
|
||||
}
|
||||
|
||||
async writeTools(config: ToolsConfig): Promise<void> {
|
||||
const validated = toolsSchema.parse(config);
|
||||
const templatePath = this.findTemplate('TOOLS.md.template');
|
||||
if (!templatePath) return;
|
||||
|
||||
const template = readFileSync(templatePath, 'utf-8');
|
||||
const vars = buildToolsTemplateVars(validated);
|
||||
const output = renderTemplate(template, vars);
|
||||
|
||||
const outPath = join(this.mosaicHome, 'TOOLS.md');
|
||||
backupFile(outPath);
|
||||
atomicWrite(outPath, output);
|
||||
}
|
||||
|
||||
async syncFramework(action: InstallAction): Promise<void> {
|
||||
const preservePaths =
|
||||
action === 'keep' || action === 'reconfigure'
|
||||
? ['SOUL.md', 'USER.md', 'TOOLS.md', 'memory']
|
||||
: [];
|
||||
|
||||
syncDirectory(this.sourceDir, this.mosaicHome, {
|
||||
preserve: preservePaths,
|
||||
excludeGit: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for template in source dir first, then mosaic home.
|
||||
*/
|
||||
private findTemplate(name: string): string | null {
|
||||
const candidates = [
|
||||
join(this.sourceDir, 'templates', name),
|
||||
join(this.mosaicHome, 'templates', name),
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
43
packages/mosaic/src/config/schemas.ts
Normal file
43
packages/mosaic/src/config/schemas.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const communicationStyleSchema = z.enum(['direct', 'friendly', 'formal']).default('direct');
|
||||
|
||||
export const soulSchema = z
|
||||
.object({
|
||||
agentName: z.string().min(1).max(50).default('Assistant'),
|
||||
roleDescription: z.string().default('execution partner and visibility engine'),
|
||||
communicationStyle: communicationStyleSchema,
|
||||
accessibility: z.string().default('none'),
|
||||
customGuardrails: z.string().default(''),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const gitProviderSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
url: z.string().min(1),
|
||||
cli: z.string().min(1),
|
||||
purpose: z.string().min(1),
|
||||
});
|
||||
|
||||
export const userSchema = z
|
||||
.object({
|
||||
userName: z.string().default(''),
|
||||
pronouns: z.string().default('They/Them'),
|
||||
timezone: z.string().default('UTC'),
|
||||
background: z.string().default('(not configured)'),
|
||||
accessibilitySection: z
|
||||
.string()
|
||||
.default('(No specific accommodations configured. Edit this section to add any.)'),
|
||||
communicationPrefs: z.string().default(''),
|
||||
personalBoundaries: z.string().default('(Edit this section to add any personal boundaries.)'),
|
||||
projectsTable: z.string().default(''),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const toolsSchema = z
|
||||
.object({
|
||||
gitProviders: z.array(gitProviderSchema).default([]),
|
||||
credentialsLocation: z.string().default('none'),
|
||||
customToolsSection: z.string().default(''),
|
||||
})
|
||||
.partial();
|
||||
38
packages/mosaic/src/constants.ts
Normal file
38
packages/mosaic/src/constants.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const VERSION = '0.1.0';
|
||||
|
||||
export const DEFAULT_MOSAIC_HOME = join(homedir(), '.config', 'mosaic');
|
||||
|
||||
export const DEFAULTS = {
|
||||
agentName: 'Assistant',
|
||||
roleDescription: 'execution partner and visibility engine',
|
||||
communicationStyle: 'direct' as const,
|
||||
pronouns: 'They/Them',
|
||||
timezone: 'UTC',
|
||||
background: '(not configured)',
|
||||
accessibilitySection: '(No specific accommodations configured. Edit this section to add any.)',
|
||||
personalBoundaries: '(Edit this section to add any personal boundaries.)',
|
||||
projectsTable: `| Project | Stack | Registry |
|
||||
|---------|-------|----------|
|
||||
| (none configured) | | |`,
|
||||
credentialsLocation: 'none',
|
||||
customToolsSection: `## Custom Tools
|
||||
|
||||
(Add any machine-specific tools, scripts, or workflows here.)`,
|
||||
gitProvidersTable: `| Instance | URL | CLI | Purpose |
|
||||
|----------|-----|-----|---------|
|
||||
| (add your git providers here) | | | |`,
|
||||
};
|
||||
|
||||
export const RECOMMENDED_SKILLS = new Set([
|
||||
'brainstorming',
|
||||
'code-review-excellence',
|
||||
'lint',
|
||||
'systematic-debugging',
|
||||
'verification-before-completion',
|
||||
'writing-plans',
|
||||
'executing-plans',
|
||||
'architecture-patterns',
|
||||
]);
|
||||
20
packages/mosaic/src/errors.ts
Normal file
20
packages/mosaic/src/errors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export class WizardCancelledError extends Error {
|
||||
override name = 'WizardCancelledError';
|
||||
constructor() {
|
||||
super('Wizard cancelled by user');
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
override name = 'ValidationError';
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class TemplateError extends Error {
|
||||
override name = 'TemplateError';
|
||||
constructor(templatePath: string, message: string) {
|
||||
super(`Template error in ${templatePath}: ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,84 @@
|
||||
export const VERSION = '0.0.0';
|
||||
#!/usr/bin/env node
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { ClackPrompter } from './prompter/clack-prompter.js';
|
||||
import { HeadlessPrompter } from './prompter/headless-prompter.js';
|
||||
import { createConfigService } from './config/config-service.js';
|
||||
import { runWizard } from './wizard.js';
|
||||
import { WizardCancelledError } from './errors.js';
|
||||
import { VERSION, DEFAULT_MOSAIC_HOME } from './constants.js';
|
||||
import type { CommunicationStyle } from './types.js';
|
||||
|
||||
export { VERSION, DEFAULT_MOSAIC_HOME };
|
||||
export { runWizard } from './wizard.js';
|
||||
export { ClackPrompter } from './prompter/clack-prompter.js';
|
||||
export { HeadlessPrompter } from './prompter/headless-prompter.js';
|
||||
export { createConfigService } from './config/config-service.js';
|
||||
export { WizardCancelledError } from './errors.js';
|
||||
|
||||
const program = new Command()
|
||||
.name('mosaic-wizard')
|
||||
.description('Mosaic Installation Wizard')
|
||||
.version(VERSION);
|
||||
|
||||
program
|
||||
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
|
||||
.option('--source-dir <path>', 'Source directory for framework files')
|
||||
.option('--mosaic-home <path>', 'Target config directory', DEFAULT_MOSAIC_HOME)
|
||||
// SOUL.md overrides
|
||||
.option('--name <name>', 'Agent name')
|
||||
.option('--role <description>', 'Agent role description')
|
||||
.option('--style <style>', 'Communication style: direct|friendly|formal')
|
||||
.option('--accessibility <prefs>', 'Accessibility preferences')
|
||||
.option('--guardrails <rules>', 'Custom guardrails')
|
||||
// USER.md overrides
|
||||
.option('--user-name <name>', 'Your name')
|
||||
.option('--pronouns <pronouns>', 'Your pronouns')
|
||||
.option('--timezone <tz>', 'Your timezone')
|
||||
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
||||
try {
|
||||
const mosaicHome = (opts['mosaicHome'] as string) ?? DEFAULT_MOSAIC_HOME;
|
||||
const sourceDir = (opts['sourceDir'] as string | undefined) ?? mosaicHome;
|
||||
|
||||
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
|
||||
|
||||
const configService = createConfigService(mosaicHome, sourceDir);
|
||||
|
||||
const style = opts['style'] as CommunicationStyle | undefined;
|
||||
|
||||
await runWizard({
|
||||
mosaicHome,
|
||||
sourceDir,
|
||||
prompter,
|
||||
configService,
|
||||
cliOverrides: {
|
||||
soul: {
|
||||
agentName: opts['name'] as string | undefined,
|
||||
roleDescription: opts['role'] as string | undefined,
|
||||
communicationStyle: style,
|
||||
accessibility: opts['accessibility'] as string | undefined,
|
||||
customGuardrails: opts['guardrails'] as string | undefined,
|
||||
},
|
||||
user: {
|
||||
userName: opts['userName'] as string | undefined,
|
||||
pronouns: opts['pronouns'] as string | undefined,
|
||||
timezone: opts['timezone'] as string | undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof WizardCancelledError) {
|
||||
console.log('\nWizard cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('Wizard failed:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const entryPath = process.argv[1] ? resolve(process.argv[1]) : '';
|
||||
if (entryPath.length > 0 && entryPath === fileURLToPath(import.meta.url)) {
|
||||
program.parse();
|
||||
}
|
||||
|
||||
39
packages/mosaic/src/platform/detect.ts
Normal file
39
packages/mosaic/src/platform/detect.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, platform } from 'node:os';
|
||||
|
||||
export type ShellType = 'zsh' | 'bash' | 'fish' | 'powershell' | 'unknown';
|
||||
|
||||
export function detectShell(): ShellType {
|
||||
const shell = process.env['SHELL'] ?? '';
|
||||
if (shell.includes('zsh')) return 'zsh';
|
||||
if (shell.includes('bash')) return 'bash';
|
||||
if (shell.includes('fish')) return 'fish';
|
||||
if (platform() === 'win32') return 'powershell';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function getShellProfilePath(): string | null {
|
||||
const home = homedir();
|
||||
|
||||
if (platform() === 'win32') {
|
||||
return join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1');
|
||||
}
|
||||
|
||||
const shell = detectShell();
|
||||
switch (shell) {
|
||||
case 'zsh': {
|
||||
const zdotdir = process.env['ZDOTDIR'] ?? home;
|
||||
return join(zdotdir, '.zshrc');
|
||||
}
|
||||
case 'bash': {
|
||||
const bashrc = join(home, '.bashrc');
|
||||
if (existsSync(bashrc)) return bashrc;
|
||||
return join(home, '.profile');
|
||||
}
|
||||
case 'fish':
|
||||
return join(home, '.config', 'fish', 'config.fish');
|
||||
default:
|
||||
return join(home, '.profile');
|
||||
}
|
||||
}
|
||||
114
packages/mosaic/src/platform/file-ops.ts
Normal file
114
packages/mosaic/src/platform/file-ops.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
copyFileSync,
|
||||
renameSync,
|
||||
readdirSync,
|
||||
unlinkSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
|
||||
const MAX_BACKUPS = 3;
|
||||
|
||||
/**
|
||||
* Atomic write: write to temp file, then rename.
|
||||
* Creates parent directories as needed.
|
||||
*/
|
||||
export function atomicWrite(filePath: string, content: string): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const tmpPath = `${filePath}.tmp-${process.pid.toString()}`;
|
||||
writeFileSync(tmpPath, content, 'utf-8');
|
||||
renameSync(tmpPath, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup of a file before overwriting.
|
||||
* Rotates backups to keep at most MAX_BACKUPS.
|
||||
*/
|
||||
export function backupFile(filePath: string): string | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '').replace('T', '-').slice(0, 19);
|
||||
const backupPath = `${filePath}.bak-${timestamp}`;
|
||||
copyFileSync(filePath, backupPath);
|
||||
rotateBackups(filePath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
function rotateBackups(filePath: string): void {
|
||||
const dir = dirname(filePath);
|
||||
const baseName = filePath.split('/').pop() ?? '';
|
||||
const prefix = `${baseName}.bak-`;
|
||||
|
||||
try {
|
||||
const backups = readdirSync(dir)
|
||||
.filter((f: string) => f.startsWith(prefix))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (let i = MAX_BACKUPS; i < backups.length; i++) {
|
||||
const backup = backups[i];
|
||||
if (backup !== undefined) {
|
||||
unlinkSync(join(dir, backup));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: backup rotation failure doesn't block writes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a source directory to a target, with optional preserve paths.
|
||||
* Replaces the rsync/cp logic from install.sh.
|
||||
*/
|
||||
export function syncDirectory(
|
||||
source: string,
|
||||
target: string,
|
||||
options: { preserve?: string[]; excludeGit?: boolean } = {},
|
||||
): void {
|
||||
const preserveSet = new Set(options.preserve ?? []);
|
||||
|
||||
// Collect files from source
|
||||
function copyRecursive(src: string, dest: string, relBase: string): void {
|
||||
if (!existsSync(src)) return;
|
||||
|
||||
const stat = statSync(src);
|
||||
if (stat.isDirectory()) {
|
||||
const relPath = relative(relBase, src);
|
||||
|
||||
// Skip .git
|
||||
if (options.excludeGit && relPath === '.git') return;
|
||||
|
||||
// Skip preserved paths at top level
|
||||
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
||||
|
||||
mkdirSync(dest, { recursive: true });
|
||||
for (const entry of readdirSync(src)) {
|
||||
copyRecursive(join(src, entry), join(dest, entry), relBase);
|
||||
}
|
||||
} else {
|
||||
const relPath = relative(relBase, src);
|
||||
|
||||
// Skip preserved files at top level
|
||||
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
||||
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
copyFileSync(src, dest);
|
||||
}
|
||||
}
|
||||
|
||||
copyRecursive(source, target, source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read a file, returning null if it doesn't exist.
|
||||
*/
|
||||
export function safeReadFile(filePath: string): string | null {
|
||||
try {
|
||||
return readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
152
packages/mosaic/src/prompter/clack-prompter.ts
Normal file
152
packages/mosaic/src/prompter/clack-prompter.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import * as p from '@clack/prompts';
|
||||
import { WizardCancelledError } from '../errors.js';
|
||||
import type {
|
||||
WizardPrompter,
|
||||
SelectOption,
|
||||
MultiSelectOption,
|
||||
ProgressHandle,
|
||||
} from './interface.js';
|
||||
|
||||
function guardCancel<T>(value: T | symbol): T {
|
||||
if (p.isCancel(value)) {
|
||||
throw new WizardCancelledError();
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export class ClackPrompter implements WizardPrompter {
|
||||
intro(message: string): void {
|
||||
p.intro(message);
|
||||
}
|
||||
|
||||
outro(message: string): void {
|
||||
p.outro(message);
|
||||
}
|
||||
|
||||
note(message: string, title?: string): void {
|
||||
p.note(message, title);
|
||||
}
|
||||
|
||||
log(message: string): void {
|
||||
p.log.info(message);
|
||||
}
|
||||
|
||||
warn(message: string): void {
|
||||
p.log.warn(message);
|
||||
}
|
||||
|
||||
async text(opts: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
validate?: (value: string) => string | void;
|
||||
}): Promise<string> {
|
||||
const validate = opts.validate
|
||||
? (v: string) => {
|
||||
const r = opts.validate!(v);
|
||||
return r === undefined ? undefined : r;
|
||||
}
|
||||
: undefined;
|
||||
const result = await p.text({
|
||||
message: opts.message,
|
||||
placeholder: opts.placeholder,
|
||||
defaultValue: opts.defaultValue,
|
||||
validate,
|
||||
});
|
||||
return guardCancel(result);
|
||||
}
|
||||
|
||||
async confirm(opts: { message: string; initialValue?: boolean }): Promise<boolean> {
|
||||
const result = await p.confirm({
|
||||
message: opts.message,
|
||||
initialValue: opts.initialValue,
|
||||
});
|
||||
return guardCancel(result);
|
||||
}
|
||||
|
||||
async select<T>(opts: {
|
||||
message: string;
|
||||
options: SelectOption<T>[];
|
||||
initialValue?: T;
|
||||
}): Promise<T> {
|
||||
const clackOptions = opts.options.map((o) => ({
|
||||
value: o.value as T,
|
||||
label: o.label,
|
||||
hint: o.hint,
|
||||
}));
|
||||
const result = await p.select({
|
||||
message: opts.message,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- clack Option conditional type needs concrete Primitive
|
||||
options: clackOptions as any,
|
||||
initialValue: opts.initialValue,
|
||||
});
|
||||
return guardCancel(result) as T;
|
||||
}
|
||||
|
||||
async multiselect<T>(opts: {
|
||||
message: string;
|
||||
options: MultiSelectOption<T>[];
|
||||
required?: boolean;
|
||||
}): Promise<T[]> {
|
||||
const clackOptions = opts.options.map((o) => ({
|
||||
value: o.value as T,
|
||||
label: o.label,
|
||||
hint: o.hint,
|
||||
}));
|
||||
const result = await p.multiselect({
|
||||
message: opts.message,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
options: clackOptions as any,
|
||||
required: opts.required,
|
||||
initialValues: opts.options.filter((o) => o.selected).map((o) => o.value),
|
||||
});
|
||||
return guardCancel(result) as T[];
|
||||
}
|
||||
|
||||
async groupMultiselect<T>(opts: {
|
||||
message: string;
|
||||
options: Record<string, MultiSelectOption<T>[]>;
|
||||
required?: boolean;
|
||||
}): Promise<T[]> {
|
||||
const grouped: Record<string, { value: T; label: string; hint?: string }[]> = {};
|
||||
for (const [group, items] of Object.entries(opts.options)) {
|
||||
grouped[group] = items.map((o) => ({
|
||||
value: o.value as T,
|
||||
label: o.label,
|
||||
hint: o.hint,
|
||||
}));
|
||||
}
|
||||
const result = await p.groupMultiselect({
|
||||
message: opts.message,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
options: grouped as any,
|
||||
required: opts.required,
|
||||
});
|
||||
return guardCancel(result) as T[];
|
||||
}
|
||||
|
||||
spinner(): ProgressHandle {
|
||||
const s = p.spinner();
|
||||
let started = false;
|
||||
return {
|
||||
update(message: string) {
|
||||
if (!started) {
|
||||
s.start(message);
|
||||
started = true;
|
||||
} else {
|
||||
s.message(message);
|
||||
}
|
||||
},
|
||||
stop(message?: string) {
|
||||
if (started) {
|
||||
s.stop(message);
|
||||
started = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
separator(): void {
|
||||
p.log.info('');
|
||||
}
|
||||
}
|
||||
131
packages/mosaic/src/prompter/headless-prompter.ts
Normal file
131
packages/mosaic/src/prompter/headless-prompter.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
WizardPrompter,
|
||||
SelectOption,
|
||||
MultiSelectOption,
|
||||
ProgressHandle,
|
||||
} from './interface.js';
|
||||
|
||||
export type AnswerValue = string | boolean | string[];
|
||||
|
||||
export class HeadlessPrompter implements WizardPrompter {
|
||||
private answers: Map<string, AnswerValue>;
|
||||
private logs: string[] = [];
|
||||
|
||||
constructor(answers: Record<string, AnswerValue> = {}) {
|
||||
this.answers = new Map(Object.entries(answers));
|
||||
}
|
||||
|
||||
intro(message: string): void {
|
||||
this.logs.push(`[intro] ${message}`);
|
||||
}
|
||||
outro(message: string): void {
|
||||
this.logs.push(`[outro] ${message}`);
|
||||
}
|
||||
note(message: string, title?: string): void {
|
||||
this.logs.push(`[note] ${title ?? ''}: ${message}`);
|
||||
}
|
||||
log(message: string): void {
|
||||
this.logs.push(`[log] ${message}`);
|
||||
}
|
||||
warn(message: string): void {
|
||||
this.logs.push(`[warn] ${message}`);
|
||||
}
|
||||
|
||||
async text(opts: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
validate?: (value: string) => string | void;
|
||||
}): Promise<string> {
|
||||
const answer = this.answers.get(opts.message);
|
||||
const value =
|
||||
typeof answer === 'string'
|
||||
? answer
|
||||
: opts.defaultValue !== undefined
|
||||
? opts.defaultValue
|
||||
: undefined;
|
||||
|
||||
if (value === undefined) {
|
||||
throw new Error(`HeadlessPrompter: no answer for "${opts.message}"`);
|
||||
}
|
||||
|
||||
if (opts.validate) {
|
||||
const error = opts.validate(value);
|
||||
if (error)
|
||||
throw new Error(`HeadlessPrompter validation failed for "${opts.message}": ${error}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
async confirm(opts: { message: string; initialValue?: boolean }): Promise<boolean> {
|
||||
const answer = this.answers.get(opts.message);
|
||||
if (typeof answer === 'boolean') return answer;
|
||||
return opts.initialValue ?? true;
|
||||
}
|
||||
|
||||
async select<T>(opts: {
|
||||
message: string;
|
||||
options: SelectOption<T>[];
|
||||
initialValue?: T;
|
||||
}): Promise<T> {
|
||||
const answer = this.answers.get(opts.message);
|
||||
if (answer !== undefined) {
|
||||
// Find matching option by value string comparison
|
||||
const match = opts.options.find((o) => String(o.value) === String(answer));
|
||||
if (match) return match.value;
|
||||
}
|
||||
if (opts.initialValue !== undefined) return opts.initialValue;
|
||||
if (opts.options.length === 0) {
|
||||
throw new Error(`HeadlessPrompter: no options for "${opts.message}"`);
|
||||
}
|
||||
const first = opts.options[0];
|
||||
if (first === undefined) {
|
||||
throw new Error(`HeadlessPrompter: no options for "${opts.message}"`);
|
||||
}
|
||||
return first.value;
|
||||
}
|
||||
|
||||
async multiselect<T>(opts: {
|
||||
message: string;
|
||||
options: MultiSelectOption<T>[];
|
||||
required?: boolean;
|
||||
}): Promise<T[]> {
|
||||
const answer = this.answers.get(opts.message);
|
||||
if (Array.isArray(answer)) {
|
||||
return opts.options
|
||||
.filter((o) => (answer as string[]).includes(String(o.value)))
|
||||
.map((o) => o.value);
|
||||
}
|
||||
return opts.options.filter((o) => o.selected).map((o) => o.value);
|
||||
}
|
||||
|
||||
async groupMultiselect<T>(opts: {
|
||||
message: string;
|
||||
options: Record<string, MultiSelectOption<T>[]>;
|
||||
required?: boolean;
|
||||
}): Promise<T[]> {
|
||||
const answer = this.answers.get(opts.message);
|
||||
if (Array.isArray(answer)) {
|
||||
const all = Object.values(opts.options).flat();
|
||||
return all.filter((o) => (answer as string[]).includes(String(o.value))).map((o) => o.value);
|
||||
}
|
||||
return Object.values(opts.options)
|
||||
.flat()
|
||||
.filter((o) => o.selected)
|
||||
.map((o) => o.value);
|
||||
}
|
||||
|
||||
spinner(): ProgressHandle {
|
||||
return {
|
||||
update(_message: string) {},
|
||||
stop(_message?: string) {},
|
||||
};
|
||||
}
|
||||
|
||||
separator(): void {}
|
||||
|
||||
getLogs(): string[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
}
|
||||
49
packages/mosaic/src/prompter/interface.ts
Normal file
49
packages/mosaic/src/prompter/interface.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface SelectOption<T = string> {
|
||||
value: T;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface MultiSelectOption<T = string> extends SelectOption<T> {
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export interface ProgressHandle {
|
||||
update(message: string): void;
|
||||
stop(message?: string): void;
|
||||
}
|
||||
|
||||
export interface WizardPrompter {
|
||||
intro(message: string): void;
|
||||
outro(message: string): void;
|
||||
note(message: string, title?: string): void;
|
||||
log(message: string): void;
|
||||
warn(message: string): void;
|
||||
|
||||
text(opts: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
validate?: (value: string) => string | void;
|
||||
}): Promise<string>;
|
||||
|
||||
confirm(opts: { message: string; initialValue?: boolean }): Promise<boolean>;
|
||||
|
||||
select<T>(opts: { message: string; options: SelectOption<T>[]; initialValue?: T }): Promise<T>;
|
||||
|
||||
multiselect<T>(opts: {
|
||||
message: string;
|
||||
options: MultiSelectOption<T>[];
|
||||
required?: boolean;
|
||||
}): Promise<T[]>;
|
||||
|
||||
groupMultiselect<T>(opts: {
|
||||
message: string;
|
||||
options: Record<string, MultiSelectOption<T>[]>;
|
||||
required?: boolean;
|
||||
}): Promise<T[]>;
|
||||
|
||||
spinner(): ProgressHandle;
|
||||
|
||||
separator(): void;
|
||||
}
|
||||
82
packages/mosaic/src/runtime/detector.ts
Normal file
82
packages/mosaic/src/runtime/detector.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { platform } from 'node:os';
|
||||
import type { RuntimeName } from '../types.js';
|
||||
|
||||
export interface RuntimeInfo {
|
||||
name: RuntimeName;
|
||||
label: string;
|
||||
installed: boolean;
|
||||
path?: string;
|
||||
version?: string;
|
||||
installHint: string;
|
||||
}
|
||||
|
||||
const RUNTIME_DEFS: Record<
|
||||
RuntimeName,
|
||||
{ label: string; command: string; versionFlag: string; installHint: string }
|
||||
> = {
|
||||
claude: {
|
||||
label: 'Claude Code',
|
||||
command: 'claude',
|
||||
versionFlag: '--version',
|
||||
installHint: 'npm install -g @anthropic-ai/claude-code',
|
||||
},
|
||||
codex: {
|
||||
label: 'Codex',
|
||||
command: 'codex',
|
||||
versionFlag: '--version',
|
||||
installHint: 'npm install -g @openai/codex',
|
||||
},
|
||||
opencode: {
|
||||
label: 'OpenCode',
|
||||
command: 'opencode',
|
||||
versionFlag: 'version',
|
||||
installHint: 'See https://opencode.ai for install instructions',
|
||||
},
|
||||
};
|
||||
|
||||
export function detectRuntime(name: RuntimeName): RuntimeInfo {
|
||||
const def = RUNTIME_DEFS[name];
|
||||
const isWindows = platform() === 'win32';
|
||||
const whichCmd = isWindows ? `where ${def.command} 2>nul` : `which ${def.command} 2>/dev/null`;
|
||||
|
||||
try {
|
||||
const pathOutput =
|
||||
execSync(whichCmd, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
})
|
||||
.trim()
|
||||
.split('\n')[0] ?? '';
|
||||
|
||||
let version: string | undefined;
|
||||
try {
|
||||
version = execSync(`${def.command} ${def.versionFlag} 2>/dev/null`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
} catch {
|
||||
// Version detection is optional
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
label: def.label,
|
||||
installed: true,
|
||||
path: pathOutput,
|
||||
version,
|
||||
installHint: def.installHint,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
name,
|
||||
label: def.label,
|
||||
installed: false,
|
||||
installHint: def.installHint,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getInstallInstructions(name: RuntimeName): string {
|
||||
return RUNTIME_DEFS[name].installHint;
|
||||
}
|
||||
12
packages/mosaic/src/runtime/installer.ts
Normal file
12
packages/mosaic/src/runtime/installer.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RuntimeName } from '../types.js';
|
||||
import { getInstallInstructions } from './detector.js';
|
||||
|
||||
export function formatInstallInstructions(name: RuntimeName): string {
|
||||
const hint = getInstallInstructions(name);
|
||||
const labels: Record<RuntimeName, string> = {
|
||||
claude: 'Claude Code',
|
||||
codex: 'Codex',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
return `To install ${labels[name]}:\n ${hint}`;
|
||||
}
|
||||
95
packages/mosaic/src/runtime/mcp-config.ts
Normal file
95
packages/mosaic/src/runtime/mcp-config.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { RuntimeName } from '../types.js';
|
||||
|
||||
const MCP_ENTRY = {
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||
};
|
||||
|
||||
export function configureMcpForRuntime(runtime: RuntimeName): void {
|
||||
switch (runtime) {
|
||||
case 'claude':
|
||||
return configureClaudeMcp();
|
||||
case 'codex':
|
||||
return configureCodexMcp();
|
||||
case 'opencode':
|
||||
return configureOpenCodeMcp();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDir(filePath: string): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
function configureClaudeMcp(): void {
|
||||
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
||||
ensureDir(settingsPath);
|
||||
|
||||
let data: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
data = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Start fresh if corrupt
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!data['mcpServers'] ||
|
||||
typeof data['mcpServers'] !== 'object' ||
|
||||
Array.isArray(data['mcpServers'])
|
||||
) {
|
||||
data['mcpServers'] = {};
|
||||
}
|
||||
(data['mcpServers'] as Record<string, unknown>)['sequential-thinking'] = MCP_ENTRY;
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
function configureCodexMcp(): void {
|
||||
const configPath = join(homedir(), '.codex', 'config.toml');
|
||||
ensureDir(configPath);
|
||||
|
||||
let content = '';
|
||||
if (existsSync(configPath)) {
|
||||
content = readFileSync(configPath, 'utf-8');
|
||||
// Remove existing sequential-thinking section
|
||||
content = content
|
||||
.replace(/\[mcp_servers\.(sequential-thinking|sequential_thinking)\][\s\S]*?(?=\n\[|$)/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
content +=
|
||||
'\n\n[mcp_servers.sequential-thinking]\n' +
|
||||
'command = "npx"\n' +
|
||||
'args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]\n';
|
||||
|
||||
writeFileSync(configPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
function configureOpenCodeMcp(): void {
|
||||
const configPath = join(homedir(), '.config', 'opencode', 'config.json');
|
||||
ensureDir(configPath);
|
||||
|
||||
let data: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
data = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Start fresh
|
||||
}
|
||||
}
|
||||
|
||||
if (!data['mcp'] || typeof data['mcp'] !== 'object' || Array.isArray(data['mcp'])) {
|
||||
data['mcp'] = {};
|
||||
}
|
||||
(data['mcp'] as Record<string, unknown>)['sequential-thinking'] = {
|
||||
type: 'local',
|
||||
command: ['npx', '-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
96
packages/mosaic/src/skills/catalog.ts
Normal file
96
packages/mosaic/src/skills/catalog.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { RECOMMENDED_SKILLS } from '../constants.js';
|
||||
|
||||
export interface SkillEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
version?: string;
|
||||
recommended: boolean;
|
||||
source: 'canonical' | 'local';
|
||||
}
|
||||
|
||||
export function loadSkillsCatalog(mosaicHome: string): SkillEntry[] {
|
||||
const skills: SkillEntry[] = [];
|
||||
|
||||
// Load canonical skills
|
||||
const canonicalDir = join(mosaicHome, 'skills');
|
||||
if (existsSync(canonicalDir)) {
|
||||
skills.push(...loadSkillsFromDir(canonicalDir, 'canonical'));
|
||||
}
|
||||
|
||||
// Fallback to source repo
|
||||
const sourceDir = join(mosaicHome, 'sources', 'agent-skills', 'skills');
|
||||
if (skills.length === 0 && existsSync(sourceDir)) {
|
||||
skills.push(...loadSkillsFromDir(sourceDir, 'canonical'));
|
||||
}
|
||||
|
||||
// Load local skills
|
||||
const localDir = join(mosaicHome, 'skills-local');
|
||||
if (existsSync(localDir)) {
|
||||
skills.push(...loadSkillsFromDir(localDir, 'local'));
|
||||
}
|
||||
|
||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function loadSkillsFromDir(dir: string, source: 'canonical' | 'local'): SkillEntry[] {
|
||||
const entries: SkillEntry[] = [];
|
||||
|
||||
let dirEntries;
|
||||
try {
|
||||
dirEntries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return entries;
|
||||
}
|
||||
|
||||
for (const entry of dirEntries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
||||
|
||||
const skillMdPath = join(dir, entry.name, 'SKILL.md');
|
||||
if (!existsSync(skillMdPath)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, 'utf-8');
|
||||
const frontmatter = parseFrontmatter(content);
|
||||
|
||||
entries.push({
|
||||
name: (frontmatter['name'] as string | undefined) ?? entry.name,
|
||||
description: (frontmatter['description'] as string | undefined) ?? '',
|
||||
version: frontmatter['version'] as string | undefined,
|
||||
recommended: RECOMMENDED_SKILLS.has(entry.name),
|
||||
source,
|
||||
});
|
||||
} catch {
|
||||
// Skip malformed skills
|
||||
entries.push({
|
||||
name: entry.name,
|
||||
description: '',
|
||||
recommended: RECOMMENDED_SKILLS.has(entry.name),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): Record<string, unknown> {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match?.[1]) return {};
|
||||
|
||||
try {
|
||||
return (parseYaml(match[1]) as Record<string, unknown>) ?? {};
|
||||
} catch {
|
||||
// Fallback: simple key-value parsing
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of match[1].split('\n')) {
|
||||
const kv = line.match(/^(\w[\w-]*)\s*:\s*(.+)/);
|
||||
if (kv?.[1] !== undefined && kv[2] !== undefined) {
|
||||
result[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
143
packages/mosaic/src/skills/categories.ts
Normal file
143
packages/mosaic/src/skills/categories.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Skill category definitions and mapping.
|
||||
* Skills are assigned to categories by name, with keyword fallback.
|
||||
*/
|
||||
|
||||
export const SKILL_CATEGORIES: Record<string, string[]> = {
|
||||
'Frontend & UI': [
|
||||
'ai-sdk',
|
||||
'algorithmic-art',
|
||||
'antfu',
|
||||
'canvas-design',
|
||||
'frontend-design',
|
||||
'next-best-practices',
|
||||
'nuxt',
|
||||
'pinia',
|
||||
'shadcn-ui',
|
||||
'slidev',
|
||||
'tailwind-design-system',
|
||||
'theme-factory',
|
||||
'ui-animation',
|
||||
'unocss',
|
||||
'vercel-composition-patterns',
|
||||
'vercel-react-best-practices',
|
||||
'vercel-react-native-skills',
|
||||
'vue',
|
||||
'vue-best-practices',
|
||||
'vue-router-best-practices',
|
||||
'vueuse-functions',
|
||||
'web-artifacts-builder',
|
||||
'web-design-guidelines',
|
||||
'vite',
|
||||
'vitepress',
|
||||
],
|
||||
'Backend & Infrastructure': [
|
||||
'architecture-patterns',
|
||||
'fastapi',
|
||||
'mcp-builder',
|
||||
'nestjs-best-practices',
|
||||
'python-performance-optimization',
|
||||
'tsdown',
|
||||
'turborepo',
|
||||
'pnpm',
|
||||
'dispatching-parallel-agents',
|
||||
'subagent-driven-development',
|
||||
'create-agent',
|
||||
'proactive-agent',
|
||||
'using-superpowers',
|
||||
'kickstart',
|
||||
'executing-plans',
|
||||
],
|
||||
'Testing & Quality': [
|
||||
'code-review-excellence',
|
||||
'lint',
|
||||
'pr-reviewer',
|
||||
'receiving-code-review',
|
||||
'requesting-code-review',
|
||||
'systematic-debugging',
|
||||
'test-driven-development',
|
||||
'verification-before-completion',
|
||||
'vitest',
|
||||
'vue-testing-best-practices',
|
||||
'webapp-testing',
|
||||
],
|
||||
'Marketing & Growth': [
|
||||
'ab-test-setup',
|
||||
'analytics-tracking',
|
||||
'competitor-alternatives',
|
||||
'copy-editing',
|
||||
'copywriting',
|
||||
'email-sequence',
|
||||
'form-cro',
|
||||
'free-tool-strategy',
|
||||
'launch-strategy',
|
||||
'marketing-ideas',
|
||||
'marketing-psychology',
|
||||
'onboarding-cro',
|
||||
'page-cro',
|
||||
'paid-ads',
|
||||
'paywall-upgrade-cro',
|
||||
'popup-cro',
|
||||
'pricing-strategy',
|
||||
'product-marketing-context',
|
||||
'programmatic-seo',
|
||||
'referral-program',
|
||||
'schema-markup',
|
||||
'seo-audit',
|
||||
'signup-flow-cro',
|
||||
'social-content',
|
||||
],
|
||||
'Product & Strategy': [
|
||||
'brainstorming',
|
||||
'brand-guidelines',
|
||||
'content-strategy',
|
||||
'writing-plans',
|
||||
'skill-creator',
|
||||
'writing-skills',
|
||||
'prd',
|
||||
],
|
||||
'Developer Practices': ['finishing-a-development-branch', 'using-git-worktrees'],
|
||||
'Auth & Security': [
|
||||
'better-auth-best-practices',
|
||||
'create-auth-skill',
|
||||
'email-and-password-best-practices',
|
||||
'organization-best-practices',
|
||||
'two-factor-authentication-best-practices',
|
||||
],
|
||||
'Content & Documentation': [
|
||||
'doc-coauthoring',
|
||||
'docx',
|
||||
'internal-comms',
|
||||
'pdf',
|
||||
'pptx',
|
||||
'slack-gif-creator',
|
||||
'xlsx',
|
||||
],
|
||||
};
|
||||
|
||||
// Reverse lookup: skill name -> category
|
||||
const SKILL_TO_CATEGORY = new Map<string, string>();
|
||||
for (const [category, skills] of Object.entries(SKILL_CATEGORIES)) {
|
||||
for (const skill of skills) {
|
||||
SKILL_TO_CATEGORY.set(skill, category);
|
||||
}
|
||||
}
|
||||
|
||||
export function categorizeSkill(name: string, description: string): string {
|
||||
const mapped = SKILL_TO_CATEGORY.get(name);
|
||||
if (mapped) return mapped;
|
||||
return inferCategoryFromDescription(description);
|
||||
}
|
||||
|
||||
function inferCategoryFromDescription(desc: string): string {
|
||||
const lower = desc.toLowerCase();
|
||||
if (/\b(react|vue|css|frontend|ui|component|tailwind|design)\b/.test(lower))
|
||||
return 'Frontend & UI';
|
||||
if (/\b(api|backend|server|docker|infra|deploy)\b/.test(lower)) return 'Backend & Infrastructure';
|
||||
if (/\b(test|lint|review|debug|quality)\b/.test(lower)) return 'Testing & Quality';
|
||||
if (/\b(marketing|seo|copy|ads|cro|conversion|email)\b/.test(lower)) return 'Marketing & Growth';
|
||||
if (/\b(auth|security|2fa|password|credential)\b/.test(lower)) return 'Auth & Security';
|
||||
if (/\b(doc|pdf|word|sheet|writing|comms)\b/.test(lower)) return 'Content & Documentation';
|
||||
if (/\b(product|strategy|brainstorm|plan|prd)\b/.test(lower)) return 'Product & Strategy';
|
||||
return 'Developer Practices';
|
||||
}
|
||||
95
packages/mosaic/src/stages/detect-install.ts
Normal file
95
packages/mosaic/src/stages/detect-install.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { ConfigService } from '../config/config-service.js';
|
||||
import type { WizardState, InstallAction } from '../types.js';
|
||||
|
||||
function detectExistingInstall(mosaicHome: string): boolean {
|
||||
if (!existsSync(mosaicHome)) return false;
|
||||
return (
|
||||
existsSync(join(mosaicHome, 'bin/mosaic')) ||
|
||||
existsSync(join(mosaicHome, 'AGENTS.md')) ||
|
||||
existsSync(join(mosaicHome, 'SOUL.md'))
|
||||
);
|
||||
}
|
||||
|
||||
function detectExistingIdentity(mosaicHome: string): {
|
||||
hasSoul: boolean;
|
||||
hasUser: boolean;
|
||||
hasTools: boolean;
|
||||
agentName?: string;
|
||||
} {
|
||||
const soulPath = join(mosaicHome, 'SOUL.md');
|
||||
const hasSoul = existsSync(soulPath);
|
||||
let agentName: string | undefined;
|
||||
|
||||
if (hasSoul) {
|
||||
try {
|
||||
const content = readFileSync(soulPath, 'utf-8');
|
||||
const match = content.match(/You are \*\*(.+?)\*\*/);
|
||||
agentName = match?.[1];
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasSoul,
|
||||
hasUser: existsSync(join(mosaicHome, 'USER.md')),
|
||||
hasTools: existsSync(join(mosaicHome, 'TOOLS.md')),
|
||||
agentName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectInstallStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
config: ConfigService,
|
||||
): Promise<void> {
|
||||
const existing = detectExistingInstall(state.mosaicHome);
|
||||
if (!existing) {
|
||||
state.installAction = 'fresh';
|
||||
return;
|
||||
}
|
||||
|
||||
const identity = detectExistingIdentity(state.mosaicHome);
|
||||
const identitySummary = identity.agentName
|
||||
? `Agent: ${identity.agentName}`
|
||||
: 'No identity configured';
|
||||
|
||||
p.note(
|
||||
`Found existing Mosaic installation at:\n${state.mosaicHome}\n\n` +
|
||||
`${identitySummary}\n` +
|
||||
`SOUL.md: ${identity.hasSoul ? 'yes' : 'no'}\n` +
|
||||
`USER.md: ${identity.hasUser ? 'yes' : 'no'}\n` +
|
||||
`TOOLS.md: ${identity.hasTools ? 'yes' : 'no'}`,
|
||||
'Existing Installation Detected',
|
||||
);
|
||||
|
||||
state.installAction = await p.select<InstallAction>({
|
||||
message: 'What would you like to do?',
|
||||
options: [
|
||||
{
|
||||
value: 'keep',
|
||||
label: 'Keep identity, update framework',
|
||||
hint: 'Preserves SOUL.md, USER.md, TOOLS.md, memory/',
|
||||
},
|
||||
{
|
||||
value: 'reconfigure',
|
||||
label: 'Reconfigure identity',
|
||||
hint: 'Re-run identity setup, update framework',
|
||||
},
|
||||
{
|
||||
value: 'reset',
|
||||
label: 'Fresh install',
|
||||
hint: 'Replace everything',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (state.installAction === 'keep') {
|
||||
state.soul = await config.readSoul();
|
||||
state.user = await config.readUser();
|
||||
state.tools = await config.readTools();
|
||||
}
|
||||
}
|
||||
165
packages/mosaic/src/stages/finalize.ts
Normal file
165
packages/mosaic/src/stages/finalize.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { platform } from 'node:os';
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { ConfigService } from '../config/config-service.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { getShellProfilePath } from '../platform/detect.js';
|
||||
|
||||
function linkRuntimeAssets(mosaicHome: string): void {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
||||
if (existsSync(script)) {
|
||||
try {
|
||||
spawnSync('bash', [script], { timeout: 30000, stdio: 'pipe' });
|
||||
} catch {
|
||||
// Non-fatal: wizard continues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncSkills(mosaicHome: string): void {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
|
||||
if (existsSync(script)) {
|
||||
try {
|
||||
spawnSync('bash', [script], { timeout: 60000, stdio: 'pipe' });
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface DoctorResult {
|
||||
warnings: number;
|
||||
output: string;
|
||||
}
|
||||
|
||||
function runDoctor(mosaicHome: string): DoctorResult {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-doctor');
|
||||
if (!existsSync(script)) {
|
||||
return { warnings: 0, output: 'mosaic-doctor not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync('bash', [script], {
|
||||
timeout: 30000,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const output = result.stdout ?? '';
|
||||
const warnings = (output.match(/WARN/g) ?? []).length;
|
||||
return { warnings, output };
|
||||
} catch {
|
||||
return { warnings: 1, output: 'Doctor check failed' };
|
||||
}
|
||||
}
|
||||
|
||||
type PathAction = 'already' | 'added' | 'skipped';
|
||||
|
||||
function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
|
||||
const binDir = join(mosaicHome, 'bin');
|
||||
const currentPath = process.env['PATH'] ?? '';
|
||||
|
||||
if (currentPath.includes(binDir)) {
|
||||
return 'already';
|
||||
}
|
||||
|
||||
const profilePath = getShellProfilePath();
|
||||
if (!profilePath) return 'skipped';
|
||||
|
||||
const isWindows = platform() === 'win32';
|
||||
const exportLine = isWindows
|
||||
? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n`
|
||||
: `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`;
|
||||
|
||||
// Check if already in profile
|
||||
if (existsSync(profilePath)) {
|
||||
const content = readFileSync(profilePath, 'utf-8');
|
||||
if (content.includes(binDir)) {
|
||||
return 'already';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
appendFileSync(profilePath, exportLine, 'utf-8');
|
||||
return 'added';
|
||||
} catch {
|
||||
return 'skipped';
|
||||
}
|
||||
}
|
||||
|
||||
export async function finalizeStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
config: ConfigService,
|
||||
): Promise<void> {
|
||||
p.separator();
|
||||
|
||||
const spin = p.spinner();
|
||||
|
||||
// 1. Sync framework files (before config writes so identity files aren't overwritten)
|
||||
spin.update('Syncing framework files...');
|
||||
await config.syncFramework(state.installAction);
|
||||
|
||||
// 2. Write config files (after sync so they aren't overwritten by source templates)
|
||||
if (state.installAction !== 'keep') {
|
||||
spin.update('Writing configuration files...');
|
||||
await config.writeSoul(state.soul);
|
||||
await config.writeUser(state.user);
|
||||
await config.writeTools(state.tools);
|
||||
}
|
||||
|
||||
// 3. Link runtime assets
|
||||
spin.update('Linking runtime assets...');
|
||||
linkRuntimeAssets(state.mosaicHome);
|
||||
|
||||
// 4. Sync skills
|
||||
if (state.selectedSkills.length > 0) {
|
||||
spin.update('Syncing skills...');
|
||||
syncSkills(state.mosaicHome);
|
||||
}
|
||||
|
||||
// 5. Run doctor
|
||||
spin.update('Running health audit...');
|
||||
const doctorResult = runDoctor(state.mosaicHome);
|
||||
|
||||
spin.stop('Installation complete');
|
||||
|
||||
// 6. PATH setup
|
||||
const pathAction = setupPath(state.mosaicHome, p);
|
||||
|
||||
// 7. Summary
|
||||
const summary: string[] = [
|
||||
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
|
||||
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
|
||||
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
|
||||
`Skills: ${state.selectedSkills.length.toString()} selected`,
|
||||
`Config: ${state.mosaicHome}`,
|
||||
];
|
||||
|
||||
if (doctorResult.warnings > 0) {
|
||||
summary.push(
|
||||
`Health: ${doctorResult.warnings.toString()} warning(s) — run 'mosaic doctor' for details`,
|
||||
);
|
||||
} else {
|
||||
summary.push('Health: all checks passed');
|
||||
}
|
||||
|
||||
p.note(summary.join('\n'), 'Installation Summary');
|
||||
|
||||
// 8. Next steps
|
||||
const nextSteps: string[] = [];
|
||||
if (pathAction === 'added') {
|
||||
const profilePath = getShellProfilePath();
|
||||
nextSteps.push(`Reload shell: source ${profilePath ?? '~/.profile'}`);
|
||||
}
|
||||
if (state.runtimes.detected.length === 0) {
|
||||
nextSteps.push('Install at least one runtime (claude, codex, or opencode)');
|
||||
}
|
||||
nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)");
|
||||
nextSteps.push('Edit identity files directly in ~/.config/mosaic/ for fine-tuning');
|
||||
|
||||
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
|
||||
|
||||
p.outro('Mosaic is ready.');
|
||||
}
|
||||
20
packages/mosaic/src/stages/mode-select.ts
Normal file
20
packages/mosaic/src/stages/mode-select.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState, WizardMode } from '../types.js';
|
||||
|
||||
export async function modeSelectStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
||||
state.mode = await p.select<WizardMode>({
|
||||
message: 'Installation mode',
|
||||
options: [
|
||||
{
|
||||
value: 'quick',
|
||||
label: 'Quick Start',
|
||||
hint: 'Sensible defaults, minimal questions (~2 min)',
|
||||
},
|
||||
{
|
||||
value: 'advanced',
|
||||
label: 'Advanced',
|
||||
hint: 'Full customization of identity, runtimes, and skills',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
64
packages/mosaic/src/stages/runtime-setup.ts
Normal file
64
packages/mosaic/src/stages/runtime-setup.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState, RuntimeName } from '../types.js';
|
||||
import { detectRuntime, type RuntimeInfo } from '../runtime/detector.js';
|
||||
import { formatInstallInstructions } from '../runtime/installer.js';
|
||||
import { configureMcpForRuntime } from '../runtime/mcp-config.js';
|
||||
|
||||
const RUNTIME_NAMES: RuntimeName[] = ['claude', 'codex', 'opencode'];
|
||||
|
||||
export async function runtimeSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
||||
p.separator();
|
||||
|
||||
const spin = p.spinner();
|
||||
spin.update('Detecting installed runtimes...');
|
||||
|
||||
const runtimes: RuntimeInfo[] = RUNTIME_NAMES.map(detectRuntime);
|
||||
|
||||
spin.stop('Runtime detection complete');
|
||||
|
||||
const detected = runtimes.filter((r) => r.installed);
|
||||
const notDetected = runtimes.filter((r) => !r.installed);
|
||||
|
||||
if (detected.length > 0) {
|
||||
const summary = detected
|
||||
.map((r) => ` ${r.label}: ${r.version ?? 'installed'} (${r.path ?? 'unknown'})`)
|
||||
.join('\n');
|
||||
p.note(summary, 'Detected Runtimes');
|
||||
} else {
|
||||
p.warn('No runtimes detected. Install at least one to use Mosaic.');
|
||||
}
|
||||
|
||||
state.runtimes.detected = detected.map((r) => r.name);
|
||||
|
||||
// Offer installation info for missing runtimes in advanced mode
|
||||
if (state.mode === 'advanced' && notDetected.length > 0) {
|
||||
const showInstall = await p.confirm({
|
||||
message: `${notDetected.length.toString()} runtime(s) not found. Show install instructions?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (showInstall) {
|
||||
for (const rt of notDetected) {
|
||||
p.note(formatInstallInstructions(rt.name), `Install ${rt.label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configure MCP sequential-thinking for detected runtimes
|
||||
if (detected.length > 0) {
|
||||
const spin2 = p.spinner();
|
||||
spin2.update('Configuring sequential-thinking MCP...');
|
||||
try {
|
||||
for (const rt of detected) {
|
||||
configureMcpForRuntime(rt.name);
|
||||
}
|
||||
spin2.stop('MCP sequential-thinking configured');
|
||||
state.runtimes.mcpConfigured = true;
|
||||
} catch (err) {
|
||||
spin2.stop('MCP configuration failed (non-fatal)');
|
||||
p.warn(
|
||||
`MCP setup failed: ${err instanceof Error ? err.message : String(err)}. Run 'mosaic seq fix' later.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
77
packages/mosaic/src/stages/skills-select.ts
Normal file
77
packages/mosaic/src/stages/skills-select.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { loadSkillsCatalog } from '../skills/catalog.js';
|
||||
import { SKILL_CATEGORIES, categorizeSkill } from '../skills/categories.js';
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
if (str.length <= max) return str;
|
||||
return str.slice(0, max - 1) + '\u2026';
|
||||
}
|
||||
|
||||
export async function skillsSelectStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
||||
p.separator();
|
||||
|
||||
const spin = p.spinner();
|
||||
spin.update('Loading skills catalog...');
|
||||
|
||||
const catalog = loadSkillsCatalog(state.mosaicHome);
|
||||
spin.stop(`Found ${catalog.length.toString()} available skills`);
|
||||
|
||||
if (catalog.length === 0) {
|
||||
p.warn("No skills found. Run 'mosaic sync' after installation to fetch skills.");
|
||||
state.selectedSkills = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.mode === 'quick') {
|
||||
const defaults = catalog.filter((s) => s.recommended).map((s) => s.name);
|
||||
state.selectedSkills = defaults;
|
||||
p.note(
|
||||
`Selected ${defaults.length.toString()} recommended skills.\n` +
|
||||
`Run 'mosaic sync' later to browse the full catalog.`,
|
||||
'Skills',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Advanced mode: categorized browsing
|
||||
p.note(
|
||||
'Skills give agents domain expertise for specific tasks.\n' +
|
||||
'Browse by category and select the ones you want.\n' +
|
||||
"You can always change this later with 'mosaic sync'.",
|
||||
'Skills Selection',
|
||||
);
|
||||
|
||||
// Build grouped options
|
||||
const grouped: Record<
|
||||
string,
|
||||
{ value: string; label: string; hint?: string; selected?: boolean }[]
|
||||
> = {};
|
||||
|
||||
// Initialize all categories
|
||||
for (const categoryName of Object.keys(SKILL_CATEGORIES)) {
|
||||
grouped[categoryName] = [];
|
||||
}
|
||||
|
||||
for (const skill of catalog) {
|
||||
const category = categorizeSkill(skill.name, skill.description);
|
||||
if (!grouped[category]) grouped[category] = [];
|
||||
grouped[category]!.push({
|
||||
value: skill.name,
|
||||
label: skill.name,
|
||||
hint: truncate(skill.description, 60),
|
||||
selected: skill.recommended,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove empty categories
|
||||
for (const key of Object.keys(grouped)) {
|
||||
if ((grouped[key]?.length ?? 0) === 0) delete grouped[key];
|
||||
}
|
||||
|
||||
state.selectedSkills = await p.groupMultiselect({
|
||||
message: 'Select skills (space to toggle)',
|
||||
options: grouped,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
70
packages/mosaic/src/stages/soul-setup.ts
Normal file
70
packages/mosaic/src/stages/soul-setup.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState, CommunicationStyle } from '../types.js';
|
||||
import { DEFAULTS } from '../constants.js';
|
||||
|
||||
export async function soulSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
||||
if (state.installAction === 'keep') return;
|
||||
|
||||
p.separator();
|
||||
p.note(
|
||||
'Your agent identity defines how AI assistants behave,\n' +
|
||||
'their principles, and communication style.\n' +
|
||||
'This creates SOUL.md.',
|
||||
'Agent Identity',
|
||||
);
|
||||
|
||||
if (!state.soul.agentName) {
|
||||
state.soul.agentName = await p.text({
|
||||
message: 'What name should agents use?',
|
||||
placeholder: 'e.g., Jarvis, Assistant, Mosaic',
|
||||
defaultValue: DEFAULTS.agentName,
|
||||
validate: (v) => {
|
||||
if (v.length === 0) return 'Name cannot be empty';
|
||||
if (v.length > 50) return 'Name must be under 50 characters';
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (state.mode === 'advanced') {
|
||||
if (!state.soul.roleDescription) {
|
||||
state.soul.roleDescription = await p.text({
|
||||
message: 'Agent role description',
|
||||
placeholder: 'e.g., execution partner and visibility engine',
|
||||
defaultValue: DEFAULTS.roleDescription,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
state.soul.roleDescription ??= DEFAULTS.roleDescription;
|
||||
}
|
||||
|
||||
if (!state.soul.communicationStyle) {
|
||||
state.soul.communicationStyle = await p.select<CommunicationStyle>({
|
||||
message: 'Communication style',
|
||||
options: [
|
||||
{ value: 'direct', label: 'Direct', hint: 'Concise, no fluff, actionable' },
|
||||
{ value: 'friendly', label: 'Friendly', hint: 'Warm but efficient, conversational' },
|
||||
{ value: 'formal', label: 'Formal', hint: 'Professional, structured, thorough' },
|
||||
],
|
||||
initialValue: 'direct',
|
||||
});
|
||||
}
|
||||
|
||||
if (state.mode === 'advanced') {
|
||||
if (!state.soul.accessibility) {
|
||||
state.soul.accessibility = await p.text({
|
||||
message: 'Accessibility preferences',
|
||||
placeholder: "e.g., ADHD-friendly chunking, dyslexia-aware formatting, or 'none'",
|
||||
defaultValue: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
if (!state.soul.customGuardrails) {
|
||||
state.soul.customGuardrails = await p.text({
|
||||
message: 'Custom guardrails (optional)',
|
||||
placeholder: 'e.g., Never auto-commit to main',
|
||||
defaultValue: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
73
packages/mosaic/src/stages/tools-setup.ts
Normal file
73
packages/mosaic/src/stages/tools-setup.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState, GitProvider } from '../types.js';
|
||||
import { DEFAULTS } from '../constants.js';
|
||||
|
||||
export async function toolsSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
||||
if (state.installAction === 'keep') return;
|
||||
|
||||
if (state.mode === 'quick') {
|
||||
state.tools.gitProviders = [];
|
||||
state.tools.credentialsLocation = DEFAULTS.credentialsLocation;
|
||||
state.tools.customToolsSection = DEFAULTS.customToolsSection;
|
||||
return;
|
||||
}
|
||||
|
||||
p.separator();
|
||||
p.note(
|
||||
'Tool configuration tells agents about your git providers,\n' +
|
||||
'credential locations, and custom tools.\n' +
|
||||
'This creates TOOLS.md.',
|
||||
'Tool Reference',
|
||||
);
|
||||
|
||||
const addProviders = await p.confirm({
|
||||
message: 'Configure git providers?',
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
state.tools.gitProviders = [];
|
||||
if (addProviders) {
|
||||
let addMore = true;
|
||||
while (addMore) {
|
||||
const name = await p.text({
|
||||
message: 'Provider name',
|
||||
placeholder: 'e.g., Gitea, GitHub',
|
||||
});
|
||||
const url = await p.text({
|
||||
message: 'Provider URL',
|
||||
placeholder: 'e.g., https://github.com',
|
||||
});
|
||||
const cli = await p.select<string>({
|
||||
message: 'CLI tool',
|
||||
options: [
|
||||
{ value: 'gh', label: 'gh (GitHub CLI)' },
|
||||
{ value: 'tea', label: 'tea (Gitea CLI)' },
|
||||
{ value: 'glab', label: 'glab (GitLab CLI)' },
|
||||
],
|
||||
});
|
||||
const purpose = await p.text({
|
||||
message: 'Purpose',
|
||||
placeholder: 'e.g., Primary code hosting',
|
||||
defaultValue: 'Code hosting',
|
||||
});
|
||||
|
||||
state.tools.gitProviders.push({
|
||||
name,
|
||||
url,
|
||||
cli,
|
||||
purpose,
|
||||
} satisfies GitProvider);
|
||||
|
||||
addMore = await p.confirm({
|
||||
message: 'Add another provider?',
|
||||
initialValue: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.tools.credentialsLocation = await p.text({
|
||||
message: 'Credential file path',
|
||||
placeholder: "e.g., ~/.secrets/credentials.env, or 'none'",
|
||||
defaultValue: DEFAULTS.credentialsLocation,
|
||||
});
|
||||
}
|
||||
77
packages/mosaic/src/stages/user-setup.ts
Normal file
77
packages/mosaic/src/stages/user-setup.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { DEFAULTS } from '../constants.js';
|
||||
import { buildCommunicationPrefs } from '../template/builders.js';
|
||||
|
||||
export async function userSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
||||
if (state.installAction === 'keep') return;
|
||||
|
||||
p.separator();
|
||||
p.note(
|
||||
'Your user profile helps agents understand your context,\n' +
|
||||
'accessibility needs, and communication preferences.\n' +
|
||||
'This creates USER.md.',
|
||||
'User Profile',
|
||||
);
|
||||
|
||||
if (!state.user.userName) {
|
||||
state.user.userName = await p.text({
|
||||
message: 'Your name',
|
||||
placeholder: 'How agents should address you',
|
||||
defaultValue: '',
|
||||
});
|
||||
}
|
||||
|
||||
if (!state.user.pronouns) {
|
||||
state.user.pronouns = await p.text({
|
||||
message: 'Your pronouns',
|
||||
placeholder: 'e.g., He/Him, She/Her, They/Them',
|
||||
defaultValue: DEFAULTS.pronouns,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-detect timezone
|
||||
let detectedTz: string;
|
||||
try {
|
||||
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
detectedTz = DEFAULTS.timezone;
|
||||
}
|
||||
|
||||
if (!state.user.timezone) {
|
||||
state.user.timezone = await p.text({
|
||||
message: 'Your timezone',
|
||||
placeholder: `e.g., ${detectedTz}`,
|
||||
defaultValue: detectedTz,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.mode === 'advanced') {
|
||||
state.user.background = await p.text({
|
||||
message: 'Professional background (brief)',
|
||||
placeholder: 'e.g., Full-stack developer, 10 years TypeScript/React',
|
||||
defaultValue: DEFAULTS.background,
|
||||
});
|
||||
|
||||
state.user.accessibilitySection = await p.text({
|
||||
message: 'Neurodivergence / accessibility accommodations',
|
||||
placeholder: 'e.g., ADHD-friendly chunking, or press Enter to skip',
|
||||
defaultValue: DEFAULTS.accessibilitySection,
|
||||
});
|
||||
|
||||
state.user.personalBoundaries = await p.text({
|
||||
message: 'Personal boundaries for agents',
|
||||
placeholder: 'e.g., No unsolicited career advice, or press Enter to skip',
|
||||
defaultValue: DEFAULTS.personalBoundaries,
|
||||
});
|
||||
} else {
|
||||
state.user.background = DEFAULTS.background;
|
||||
state.user.accessibilitySection = DEFAULTS.accessibilitySection;
|
||||
state.user.personalBoundaries = DEFAULTS.personalBoundaries;
|
||||
}
|
||||
|
||||
// Derive communication preferences from SOUL style
|
||||
state.user.communicationPrefs = buildCommunicationPrefs(
|
||||
state.soul.communicationStyle ?? 'direct',
|
||||
);
|
||||
}
|
||||
15
packages/mosaic/src/stages/welcome.ts
Normal file
15
packages/mosaic/src/stages/welcome.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { VERSION } from '../constants.js';
|
||||
|
||||
export async function welcomeStage(p: WizardPrompter, _state: WizardState): Promise<void> {
|
||||
p.intro(`Mosaic Installation Wizard v${VERSION}`);
|
||||
p.note(
|
||||
`Mosaic is an agent framework that gives AI coding assistants\n` +
|
||||
`a persistent identity, shared skills, and structured workflows.\n\n` +
|
||||
`It works with Claude Code, Codex, and OpenCode.\n\n` +
|
||||
`All config is stored locally in ~/.config/mosaic/.\n` +
|
||||
`No data is sent anywhere. No accounts required.`,
|
||||
'What is Mosaic?',
|
||||
);
|
||||
}
|
||||
144
packages/mosaic/src/template/builders.ts
Normal file
144
packages/mosaic/src/template/builders.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
CommunicationStyle,
|
||||
SoulConfig,
|
||||
UserConfig,
|
||||
ToolsConfig,
|
||||
GitProvider,
|
||||
} from '../types.js';
|
||||
import { DEFAULTS } from '../constants.js';
|
||||
import type { TemplateVars } from './engine.js';
|
||||
|
||||
/**
|
||||
* Build behavioral principles text based on communication style.
|
||||
* Replicates mosaic-init lines 177-204 exactly.
|
||||
*/
|
||||
function buildBehavioralPrinciples(style: CommunicationStyle, accessibility?: string): string {
|
||||
let principles: string;
|
||||
|
||||
switch (style) {
|
||||
case 'direct':
|
||||
principles = `1. Clarity over performance theater.
|
||||
2. Practical execution over abstract planning.
|
||||
3. Truthfulness over confidence: state uncertainty explicitly.
|
||||
4. Visible state over hidden assumptions.
|
||||
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations.`;
|
||||
break;
|
||||
case 'friendly':
|
||||
principles = `1. Be helpful and approachable while staying efficient.
|
||||
2. Provide context and explain reasoning when helpful.
|
||||
3. Truthfulness over confidence: state uncertainty explicitly.
|
||||
4. Visible state over hidden assumptions.
|
||||
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations.`;
|
||||
break;
|
||||
case 'formal':
|
||||
principles = `1. Maintain professional, structured communication.
|
||||
2. Provide thorough analysis with explicit tradeoffs.
|
||||
3. Truthfulness over confidence: state uncertainty explicitly.
|
||||
4. Document decisions and rationale clearly.
|
||||
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations.`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (accessibility && accessibility !== 'none' && accessibility.length > 0) {
|
||||
principles += `\n6. ${accessibility}.`;
|
||||
}
|
||||
|
||||
return principles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build communication style text based on style choice.
|
||||
* Replicates mosaic-init lines 208-227 exactly.
|
||||
*/
|
||||
function buildCommunicationStyleText(style: CommunicationStyle): string {
|
||||
switch (style) {
|
||||
case 'direct':
|
||||
return `- Be direct, concise, and concrete.
|
||||
- Avoid fluff, hype, and anthropomorphic roleplay.
|
||||
- Do not simulate certainty when facts are missing.
|
||||
- Prefer actionable next steps and explicit tradeoffs.`;
|
||||
case 'friendly':
|
||||
return `- Be warm and conversational while staying focused.
|
||||
- Explain your reasoning when it helps the user.
|
||||
- Do not simulate certainty when facts are missing.
|
||||
- Prefer actionable next steps with clear context.`;
|
||||
case 'formal':
|
||||
return `- Use professional, structured language.
|
||||
- Provide thorough explanations with supporting detail.
|
||||
- Do not simulate certainty when facts are missing.
|
||||
- Present options with explicit tradeoffs and recommendations.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build communication preferences for USER.md based on style.
|
||||
* Replicates mosaic-init lines 299-316 exactly.
|
||||
*/
|
||||
export function buildCommunicationPrefs(style: CommunicationStyle): string {
|
||||
switch (style) {
|
||||
case 'direct':
|
||||
return `- Direct and concise
|
||||
- No sycophancy
|
||||
- Executive summaries and tables for overview`;
|
||||
case 'friendly':
|
||||
return `- Warm and conversational
|
||||
- Explain reasoning when helpful
|
||||
- Balance thoroughness with brevity`;
|
||||
case 'formal':
|
||||
return `- Professional and structured
|
||||
- Thorough explanations with supporting detail
|
||||
- Formal tone with explicit recommendations`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build git providers markdown table from provider list.
|
||||
* Replicates mosaic-init lines 362-384.
|
||||
*/
|
||||
function buildGitProvidersTable(providers?: GitProvider[]): string {
|
||||
if (!providers || providers.length === 0) {
|
||||
return DEFAULTS.gitProvidersTable;
|
||||
}
|
||||
|
||||
const rows = providers
|
||||
.map((p) => `| ${p.name} | ${p.url} | \`${p.cli}\` | ${p.purpose} |`)
|
||||
.join('\n');
|
||||
|
||||
return `| Instance | URL | CLI | Purpose |
|
||||
|----------|-----|-----|---------|
|
||||
${rows}`;
|
||||
}
|
||||
|
||||
export function buildSoulTemplateVars(config: SoulConfig): TemplateVars {
|
||||
const style = config.communicationStyle ?? 'direct';
|
||||
const guardrails = config.customGuardrails ? `- ${config.customGuardrails}` : '';
|
||||
|
||||
return {
|
||||
AGENT_NAME: config.agentName ?? DEFAULTS.agentName,
|
||||
ROLE_DESCRIPTION: config.roleDescription ?? DEFAULTS.roleDescription,
|
||||
BEHAVIORAL_PRINCIPLES: buildBehavioralPrinciples(style, config.accessibility),
|
||||
COMMUNICATION_STYLE: buildCommunicationStyleText(style),
|
||||
CUSTOM_GUARDRAILS: guardrails,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUserTemplateVars(config: UserConfig): TemplateVars {
|
||||
return {
|
||||
USER_NAME: config.userName ?? '',
|
||||
PRONOUNS: config.pronouns ?? DEFAULTS.pronouns,
|
||||
TIMEZONE: config.timezone ?? DEFAULTS.timezone,
|
||||
BACKGROUND: config.background ?? DEFAULTS.background,
|
||||
ACCESSIBILITY_SECTION: config.accessibilitySection ?? DEFAULTS.accessibilitySection,
|
||||
COMMUNICATION_PREFS: config.communicationPrefs ?? buildCommunicationPrefs('direct'),
|
||||
PERSONAL_BOUNDARIES: config.personalBoundaries ?? DEFAULTS.personalBoundaries,
|
||||
PROJECTS_TABLE: config.projectsTable ?? DEFAULTS.projectsTable,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildToolsTemplateVars(config: ToolsConfig): TemplateVars {
|
||||
return {
|
||||
GIT_PROVIDERS_TABLE: buildGitProvidersTable(config.gitProviders),
|
||||
CREDENTIALS_LOCATION: config.credentialsLocation ?? DEFAULTS.credentialsLocation,
|
||||
CUSTOM_TOOLS_SECTION: config.customToolsSection ?? DEFAULTS.customToolsSection,
|
||||
};
|
||||
}
|
||||
23
packages/mosaic/src/template/engine.ts
Normal file
23
packages/mosaic/src/template/engine.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface TemplateVars {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces {{PLACEHOLDER}} tokens with provided values.
|
||||
* Does NOT expand ${ENV_VAR} syntax — those pass through for shell resolution.
|
||||
*/
|
||||
export function renderTemplate(
|
||||
template: string,
|
||||
vars: TemplateVars,
|
||||
options: { strict?: boolean } = {},
|
||||
): string {
|
||||
return template.replace(/\{\{([A-Z_][A-Z0-9_]*)\}\}/g, (match, varName: string) => {
|
||||
if (varName in vars) {
|
||||
return vars[varName] ?? '';
|
||||
}
|
||||
if (options.strict) {
|
||||
throw new Error(`Template variable not provided: {{${varName}}}`);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
}
|
||||
53
packages/mosaic/src/types.ts
Normal file
53
packages/mosaic/src/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type WizardMode = 'quick' | 'advanced';
|
||||
export type InstallAction = 'fresh' | 'keep' | 'reconfigure' | 'reset';
|
||||
export type CommunicationStyle = 'direct' | 'friendly' | 'formal';
|
||||
export type RuntimeName = 'claude' | 'codex' | 'opencode';
|
||||
|
||||
export interface SoulConfig {
|
||||
agentName?: string;
|
||||
roleDescription?: string;
|
||||
communicationStyle?: CommunicationStyle;
|
||||
accessibility?: string;
|
||||
customGuardrails?: string;
|
||||
}
|
||||
|
||||
export interface UserConfig {
|
||||
userName?: string;
|
||||
pronouns?: string;
|
||||
timezone?: string;
|
||||
background?: string;
|
||||
accessibilitySection?: string;
|
||||
communicationPrefs?: string;
|
||||
personalBoundaries?: string;
|
||||
projectsTable?: string;
|
||||
}
|
||||
|
||||
export interface GitProvider {
|
||||
name: string;
|
||||
url: string;
|
||||
cli: string;
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
export interface ToolsConfig {
|
||||
gitProviders?: GitProvider[];
|
||||
credentialsLocation?: string;
|
||||
customToolsSection?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeState {
|
||||
detected: RuntimeName[];
|
||||
mcpConfigured: boolean;
|
||||
}
|
||||
|
||||
export interface WizardState {
|
||||
mosaicHome: string;
|
||||
sourceDir: string;
|
||||
mode: WizardMode;
|
||||
installAction: InstallAction;
|
||||
soul: SoulConfig;
|
||||
user: UserConfig;
|
||||
tools: ToolsConfig;
|
||||
runtimes: RuntimeState;
|
||||
selectedSkills: string[];
|
||||
}
|
||||
95
packages/mosaic/src/wizard.ts
Normal file
95
packages/mosaic/src/wizard.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { WizardPrompter } from './prompter/interface.js';
|
||||
import type { ConfigService } from './config/config-service.js';
|
||||
import type { WizardState } from './types.js';
|
||||
import { welcomeStage } from './stages/welcome.js';
|
||||
import { detectInstallStage } from './stages/detect-install.js';
|
||||
import { modeSelectStage } from './stages/mode-select.js';
|
||||
import { soulSetupStage } from './stages/soul-setup.js';
|
||||
import { userSetupStage } from './stages/user-setup.js';
|
||||
import { toolsSetupStage } from './stages/tools-setup.js';
|
||||
import { runtimeSetupStage } from './stages/runtime-setup.js';
|
||||
import { skillsSelectStage } from './stages/skills-select.js';
|
||||
import { finalizeStage } from './stages/finalize.js';
|
||||
|
||||
export interface WizardOptions {
|
||||
mosaicHome: string;
|
||||
sourceDir: string;
|
||||
prompter: WizardPrompter;
|
||||
configService: ConfigService;
|
||||
cliOverrides?: Partial<WizardState>;
|
||||
}
|
||||
|
||||
export async function runWizard(options: WizardOptions): Promise<void> {
|
||||
const { prompter, configService, mosaicHome, sourceDir } = options;
|
||||
|
||||
const state: WizardState = {
|
||||
mosaicHome,
|
||||
sourceDir,
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
|
||||
// Apply CLI overrides (strip undefined values)
|
||||
if (options.cliOverrides) {
|
||||
if (options.cliOverrides.soul) {
|
||||
for (const [k, v] of Object.entries(options.cliOverrides.soul)) {
|
||||
if (v !== undefined) {
|
||||
(state.soul as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.cliOverrides.user) {
|
||||
for (const [k, v] of Object.entries(options.cliOverrides.user)) {
|
||||
if (v !== undefined) {
|
||||
(state.user as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.cliOverrides.tools) {
|
||||
for (const [k, v] of Object.entries(options.cliOverrides.tools)) {
|
||||
if (v !== undefined) {
|
||||
(state.tools as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.cliOverrides.mode) {
|
||||
state.mode = options.cliOverrides.mode;
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 1: Welcome
|
||||
await welcomeStage(prompter, state);
|
||||
|
||||
// Stage 2: Existing Install Detection
|
||||
await detectInstallStage(prompter, state, configService);
|
||||
|
||||
// Stage 3: Quick Start vs Advanced (skip if keeping existing)
|
||||
if (state.installAction === 'fresh' || state.installAction === 'reset') {
|
||||
await modeSelectStage(prompter, state);
|
||||
} else if (state.installAction === 'reconfigure') {
|
||||
state.mode = 'advanced';
|
||||
}
|
||||
|
||||
// Stage 4: SOUL.md
|
||||
await soulSetupStage(prompter, state);
|
||||
|
||||
// Stage 5: USER.md
|
||||
await userSetupStage(prompter, state);
|
||||
|
||||
// Stage 6: TOOLS.md
|
||||
await toolsSetupStage(prompter, state);
|
||||
|
||||
// Stage 7: Runtime Detection & Installation
|
||||
await runtimeSetupStage(prompter, state);
|
||||
|
||||
// Stage 8: Skills Selection
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Stage 9: Finalize
|
||||
await finalizeStage(prompter, state, configService);
|
||||
}
|
||||
@@ -15,7 +15,15 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.9.0",
|
||||
"commander": "^12.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
|
||||
100
packages/prdy/src/cli.ts
Normal file
100
packages/prdy/src/cli.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { createPrd, listPrds, loadPrd } from './prd.js';
|
||||
import { runPrdWizard } from './wizard.js';
|
||||
|
||||
interface InitCommandOptions {
|
||||
readonly name: string;
|
||||
readonly project: string;
|
||||
readonly template?: 'software' | 'feature' | 'spike';
|
||||
}
|
||||
|
||||
interface ListCommandOptions {
|
||||
readonly project: string;
|
||||
}
|
||||
|
||||
interface ShowCommandOptions {
|
||||
readonly project: string;
|
||||
readonly id?: string;
|
||||
}
|
||||
|
||||
export function buildPrdyCli(): Command {
|
||||
const program = new Command();
|
||||
program.name('mosaic').description('Mosaic CLI').exitOverride();
|
||||
|
||||
const prdy = program.command('prdy').description('PRD wizard commands');
|
||||
|
||||
prdy
|
||||
.command('init')
|
||||
.description('Create a PRD document')
|
||||
.requiredOption('--name <name>', 'PRD name')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.option('--template <template>', 'Template (software|feature|spike)')
|
||||
.action(async (options: InitCommandOptions) => {
|
||||
const doc = process.stdout.isTTY
|
||||
? await runPrdWizard({
|
||||
name: options.name,
|
||||
projectPath: options.project,
|
||||
template: options.template,
|
||||
interactive: true,
|
||||
})
|
||||
: await createPrd({
|
||||
name: options.name,
|
||||
projectPath: options.project,
|
||||
template: options.template,
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
status: doc.status,
|
||||
projectPath: doc.projectPath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
prdy
|
||||
.command('list')
|
||||
.description('List PRD documents for a project')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.action(async (options: ListCommandOptions) => {
|
||||
const docs = await listPrds(options.project);
|
||||
console.log(JSON.stringify(docs, null, 2));
|
||||
});
|
||||
|
||||
prdy
|
||||
.command('show')
|
||||
.description('Show a PRD document')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.option('--id <id>', 'PRD document id')
|
||||
.action(async (options: ShowCommandOptions) => {
|
||||
if (options.id !== undefined) {
|
||||
const docs = await listPrds(options.project);
|
||||
const match = docs.find((doc) => doc.id === options.id);
|
||||
|
||||
if (match === undefined) {
|
||||
throw new Error(`PRD id not found: ${options.id}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(match, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await loadPrd(options.project);
|
||||
console.log(JSON.stringify(doc, null, 2));
|
||||
});
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
export async function runPrdyCli(argv: readonly string[] = process.argv): Promise<void> {
|
||||
const program = buildPrdyCli();
|
||||
await program.parseAsync(argv);
|
||||
}
|
||||
@@ -1 +1,12 @@
|
||||
export const VERSION = '0.0.0';
|
||||
export { createPrd, loadPrd, savePrd, listPrds } from './prd.js';
|
||||
export { runPrdWizard } from './wizard.js';
|
||||
export { buildPrdyCli, runPrdyCli } from './cli.js';
|
||||
export { BUILTIN_PRD_TEMPLATES, resolveTemplate } from './templates.js';
|
||||
export type {
|
||||
PrdStatus,
|
||||
PrdTemplate,
|
||||
PrdTemplateSection,
|
||||
PrdSection,
|
||||
PrdDocument,
|
||||
CreatePrdOptions,
|
||||
} from './types.js';
|
||||
|
||||
199
packages/prdy/src/prd.ts
Normal file
199
packages/prdy/src/prd.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { type Dirent, promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import yaml from 'js-yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { resolveTemplate } from './templates.js';
|
||||
import type { CreatePrdOptions, PrdDocument } from './types.js';
|
||||
|
||||
const PRD_DIRECTORY = path.join('docs', 'prdy');
|
||||
const PRD_FILE_EXTENSIONS = new Set(['.yaml', '.yml']);
|
||||
|
||||
const prdSectionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
fields: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
const prdDocumentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
status: z.enum(['draft', 'review', 'approved', 'archived']),
|
||||
projectPath: z.string().min(1),
|
||||
template: z.string().min(1),
|
||||
sections: z.array(prdSectionSchema),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
function expandHome(projectPath: string): string {
|
||||
if (!projectPath.startsWith('~')) {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
if (projectPath === '~') {
|
||||
return os.homedir();
|
||||
}
|
||||
|
||||
if (projectPath.startsWith('~/')) {
|
||||
return path.join(os.homedir(), projectPath.slice(2));
|
||||
}
|
||||
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
function resolveProjectPath(projectPath: string): string {
|
||||
return path.resolve(expandHome(projectPath));
|
||||
}
|
||||
|
||||
function toSlug(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-');
|
||||
}
|
||||
|
||||
function buildTimestamp(date: Date): { datePart: string; timePart: string } {
|
||||
const iso = date.toISOString();
|
||||
return {
|
||||
datePart: iso.slice(0, 10).replace(/-/g, ''),
|
||||
timePart: iso.slice(11, 19).replace(/:/g, ''),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrdId(name: string): string {
|
||||
const slug = toSlug(name);
|
||||
const { datePart, timePart } = buildTimestamp(new Date());
|
||||
return `${slug || 'prd'}-${datePart}-${timePart}`;
|
||||
}
|
||||
|
||||
function prdDirectory(projectPath: string): string {
|
||||
return path.join(projectPath, PRD_DIRECTORY);
|
||||
}
|
||||
|
||||
function prdFilePath(projectPath: string, id: string): string {
|
||||
return path.join(prdDirectory(projectPath), `${id}.yaml`);
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
|
||||
const directory = path.dirname(filePath);
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
|
||||
const tempPath = path.join(
|
||||
directory,
|
||||
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
|
||||
.toString(16)
|
||||
.slice(2)}`,
|
||||
);
|
||||
|
||||
await fs.writeFile(tempPath, content, 'utf8');
|
||||
await fs.rename(tempPath, filePath);
|
||||
}
|
||||
|
||||
export async function createPrd(options: CreatePrdOptions): Promise<PrdDocument> {
|
||||
const resolvedProjectPath = resolveProjectPath(options.projectPath);
|
||||
const template = resolveTemplate(options.template);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const document: PrdDocument = {
|
||||
id: buildPrdId(options.name),
|
||||
title: options.name.trim(),
|
||||
status: 'draft',
|
||||
projectPath: resolvedProjectPath,
|
||||
template: template.id,
|
||||
sections: template.sections.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
fields: Object.fromEntries(section.fields.map((field) => [field, ''])),
|
||||
})),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await savePrd(document);
|
||||
return document;
|
||||
}
|
||||
|
||||
export async function loadPrd(projectPath: string): Promise<PrdDocument> {
|
||||
const documents = await listPrds(projectPath);
|
||||
if (documents.length === 0) {
|
||||
const resolvedProjectPath = resolveProjectPath(projectPath);
|
||||
throw new Error(`No PRD documents found in ${prdDirectory(resolvedProjectPath)}`);
|
||||
}
|
||||
|
||||
return documents[0]!;
|
||||
}
|
||||
|
||||
export async function savePrd(doc: PrdDocument): Promise<void> {
|
||||
const normalized = prdDocumentSchema.parse({
|
||||
...doc,
|
||||
projectPath: resolveProjectPath(doc.projectPath),
|
||||
});
|
||||
|
||||
const filePath = prdFilePath(normalized.projectPath, normalized.id);
|
||||
const serialized = yaml.dump(normalized, {
|
||||
noRefs: true,
|
||||
sortKeys: false,
|
||||
lineWidth: 120,
|
||||
});
|
||||
|
||||
const content = serialized.endsWith('\n') ? serialized : `${serialized}\n`;
|
||||
await writeFileAtomic(filePath, content);
|
||||
}
|
||||
|
||||
export async function listPrds(projectPath: string): Promise<PrdDocument[]> {
|
||||
const resolvedProjectPath = resolveProjectPath(projectPath);
|
||||
const directory = prdDirectory(resolvedProjectPath);
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(directory, { withFileTypes: true, encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, 'ENOENT')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const documents: PrdDocument[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = path.extname(entry.name);
|
||||
if (!PRD_FILE_EXTENSIONS.has(ext)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(directory, entry.name);
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = yaml.load(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`);
|
||||
}
|
||||
|
||||
const document = prdDocumentSchema.parse(parsed);
|
||||
documents.push(document);
|
||||
}
|
||||
|
||||
documents.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
return documents;
|
||||
}
|
||||
93
packages/prdy/src/templates.ts
Normal file
93
packages/prdy/src/templates.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { PrdTemplate } from './types.js';
|
||||
|
||||
export const BUILTIN_PRD_TEMPLATES: Record<string, PrdTemplate> = {
|
||||
software: {
|
||||
id: 'software',
|
||||
name: 'Software Project',
|
||||
fields: ['owner', 'status', 'scopeVersion', 'successMetrics'],
|
||||
sections: [
|
||||
{ id: 'introduction', title: 'Introduction', fields: ['context', 'objective'] },
|
||||
{ id: 'problem-statement', title: 'Problem Statement', fields: ['painPoints'] },
|
||||
{ id: 'scope-non-goals', title: 'Scope / Non-Goals', fields: ['inScope', 'outOfScope'] },
|
||||
{ id: 'user-stories', title: 'User Stories / Requirements', fields: ['stories'] },
|
||||
{ id: 'functional-requirements', title: 'Functional Requirements', fields: ['requirements'] },
|
||||
{
|
||||
id: 'non-functional-requirements',
|
||||
title: 'Non-Functional Requirements',
|
||||
fields: ['performance', 'reliability', 'security'],
|
||||
},
|
||||
{ id: 'acceptance-criteria', title: 'Acceptance Criteria', fields: ['criteria'] },
|
||||
{
|
||||
id: 'technical-considerations',
|
||||
title: 'Technical Considerations',
|
||||
fields: ['constraints', 'dependencies'],
|
||||
},
|
||||
{
|
||||
id: 'risks-open-questions',
|
||||
title: 'Risks / Open Questions',
|
||||
fields: ['risks', 'openQuestions'],
|
||||
},
|
||||
{
|
||||
id: 'milestones-delivery',
|
||||
title: 'Milestones / Delivery',
|
||||
fields: ['milestones', 'timeline'],
|
||||
},
|
||||
],
|
||||
},
|
||||
feature: {
|
||||
id: 'feature',
|
||||
name: 'Feature PRD',
|
||||
fields: ['owner', 'status', 'releaseTarget'],
|
||||
sections: [
|
||||
{ id: 'problem-statement', title: 'Problem Statement', fields: ['problem'] },
|
||||
{ id: 'goals', title: 'Goals', fields: ['goals'] },
|
||||
{ id: 'scope', title: 'Scope', fields: ['inScope', 'outOfScope'] },
|
||||
{ id: 'user-stories', title: 'User Stories', fields: ['stories'] },
|
||||
{ id: 'requirements', title: 'Requirements', fields: ['functional', 'nonFunctional'] },
|
||||
{ id: 'acceptance-criteria', title: 'Acceptance Criteria', fields: ['criteria'] },
|
||||
{
|
||||
id: 'technical-considerations',
|
||||
title: 'Technical Considerations',
|
||||
fields: ['constraints'],
|
||||
},
|
||||
{
|
||||
id: 'risks-open-questions',
|
||||
title: 'Risks / Open Questions',
|
||||
fields: ['risks', 'questions'],
|
||||
},
|
||||
{ id: 'milestones', title: 'Milestones', fields: ['milestones'] },
|
||||
{ id: 'success-metrics', title: 'Success Metrics / Testing', fields: ['metrics', 'testing'] },
|
||||
],
|
||||
},
|
||||
spike: {
|
||||
id: 'spike',
|
||||
name: 'Research Spike',
|
||||
fields: ['owner', 'status', 'decisionDeadline'],
|
||||
sections: [
|
||||
{ id: 'background', title: 'Background', fields: ['context'] },
|
||||
{ id: 'research-questions', title: 'Research Questions', fields: ['questions'] },
|
||||
{ id: 'constraints', title: 'Constraints', fields: ['constraints'] },
|
||||
{ id: 'options', title: 'Options Considered', fields: ['options'] },
|
||||
{ id: 'evaluation', title: 'Evaluation Criteria', fields: ['criteria'] },
|
||||
{ id: 'findings', title: 'Findings', fields: ['findings'] },
|
||||
{ id: 'recommendation', title: 'Recommendation', fields: ['recommendation'] },
|
||||
{ id: 'risks', title: 'Risks / Unknowns', fields: ['risks', 'unknowns'] },
|
||||
{ id: 'next-steps', title: 'Next Steps', fields: ['nextSteps'] },
|
||||
{ id: 'milestones', title: 'Milestones / Delivery', fields: ['milestones'] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveTemplate(templateName?: string): PrdTemplate {
|
||||
const name =
|
||||
templateName === undefined || templateName.trim().length === 0 ? 'software' : templateName;
|
||||
|
||||
const template = BUILTIN_PRD_TEMPLATES[name];
|
||||
if (template === undefined) {
|
||||
throw new Error(
|
||||
`Unknown PRD template: ${name}. Expected one of: ${Object.keys(BUILTIN_PRD_TEMPLATES).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
38
packages/prdy/src/types.ts
Normal file
38
packages/prdy/src/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type PrdStatus = 'draft' | 'review' | 'approved' | 'archived';
|
||||
|
||||
export interface PrdTemplateSection {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface PrdTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
sections: PrdTemplateSection[];
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface PrdSection {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PrdDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
status: PrdStatus;
|
||||
projectPath: string;
|
||||
template: string;
|
||||
sections: PrdSection[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreatePrdOptions {
|
||||
name: string;
|
||||
projectPath: string;
|
||||
template?: string;
|
||||
interactive?: boolean;
|
||||
}
|
||||
103
packages/prdy/src/wizard.ts
Normal file
103
packages/prdy/src/wizard.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts';
|
||||
|
||||
import { createPrd, savePrd } from './prd.js';
|
||||
import type { CreatePrdOptions, PrdDocument } from './types.js';
|
||||
|
||||
interface WizardAnswers {
|
||||
goals: string;
|
||||
constraints: string;
|
||||
milestones: string;
|
||||
}
|
||||
|
||||
function updateSectionField(doc: PrdDocument, sectionKeyword: string, value: string): void {
|
||||
const section = doc.sections.find((candidate) => candidate.id.includes(sectionKeyword));
|
||||
|
||||
if (section === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldName =
|
||||
Object.keys(section.fields).find((field) => field.toLowerCase().includes(sectionKeyword)) ??
|
||||
Object.keys(section.fields)[0];
|
||||
|
||||
if (fieldName !== undefined) {
|
||||
section.fields[fieldName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
async function promptText(message: string, initialValue = ''): Promise<string> {
|
||||
const response = await text({
|
||||
message,
|
||||
initialValue,
|
||||
});
|
||||
|
||||
if (isCancel(response)) {
|
||||
cancel('PRD wizard cancelled.');
|
||||
throw new Error('PRD wizard cancelled');
|
||||
}
|
||||
|
||||
return response.trim();
|
||||
}
|
||||
|
||||
async function promptTemplate(template?: string): Promise<string> {
|
||||
if (template !== undefined && template.trim().length > 0) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const choice = await select({
|
||||
message: 'PRD type',
|
||||
options: [
|
||||
{ value: 'software', label: 'Software project' },
|
||||
{ value: 'feature', label: 'Feature' },
|
||||
{ value: 'spike', label: 'Research spike' },
|
||||
],
|
||||
});
|
||||
|
||||
if (isCancel(choice)) {
|
||||
cancel('PRD wizard cancelled.');
|
||||
throw new Error('PRD wizard cancelled');
|
||||
}
|
||||
|
||||
return choice;
|
||||
}
|
||||
|
||||
function applyWizardAnswers(doc: PrdDocument, answers: WizardAnswers): PrdDocument {
|
||||
updateSectionField(doc, 'goal', answers.goals);
|
||||
updateSectionField(doc, 'constraint', answers.constraints);
|
||||
updateSectionField(doc, 'milestone', answers.milestones);
|
||||
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocument> {
|
||||
intro('Mosaic PRD wizard');
|
||||
|
||||
const name =
|
||||
options.name.trim().length > 0 ? options.name.trim() : await promptText('Project name');
|
||||
const template = await promptTemplate(options.template);
|
||||
const goals = await promptText('Primary goals');
|
||||
const constraints = await promptText('Key constraints');
|
||||
const milestones = await promptText('Planned milestones');
|
||||
|
||||
const doc = await createPrd({
|
||||
...options,
|
||||
name,
|
||||
template,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
const updated = applyWizardAnswers(doc, {
|
||||
goals,
|
||||
constraints,
|
||||
milestones,
|
||||
});
|
||||
|
||||
await savePrd(updated);
|
||||
|
||||
outro(`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`);
|
||||
|
||||
return updated;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@mosaic/quality-rails",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
@@ -15,7 +16,11 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
|
||||
202
packages/quality-rails/src/cli.ts
Normal file
202
packages/quality-rails/src/cli.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { detectProjectKind } from './detect.js';
|
||||
import { scaffoldQualityRails } from './scaffolder.js';
|
||||
import type { ProjectKind, QualityProfile, RailsConfig } from './types.js';
|
||||
|
||||
const VALID_PROFILES: readonly QualityProfile[] = ['strict', 'standard', 'minimal'];
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseProfile(rawProfile: string): QualityProfile {
|
||||
if (VALID_PROFILES.includes(rawProfile as QualityProfile)) {
|
||||
return rawProfile as QualityProfile;
|
||||
}
|
||||
throw new Error(`Invalid profile: ${rawProfile}. Use one of ${VALID_PROFILES.join(', ')}.`);
|
||||
}
|
||||
|
||||
function defaultLinters(kind: ProjectKind): string[] {
|
||||
if (kind === 'node') {
|
||||
return ['eslint', 'biome'];
|
||||
}
|
||||
|
||||
if (kind === 'python') {
|
||||
return ['ruff'];
|
||||
}
|
||||
|
||||
if (kind === 'rust') {
|
||||
return ['clippy'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function defaultFormatters(kind: ProjectKind): string[] {
|
||||
if (kind === 'node') {
|
||||
return ['prettier'];
|
||||
}
|
||||
|
||||
if (kind === 'python') {
|
||||
return ['black'];
|
||||
}
|
||||
|
||||
if (kind === 'rust') {
|
||||
return ['rustfmt'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function expectedFilesForKind(kind: ProjectKind): string[] {
|
||||
if (kind === 'node') {
|
||||
return ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
|
||||
}
|
||||
|
||||
if (kind === 'python') {
|
||||
return ['pyproject.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
|
||||
}
|
||||
|
||||
if (kind === 'rust') {
|
||||
return ['rustfmt.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
|
||||
}
|
||||
|
||||
return ['.githooks/pre-commit', 'PR-CHECKLIST.md'];
|
||||
}
|
||||
|
||||
function printScaffoldResult(
|
||||
config: RailsConfig,
|
||||
filesWritten: string[],
|
||||
warnings: string[],
|
||||
commandsToRun: string[],
|
||||
): void {
|
||||
console.log(`[quality-rails] initialized at ${config.projectPath}`);
|
||||
console.log(`kind=${config.kind} profile=${config.profile}`);
|
||||
|
||||
if (filesWritten.length > 0) {
|
||||
console.log('files written:');
|
||||
for (const filePath of filesWritten) {
|
||||
console.log(` - ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (commandsToRun.length > 0) {
|
||||
console.log('run next:');
|
||||
for (const command of commandsToRun) {
|
||||
console.log(` - ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.log('warnings:');
|
||||
for (const warning of warnings) {
|
||||
console.log(` - ${warning}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createQualityRailsCli(): Command {
|
||||
const program = new Command('mosaic');
|
||||
const qualityRails = program
|
||||
.command('quality-rails')
|
||||
.description('Manage quality rails scaffolding');
|
||||
|
||||
qualityRails
|
||||
.command('init')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.option('--profile <profile>', 'strict|standard|minimal', 'standard')
|
||||
.action(async (options: { project: string; profile: string }) => {
|
||||
const profile = parseProfile(options.profile);
|
||||
const projectPath = resolve(options.project);
|
||||
const kind = await detectProjectKind(projectPath);
|
||||
|
||||
const config: RailsConfig = {
|
||||
projectPath,
|
||||
kind,
|
||||
profile,
|
||||
linters: defaultLinters(kind),
|
||||
formatters: defaultFormatters(kind),
|
||||
hooks: true,
|
||||
};
|
||||
|
||||
const result = await scaffoldQualityRails(config);
|
||||
printScaffoldResult(config, result.filesWritten, result.warnings, result.commandsToRun);
|
||||
});
|
||||
|
||||
qualityRails
|
||||
.command('check')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.action(async (options: { project: string }) => {
|
||||
const projectPath = resolve(options.project);
|
||||
const kind = await detectProjectKind(projectPath);
|
||||
const expected = expectedFilesForKind(kind);
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const relativePath of expected) {
|
||||
const exists = await fileExists(resolve(projectPath, relativePath));
|
||||
if (!exists) {
|
||||
missing.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error('[quality-rails] missing files:');
|
||||
for (const relativePath of missing) {
|
||||
console.error(` - ${relativePath}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[quality-rails] all expected files present for ${kind} project`);
|
||||
});
|
||||
|
||||
qualityRails
|
||||
.command('doctor')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.action(async (options: { project: string }) => {
|
||||
const projectPath = resolve(options.project);
|
||||
const kind = await detectProjectKind(projectPath);
|
||||
const expected = expectedFilesForKind(kind);
|
||||
|
||||
console.log(`[quality-rails] doctor for ${projectPath}`);
|
||||
console.log(`detected project kind: ${kind}`);
|
||||
|
||||
for (const relativePath of expected) {
|
||||
const exists = await fileExists(resolve(projectPath, relativePath));
|
||||
console.log(` - ${exists ? 'ok' : 'missing'}: ${relativePath}`);
|
||||
}
|
||||
|
||||
if (kind === 'unknown') {
|
||||
console.log(
|
||||
'recommendation: add package.json, pyproject.toml, or Cargo.toml for better defaults.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
export async function runQualityRailsCli(argv: string[] = process.argv): Promise<void> {
|
||||
const program = createQualityRailsCli();
|
||||
await program.parseAsync(argv);
|
||||
}
|
||||
|
||||
const entryPath = process.argv[1] ? resolve(process.argv[1]) : '';
|
||||
if (entryPath.length > 0 && entryPath === fileURLToPath(import.meta.url)) {
|
||||
runQualityRailsCli().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
30
packages/quality-rails/src/detect.ts
Normal file
30
packages/quality-rails/src/detect.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ProjectKind } from './types.js';
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectProjectKind(projectPath: string): Promise<ProjectKind> {
|
||||
if (await fileExists(join(projectPath, 'package.json'))) {
|
||||
return 'node';
|
||||
}
|
||||
|
||||
if (await fileExists(join(projectPath, 'pyproject.toml'))) {
|
||||
return 'python';
|
||||
}
|
||||
|
||||
if (await fileExists(join(projectPath, 'Cargo.toml'))) {
|
||||
return 'rust';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
export const VERSION = '0.0.0';
|
||||
export * from './cli.js';
|
||||
export * from './detect.js';
|
||||
export * from './scaffolder.js';
|
||||
export * from './templates.js';
|
||||
export * from './types.js';
|
||||
|
||||
206
packages/quality-rails/src/scaffolder.ts
Normal file
206
packages/quality-rails/src/scaffolder.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import {
|
||||
biomeTemplate,
|
||||
eslintTemplate,
|
||||
prChecklistTemplate,
|
||||
preCommitHookTemplate,
|
||||
pyprojectSection,
|
||||
rustfmtTemplate,
|
||||
} from './templates.js';
|
||||
import type { RailsConfig, ScaffoldResult } from './types.js';
|
||||
|
||||
const PYPROJECT_START_MARKER = '# >>> mosaic-quality-rails >>>';
|
||||
const PYPROJECT_END_MARKER = '# <<< mosaic-quality-rails <<<';
|
||||
|
||||
async function ensureDirectory(filePath: string): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
async function writeRelativeFile(
|
||||
projectPath: string,
|
||||
relativePath: string,
|
||||
contents: string,
|
||||
result: ScaffoldResult,
|
||||
): Promise<void> {
|
||||
const absolutePath = join(projectPath, relativePath);
|
||||
await ensureDirectory(absolutePath);
|
||||
await writeFile(absolutePath, contents, { encoding: 'utf8', mode: 0o644 });
|
||||
result.filesWritten.push(relativePath);
|
||||
}
|
||||
|
||||
async function upsertPyproject(
|
||||
projectPath: string,
|
||||
profile: RailsConfig['profile'],
|
||||
result: ScaffoldResult,
|
||||
): Promise<void> {
|
||||
const pyprojectPath = join(projectPath, 'pyproject.toml');
|
||||
const nextSection = pyprojectSection(profile);
|
||||
|
||||
let previous = '';
|
||||
try {
|
||||
previous = await readFile(pyprojectPath, 'utf8');
|
||||
} catch {
|
||||
previous = '';
|
||||
}
|
||||
|
||||
const existingStart = previous.indexOf(PYPROJECT_START_MARKER);
|
||||
const existingEnd = previous.indexOf(PYPROJECT_END_MARKER);
|
||||
|
||||
if (existingStart >= 0 && existingEnd > existingStart) {
|
||||
const before = previous.slice(0, existingStart).trimEnd();
|
||||
const after = previous.slice(existingEnd + PYPROJECT_END_MARKER.length).trimStart();
|
||||
const rebuilt = [before, nextSection.trim(), after]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join('\n\n');
|
||||
await writeRelativeFile(projectPath, 'pyproject.toml', `${rebuilt}\n`, result);
|
||||
return;
|
||||
}
|
||||
|
||||
const separator = previous.trim().length > 0 ? '\n\n' : '';
|
||||
await writeRelativeFile(
|
||||
projectPath,
|
||||
'pyproject.toml',
|
||||
`${previous.trimEnd()}${separator}${nextSection}`,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: 'ignore',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Command failed (${code ?? 'unknown'}): ${command} ${args.join(' ')}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildNodeDevDependencies(config: RailsConfig): string[] {
|
||||
const dependencies = new Set<string>();
|
||||
|
||||
if (config.linters.some((linter) => linter.toLowerCase() === 'eslint')) {
|
||||
dependencies.add('eslint');
|
||||
dependencies.add('@typescript-eslint/parser');
|
||||
dependencies.add('@typescript-eslint/eslint-plugin');
|
||||
}
|
||||
|
||||
if (config.linters.some((linter) => linter.toLowerCase() === 'biome')) {
|
||||
dependencies.add('@biomejs/biome');
|
||||
}
|
||||
|
||||
if (config.formatters.some((formatter) => formatter.toLowerCase() === 'prettier')) {
|
||||
dependencies.add('prettier');
|
||||
}
|
||||
|
||||
if (config.hooks) {
|
||||
dependencies.add('husky');
|
||||
}
|
||||
|
||||
return [...dependencies];
|
||||
}
|
||||
|
||||
async function installNodeDependencies(config: RailsConfig, result: ScaffoldResult): Promise<void> {
|
||||
const dependencies = buildNodeDevDependencies(config);
|
||||
if (dependencies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commandLine = `pnpm add -D ${dependencies.join(' ')}`;
|
||||
|
||||
if (process.env['MOSAIC_QUALITY_RAILS_SKIP_INSTALL'] === '1') {
|
||||
result.commandsToRun.push(commandLine);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runCommand('pnpm', ['add', '-D', ...dependencies], config.projectPath);
|
||||
} catch (error) {
|
||||
result.warnings.push(
|
||||
`Failed to auto-install Node dependencies: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
result.commandsToRun.push(commandLine);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scaffoldQualityRails(config: RailsConfig): Promise<ScaffoldResult> {
|
||||
const result: ScaffoldResult = {
|
||||
filesWritten: [],
|
||||
commandsToRun: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const normalizedLinters = new Set(config.linters.map((linter) => linter.toLowerCase()));
|
||||
|
||||
if (config.kind === 'node') {
|
||||
if (normalizedLinters.has('eslint')) {
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'.eslintrc',
|
||||
eslintTemplate(config.profile),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedLinters.has('biome')) {
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'biome.json',
|
||||
biomeTemplate(config.profile),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
await installNodeDependencies(config, result);
|
||||
}
|
||||
|
||||
if (config.kind === 'python') {
|
||||
await upsertPyproject(config.projectPath, config.profile, result);
|
||||
}
|
||||
|
||||
if (config.kind === 'rust') {
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'rustfmt.toml',
|
||||
rustfmtTemplate(config.profile),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.hooks) {
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'.githooks/pre-commit',
|
||||
preCommitHookTemplate(config),
|
||||
result,
|
||||
);
|
||||
await chmod(join(config.projectPath, '.githooks/pre-commit'), 0o755);
|
||||
result.commandsToRun.push('git config core.hooksPath .githooks');
|
||||
}
|
||||
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'PR-CHECKLIST.md',
|
||||
prChecklistTemplate(config.profile),
|
||||
result,
|
||||
);
|
||||
|
||||
if (config.kind === 'unknown') {
|
||||
result.warnings.push(
|
||||
'Unable to detect project kind. Generated generic rails only (hooks + PR checklist).',
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
181
packages/quality-rails/src/templates.ts
Normal file
181
packages/quality-rails/src/templates.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { QualityProfile, RailsConfig } from './types.js';
|
||||
|
||||
const PROFILE_TO_MAX_WARNINGS: Record<QualityProfile, number> = {
|
||||
strict: 0,
|
||||
standard: 10,
|
||||
minimal: 50,
|
||||
};
|
||||
|
||||
const PROFILE_TO_LINE_LENGTH: Record<QualityProfile, number> = {
|
||||
strict: 100,
|
||||
standard: 110,
|
||||
minimal: 120,
|
||||
};
|
||||
|
||||
export function eslintTemplate(profile: QualityProfile): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
env: {
|
||||
node: true,
|
||||
es2022: true,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'no-console': profile === 'strict' ? 'error' : 'warn',
|
||||
'@typescript-eslint/no-explicit-any':
|
||||
profile === 'minimal' ? 'off' : profile === 'strict' ? 'error' : 'warn',
|
||||
'@typescript-eslint/explicit-function-return-type': profile === 'strict' ? 'warn' : 'off',
|
||||
'max-lines-per-function': [
|
||||
profile === 'minimal' ? 'off' : 'warn',
|
||||
{
|
||||
max: profile === 'strict' ? 60 : 100,
|
||||
skipBlankLines: true,
|
||||
skipComments: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function biomeTemplate(profile: QualityProfile): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
$schema: 'https://biomejs.dev/schemas/1.8.3/schema.json',
|
||||
formatter: {
|
||||
enabled: true,
|
||||
indentStyle: 'space',
|
||||
indentWidth: 2,
|
||||
lineWidth: PROFILE_TO_LINE_LENGTH[profile],
|
||||
},
|
||||
linter: {
|
||||
enabled: true,
|
||||
rules: {
|
||||
recommended: true,
|
||||
suspicious: {
|
||||
noConsole: profile === 'strict' ? 'error' : 'warn',
|
||||
},
|
||||
complexity: {
|
||||
noExcessiveCognitiveComplexity:
|
||||
profile === 'strict' ? 'warn' : profile === 'standard' ? 'info' : 'off',
|
||||
},
|
||||
},
|
||||
},
|
||||
javascript: {
|
||||
formatter: {
|
||||
quoteStyle: 'single',
|
||||
trailingCommas: 'all',
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function pyprojectSection(profile: QualityProfile): string {
|
||||
const lineLength = PROFILE_TO_LINE_LENGTH[profile];
|
||||
return [
|
||||
'# >>> mosaic-quality-rails >>>',
|
||||
'[tool.ruff]',
|
||||
`line-length = ${lineLength}`,
|
||||
'target-version = "py311"',
|
||||
'',
|
||||
'[tool.ruff.lint]',
|
||||
'select = ["E", "F", "I", "UP", "B"]',
|
||||
`ignore = ${profile === 'minimal' ? '[]' : '["E501"]'}`,
|
||||
'',
|
||||
'[tool.black]',
|
||||
`line-length = ${lineLength}`,
|
||||
'',
|
||||
'# <<< mosaic-quality-rails <<<',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function rustfmtTemplate(profile: QualityProfile): string {
|
||||
const maxWidth = PROFILE_TO_LINE_LENGTH[profile];
|
||||
const useSmallHeuristics = profile === 'strict' ? 'Max' : 'Default';
|
||||
|
||||
return [
|
||||
`max_width = ${maxWidth}`,
|
||||
`use_small_heuristics = "${useSmallHeuristics}"`,
|
||||
`imports_granularity = "${profile === 'minimal' ? 'Crate' : 'Module'}"`,
|
||||
`group_imports = "${profile === 'strict' ? 'StdExternalCrate' : 'Preserve'}"`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function resolveHookCommands(config: RailsConfig): string[] {
|
||||
const commands: string[] = [];
|
||||
|
||||
if (config.kind === 'node') {
|
||||
if (config.linters.some((linter) => linter.toLowerCase() === 'eslint')) {
|
||||
commands.push('pnpm lint');
|
||||
}
|
||||
if (config.linters.some((linter) => linter.toLowerCase() === 'biome')) {
|
||||
commands.push('pnpm biome check .');
|
||||
}
|
||||
if (config.formatters.some((formatter) => formatter.toLowerCase() === 'prettier')) {
|
||||
commands.push('pnpm prettier --check .');
|
||||
}
|
||||
commands.push('pnpm test --if-present');
|
||||
}
|
||||
|
||||
if (config.kind === 'python') {
|
||||
commands.push('ruff check .');
|
||||
commands.push('black --check .');
|
||||
}
|
||||
|
||||
if (config.kind === 'rust') {
|
||||
commands.push('cargo fmt --check');
|
||||
commands.push('cargo clippy --all-targets --all-features -- -D warnings');
|
||||
}
|
||||
|
||||
if (commands.length === 0) {
|
||||
commands.push('echo "No quality commands configured for this project kind"');
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
export function preCommitHookTemplate(config: RailsConfig): string {
|
||||
const commands = resolveHookCommands(config)
|
||||
.map((command) => `${command} || exit 1`)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'#!/usr/bin/env sh',
|
||||
'set -eu',
|
||||
'',
|
||||
'echo "[quality-rails] Running pre-commit checks..."',
|
||||
commands,
|
||||
'echo "[quality-rails] Checks passed."',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function prChecklistTemplate(profile: QualityProfile): string {
|
||||
return [
|
||||
'# Code Review Checklist',
|
||||
'',
|
||||
`Profile: **${profile}**`,
|
||||
'',
|
||||
'- [ ] Requirements mapped to tests',
|
||||
'- [ ] Error handling covers unhappy paths',
|
||||
'- [ ] Lint and typecheck are clean',
|
||||
'- [ ] Test suite passes',
|
||||
'- [ ] Security-sensitive paths reviewed',
|
||||
`- [ ] Warnings count <= ${PROFILE_TO_MAX_WARNINGS[profile]}`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
18
packages/quality-rails/src/types.ts
Normal file
18
packages/quality-rails/src/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type ProjectKind = 'node' | 'python' | 'rust' | 'unknown';
|
||||
|
||||
export type QualityProfile = 'strict' | 'standard' | 'minimal';
|
||||
|
||||
export interface RailsConfig {
|
||||
projectPath: string;
|
||||
kind: ProjectKind;
|
||||
profile: QualityProfile;
|
||||
linters: string[];
|
||||
formatters: string[];
|
||||
hooks: boolean;
|
||||
}
|
||||
|
||||
export interface ScaffoldResult {
|
||||
filesWritten: string[];
|
||||
commandsToRun: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
23
plugins/telegram/README.md
Normal file
23
plugins/telegram/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# @mosaic/telegram-plugin
|
||||
|
||||
`@mosaic/telegram-plugin` connects a Telegram bot to the Mosaic gateway chat namespace so Telegram chats can participate in the same conversation flow as the web, TUI, and Discord channels.
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN`: Bot token issued by BotFather
|
||||
- `TELEGRAM_GATEWAY_URL`: Base URL for the Mosaic gateway, for example `http://localhost:3000`
|
||||
|
||||
## What It Does
|
||||
|
||||
- Launches a Telegram bot with `telegraf`
|
||||
- Connects to `${TELEGRAM_GATEWAY_URL}/chat` with `socket.io-client`
|
||||
- Maps Telegram `chat.id` values to Mosaic `conversationId` values
|
||||
- Forwards inbound Telegram text messages to the gateway as user messages
|
||||
- Buffers `agent:start` / `agent:text` / `agent:end` socket events and sends the completed response back to the Telegram chat
|
||||
|
||||
## Getting a Bot Token
|
||||
|
||||
1. Open Telegram and start a chat with `@BotFather`
|
||||
2. Run `/newbot`
|
||||
3. Follow the prompts to name the bot and choose a username
|
||||
4. Copy the generated token and assign it to `TELEGRAM_BOT_TOKEN`
|
||||
@@ -18,5 +18,9 @@
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"socket.io-client": "^4.8.0",
|
||||
"telegraf": "^4.16.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,187 @@
|
||||
export const VERSION = '0.0.0';
|
||||
import { Telegraf } from 'telegraf';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
|
||||
interface TelegramPluginConfig {
|
||||
token: string;
|
||||
gatewayUrl: string;
|
||||
}
|
||||
|
||||
interface TelegramUser {
|
||||
is_bot?: boolean;
|
||||
}
|
||||
|
||||
interface TelegramChat {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface TelegramTextMessage {
|
||||
chat: TelegramChat;
|
||||
from?: TelegramUser;
|
||||
text: string;
|
||||
}
|
||||
|
||||
class TelegramPlugin {
|
||||
readonly name = 'telegram';
|
||||
|
||||
private bot: Telegraf;
|
||||
private socket: Socket | null = null;
|
||||
private config: TelegramPluginConfig;
|
||||
/** Map Telegram chat ID → Mosaic conversation ID */
|
||||
private chatConversations = new Map<string, string>();
|
||||
/** Track in-flight responses to avoid duplicate streaming */
|
||||
private pendingResponses = new Map<string, string>();
|
||||
|
||||
constructor(config: TelegramPluginConfig) {
|
||||
this.config = config;
|
||||
this.bot = new Telegraf(this.config.token);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Connect to gateway WebSocket
|
||||
this.socket = io(`${this.config.gatewayUrl}/chat`, {
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('[telegram] Connected to gateway');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason: string) => {
|
||||
console.error(`[telegram] Disconnected from gateway: ${reason}`);
|
||||
this.pendingResponses.clear();
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (err: Error) => {
|
||||
console.error(`[telegram] Gateway connection error: ${err.message}`);
|
||||
});
|
||||
|
||||
// Handle streaming text from gateway
|
||||
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
|
||||
const pending = this.pendingResponses.get(data.conversationId);
|
||||
if (pending !== undefined) {
|
||||
this.pendingResponses.set(data.conversationId, pending + data.text);
|
||||
}
|
||||
});
|
||||
|
||||
// When agent finishes, send the accumulated response
|
||||
this.socket.on('agent:end', (data: { conversationId: string }) => {
|
||||
const text = this.pendingResponses.get(data.conversationId);
|
||||
if (text) {
|
||||
this.pendingResponses.delete(data.conversationId);
|
||||
this.sendToTelegram(data.conversationId, text).catch((err) => {
|
||||
console.error(`[telegram] Error sending response for ${data.conversationId}:`, err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('agent:start', (data: { conversationId: string }) => {
|
||||
this.pendingResponses.set(data.conversationId, '');
|
||||
});
|
||||
|
||||
// Set up Telegram message handler
|
||||
this.bot.on('message', (ctx) => {
|
||||
const message = this.getTextMessage(ctx.message);
|
||||
if (message) {
|
||||
this.handleTelegramMessage(message);
|
||||
}
|
||||
});
|
||||
|
||||
await this.bot.launch();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.bot.stop('SIGTERM');
|
||||
this.socket?.disconnect();
|
||||
}
|
||||
|
||||
private handleTelegramMessage(message: TelegramTextMessage): void {
|
||||
// Ignore bot messages
|
||||
if (message.from?.is_bot) return;
|
||||
|
||||
const content = message.text.trim();
|
||||
if (!content) return;
|
||||
|
||||
// Get or create conversation for this Telegram chat
|
||||
const chatId = String(message.chat.id);
|
||||
let conversationId = this.chatConversations.get(chatId);
|
||||
if (!conversationId) {
|
||||
conversationId = `telegram-${chatId}`;
|
||||
this.chatConversations.set(chatId, conversationId);
|
||||
}
|
||||
|
||||
// Send to gateway
|
||||
if (!this.socket?.connected) {
|
||||
console.error(`[telegram] Cannot forward message: not connected to gateway. chat=${chatId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit('message', {
|
||||
conversationId,
|
||||
content,
|
||||
role: 'user',
|
||||
});
|
||||
}
|
||||
|
||||
private getTextMessage(message: unknown): TelegramTextMessage | null {
|
||||
if (!message || typeof message !== 'object') return null;
|
||||
|
||||
const candidate = message as Partial<TelegramTextMessage>;
|
||||
if (typeof candidate.text !== 'string') return null;
|
||||
if (!candidate.chat || typeof candidate.chat.id !== 'number') return null;
|
||||
|
||||
return {
|
||||
chat: candidate.chat,
|
||||
from: candidate.from,
|
||||
text: candidate.text,
|
||||
};
|
||||
}
|
||||
|
||||
private async sendToTelegram(conversationId: string, text: string): Promise<void> {
|
||||
// Find the Telegram chat for this conversation
|
||||
const chatId = Array.from(this.chatConversations.entries()).find(
|
||||
([, convId]) => convId === conversationId,
|
||||
)?.[0];
|
||||
|
||||
if (!chatId) {
|
||||
console.error(`[telegram] No chat found for conversation ${conversationId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Chunk responses for Telegram's 4096-char limit
|
||||
const chunks = this.chunkText(text, 4000);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await this.bot.telegram.sendMessage(chatId, chunk);
|
||||
} catch (err) {
|
||||
console.error(`[telegram] Failed to send message to chat ${chatId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private chunkText(text: string, maxLength: number): string[] {
|
||||
if (text.length <= maxLength) return [text];
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to break at a newline
|
||||
let breakPoint = remaining.lastIndexOf('\n', maxLength);
|
||||
if (breakPoint <= 0) breakPoint = maxLength;
|
||||
|
||||
chunks.push(remaining.slice(0, breakPoint));
|
||||
remaining = remaining.slice(breakPoint).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
export { TelegramPlugin };
|
||||
export type { TelegramPluginConfig };
|
||||
export const VERSION = '0.0.5';
|
||||
|
||||
267
pnpm-lock.yaml
generated
267
pnpm-lock.yaml
generated
@@ -41,6 +41,9 @@ importers:
|
||||
|
||||
apps/gateway:
|
||||
dependencies:
|
||||
'@fastify/helmet':
|
||||
specifier: ^13.0.2
|
||||
version: 13.0.2
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ~0.57.1
|
||||
version: 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
@@ -59,12 +62,18 @@ importers:
|
||||
'@mosaic/db':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/db
|
||||
'@mosaic/discord-plugin':
|
||||
specifier: workspace:^
|
||||
version: link:../../plugins/discord
|
||||
'@mosaic/log':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/log
|
||||
'@mosaic/memory':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/memory
|
||||
'@mosaic/telegram-plugin':
|
||||
specifier: workspace:^
|
||||
version: link:../../plugins/telegram
|
||||
'@mosaic/types':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/types
|
||||
@@ -80,6 +89,9 @@ importers:
|
||||
'@nestjs/platform-socket.io':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(rxjs@7.8.2)
|
||||
'@nestjs/throttler':
|
||||
specifier: ^6.5.0
|
||||
version: 6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)
|
||||
'@nestjs/websockets':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -110,6 +122,12 @@ importers:
|
||||
better-auth:
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.5(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(lightningcss@1.31.1))
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
class-validator:
|
||||
specifier: ^0.15.1
|
||||
version: 0.15.1
|
||||
fastify:
|
||||
specifier: ^5.0.0
|
||||
version: 5.8.2
|
||||
@@ -250,6 +268,15 @@ importers:
|
||||
|
||||
packages/cli:
|
||||
dependencies:
|
||||
'@mosaic/mosaic':
|
||||
specifier: workspace:^
|
||||
version: link:../mosaic
|
||||
'@mosaic/prdy':
|
||||
specifier: workspace:^
|
||||
version: link:../prdy
|
||||
'@mosaic/quality-rails':
|
||||
specifier: workspace:^
|
||||
version: link:../quality-rails
|
||||
commander:
|
||||
specifier: ^13.0.0
|
||||
version: 13.1.0
|
||||
@@ -269,6 +296,9 @@ importers:
|
||||
specifier: ^4.8.0
|
||||
version: 4.8.3
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.15
|
||||
'@types/react':
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.28
|
||||
@@ -368,7 +398,26 @@ importers:
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
|
||||
packages/mosaic:
|
||||
dependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.9.1
|
||||
version: 0.9.1
|
||||
commander:
|
||||
specifier: ^12.1.0
|
||||
version: 12.1.0
|
||||
picocolors:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
yaml:
|
||||
specifier: ^2.6.1
|
||||
version: 2.8.2
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.15
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
@@ -377,7 +426,26 @@ importers:
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
|
||||
packages/prdy:
|
||||
dependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.1
|
||||
commander:
|
||||
specifier: ^12.0.0
|
||||
version: 12.1.0
|
||||
js-yaml:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.1
|
||||
zod:
|
||||
specifier: ^3.22.0
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/js-yaml':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.15
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
@@ -386,7 +454,14 @@ importers:
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
|
||||
packages/quality-rails:
|
||||
dependencies:
|
||||
commander:
|
||||
specifier: ^12.0.0
|
||||
version: 12.1.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.15
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
@@ -446,6 +521,13 @@ importers:
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
|
||||
plugins/telegram:
|
||||
dependencies:
|
||||
socket.io-client:
|
||||
specifier: ^4.8.0
|
||||
version: 4.8.3
|
||||
telegraf:
|
||||
specifier: ^4.16.3
|
||||
version: 4.16.3
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
@@ -681,6 +763,12 @@ packages:
|
||||
'@borewit/text-codec@0.2.2':
|
||||
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
||||
|
||||
'@clack/core@0.4.1':
|
||||
resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==}
|
||||
|
||||
'@clack/prompts@0.9.1':
|
||||
resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==}
|
||||
|
||||
'@discordjs/builders@1.13.1':
|
||||
resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
@@ -1361,6 +1449,9 @@ packages:
|
||||
'@fastify/forwarded@3.0.1':
|
||||
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
|
||||
|
||||
'@fastify/helmet@13.0.2':
|
||||
resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==}
|
||||
|
||||
'@fastify/merge-json-schemas@0.2.1':
|
||||
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||
|
||||
@@ -1710,6 +1801,13 @@ packages:
|
||||
'@nestjs/websockets': ^11.0.0
|
||||
rxjs: ^7.1.0
|
||||
|
||||
'@nestjs/throttler@6.5.0':
|
||||
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||
reflect-metadata: ^0.1.13 || ^0.2.0
|
||||
|
||||
'@nestjs/websockets@11.1.16':
|
||||
resolution: {integrity: sha512-kfLhCFsq6139JVFCQpbFB6LOEjZzdpE7JzXsZtRbVjqmsgTKVSIh8gKRgzpcq27rbLNqHhhZavboOltOfSxZow==}
|
||||
peerDependencies:
|
||||
@@ -2716,6 +2814,9 @@ packages:
|
||||
'@tailwindcss/postcss@4.2.1':
|
||||
resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==}
|
||||
|
||||
'@telegraf/types@7.1.0':
|
||||
resolution: {integrity: sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==}
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2741,6 +2842,9 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/js-yaml@4.0.9':
|
||||
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
@@ -2898,6 +3002,10 @@ packages:
|
||||
resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
|
||||
abstract-logging@2.0.1:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
@@ -3100,12 +3208,21 @@ packages:
|
||||
resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
buffer-alloc-unsafe@1.1.0:
|
||||
resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==}
|
||||
|
||||
buffer-alloc@1.2.0:
|
||||
resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==}
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer-fill@1.0.0:
|
||||
resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -3202,6 +3319,10 @@ packages:
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
commander@12.1.0:
|
||||
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
commander@13.1.0:
|
||||
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3525,6 +3646,10 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
event-target-shim@5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
@@ -3709,6 +3834,10 @@ packages:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
helmet@8.1.0:
|
||||
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
highlight.js@10.7.3:
|
||||
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
|
||||
|
||||
@@ -4136,6 +4265,10 @@ packages:
|
||||
socks:
|
||||
optional: true
|
||||
|
||||
mri@1.2.0:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -4192,6 +4325,15 @@ packages:
|
||||
engines: {node: '>=10.5.0'}
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
node-fetch@3.3.2:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -4255,6 +4397,10 @@ packages:
|
||||
resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-timeout@4.1.0:
|
||||
resolution: {integrity: sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
pac-proxy-agent@7.2.0:
|
||||
resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -4525,6 +4671,9 @@ packages:
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
safe-compare@1.1.4:
|
||||
resolution: {integrity: sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==}
|
||||
|
||||
safe-regex2@5.1.0:
|
||||
resolution: {integrity: sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==}
|
||||
hasBin: true
|
||||
@@ -4533,6 +4682,10 @@ packages:
|
||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
sandwich-stream@2.0.2:
|
||||
resolution: {integrity: sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
@@ -4575,6 +4728,9 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
slice-ansi@5.0.0:
|
||||
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4710,6 +4866,11 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
telegraf@4.16.3:
|
||||
resolution: {integrity: sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==}
|
||||
engines: {node: ^12.20.0 || >=14.13.1}
|
||||
hasBin: true
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -4755,6 +4916,9 @@ packages:
|
||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
tr46@5.1.1:
|
||||
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4932,6 +5096,9 @@ packages:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
webidl-conversions@7.0.0:
|
||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4940,6 +5107,9 @@ packages:
|
||||
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -5049,6 +5219,9 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25 || ^4
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
@@ -5497,6 +5670,17 @@ snapshots:
|
||||
|
||||
'@borewit/text-codec@0.2.2': {}
|
||||
|
||||
'@clack/core@0.4.1':
|
||||
dependencies:
|
||||
picocolors: 1.1.1
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@clack/prompts@0.9.1':
|
||||
dependencies:
|
||||
'@clack/core': 0.4.1
|
||||
picocolors: 1.1.1
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@discordjs/builders@1.13.1':
|
||||
dependencies:
|
||||
'@discordjs/formatters': 0.6.2
|
||||
@@ -5924,6 +6108,11 @@ snapshots:
|
||||
|
||||
'@fastify/forwarded@3.0.1': {}
|
||||
|
||||
'@fastify/helmet@13.0.2':
|
||||
dependencies:
|
||||
fastify-plugin: 5.1.0
|
||||
helmet: 8.1.0
|
||||
|
||||
'@fastify/merge-json-schemas@0.2.1':
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
@@ -6228,8 +6417,8 @@ snapshots:
|
||||
'@mistralai/mistralai@1.14.1':
|
||||
dependencies:
|
||||
ws: 8.19.0
|
||||
zod: 4.3.6
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
@@ -6294,6 +6483,12 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
reflect-metadata: 0.2.2
|
||||
|
||||
'@nestjs/websockets@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -7522,6 +7717,8 @@ snapshots:
|
||||
postcss: 8.5.8
|
||||
tailwindcss: 4.2.1
|
||||
|
||||
'@telegraf/types@7.1.0': {}
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -7549,6 +7746,8 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/js-yaml@4.0.9': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/memcached@2.2.10':
|
||||
@@ -7754,6 +7953,10 @@ snapshots:
|
||||
|
||||
'@vladfrangu/async_event_emitter@2.4.7': {}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
abstract-logging@2.0.1: {}
|
||||
|
||||
accepts@1.3.8:
|
||||
@@ -7898,10 +8101,19 @@ snapshots:
|
||||
|
||||
bson@7.2.0: {}
|
||||
|
||||
buffer-alloc-unsafe@1.1.0: {}
|
||||
|
||||
buffer-alloc@1.2.0:
|
||||
dependencies:
|
||||
buffer-alloc-unsafe: 1.1.0
|
||||
buffer-fill: 1.0.0
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-fill@1.0.0: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
cac@6.7.14: {}
|
||||
@@ -7993,6 +8205,8 @@ snapshots:
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
commander@12.1.0: {}
|
||||
|
||||
commander@13.1.0: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
@@ -8347,6 +8561,8 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
execa@8.0.1:
|
||||
@@ -8590,6 +8806,8 @@ snapshots:
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
helmet@8.1.0: {}
|
||||
|
||||
highlight.js@10.7.3: {}
|
||||
|
||||
hosted-git-info@9.0.2:
|
||||
@@ -8973,6 +9191,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
socks: 2.8.7
|
||||
|
||||
mri@1.2.0: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mz@2.7.0:
|
||||
@@ -9020,6 +9240,10 @@ snapshots:
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
node-fetch@3.3.2:
|
||||
dependencies:
|
||||
data-uri-to-buffer: 4.0.1
|
||||
@@ -9079,6 +9303,8 @@ snapshots:
|
||||
'@types/retry': 0.12.0
|
||||
retry: 0.13.1
|
||||
|
||||
p-timeout@4.1.0: {}
|
||||
|
||||
pac-proxy-agent@7.2.0:
|
||||
dependencies:
|
||||
'@tootallnate/quickjs-emscripten': 0.23.0
|
||||
@@ -9363,12 +9589,18 @@ snapshots:
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safe-compare@1.1.4:
|
||||
dependencies:
|
||||
buffer-alloc: 1.2.0
|
||||
|
||||
safe-regex2@5.1.0:
|
||||
dependencies:
|
||||
ret: 0.5.0
|
||||
|
||||
safe-stable-stringify@2.5.0: {}
|
||||
|
||||
sandwich-stream@2.0.2: {}
|
||||
|
||||
scheduler@0.23.2:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -9427,6 +9659,8 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
slice-ansi@5.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
@@ -9575,6 +9809,20 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
telegraf@4.16.3:
|
||||
dependencies:
|
||||
'@telegraf/types': 7.1.0
|
||||
abort-controller: 3.0.0
|
||||
debug: 4.4.3
|
||||
mri: 1.2.0
|
||||
node-fetch: 2.7.0
|
||||
p-timeout: 4.1.0
|
||||
safe-compare: 1.1.4
|
||||
sandwich-stream: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
@@ -9614,6 +9862,8 @@ snapshots:
|
||||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
tr46@5.1.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
@@ -9768,6 +10018,8 @@ snapshots:
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@7.0.0: {}
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
@@ -9775,6 +10027,11 @@ snapshots:
|
||||
tr46: 5.1.1
|
||||
webidl-conversions: 7.0.0
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -9857,8 +10114,14 @@ snapshots:
|
||||
|
||||
yoga-layout@3.2.1: {}
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@4.3.6):
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"dependsOn": ["^lint"]
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^typecheck"]
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^build"],
|
||||
|
||||
Reference in New Issue
Block a user