agentuity-vscode 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,483 @@
1
+ import * as vscode from 'vscode';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import { spawn } from 'child_process';
6
+ import { getCliClient, CliClient, type SandboxInfo } from './cliClient';
7
+
8
+ /** Default remote path for sandbox file operations */
9
+ export const DEFAULT_SANDBOX_PATH = CliClient.SANDBOX_HOME;
10
+
11
+ /**
12
+ * Represents a sandbox linked to the current workspace.
13
+ */
14
+ export interface LinkedSandbox {
15
+ sandboxId: string;
16
+ name?: string;
17
+ linkedAt: string;
18
+ lastSyncedAt?: string;
19
+ remotePath: string;
20
+ }
21
+
22
+ /**
23
+ * Options for syncing files to a sandbox.
24
+ */
25
+ export interface SyncOptions {
26
+ remotePath?: string;
27
+ clean?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Result of a sync operation.
32
+ */
33
+ export interface SyncResult {
34
+ filesUploaded: number;
35
+ bytesTransferred: number;
36
+ duration: number;
37
+ }
38
+
39
+ const LINKED_SANDBOXES_KEY = 'agentuity.linkedSandboxes';
40
+
41
+ let _sandboxManager: SandboxManager | undefined;
42
+ const _onLinkedSandboxesChanged = new vscode.EventEmitter<LinkedSandbox[]>();
43
+ export const onLinkedSandboxesChanged = _onLinkedSandboxesChanged.event;
44
+
45
+ /**
46
+ * Manages sandbox linking and file synchronization for workspaces.
47
+ */
48
+ export class SandboxManager {
49
+ private context: vscode.ExtensionContext;
50
+ private cliClient: CliClient;
51
+
52
+ constructor(context: vscode.ExtensionContext) {
53
+ this.context = context;
54
+ this.cliClient = getCliClient();
55
+ }
56
+
57
+ /**
58
+ * Get all sandboxes linked to the current workspace.
59
+ */
60
+ getLinkedSandboxes(): LinkedSandbox[] {
61
+ const workspaceKey = this.getWorkspaceKey();
62
+ if (!workspaceKey) return [];
63
+
64
+ const allLinked = this.context.workspaceState.get<Record<string, LinkedSandbox[]>>(
65
+ LINKED_SANDBOXES_KEY,
66
+ {}
67
+ );
68
+ return allLinked[workspaceKey] || [];
69
+ }
70
+
71
+ /**
72
+ * Link a sandbox to the current workspace.
73
+ */
74
+ async linkSandbox(
75
+ sandboxId: string,
76
+ options: { name?: string; remotePath?: string } = {}
77
+ ): Promise<void> {
78
+ const workspaceKey = this.getWorkspaceKey();
79
+ if (!workspaceKey) {
80
+ throw new Error('No workspace folder open');
81
+ }
82
+
83
+ // Verify sandbox exists
84
+ const result = await this.cliClient.sandboxGet(sandboxId);
85
+ if (!result.success) {
86
+ throw new Error(`Failed to verify sandbox: ${result.error}`);
87
+ }
88
+
89
+ const allLinked = this.context.workspaceState.get<Record<string, LinkedSandbox[]>>(
90
+ LINKED_SANDBOXES_KEY,
91
+ {}
92
+ );
93
+ const workspaceLinks = allLinked[workspaceKey] || [];
94
+
95
+ // Check if already linked
96
+ const existingIndex = workspaceLinks.findIndex((l) => l.sandboxId === sandboxId);
97
+ if (existingIndex >= 0) {
98
+ // Update existing link
99
+ workspaceLinks[existingIndex] = {
100
+ ...workspaceLinks[existingIndex],
101
+ name: options.name ?? workspaceLinks[existingIndex].name,
102
+ remotePath: options.remotePath ?? workspaceLinks[existingIndex].remotePath,
103
+ };
104
+ } else {
105
+ // Add new link
106
+ workspaceLinks.push({
107
+ sandboxId,
108
+ name: options.name,
109
+ linkedAt: new Date().toISOString(),
110
+ remotePath: options.remotePath ?? DEFAULT_SANDBOX_PATH,
111
+ });
112
+ }
113
+
114
+ allLinked[workspaceKey] = workspaceLinks;
115
+ await this.context.workspaceState.update(LINKED_SANDBOXES_KEY, allLinked);
116
+ _onLinkedSandboxesChanged.fire(workspaceLinks);
117
+ }
118
+
119
+ /**
120
+ * Unlink a sandbox from the current workspace.
121
+ */
122
+ async unlinkSandbox(sandboxId: string): Promise<void> {
123
+ const workspaceKey = this.getWorkspaceKey();
124
+ if (!workspaceKey) return;
125
+
126
+ const allLinked = this.context.workspaceState.get<Record<string, LinkedSandbox[]>>(
127
+ LINKED_SANDBOXES_KEY,
128
+ {}
129
+ );
130
+ const workspaceLinks = allLinked[workspaceKey] || [];
131
+
132
+ const filtered = workspaceLinks.filter((l) => l.sandboxId !== sandboxId);
133
+ allLinked[workspaceKey] = filtered;
134
+ await this.context.workspaceState.update(LINKED_SANDBOXES_KEY, allLinked);
135
+ _onLinkedSandboxesChanged.fire(filtered);
136
+ }
137
+
138
+ /**
139
+ * Check if a sandbox is linked to the current workspace.
140
+ */
141
+ isLinked(sandboxId: string): boolean {
142
+ return this.getLinkedSandboxes().some((l) => l.sandboxId === sandboxId);
143
+ }
144
+
145
+ /**
146
+ * Get linked sandbox info by ID.
147
+ */
148
+ getLinkedSandbox(sandboxId: string): LinkedSandbox | undefined {
149
+ return this.getLinkedSandboxes().find((l) => l.sandboxId === sandboxId);
150
+ }
151
+
152
+ /**
153
+ * Sync workspace files to a sandbox, respecting .gitignore.
154
+ */
155
+ async syncToSandbox(sandboxId: string, options: SyncOptions = {}): Promise<SyncResult> {
156
+ const workspaceFolder = this.getWorkspaceFolder();
157
+ if (!workspaceFolder) {
158
+ throw new Error('No workspace folder open');
159
+ }
160
+
161
+ const remotePath = options.remotePath ?? DEFAULT_SANDBOX_PATH;
162
+ const startTime = Date.now();
163
+
164
+ // Get files to sync (respecting .gitignore)
165
+ const files = await this.getFilesToSync(workspaceFolder);
166
+ if (files.length === 0) {
167
+ return { filesUploaded: 0, bytesTransferred: 0, duration: Date.now() - startTime };
168
+ }
169
+
170
+ // Create tar.gz archive
171
+ const archivePath = await this.createSyncArchive(files, workspaceFolder.uri.fsPath);
172
+
173
+ try {
174
+ // Get archive size
175
+ const stats = fs.statSync(archivePath);
176
+ const bytesTransferred = stats.size;
177
+
178
+ // Upload and extract
179
+ const uploadResult = await this.cliClient.sandboxUpload(sandboxId, archivePath, remotePath);
180
+ if (!uploadResult.success) {
181
+ throw new Error(`Failed to upload files: ${uploadResult.error}`);
182
+ }
183
+
184
+ // Update last synced time
185
+ await this.updateLastSynced(sandboxId);
186
+
187
+ return {
188
+ filesUploaded: files.length,
189
+ bytesTransferred,
190
+ duration: Date.now() - startTime,
191
+ };
192
+ } finally {
193
+ // Clean up temp archive
194
+ try {
195
+ fs.unlinkSync(archivePath);
196
+ } catch {
197
+ // Ignore cleanup errors
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Download files from a sandbox to a local path.
204
+ */
205
+ async downloadFromSandbox(
206
+ sandboxId: string,
207
+ remotePath: string,
208
+ localPath: string
209
+ ): Promise<void> {
210
+ const result = await this.cliClient.sandboxCpFromSandbox(sandboxId, remotePath, localPath, true);
211
+ if (!result.success) {
212
+ throw new Error(`Failed to download files: ${result.error}`);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Get the list of files to sync, respecting .gitignore and default exclusions.
218
+ */
219
+ private async getFilesToSync(workspaceFolder: vscode.WorkspaceFolder): Promise<string[]> {
220
+ const rootPath = workspaceFolder.uri.fsPath;
221
+
222
+ // Get default exclusions from settings
223
+ const config = vscode.workspace.getConfiguration('agentuity');
224
+ const defaultExclusions = config.get<string[]>('sandbox.syncExclusions', [
225
+ '.git',
226
+ 'node_modules',
227
+ '.agentuity',
228
+ 'dist',
229
+ 'build',
230
+ ]);
231
+
232
+ // Use git ls-files if in a git repo, otherwise walk directory
233
+ const isGitRepo = fs.existsSync(path.join(rootPath, '.git'));
234
+
235
+ if (isGitRepo) {
236
+ return this.getGitTrackedFiles(rootPath, defaultExclusions);
237
+ } else {
238
+ return this.walkDirectory(rootPath, defaultExclusions);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Get files tracked by git (respects .gitignore automatically).
244
+ */
245
+ private getGitTrackedFiles(rootPath: string, additionalExclusions: string[]): Promise<string[]> {
246
+ return new Promise((resolve, reject) => {
247
+ // Use git ls-files to get all tracked and untracked (but not ignored) files
248
+ const child = spawn('git', ['ls-files', '-co', '--exclude-standard'], {
249
+ cwd: rootPath,
250
+ shell: true,
251
+ });
252
+
253
+ let stdout = '';
254
+ let stderr = '';
255
+
256
+ child.stdout?.on('data', (data: Buffer) => {
257
+ stdout += data.toString();
258
+ });
259
+
260
+ child.stderr?.on('data', (data: Buffer) => {
261
+ stderr += data.toString();
262
+ });
263
+
264
+ child.on('error', (err) => {
265
+ reject(new Error(`Git command failed: ${err.message}`));
266
+ });
267
+
268
+ child.on('close', (code) => {
269
+ if (code !== 0) {
270
+ reject(new Error(`Git command failed: ${stderr}`));
271
+ return;
272
+ }
273
+
274
+ const files = stdout
275
+ .trim()
276
+ .split('\n')
277
+ .filter((f) => f.length > 0)
278
+ .filter((f) => {
279
+ // Apply additional exclusions
280
+ for (const exclusion of additionalExclusions) {
281
+ if (f.startsWith(exclusion + '/') || f === exclusion) {
282
+ return false;
283
+ }
284
+ }
285
+ return true;
286
+ });
287
+
288
+ resolve(files);
289
+ });
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Walk directory manually (for non-git projects).
295
+ */
296
+ private walkDirectory(rootPath: string, exclusions: string[]): Promise<string[]> {
297
+ return new Promise((resolve) => {
298
+ const files: string[] = [];
299
+
300
+ const walk = (dir: string, relativePath: string = '') => {
301
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
302
+
303
+ for (const entry of entries) {
304
+ const entryRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
305
+
306
+ // Check exclusions
307
+ let excluded = false;
308
+ for (const exclusion of exclusions) {
309
+ if (entryRelPath.startsWith(exclusion + '/') || entryRelPath === exclusion) {
310
+ excluded = true;
311
+ break;
312
+ }
313
+ }
314
+ if (excluded) continue;
315
+
316
+ const fullPath = path.join(dir, entry.name);
317
+
318
+ if (entry.isDirectory()) {
319
+ walk(fullPath, entryRelPath);
320
+ } else if (entry.isFile()) {
321
+ files.push(entryRelPath);
322
+ }
323
+ }
324
+ };
325
+
326
+ walk(rootPath);
327
+ resolve(files);
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Create a tar.gz archive of the specified files.
333
+ */
334
+ private createSyncArchive(files: string[], rootPath: string): Promise<string> {
335
+ return new Promise((resolve, reject) => {
336
+ const tmpDir = os.tmpdir();
337
+ const archiveName = `agentuity-sync-${Date.now()}.tar.gz`;
338
+ const archivePath = path.join(tmpDir, archiveName);
339
+
340
+ // Write file list to a temp file for tar
341
+ const fileListPath = path.join(tmpDir, `agentuity-files-${Date.now()}.txt`);
342
+ fs.writeFileSync(fileListPath, files.join('\n'));
343
+
344
+ // Create tar.gz using tar command
345
+ const child = spawn('tar', ['-czf', archivePath, '-T', fileListPath], {
346
+ cwd: rootPath,
347
+ shell: true,
348
+ });
349
+
350
+ let stderr = '';
351
+
352
+ child.stderr?.on('data', (data: Buffer) => {
353
+ stderr += data.toString();
354
+ });
355
+
356
+ child.on('error', (err) => {
357
+ try {
358
+ fs.unlinkSync(fileListPath);
359
+ } catch {
360
+ // Ignore
361
+ }
362
+ reject(new Error(`Failed to create archive: ${err.message}`));
363
+ });
364
+
365
+ child.on('close', (code) => {
366
+ try {
367
+ fs.unlinkSync(fileListPath);
368
+ } catch {
369
+ // Ignore
370
+ }
371
+
372
+ if (code !== 0) {
373
+ reject(new Error(`Failed to create archive: ${stderr}`));
374
+ return;
375
+ }
376
+
377
+ resolve(archivePath);
378
+ });
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Update the last synced timestamp for a linked sandbox.
384
+ */
385
+ private async updateLastSynced(sandboxId: string): Promise<void> {
386
+ const workspaceKey = this.getWorkspaceKey();
387
+ if (!workspaceKey) return;
388
+
389
+ const allLinked = this.context.workspaceState.get<Record<string, LinkedSandbox[]>>(
390
+ LINKED_SANDBOXES_KEY,
391
+ {}
392
+ );
393
+ const workspaceLinks = allLinked[workspaceKey] || [];
394
+
395
+ const linkIndex = workspaceLinks.findIndex((l) => l.sandboxId === sandboxId);
396
+ if (linkIndex >= 0) {
397
+ workspaceLinks[linkIndex].lastSyncedAt = new Date().toISOString();
398
+ allLinked[workspaceKey] = workspaceLinks;
399
+ await this.context.workspaceState.update(LINKED_SANDBOXES_KEY, allLinked);
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Get a unique key for the current workspace.
405
+ */
406
+ private getWorkspaceKey(): string | undefined {
407
+ const folder = this.getWorkspaceFolder();
408
+ return folder?.uri.fsPath;
409
+ }
410
+
411
+ /**
412
+ * Get the current workspace folder.
413
+ */
414
+ private getWorkspaceFolder(): vscode.WorkspaceFolder | undefined {
415
+ const folders = vscode.workspace.workspaceFolders;
416
+ return folders?.[0];
417
+ }
418
+
419
+ /**
420
+ * Refresh sandbox info for all linked sandboxes.
421
+ * Returns info about which sandboxes are still valid.
422
+ */
423
+ async refreshLinkedSandboxes(): Promise<Map<string, SandboxInfo | null>> {
424
+ const linked = this.getLinkedSandboxes();
425
+ const results = new Map<string, SandboxInfo | null>();
426
+
427
+ for (const link of linked) {
428
+ const result = await this.cliClient.sandboxGet(link.sandboxId);
429
+ if (result.success && result.data) {
430
+ results.set(link.sandboxId, result.data);
431
+ } else {
432
+ results.set(link.sandboxId, null);
433
+ }
434
+ }
435
+
436
+ return results;
437
+ }
438
+
439
+ dispose(): void {
440
+ // Nothing to dispose currently
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Initialize the sandbox manager.
446
+ */
447
+ export function initSandboxManager(context: vscode.ExtensionContext): SandboxManager {
448
+ if (!_sandboxManager) {
449
+ _sandboxManager = new SandboxManager(context);
450
+ }
451
+ return _sandboxManager;
452
+ }
453
+
454
+ /**
455
+ * Get the sandbox manager instance.
456
+ */
457
+ export function getSandboxManager(): SandboxManager {
458
+ if (!_sandboxManager) {
459
+ throw new Error('SandboxManager not initialized. Call initSandboxManager first.');
460
+ }
461
+ return _sandboxManager;
462
+ }
463
+
464
+ /**
465
+ * Dispose the sandbox manager.
466
+ */
467
+ export function disposeSandboxManager(): void {
468
+ if (_sandboxManager) {
469
+ _sandboxManager.dispose();
470
+ _sandboxManager = undefined;
471
+ }
472
+ _onLinkedSandboxesChanged.dispose();
473
+ }
474
+
475
+ /**
476
+ * Format bytes to human-readable string.
477
+ */
478
+ export function formatBytes(bytes: number): string {
479
+ if (bytes < 1024) return `${bytes} B`;
480
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
481
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
482
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
483
+ }
package/src/extension.ts CHANGED
@@ -15,6 +15,8 @@ import { registerReadonlyDocumentProvider } from './core/readonlyDocument';
15
15
  import { registerAgentExplorer } from './features/agentExplorer';
16
16
  import { registerDataExplorer } from './features/dataExplorer';
17
17
  import { registerDeploymentExplorer } from './features/deploymentExplorer';
18
+ import { registerSandboxExplorer } from './features/sandboxExplorer';
19
+ import { disposeSandboxManager } from './core/sandboxManager';
18
20
  import { registerDevServerCommands } from './features/devServer';
19
21
  import { registerWorkbenchCommands } from './features/workbench';
20
22
  import {
@@ -62,11 +64,13 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
62
64
  const agentProvider = registerAgentExplorer(context);
63
65
  const dataProvider = registerDataExplorer(context);
64
66
  const deploymentProvider = registerDeploymentExplorer(context);
67
+ const sandboxProvider = registerSandboxExplorer(context);
65
68
 
66
69
  registerRefreshCommands(context, {
67
70
  agents: agentProvider,
68
71
  data: dataProvider,
69
72
  deployments: deploymentProvider,
73
+ sandboxes: sandboxProvider,
70
74
  });
71
75
 
72
76
  context.subscriptions.push({
@@ -74,6 +78,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
74
78
  agentProvider.dispose();
75
79
  dataProvider.dispose();
76
80
  deploymentProvider.dispose();
81
+ sandboxProvider.dispose();
77
82
  },
78
83
  });
79
84
 
@@ -252,6 +257,7 @@ function registerRefreshCommands(
252
257
  agents: ReturnType<typeof registerAgentExplorer>;
253
258
  data: ReturnType<typeof registerDataExplorer>;
254
259
  deployments: ReturnType<typeof registerDeploymentExplorer>;
260
+ sandboxes: ReturnType<typeof registerSandboxExplorer>;
255
261
  }
256
262
  ): void {
257
263
  context.subscriptions.push(
@@ -261,6 +267,7 @@ function registerRefreshCommands(
261
267
  providers.agents.forceRefresh();
262
268
  providers.data.refresh();
263
269
  providers.deployments.forceRefresh();
270
+ providers.sandboxes.forceRefresh();
264
271
  vscode.window.showInformationMessage('Agentuity refreshed');
265
272
  })
266
273
  );
@@ -282,11 +289,18 @@ function registerRefreshCommands(
282
289
  providers.data.refresh();
283
290
  })
284
291
  );
292
+
293
+ context.subscriptions.push(
294
+ vscode.commands.registerCommand('agentuity.sandbox.refresh', () => {
295
+ void providers.sandboxes.forceRefresh();
296
+ })
297
+ );
285
298
  }
286
299
 
287
300
  export function deactivate(): void {
288
301
  disposeCliClient();
289
302
  disposeAuth();
290
303
  disposeProject();
304
+ disposeSandboxManager();
291
305
  disposeLogger();
292
306
  }