diff --git a/.github/workflows/trigger-tasks-deploy-main.yml b/.github/workflows/trigger-tasks-deploy-main.yml index 7d5a975c6..387b35c20 100644 --- a/.github/workflows/trigger-tasks-deploy-main.yml +++ b/.github/workflows/trigger-tasks-deploy-main.yml @@ -24,9 +24,15 @@ jobs: - name: Install Email package dependencies working-directory: ./packages/email run: bun install --frozen-lockfile --ignore-scripts - - name: Generate Prisma client + - name: Build DB package working-directory: ./packages/db - run: bunx prisma generate + run: bun run build + - name: Copy schema to app and generate client + working-directory: ./apps/app + run: | + mkdir -p prisma + cp ../../packages/db/dist/schema.prisma prisma/schema.prisma + bunx prisma generate - name: 🚀 Deploy Trigger.dev working-directory: ./apps/app timeout-minutes: 20 diff --git a/apps/api/buildspec.yml b/apps/api/buildspec.yml index 7a8b8c257..10238999f 100644 --- a/apps/api/buildspec.yml +++ b/apps/api/buildspec.yml @@ -27,6 +27,9 @@ phases: - '[ -n "$BASE_URL" ] || { echo "❌ BASE_URL is not set"; exit 1; }' - '[ -n "$BETTER_AUTH_URL" ] || { echo "❌ BETTER_AUTH_URL is not set"; exit 1; }' - '[ -n "$TRUST_APP_URL" ] || { echo "❌ TRUST_APP_URL is not set"; exit 1; }' + - '[ -n "$APP_AWS_BUCKET_NAME" ] || { echo "❌ APP_AWS_BUCKET_NAME is not set"; exit 1; }' + - '[ -n "$APP_AWS_ACCESS_KEY_ID" ] || { echo "❌ APP_AWS_ACCESS_KEY_ID is not set"; exit 1; }' + - '[ -n "$APP_AWS_SECRET_ACCESS_KEY" ] || { echo "❌ APP_AWS_SECRET_ACCESS_KEY is not set"; exit 1; }' # Install only API workspace dependencies - echo "Installing API dependencies only..." diff --git a/apps/api/src/attachments/attachments.service.ts b/apps/api/src/attachments/attachments.service.ts index a63c72038..cf93e12ca 100644 --- a/apps/api/src/attachments/attachments.service.ts +++ b/apps/api/src/attachments/attachments.service.ts @@ -26,6 +26,11 @@ export class AttachmentsService { // AWS configuration is validated at startup via ConfigModule // Safe to access environment variables directly since they're validated this.bucketName = process.env.APP_AWS_BUCKET_NAME!; + + if (!process.env.APP_AWS_ACCESS_KEY_ID || !process.env.APP_AWS_SECRET_ACCESS_KEY) { + console.warn('AWS credentials are missing, S3 client may fail'); + } + this.s3Client = new S3Client({ region: process.env.APP_AWS_REGION || 'us-east-1', credentials: { diff --git a/apps/app/customPrismaExtension.ts b/apps/app/customPrismaExtension.ts index 5afb75c50..f48e0f2fc 100644 --- a/apps/app/customPrismaExtension.ts +++ b/apps/app/customPrismaExtension.ts @@ -1,7 +1,8 @@ import { binaryForRuntime, BuildContext, BuildExtension, BuildManifest } from '@trigger.dev/build'; import assert from 'node:assert'; +import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { cp } from 'node:fs/promises'; +import { cp, mkdir } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; export type PrismaExtensionOptions = { @@ -14,6 +15,12 @@ export type PrismaExtensionOptions = { dbPackageVersion?: string; }; +type ExtendedBuildContext = BuildContext & { workspaceDir?: string }; +type SchemaResolution = { + path?: string; + searched: string[]; +}; + export function prismaExtension(options: PrismaExtensionOptions = {}): PrismaExtension { return new PrismaExtension(options); } @@ -43,43 +50,19 @@ export class PrismaExtension implements BuildExtension { return; } - // Resolve the path to the schema from the published @trycompai/db package - // In a monorepo, node_modules are typically hoisted to the workspace root - // Walk up the directory tree to find the workspace root (where node_modules/@trycompai/db exists) - let workspaceRoot = context.workingDir; - let dbPackagePath = resolve(workspaceRoot, 'node_modules/@trycompai/db/dist/schema.prisma'); - - // If not found in working dir, try parent directories - while (!existsSync(dbPackagePath) && workspaceRoot !== dirname(workspaceRoot)) { - workspaceRoot = dirname(workspaceRoot); - dbPackagePath = resolve(workspaceRoot, 'node_modules/@trycompai/db/dist/schema.prisma'); - } - - this._resolvedSchemaPath = dbPackagePath; - - context.logger.debug(`Workspace root: ${workspaceRoot}`); - context.logger.debug( - `Resolved the prisma schema from @trycompai/db package to: ${this._resolvedSchemaPath}`, - ); + const resolution = this.tryResolveSchemaPath(context as ExtendedBuildContext); - // Debug: List contents of the @trycompai/db package directory - const dbPackageDir = resolve(workspaceRoot, 'node_modules/@trycompai/db'); - const dbDistDir = resolve(workspaceRoot, 'node_modules/@trycompai/db/dist'); - - try { - const { readdirSync } = require('node:fs'); - context.logger.debug(`@trycompai/db package directory contents:`, readdirSync(dbPackageDir)); - context.logger.debug(`@trycompai/db/dist directory contents:`, readdirSync(dbDistDir)); - } catch (err) { - context.logger.debug(`Failed to list directory contents:`, err); - } - - // Check that the prisma schema exists in the published package - if (!existsSync(this._resolvedSchemaPath)) { - throw new Error( - `PrismaExtension could not find the prisma schema at ${this._resolvedSchemaPath}. Make sure @trycompai/db package is installed with version ${this.options.dbPackageVersion || 'latest'}`, + if (!resolution.path) { + context.logger.debug( + 'Prisma schema not found during build start, likely before dependencies are installed.', + { searched: resolution.searched }, ); + return; } + + this._resolvedSchemaPath = resolution.path; + context.logger.debug(`Resolved prisma schema to ${resolution.path}`); + await this.ensureLocalPrismaClient(context as ExtendedBuildContext, resolution.path); } async onBuildComplete(context: BuildContext, manifest: BuildManifest) { @@ -87,7 +70,27 @@ export class PrismaExtension implements BuildExtension { return; } + if (!this._resolvedSchemaPath || !existsSync(this._resolvedSchemaPath)) { + const resolution = this.tryResolveSchemaPath(context as ExtendedBuildContext); + + if (!resolution.path) { + throw new Error( + [ + 'PrismaExtension could not find the prisma schema. Make sure @trycompai/db is installed', + `with version ${this.options.dbPackageVersion || 'latest'} and that its dist files are built.`, + 'Searched the following locations:', + ...resolution.searched.map((candidate) => ` - ${candidate}`), + ].join('\n'), + ); + } + + this._resolvedSchemaPath = resolution.path; + } + assert(this._resolvedSchemaPath, 'Resolved schema path is not set'); + const schemaPath = this._resolvedSchemaPath; + + await this.ensureLocalPrismaClient(context as ExtendedBuildContext, schemaPath); context.logger.debug('Looking for @prisma/client in the externals', { externals: manifest.externals, @@ -114,9 +117,9 @@ export class PrismaExtension implements BuildExtension { // Copy the prisma schema from the published package to the build output path const schemaDestinationPath = join(manifest.outputPath, 'prisma', 'schema.prisma'); context.logger.debug( - `Copying the prisma schema from ${this._resolvedSchemaPath} to ${schemaDestinationPath}`, + `Copying the prisma schema from ${schemaPath} to ${schemaDestinationPath}`, ); - await cp(this._resolvedSchemaPath, schemaDestinationPath); + await cp(schemaPath, schemaDestinationPath); // Add prisma generate command to generate the client from the copied schema commands.push( @@ -176,4 +179,116 @@ export class PrismaExtension implements BuildExtension { }, }); } + + private async ensureLocalPrismaClient( + context: ExtendedBuildContext, + schemaSourcePath: string, + ): Promise { + const schemaDir = resolve(context.workingDir, 'prisma'); + const schemaDestinationPath = resolve(schemaDir, 'schema.prisma'); + + await mkdir(schemaDir, { recursive: true }); + await cp(schemaSourcePath, schemaDestinationPath); + + const clientEntryPoint = resolve(context.workingDir, 'node_modules/.prisma/client/default.js'); + + if (existsSync(clientEntryPoint) && !process.env.TRIGGER_PRISMA_FORCE_GENERATE) { + context.logger.debug('Prisma client already generated locally, skipping regenerate.'); + return; + } + + const prismaBinary = this.resolvePrismaBinary(context.workingDir); + + if (!prismaBinary) { + context.logger.debug( + 'Prisma CLI not available yet, skipping local generate until install finishes.', + ); + return; + } + + context.logger.log('Prisma client missing. Generating before Trigger indexing.'); + await this.runPrismaGenerate(context, prismaBinary, schemaDestinationPath); + } + + private runPrismaGenerate( + context: ExtendedBuildContext, + prismaBinary: string, + schemaPath: string, + ): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(prismaBinary, ['generate', `--schema=${schemaPath}`], { + cwd: context.workingDir, + env: { + ...process.env, + PRISMA_HIDE_UPDATE_MESSAGE: '1', + }, + }); + + child.stdout?.on('data', (data: Buffer) => { + context.logger.debug(data.toString().trim()); + }); + + child.stderr?.on('data', (data: Buffer) => { + context.logger.warn(data.toString().trim()); + }); + + child.on('error', (error) => { + rejectPromise(error); + }); + + child.on('close', (code) => { + if (code === 0) { + resolvePromise(); + } else { + rejectPromise(new Error(`prisma generate exited with code ${code}`)); + } + }); + }); + } + + private resolvePrismaBinary(workingDir: string): string | undefined { + const binDir = resolve(workingDir, 'node_modules', '.bin'); + const executable = process.platform === 'win32' ? 'prisma.cmd' : 'prisma'; + const binaryPath = resolve(binDir, executable); + + if (!existsSync(binaryPath)) { + return undefined; + } + + return binaryPath; + } + + private tryResolveSchemaPath(context: ExtendedBuildContext): SchemaResolution { + const candidates = this.buildSchemaCandidates(context); + const path = candidates.find((candidate) => existsSync(candidate)); + return { path, searched: candidates }; + } + + private buildSchemaCandidates(context: ExtendedBuildContext): string[] { + const candidates = new Set(); + + const addNodeModuleCandidates = (start: string | undefined) => { + if (!start) { + return; + } + + let current = start; + while (true) { + candidates.add(resolve(current, 'node_modules/@trycompai/db/dist/schema.prisma')); + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; + } + }; + + addNodeModuleCandidates(context.workingDir); + addNodeModuleCandidates(context.workspaceDir); + + candidates.add(resolve(context.workingDir, '../../packages/db/dist/schema.prisma')); + candidates.add(resolve(context.workingDir, '../packages/db/dist/schema.prisma')); + + return Array.from(candidates); + } } diff --git a/apps/app/package.json b/apps/app/package.json index 6de9953a8..99ab867b8 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -84,6 +84,7 @@ "playwright-core": "^1.52.0", "posthog-js": "^1.236.6", "posthog-node": "^5.8.2", + "prisma": "^6.13.0", "puppeteer-core": "^24.7.2", "react": "^19.1.1", "react-dom": "^19.1.0", @@ -133,7 +134,6 @@ "glob": "^11.0.3", "jsdom": "^26.1.0", "postcss": "^8.5.4", - "prisma": "^6.13.0", "raw-loader": "^4.0.2", "tailwindcss": "^4.1.8", "typescript": "^5.8.3", @@ -164,6 +164,7 @@ "dev": "bun i && bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"next dev --turbo -p 3000\" \"bunx trigger.dev@4.0.6 dev\"", "lint": "next lint && prettier --check .", "prebuild": "bun run db:generate", + "postinstall": "prisma generate --schema=./prisma/schema.prisma || exit 0", "start": "next start", "test": "vitest", "test:all": "./scripts/test-all.sh", diff --git a/apps/app/scripts/trigger-generate-prisma-client.js b/apps/app/scripts/trigger-generate-prisma-client.js new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/app/scripts/trigger-generate-prisma-client.js @@ -0,0 +1 @@ + diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx index 458835282..fa37a99e9 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx @@ -1,5 +1,10 @@ 'use client'; +import { + MAC_APPLE_SILICON_FILENAME, + MAC_INTEL_FILENAME, + WINDOWS_FILENAME, +} from '@/app/api/download-agent/constants'; import { detectOSFromUserAgent, SupportedOS } from '@/utils/os'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@comp/ui/accordion'; import { Button } from '@comp/ui/button'; @@ -51,6 +56,11 @@ export function DeviceAgentAccordionItem({ const isCompleted = hasInstalledAgent && failedPoliciesCount === 0; const handleDownload = async () => { + if (!detectedOS) { + toast.error('Could not detect your OS. Please refresh and try again.'); + return; + } + setIsDownloading(true); try { @@ -61,6 +71,7 @@ export function DeviceAgentAccordionItem({ body: JSON.stringify({ orgId: member.organizationId, employeeId: member.id, + os: detectedOS, }), }); @@ -73,7 +84,7 @@ export function DeviceAgentAccordionItem({ // Now trigger the actual download using the browser's native download mechanism // This will show in the browser's download UI immediately - const downloadUrl = `/api/download-agent?token=${encodeURIComponent(token)}&os=${detectedOS}`; + const downloadUrl = `/api/download-agent?token=${encodeURIComponent(token)}`; // Method 1: Using a temporary link (most reliable) const a = document.createElement('a'); @@ -81,12 +92,9 @@ export function DeviceAgentAccordionItem({ // Set filename based on OS and architecture if (isMacOS) { - a.download = - detectedOS === 'macos' - ? 'Comp AI Agent-1.0.0-arm64.dmg' - : 'Comp AI Agent-1.0.0-intel.dmg'; + a.download = detectedOS === 'macos' ? MAC_APPLE_SILICON_FILENAME : MAC_INTEL_FILENAME; } else { - a.download = 'Comp AI Agent 1.0.0.exe'; + a.download = WINDOWS_FILENAME; } document.body.appendChild(a); diff --git a/apps/portal/src/app/api/download-agent/constants.ts b/apps/portal/src/app/api/download-agent/constants.ts new file mode 100644 index 000000000..dc77a5eba --- /dev/null +++ b/apps/portal/src/app/api/download-agent/constants.ts @@ -0,0 +1,3 @@ +export const MAC_APPLE_SILICON_FILENAME = 'Comp AI Agent-1.0.0-arm64.dmg'; +export const MAC_INTEL_FILENAME = 'Comp AI Agent-1.0.0.dmg'; +export const WINDOWS_FILENAME = 'Comp AI Agent 1.0.0.exe'; diff --git a/apps/portal/src/app/api/download-agent/route.ts b/apps/portal/src/app/api/download-agent/route.ts index 6e8ddaa61..e88a2c12c 100644 --- a/apps/portal/src/app/api/download-agent/route.ts +++ b/apps/portal/src/app/api/download-agent/route.ts @@ -1,114 +1,149 @@ import { logger } from '@/utils/logger'; import { s3Client } from '@/utils/s3'; -import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { client as kv } from '@comp/kv'; import { type NextRequest, NextResponse } from 'next/server'; import { Readable } from 'stream'; +import { MAC_APPLE_SILICON_FILENAME, MAC_INTEL_FILENAME, WINDOWS_FILENAME } from './constants'; +import type { SupportedOS } from './types'; + export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const maxDuration = 60; -// GET handler for direct browser downloads using token -export async function GET(req: NextRequest) { - const searchParams = req.nextUrl.searchParams; - const token = searchParams.get('token'); - const os = searchParams.get('os'); +interface DownloadTokenInfo { + orgId: string; + employeeId: string; + userId: string; + os: SupportedOS; + createdAt: number; +} + +interface DownloadTarget { + key: string; + filename: string; + contentType: string; +} + +const getDownloadTarget = (os: SupportedOS): DownloadTarget => { + if (os === 'windows') { + return { + key: `windows/${WINDOWS_FILENAME}`, + filename: WINDOWS_FILENAME, + contentType: 'application/octet-stream', + }; + } - if (!os) { - return new NextResponse('Missing OS', { status: 400 }); + const isAppleSilicon = os === 'macos'; + const filename = isAppleSilicon ? MAC_APPLE_SILICON_FILENAME : MAC_INTEL_FILENAME; + + return { + key: `macos/${filename}`, + filename, + contentType: 'application/x-apple-diskimage', + }; +}; + +const buildResponseHeaders = ( + target: DownloadTarget, + contentLength?: number | null, +): Record => { + const headers: Record = { + 'Content-Type': target.contentType, + 'Content-Disposition': `attachment; filename="${target.filename}"`, + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'X-Accel-Buffering': 'no', + }; + + if (typeof contentLength === 'number' && Number.isFinite(contentLength)) { + headers['Content-Length'] = contentLength.toString(); } + return headers; +}; + +const getDownloadToken = async (token: string): Promise => { + const info = await kv.get(`download:${token}`); + return info ?? null; +}; + +const ensureBucket = (): string | null => { + const bucket = process.env.FLEET_AGENT_BUCKET_NAME; + return bucket ?? null; +}; + +const handleDownload = async (req: NextRequest, isHead: boolean) => { + const token = req.nextUrl.searchParams.get('token'); + if (!token) { return new NextResponse('Missing download token', { status: 400 }); } - // Retrieve download info from KV store - const downloadInfo = await kv.get(`download:${token}`); + const downloadInfo = await getDownloadToken(token); if (!downloadInfo) { return new NextResponse('Invalid or expired download token', { status: 403 }); } - // Delete token after retrieval (one-time use) - await kv.del(`download:${token}`); - - // Hardcoded device marker paths used by the setup scripts - const fleetBucketName = process.env.FLEET_AGENT_BUCKET_NAME; + const fleetBucketName = ensureBucket(); if (!fleetBucketName) { + logger('Device agent download misconfigured: missing bucket'); return new NextResponse('Server configuration error', { status: 500 }); } - // For macOS, serve the DMG directly. For Windows, create a zip with script and installer. - if (os === 'macos' || os === 'macos-intel') { - try { - // Direct DMG download for macOS - const macosPackageFilename = - os === 'macos' ? 'Comp AI Agent-1.0.0-arm64.dmg' : 'Comp AI Agent-1.0.0.dmg'; - const packageKey = `macos/${macosPackageFilename}`; + const target = getDownloadTarget(downloadInfo.os); - const getObjectCommand = new GetObjectCommand({ + try { + if (isHead) { + const headCommand = new HeadObjectCommand({ Bucket: fleetBucketName, - Key: packageKey, + Key: target.key, }); - const s3Response = await s3Client.send(getObjectCommand); + const headResult = await s3Client.send(headCommand); - if (!s3Response.Body) { - return new NextResponse('DMG file not found', { status: 404 }); - } - - // Convert S3 stream to Web Stream for NextResponse - const s3Stream = s3Response.Body as Readable; - const webStream = Readable.toWeb(s3Stream) as unknown as ReadableStream; - - // Return streaming response with headers that trigger browser download - return new NextResponse(webStream, { - headers: { - 'Content-Type': 'application/x-apple-diskimage', - 'Content-Disposition': `attachment; filename="${macosPackageFilename}"`, - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'X-Accel-Buffering': 'no', - }, + return new NextResponse(null, { + headers: buildResponseHeaders(target, headResult.ContentLength ?? null), }); - } catch (error) { - logger('Error downloading macOS DMG', { error }); - return new NextResponse('Failed to download macOS agent', { status: 500 }); } - } - - // Windows flow: Generate script and create zip const fleetDevicePath = fleetDevicePathWindows; - try { - const windowsPackageFilename = 'Comp AI Agent 1.0.0.exe'; - const packageKey = `windows/${windowsPackageFilename}`; const getObjectCommand = new GetObjectCommand({ Bucket: fleetBucketName, - Key: packageKey, + Key: target.key, }); const s3Response = await s3Client.send(getObjectCommand); if (!s3Response.Body) { - return new NextResponse('Executable file not found', { status: 404 }); + return new NextResponse('Installer file not found', { status: 404 }); } - // Convert S3 stream to Web Stream for NextResponse + await kv.del(`download:${token}`); + const s3Stream = s3Response.Body as Readable; const webStream = Readable.toWeb(s3Stream) as unknown as ReadableStream; - // Return streaming response with headers that trigger browser download return new NextResponse(webStream, { - headers: { - 'Content-Type': 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${windowsPackageFilename}"`, - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'X-Accel-Buffering': 'no', - }, + headers: buildResponseHeaders(target, s3Response.ContentLength ?? null), }); } catch (error) { - logger('Error creating agent download', { error }); - return new NextResponse('Failed to create download', { status: 500 }); + logger('Error serving device agent download', { + error, + token, + os: downloadInfo.os, + method: isHead ? 'HEAD' : 'GET', + }); + + return new NextResponse('Failed to download agent', { status: 500 }); } +}; + +export async function GET(req: NextRequest) { + return handleDownload(req, false); +} + +export async function HEAD(req: NextRequest) { + return handleDownload(req, true); } diff --git a/apps/portal/src/app/api/download-agent/token/route.ts b/apps/portal/src/app/api/download-agent/token/route.ts index 883f3e8d1..b7da41945 100644 --- a/apps/portal/src/app/api/download-agent/token/route.ts +++ b/apps/portal/src/app/api/download-agent/token/route.ts @@ -7,6 +7,11 @@ import { createFleetLabel } from '../fleet-label'; import type { DownloadAgentRequest, SupportedOS } from '../types'; import { detectOSFromUserAgent, validateMemberAndOrg } from '../utils'; +const SUPPORTED_OSES: SupportedOS[] = ['macos', 'macos-intel', 'windows']; + +const isSupportedOS = (value: unknown): value is SupportedOS => + typeof value === 'string' && SUPPORTED_OSES.includes(value as SupportedOS); + export async function POST(req: NextRequest) { // Authentication const session = await auth.api.getSession({ @@ -18,7 +23,7 @@ export async function POST(req: NextRequest) { } // Validate request body - const { orgId, employeeId }: DownloadAgentRequest = await req.json(); + const { orgId, employeeId, os }: DownloadAgentRequest = await req.json(); if (!orgId || !employeeId) { return new NextResponse('Missing orgId or employeeId', { status: 400 }); @@ -30,13 +35,13 @@ export async function POST(req: NextRequest) { return new NextResponse('Member not found or organization invalid', { status: 404 }); } - // Auto-detect OS from User-Agent + // Auto-detect OS from User-Agent, but allow explicit overrides from the client const userAgent = req.headers.get('user-agent'); - const detectedOS = detectOSFromUserAgent(userAgent); + const detectedOS = isSupportedOS(os) ? os : detectOSFromUserAgent(userAgent); if (!detectedOS) { return new NextResponse( - 'Could not detect OS from User-Agent. Please use a standard browser on macOS or Windows.', + 'Could not determine operating system. Please select an OS and try again.', { status: 400 }, ); } @@ -58,7 +63,7 @@ export async function POST(req: NextRequest) { await createFleetLabel({ employeeId, memberId: member.id, - os: detectedOS as SupportedOS, + os: detectedOS, fleetDevicePathMac, fleetDevicePathWindows, }); diff --git a/apps/portal/src/app/api/download-agent/types.ts b/apps/portal/src/app/api/download-agent/types.ts index 04b7c8a55..8d7b33c88 100644 --- a/apps/portal/src/app/api/download-agent/types.ts +++ b/apps/portal/src/app/api/download-agent/types.ts @@ -23,6 +23,7 @@ export interface CreateFleetLabelParams { export interface DownloadAgentRequest { orgId: string; employeeId: string; + os?: SupportedOS; } export interface FleetDevicePaths { diff --git a/apps/portal/src/app/api/download-agent/utils.ts b/apps/portal/src/app/api/download-agent/utils.ts index 5b7841286..8f8c783e3 100644 --- a/apps/portal/src/app/api/download-agent/utils.ts +++ b/apps/portal/src/app/api/download-agent/utils.ts @@ -15,27 +15,34 @@ import type { SupportedOS } from './types'; * - macOS (Intel): "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" * - macOS (Apple Silicon): "Mozilla/5.0 (Macintosh; ARM Mac OS X 11_2_3) AppleWebKit/537.36" */ +const isSafariUA = (ua: string) => + ua.includes('safari') && + !ua.includes('chrome') && + !ua.includes('crios') && + !ua.includes('fxios') && + !ua.includes('edgios'); + +const hasArmIndicators = (ua: string) => + ua.includes('arm') || ua.includes('arm64') || ua.includes('aarch64') || ua.includes('apple'); + export function detectOSFromUserAgent(userAgent: string | null): SupportedOS | null { if (!userAgent) return null; const ua = userAgent.toLowerCase(); - // Check for Windows (must check before Android since Android UA contains "linux") if (ua.includes('windows') || ua.includes('win32') || ua.includes('win64')) { return 'windows'; } - // Check for macOS (and further distinguish Apple Silicon vs Intel) if (ua.includes('macintosh') || (ua.includes('mac os') && !ua.includes('like mac'))) { - // User-Agent containing 'arm' or 'apple' usually means Apple Silicon - if (ua.includes('arm') || ua.includes('apple')) { + if (hasArmIndicators(ua)) { return 'macos'; } - // 'intel' in UA indicates Intel-based mac - if (ua.includes('intel')) { + + if (!isSafariUA(ua) && ua.includes('intel')) { return 'macos-intel'; } - // Fallback for when arch info is missing, treat as Apple Silicon (modern default) + return 'macos'; } diff --git a/apps/portal/src/utils/os.ts b/apps/portal/src/utils/os.ts index 4d028d269..610a4bf97 100644 --- a/apps/portal/src/utils/os.ts +++ b/apps/portal/src/utils/os.ts @@ -1,17 +1,24 @@ export type SupportedOS = 'macos' | 'windows' | 'macos-intel'; +const isSafariUA = (ua: string) => + ua.includes('safari') && + !ua.includes('chrome') && + !ua.includes('crios') && + !ua.includes('fxios') && + !ua.includes('edgios'); + +const hasArmIndicators = (ua: string) => + ua.includes('arm64') || ua.includes('aarch64') || ua.includes('apple'); + export async function detectOSFromUserAgent(): Promise { try { const ua = navigator.userAgent.toLowerCase(); - // Detect Windows if (ua.includes('win')) { return 'windows'; } - // Detect macOS if (ua.includes('mac')) { - // Try modern userAgentData API first (Chrome, Edge) if ('userAgentData' in navigator && navigator.userAgentData) { const data: { architecture?: string } = await ( navigator.userAgentData as { @@ -23,11 +30,14 @@ export async function detectOSFromUserAgent(): Promise { if (data.architecture === 'x86') return 'macos-intel'; } - // Fallback to userAgent string parsing - if (ua.includes('arm64')) return 'macos'; - if (ua.includes('intel')) return 'macos-intel'; + if (hasArmIndicators(ua)) return 'macos'; + + const safari = isSafariUA(ua); + + if (!safari && ua.includes('intel')) { + return 'macos-intel'; + } - // Default to macos if we can't determine architecture return 'macos'; } diff --git a/bun.lock b/bun.lock index 1ef558a93..3c9f1cd5e 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "comp", @@ -211,6 +210,7 @@ "playwright-core": "^1.52.0", "posthog-js": "^1.236.6", "posthog-node": "^5.8.2", + "prisma": "^6.13.0", "puppeteer-core": "^24.7.2", "react": "^19.1.1", "react-dom": "^19.1.0", @@ -260,7 +260,6 @@ "glob": "^11.0.3", "jsdom": "^26.1.0", "postcss": "^8.5.4", - "prisma": "^6.13.0", "raw-loader": "^4.0.2", "tailwindcss": "^4.1.8", "typescript": "^5.8.3", @@ -333,6 +332,9 @@ "packages/db": { "name": "@trycompai/db", "version": "1.3.17", + "bin": { + "comp-prisma-postinstall": "./dist/postinstall.js", + }, "dependencies": { "@prisma/client": "^6.13.0", "dotenv": "^16.4.5", diff --git a/packages/db/package.json b/packages/db/package.json index d1276ac74..0bd086bf4 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -14,7 +14,11 @@ "typescript": "^5.9.2" }, "exports": { - ".": "./dist/index.js" + ".": "./dist/index.js", + "./postinstall": "./dist/postinstall.js" + }, + "bin": { + "comp-prisma-postinstall": "./dist/postinstall.js" }, "files": [ "dist", diff --git a/packages/db/src/postinstall.ts b/packages/db/src/postinstall.ts new file mode 100644 index 000000000..4d3cfd0bc --- /dev/null +++ b/packages/db/src/postinstall.ts @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +type GenerateOptions = { + projectRoot?: string; + force?: boolean; + log?: (message: string) => void; +}; + +type SchemaResolution = { + path?: string; + searched: string[]; +}; + +const executableName = process.platform === 'win32' ? 'prisma.cmd' : 'prisma'; + +export function generatePrismaClient(options: GenerateOptions = {}): { schema: string } { + const projectRoot = options.projectRoot ?? process.cwd(); + const log = options.log ?? ((message: string) => console.log(`[prisma-postinstall] ${message}`)); + + const resolution = resolveSchemaPath(projectRoot); + + if (!resolution.path) { + throw new Error( + [ + 'Unable to locate schema.prisma from @trycompai/db.', + 'Looked in the following locations:', + ...resolution.searched.map((candidate) => ` - ${candidate}`), + ].join('\n'), + ); + } + + const schemaDir = resolve(projectRoot, 'prisma'); + const schemaDestination = resolve(schemaDir, 'schema.prisma'); + + mkdirSync(schemaDir, { recursive: true }); + copyFileSync(resolution.path, schemaDestination); + log(`Copied schema from ${resolution.path} to ${schemaDestination}`); + + const clientEntryPoint = resolve(projectRoot, 'node_modules/.prisma/client/default.js'); + if (!options.force && existsSync(clientEntryPoint)) { + log('Prisma client already exists. Skipping generation.'); + return { schema: schemaDestination }; + } + + const prismaBinary = resolvePrismaBinary(projectRoot); + + if (!prismaBinary) { + throw new Error( + [ + 'Prisma CLI not found in this workspace. Ensure "prisma" is installed.', + `Checked paths:`, + ...buildBinaryCandidates(projectRoot).map((candidate) => ` - ${candidate}`), + ].join('\n'), + ); + } + + log('Generating Prisma client for Trigger deploy...'); + const result = spawnSync(prismaBinary, ['generate', `--schema=${schemaDestination}`], { + cwd: projectRoot, + stdio: 'inherit', + env: { + ...process.env, + PRISMA_HIDE_UPDATE_MESSAGE: '1', + }, + }); + + if (result.status !== 0) { + throw new Error(`Prisma generate exited with code ${result.status ?? -1}`); + } + + log('Prisma client generation complete.'); + return { schema: schemaDestination }; +} + +function resolveSchemaPath(projectRoot: string): SchemaResolution { + const candidates = buildSchemaCandidates(projectRoot); + const path = candidates.find((candidate) => existsSync(candidate)); + return { path, searched: candidates }; +} + +function buildSchemaCandidates(projectRoot: string): string[] { + const candidates = new Set(); + + const addCandidates = (start: string | undefined) => { + if (!start) { + return; + } + + let current = start; + while (true) { + candidates.add(resolve(current, 'node_modules/@trycompai/db/dist/schema.prisma')); + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; + } + }; + + addCandidates(projectRoot); + const initCwd = process.env.INIT_CWD; + if (initCwd && initCwd !== projectRoot) { + addCandidates(initCwd); + } + + candidates.add(resolve(projectRoot, '../../packages/db/dist/schema.prisma')); + candidates.add(resolve(projectRoot, '../packages/db/dist/schema.prisma')); + + return Array.from(candidates); +} + +function resolvePrismaBinary(projectRoot: string): string | undefined { + const candidates = buildBinaryCandidates(projectRoot); + return candidates.find((candidate) => existsSync(candidate)); +} + +function buildBinaryCandidates(projectRoot: string): string[] { + const candidates = new Set(); + + const addCandidates = (start: string | undefined) => { + if (!start) { + return; + } + + let current = start; + while (true) { + candidates.add(resolve(current, 'node_modules', '.bin', executableName)); + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; + } + }; + + addCandidates(projectRoot); + const initCwd = process.env.INIT_CWD; + if (initCwd && initCwd !== projectRoot) { + addCandidates(initCwd); + } + + return Array.from(candidates); +} + +function shouldRunCli(force: boolean): boolean { + if (force) { + return true; + } + + if (process.env.TRIGGER_PRISMA_FORCE_GENERATE === '1') { + return true; + } + + return Boolean( + process.env.TRIGGER_SECRET_KEY || + process.env.TRIGGER_DEPLOYMENT || + process.env.CI === 'true' || + process.env.PRISMA_GENERATE_ON_INSTALL === '1', + ); +} + +function runCli() { + const force = process.argv.includes('--force'); + + if (!shouldRunCli(force)) { + process.exit(0); + } + + try { + generatePrismaClient({ projectRoot: process.cwd(), force }); + } catch (error) { + console.error('[prisma-postinstall] Failed to generate Prisma client:', error); + process.exit(1); + } +} + +const executedAsScript = + typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module; + +if (executedAsScript) { + runCli(); +} diff --git a/packages/docs/cloud-tests/gcp.mdx b/packages/docs/cloud-tests/gcp.mdx index a8f64ee06..e4d2bb678 100644 --- a/packages/docs/cloud-tests/gcp.mdx +++ b/packages/docs/cloud-tests/gcp.mdx @@ -17,17 +17,33 @@ Before setting up the Cloud Test, ensure you have: 2. Admin access to your Comp AI workspace 3. Permissions to create service accounts + + Security Command Center must be enabled on your GCP account. + + GCP Console =\> Security =\> Risk Overview + + ### Configuration Steps 1. Navigate to **Cloud Tests** in your Comp AI dashboard 2. Click on **Connect** next to the GCP integration card 3. Link Service Account - - Go to the [GCP Console → IAM & Admin → Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts) + - In a Project, go to the [GCP Console → IAM & Admin → Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts) - Click **“Create Service Account”** and give it a name like `comp-ai-integration` - Click **Continue**, then grant the following roles: - `Security Center Findings Viewer` (`roles/securitycenter.findingsViewer`) - - _(Optional)_ `Viewer` (`roles/viewer`) + - `Service Usage Consumer` + - `Viewer` (`roles/viewer`) - Complete the service account creation + - Copy the Service Account Email that was generated (this will be needed for the next step) + + Screenshot 2025-11-18 at 10.40.02 AM.png - After creating the service account: - Go to the **“Keys”** tab - Click **“Add Key” → “Create new key”**, select **JSON**, then click **Create** @@ -35,9 +51,33 @@ Before setting up the Cloud Test, ensure you have: - Go to the [API Library](https://console.cloud.google.com/apis/library) and enable: - **Security Command Center API** - **Cloud Resource Manager API** - - Open the downloaded JSON file, and copy its entire contents - - Paste it into the **Service Account Key** field in the Comp AI connection form -4. Click **Save and Connect** +4. At the Org level, go to the GCP Console → IAM & Admin → IAM + - Grant access + - Add the Service account email from the last step + - Grant the following role: + - `Security Center Findings Viewer` (`roles/securitycenter.findingsViewer`) + +Screenshot 2025-11-18 at 10.50.37 AM.png + +4. Add Credentials to GCP Cloud Test in Comp AI app + 1. Copy & Paste the Organization Id (Not the project Id) from Google Cloud into the **Organization ID** field in the Comp AI connection form + 1. Open the downloaded JSON file, and copy its entire contents + 2. Paste it into the **Service Account Key** field in the Comp AI connection form + + Screenshot 2025-11-20 at 9.31.56 AM.png +1. Click Connect GCP ## Capabilities diff --git a/packages/docs/images/Screenshot2025-11-18at10.40.02AM.png b/packages/docs/images/Screenshot2025-11-18at10.40.02AM.png new file mode 100644 index 000000000..fb6ab271c Binary files /dev/null and b/packages/docs/images/Screenshot2025-11-18at10.40.02AM.png differ diff --git a/packages/docs/images/Screenshot2025-11-18at10.50.37AM.png b/packages/docs/images/Screenshot2025-11-18at10.50.37AM.png new file mode 100644 index 000000000..d6ef837a4 Binary files /dev/null and b/packages/docs/images/Screenshot2025-11-18at10.50.37AM.png differ diff --git a/packages/docs/images/Screenshot2025-11-20at9.31.56AM.png b/packages/docs/images/Screenshot2025-11-20at9.31.56AM.png new file mode 100644 index 000000000..a3f2df021 Binary files /dev/null and b/packages/docs/images/Screenshot2025-11-20at9.31.56AM.png differ