Skip to content

Commit e7eaa28

Browse files
authored
Merge pull request #1000 from trycompai/mariano/improve-agent-installer
[dev] [Marfuen] mariano/improve-agent-installer
2 parents a1e7a7d + 3ac4874 commit e7eaa28

7 files changed

Lines changed: 311 additions & 43 deletions

File tree

apps/app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"@ai-sdk/react": "^1.2.9",
99
"@aws-sdk/client-s3": "^3.806.0",
1010
"@aws-sdk/client-sts": "^3.808.0",
11-
"@aws-sdk/s3-request-presigner": "^3.806.0",
11+
"@aws-sdk/s3-request-presigner": "^3.832.0",
1212
"@azure/core-rest-pipeline": "^1.21.0",
1313
"@browserbasehq/sdk": "^2.5.0",
1414
"@calcom/atoms": "^1.0.102-framer",

apps/portal/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22
"name": "@comp/portal",
33
"version": "0.1.0",
44
"dependencies": {
5+
"@aws-sdk/s3-request-presigner": "^3.832.0",
56
"@comp/db": "workspace:*",
67
"@comp/ui": "workspace:*",
78
"@react-email/components": "^0.0.41",
89
"@react-email/render": "^1.1.2",
910
"@t3-oss/env-nextjs": "^0.13.8",
11+
"@types/jszip": "^3.4.1",
1012
"archiver": "^7.0.1",
1113
"better-auth": "^1.2.8",
1214
"class-variance-authority": "^0.7.1",
15+
"jszip": "^3.10.1",
1316
"next": "15.4.0-canary.85",
1417
"react-email": "^4.0.15"
1518
},

apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx

Lines changed: 164 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@c
55
import { Button } from '@comp/ui/button';
66
import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card';
77
import { cn } from '@comp/ui/cn';
8-
import { CheckCircle2, Circle, Download, XCircle } from 'lucide-react';
8+
import { Progress } from '@comp/ui/progress';
9+
import JSZip from 'jszip';
10+
import { CheckCircle2, Circle, Download, Loader2, XCircle } from 'lucide-react';
911
import Image from 'next/image';
1012
import { useState } from 'react';
1113
import { toast } from 'sonner';
@@ -17,21 +19,27 @@ interface DeviceAgentAccordionItemProps {
1719
fleetPolicies?: FleetPolicy[];
1820
}
1921

22+
type DownloadStatus = 'idle' | 'preparing' | 'downloading' | 'creating-zip' | 'complete';
23+
2024
export function DeviceAgentAccordionItem({
2125
member,
2226
host,
2327
fleetPolicies = [],
2428
}: DeviceAgentAccordionItemProps) {
25-
const [isDownloading, setIsDownloading] = useState(false);
29+
const [downloadStatus, setDownloadStatus] = useState<DownloadStatus>('idle');
30+
const [downloadProgress, setDownloadProgress] = useState(0);
2631

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

3237
const handleDownload = async () => {
33-
setIsDownloading(true);
38+
setDownloadStatus('preparing');
39+
setDownloadProgress(0);
40+
3441
try {
42+
// Step 1: Get download URL and script content from the API
3543
const response = await fetch('/api/download-agent', {
3644
method: 'POST',
3745
headers: { 'Content-Type': 'application/json' },
@@ -43,23 +51,166 @@ export function DeviceAgentAccordionItem({
4351

4452
if (!response.ok) {
4553
const errorText = await response.text();
46-
throw new Error(errorText || 'Failed to download agent.');
54+
throw new Error(errorText || 'Failed to get download information.');
55+
}
56+
57+
const { scriptContent, scriptFilename, packageDownloadUrl, packageFilename } =
58+
await response.json();
59+
60+
// Step 2: Download the package file
61+
setDownloadStatus('downloading');
62+
setDownloadProgress(10);
63+
64+
const packageResponse = await fetch(packageDownloadUrl);
65+
66+
if (!packageResponse.ok) {
67+
throw new Error('Failed to download agent package.');
68+
}
69+
70+
// Get the content length for progress tracking
71+
const contentLength = packageResponse.headers.get('content-length');
72+
const total = contentLength ? parseInt(contentLength, 10) : 0;
73+
74+
// Read the response with progress tracking
75+
const reader = packageResponse.body?.getReader();
76+
if (!reader) {
77+
throw new Error('Failed to read package data.');
4778
}
4879

49-
const blob = await response.blob();
50-
const url = window.URL.createObjectURL(blob);
80+
const chunks: Uint8Array[] = [];
81+
let receivedLength = 0;
82+
83+
while (true) {
84+
const { done, value } = await reader.read();
85+
86+
if (done) break;
87+
88+
chunks.push(value);
89+
receivedLength += value.length;
90+
91+
if (total > 0) {
92+
// Update progress (10-70% range for download)
93+
const downloadPercent = (receivedLength / total) * 60 + 10;
94+
setDownloadProgress(Math.round(downloadPercent));
95+
}
96+
}
97+
98+
// Combine chunks into a single Uint8Array
99+
const chunksAll = new Uint8Array(receivedLength);
100+
let position = 0;
101+
for (const chunk of chunks) {
102+
chunksAll.set(chunk, position);
103+
position += chunk.length;
104+
}
105+
106+
const packageBlob = new Blob([chunksAll]);
107+
108+
// Step 3: Create zip file using JSZip
109+
setDownloadStatus('creating-zip');
110+
setDownloadProgress(75);
111+
112+
const zip = new JSZip();
113+
114+
// Add the script file
115+
const scriptBlob = new Blob([scriptContent], { type: 'text/plain' });
116+
zip.file(scriptFilename, scriptBlob);
117+
118+
// Add the package file
119+
zip.file(packageFilename, packageBlob);
120+
121+
// Add README
122+
const readmeContent = `Comp AI Device Agent Installation Instructions
123+
124+
1. Extract this zip file to a folder on your computer
125+
2. Run the "Install Me First" file first
126+
3. Then run the Fleet installer package
127+
128+
For macOS:
129+
- Run: ./${scriptFilename}
130+
- Then open the .pkg file
131+
132+
For Windows:
133+
- Run: ${scriptFilename}
134+
- Then run the .msi installer
135+
136+
If you have any issues, please contact your IT administrator.`;
137+
138+
zip.file('README.txt', readmeContent);
139+
140+
setDownloadProgress(85);
141+
142+
// Step 4: Generate and download the zip
143+
const zipBlob = await zip.generateAsync({
144+
type: 'blob',
145+
compression: 'DEFLATE',
146+
compressionOptions: { level: 9 },
147+
});
148+
149+
setDownloadProgress(100);
150+
151+
// Create download link
152+
const url = window.URL.createObjectURL(zipBlob);
51153
const a = document.createElement('a');
52154
a.href = url;
53155
a.download = 'compai-device-agent.zip';
54156
document.body.appendChild(a);
55157
a.click();
56158
a.remove();
57159
window.URL.revokeObjectURL(url);
160+
161+
setDownloadStatus('complete');
162+
toast.success('Download completed successfully!');
163+
164+
// Reset after a delay
165+
setTimeout(() => {
166+
setDownloadStatus('idle');
167+
setDownloadProgress(0);
168+
}, 3000);
58169
} catch (error) {
59170
console.error(error);
60171
toast.error(error instanceof Error ? error.message : 'An unknown error occurred.');
61-
} finally {
62-
setIsDownloading(false);
172+
setDownloadStatus('idle');
173+
setDownloadProgress(0);
174+
}
175+
};
176+
177+
const getButtonContent = () => {
178+
switch (downloadStatus) {
179+
case 'preparing':
180+
return (
181+
<>
182+
<Loader2 className="h-4 w-4 animate-spin" />
183+
Preparing download...
184+
</>
185+
);
186+
case 'downloading':
187+
return (
188+
<>
189+
<Loader2 className="h-4 w-4 animate-spin" />
190+
Downloading package...
191+
</>
192+
);
193+
case 'creating-zip':
194+
return (
195+
<>
196+
<Loader2 className="h-4 w-4 animate-spin" />
197+
Creating installer...
198+
</>
199+
);
200+
case 'complete':
201+
return (
202+
<>
203+
<CheckCircle2 className="h-4 w-4" />
204+
Download complete!
205+
</>
206+
);
207+
default:
208+
return (
209+
<>
210+
<Download className="h-4 w-4" />
211+
Download Agent
212+
</>
213+
);
63214
}
64215
};
65216

@@ -101,12 +252,14 @@ export function DeviceAgentAccordionItem({
101252
size="sm"
102253
variant="default"
103254
onClick={handleDownload}
104-
disabled={isDownloading || hasInstalledAgent}
255+
disabled={downloadStatus !== 'idle' || hasInstalledAgent}
105256
className="gap-2 mt-2"
106257
>
107-
<Download className="h-4 w-4" />
108-
{isDownloading ? 'Downloading...' : 'Download Agent'}
258+
{getButtonContent()}
109259
</Button>
260+
{downloadStatus !== 'idle' && downloadStatus !== 'complete' && (
261+
<Progress value={downloadProgress} className="mt-2 h-2" />
262+
)}
110263
</li>
111264
<li>
112265
<strong>Run the "Install Me First" file</strong>

apps/portal/src/app/api/download-agent/route.ts

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { auth } from '@/app/lib/auth';
22
import { logger } from '@/utils/logger';
3+
import { BUCKET_NAME, getPresignedDownloadUrl } from '@/utils/s3';
34
import { type NextRequest, NextResponse } from 'next/server';
4-
import { promises as fs } from 'node:fs';
5-
import { tmpdir } from 'node:os';
6-
import path from 'node:path';
7-
import { createAgentArchive } from './archive';
85
import { createFleetLabel } from './fleet-label';
9-
import { generateMacScript, generateWindowsScript } from './scripts';
6+
import {
7+
generateMacScript,
8+
generateWindowsScript,
9+
getPackageFilename,
10+
getScriptFilename,
11+
} from './scripts';
1012
import type { DownloadAgentRequest, SupportedOS } from './types';
1113
import { detectOSFromUserAgent, validateMemberAndOrg } from './utils';
1214

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

4851
if (!fleetDevicePathMac || !fleetDevicePathWindows) {
4952
logger(
@@ -57,6 +60,12 @@ export async function POST(req: NextRequest) {
5760
);
5861
}
5962

63+
if (!fleetBucketName || !BUCKET_NAME) {
64+
return new NextResponse('Server configuration error: S3 bucket names are missing.', {
65+
status: 500,
66+
});
67+
}
68+
6069
// Validate member and organization
6170
const member = await validateMemberAndOrg(session.user.id, orgId);
6271
if (!member) {
@@ -70,18 +79,7 @@ export async function POST(req: NextRequest) {
7079
? generateMacScript({ orgId, employeeId, fleetDevicePath })
7180
: generateWindowsScript({ orgId, employeeId, fleetDevicePath });
7281

73-
// Create temporary directory
74-
const tempDir = path.join(tmpdir(), `compai-agent-${Date.now()}`);
75-
await fs.mkdir(tempDir, { recursive: true });
76-
7782
try {
78-
// Create the archive
79-
const stream = await createAgentArchive({
80-
os: os as SupportedOS,
81-
script,
82-
tempDir,
83-
});
84-
8583
// Create Fleet label
8684
await createFleetLabel({
8785
employeeId,
@@ -91,20 +89,26 @@ export async function POST(req: NextRequest) {
9189
fleetDevicePathWindows,
9290
});
9391

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

96-
return new NextResponse(stream as unknown as ReadableStream, {
97-
headers: {
98-
'Content-Type': 'application/zip',
99-
'Content-Disposition': `attachment; filename="${filename}"`,
100-
},
95+
// Get presigned URL for the Fleet agent package
96+
const packageFilename = getPackageFilename(os);
97+
const packageKey = `${os}/fleet-osquery.${os === 'macos' ? 'pkg' : 'msi'}`;
98+
const packageDownloadUrl = await getPresignedDownloadUrl({
99+
bucketName: fleetBucketName,
100+
key: packageKey,
101+
expiresIn: 3600, // 1 hour
102+
});
103+
104+
return NextResponse.json({
105+
scriptContent: script,
106+
scriptFilename,
107+
packageDownloadUrl,
108+
packageFilename,
101109
});
102-
} finally {
103-
// Clean up temp directory
104-
try {
105-
await fs.rm(tempDir, { recursive: true, force: true });
106-
} catch (cleanupError) {
107-
logger('Failed to clean up temp directory', { error: cleanupError, tempDir });
108-
}
110+
} catch (error) {
111+
logger('Error generating presigned URLs', { error });
112+
return new NextResponse('Failed to generate download URLs', { status: 500 });
109113
}
110114
}

0 commit comments

Comments
 (0)