Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"@ai-sdk/react": "^1.2.9",
"@aws-sdk/client-s3": "^3.806.0",
"@aws-sdk/client-sts": "^3.808.0",
"@aws-sdk/s3-request-presigner": "^3.806.0",
"@aws-sdk/s3-request-presigner": "^3.832.0",
"@azure/core-rest-pipeline": "^1.21.0",
"@browserbasehq/sdk": "^2.5.0",
"@calcom/atoms": "^1.0.102-framer",
Expand Down
3 changes: 3 additions & 0 deletions apps/portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
"name": "@comp/portal",
"version": "0.1.0",
"dependencies": {
"@aws-sdk/s3-request-presigner": "^3.832.0",
"@comp/db": "workspace:*",
"@comp/ui": "workspace:*",
"@react-email/components": "^0.0.41",
"@react-email/render": "^1.1.2",
"@t3-oss/env-nextjs": "^0.13.8",
"@types/jszip": "^3.4.1",
"archiver": "^7.0.1",
"better-auth": "^1.2.8",
"class-variance-authority": "^0.7.1",
"jszip": "^3.10.1",
"next": "15.4.0-canary.85",
"react-email": "^4.0.15"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@c
import { Button } from '@comp/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card';
import { cn } from '@comp/ui/cn';
import { CheckCircle2, Circle, Download, XCircle } from 'lucide-react';
import { Progress } from '@comp/ui/progress';
import JSZip from 'jszip';
import { CheckCircle2, Circle, Download, Loader2, XCircle } from 'lucide-react';
import Image from 'next/image';
import { useState } from 'react';
import { toast } from 'sonner';
Expand All @@ -17,21 +19,27 @@ interface DeviceAgentAccordionItemProps {
fleetPolicies?: FleetPolicy[];
}

type DownloadStatus = 'idle' | 'preparing' | 'downloading' | 'creating-zip' | 'complete';

export function DeviceAgentAccordionItem({
member,
host,
fleetPolicies = [],
}: DeviceAgentAccordionItemProps) {
const [isDownloading, setIsDownloading] = useState(false);
const [downloadStatus, setDownloadStatus] = useState<DownloadStatus>('idle');
const [downloadProgress, setDownloadProgress] = useState(0);

const hasInstalledAgent = host !== null;
const allPoliciesPass =
fleetPolicies.length === 0 || fleetPolicies.every((policy) => policy.response === 'pass');
const isCompleted = hasInstalledAgent && allPoliciesPass;

const handleDownload = async () => {
setIsDownloading(true);
setDownloadStatus('preparing');
setDownloadProgress(0);

try {
// Step 1: Get download URL and script content from the API
const response = await fetch('/api/download-agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -43,23 +51,166 @@ export function DeviceAgentAccordionItem({

if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to download agent.');
throw new Error(errorText || 'Failed to get download information.');
}

const { scriptContent, scriptFilename, packageDownloadUrl, packageFilename } =
await response.json();

// Step 2: Download the package file
setDownloadStatus('downloading');
setDownloadProgress(10);

const packageResponse = await fetch(packageDownloadUrl);

if (!packageResponse.ok) {
throw new Error('Failed to download agent package.');
}

// Get the content length for progress tracking
const contentLength = packageResponse.headers.get('content-length');
const total = contentLength ? parseInt(contentLength, 10) : 0;

// Read the response with progress tracking
const reader = packageResponse.body?.getReader();
if (!reader) {
throw new Error('Failed to read package data.');
}

const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const chunks: Uint8Array[] = [];
let receivedLength = 0;

while (true) {
const { done, value } = await reader.read();

if (done) break;

chunks.push(value);
receivedLength += value.length;

if (total > 0) {
// Update progress (10-70% range for download)
const downloadPercent = (receivedLength / total) * 60 + 10;
setDownloadProgress(Math.round(downloadPercent));
}
}

// Combine chunks into a single Uint8Array
const chunksAll = new Uint8Array(receivedLength);
let position = 0;
for (const chunk of chunks) {
chunksAll.set(chunk, position);
position += chunk.length;
}

const packageBlob = new Blob([chunksAll]);

// Step 3: Create zip file using JSZip
setDownloadStatus('creating-zip');
setDownloadProgress(75);

const zip = new JSZip();

// Add the script file
const scriptBlob = new Blob([scriptContent], { type: 'text/plain' });
zip.file(scriptFilename, scriptBlob);

// Add the package file
zip.file(packageFilename, packageBlob);

// Add README
const readmeContent = `Comp AI Device Agent Installation Instructions

1. Extract this zip file to a folder on your computer
2. Run the "Install Me First" file first
3. Then run the Fleet installer package

For macOS:
- Run: ./${scriptFilename}
- Then open the .pkg file

For Windows:
- Run: ${scriptFilename}
- Then run the .msi installer

If you have any issues, please contact your IT administrator.`;

zip.file('README.txt', readmeContent);

setDownloadProgress(85);

// Step 4: Generate and download the zip
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 9 },
});

setDownloadProgress(100);

// Create download link
const url = window.URL.createObjectURL(zipBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'compai-device-agent.zip';
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);

setDownloadStatus('complete');
toast.success('Download completed successfully!');

// Reset after a delay
setTimeout(() => {
setDownloadStatus('idle');
setDownloadProgress(0);
}, 3000);
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'An unknown error occurred.');
} finally {
setIsDownloading(false);
setDownloadStatus('idle');
setDownloadProgress(0);
}
};

const getButtonContent = () => {
switch (downloadStatus) {
case 'preparing':
return (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Preparing download...
</>
);
case 'downloading':
return (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Downloading package...
</>
);
case 'creating-zip':
return (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Creating installer...
</>
);
case 'complete':
return (
<>
<CheckCircle2 className="h-4 w-4" />
Download complete!
</>
);
default:
return (
<>
<Download className="h-4 w-4" />
Download Agent
</>
);
}
};

Expand Down Expand Up @@ -101,12 +252,14 @@ export function DeviceAgentAccordionItem({
size="sm"
variant="default"
onClick={handleDownload}
disabled={isDownloading || hasInstalledAgent}
disabled={downloadStatus !== 'idle' || hasInstalledAgent}
className="gap-2 mt-2"
>
<Download className="h-4 w-4" />
{isDownloading ? 'Downloading...' : 'Download Agent'}
{getButtonContent()}
</Button>
{downloadStatus !== 'idle' && downloadStatus !== 'complete' && (
<Progress value={downloadProgress} className="mt-2 h-2" />
)}
</li>
<li>
<strong>Run the "Install Me First" file</strong>
Expand Down
62 changes: 33 additions & 29 deletions apps/portal/src/app/api/download-agent/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { auth } from '@/app/lib/auth';
import { logger } from '@/utils/logger';
import { BUCKET_NAME, getPresignedDownloadUrl } from '@/utils/s3';
import { type NextRequest, NextResponse } from 'next/server';
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { createAgentArchive } from './archive';
import { createFleetLabel } from './fleet-label';
import { generateMacScript, generateWindowsScript } from './scripts';
import {
generateMacScript,
generateWindowsScript,
getPackageFilename,
getScriptFilename,
} from './scripts';
import type { DownloadAgentRequest, SupportedOS } from './types';
import { detectOSFromUserAgent, validateMemberAndOrg } from './utils';

Expand Down Expand Up @@ -44,6 +46,7 @@ export async function POST(req: NextRequest) {
// Check environment configuration
const fleetDevicePathMac = process.env.FLEET_DEVICE_PATH_MAC;
const fleetDevicePathWindows = process.env.FLEET_DEVICE_PATH_WINDOWS;
const fleetBucketName = process.env.FLEET_AGENT_BUCKET_NAME;

if (!fleetDevicePathMac || !fleetDevicePathWindows) {
logger(
Expand All @@ -57,6 +60,12 @@ export async function POST(req: NextRequest) {
);
}

if (!fleetBucketName || !BUCKET_NAME) {
return new NextResponse('Server configuration error: S3 bucket names are missing.', {
status: 500,
});
}

// Validate member and organization
const member = await validateMemberAndOrg(session.user.id, orgId);
if (!member) {
Expand All @@ -70,18 +79,7 @@ export async function POST(req: NextRequest) {
? generateMacScript({ orgId, employeeId, fleetDevicePath })
: generateWindowsScript({ orgId, employeeId, fleetDevicePath });

// Create temporary directory
const tempDir = path.join(tmpdir(), `compai-agent-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });

try {
// Create the archive
const stream = await createAgentArchive({
os: os as SupportedOS,
script,
tempDir,
});

// Create Fleet label
await createFleetLabel({
employeeId,
Expand All @@ -91,20 +89,26 @@ export async function POST(req: NextRequest) {
fleetDevicePathWindows,
});

const filename = `compai-device-agent-${os}.zip`;
// Get script filename
const scriptFilename = getScriptFilename(os);

return new NextResponse(stream as unknown as ReadableStream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${filename}"`,
},
// Get presigned URL for the Fleet agent package
const packageFilename = getPackageFilename(os);
const packageKey = `${os}/fleet-osquery.${os === 'macos' ? 'pkg' : 'msi'}`;
const packageDownloadUrl = await getPresignedDownloadUrl({
bucketName: fleetBucketName,
key: packageKey,
expiresIn: 3600, // 1 hour
});

return NextResponse.json({
scriptContent: script,
scriptFilename,
packageDownloadUrl,
packageFilename,
});
} finally {
// Clean up temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch (cleanupError) {
logger('Failed to clean up temp directory', { error: cleanupError, tempDir });
}
} catch (error) {
logger('Error generating presigned URLs', { error });
return new NextResponse('Failed to generate download URLs', { status: 500 });
}
}
Loading