@papercraneai/cli 1.1.0 → 1.3.0

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.
package/bin/papercrane.js CHANGED
@@ -3,68 +3,121 @@
3
3
  import { Command } from 'commander';
4
4
  import chalk from 'chalk';
5
5
  import readline from 'readline';
6
- import { setApiKey, clearConfig, isLoggedIn } from '../lib/config.js';
6
+ import fs from 'fs/promises';
7
+ import { setApiKey, clearConfig, isLoggedIn, setDefaultWorkspace, getDefaultWorkspace } from '../lib/config.js';
7
8
  import { validateApiKey } from '../lib/cloud-client.js';
8
9
  import { listFunctions, getFunction, runFunction, formatDescribe, formatDescribeRoot, formatFlat, formatResult, formatUnconnected } from '../lib/function-client.js';
10
+ import { listWorkspaces, resolveWorkspaceId, getFileTree, readFile, writeFile, editFile, deleteFile, getLocalWorkspacePath, pullWorkspace, pushWorkspace } from '../lib/environment-client.js';
9
11
 
10
12
  const program = new Command();
11
13
 
12
14
  program
13
15
  .name('papercrane')
14
- .description('CLI for discovering and calling endpoints.\n\nRun "papercrane" with no arguments to list connected modules and endpoints.')
16
+ .description('CLI for discovering APIs and building dashboards in cloud workspaces.\n\nRun "papercrane" with no arguments to list connected modules and endpoints.')
15
17
  .version('1.0.0');
16
18
 
17
19
  program
18
20
  .command('login')
19
- .description('Login to cloud credential storage with API key')
20
- .option('--api-key <key>', 'API key for cloud storage')
21
- .option('--url <url>', 'API base URL (default: https://fly.papercrane.ai)')
21
+ .description('Login to Papercrane. Opens browser for authentication, or use --api-key for direct login.')
22
+ .option('--api-key <key>', 'API key for direct login (skips browser)')
23
+ .option('--url <url>', 'API base URL (saves to config)')
22
24
  .action(async (options) => {
23
25
  try {
24
- let apiKey = options.apiKey;
26
+ const { getApiBaseUrl, setApiBaseUrl } = await import('../lib/config.js');
25
27
 
26
- // If no API key provided via option, prompt for it
27
- if (!apiKey) {
28
- const rl = readline.createInterface({
29
- input: process.stdin,
30
- output: process.stdout
31
- });
28
+ // Resolve API base URL: --url flag > config > default
29
+ let baseUrl;
30
+ if (options.url) {
31
+ baseUrl = options.url.replace(/\/+$/, ''); // Strip trailing slash
32
+ await setApiBaseUrl(baseUrl);
33
+ console.log(chalk.dim(`Using API URL: ${baseUrl}`));
34
+ } else {
35
+ baseUrl = await getApiBaseUrl();
36
+ }
37
+
38
+ // If --api-key provided, use direct login flow
39
+ if (options.apiKey) {
40
+ await setApiKey(options.apiKey);
32
41
 
33
- apiKey = await new Promise((resolve) => {
34
- rl.question(chalk.cyan('Enter your API key: '), (answer) => {
35
- rl.close();
36
- resolve(answer.trim());
37
- });
38
- });
42
+ console.log(chalk.cyan('Validating API key...'));
43
+ const isValid = await validateApiKey();
44
+
45
+ if (isValid) {
46
+ console.log(chalk.green('✓ Successfully logged in\n'));
47
+ } else {
48
+ console.log(chalk.yellow('⚠️ API key saved, but validation failed.'));
49
+ console.log(chalk.yellow(' The key may be invalid or the service may be unavailable.\n'));
50
+ }
51
+ return;
39
52
  }
40
53
 
41
- if (!apiKey) {
42
- console.error(chalk.red('Error: API key is required'));
43
- process.exit(1);
54
+ // Browser-based login flow (polling)
55
+ const { generateState } = await import('../lib/auth-server.js');
56
+ const open = (await import('open')).default;
57
+
58
+ const session = generateState();
59
+
60
+ // Initialize session on server
61
+ const initRes = await fetch(`${baseUrl}/api/cli-auth/init`, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ session })
65
+ });
66
+
67
+ if (!initRes.ok) {
68
+ throw new Error('Failed to initialize login session');
44
69
  }
45
70
 
46
- // Save the API key
47
- await setApiKey(apiKey);
71
+ const authUrl = `${baseUrl}/cli-auth?session=${session}`;
48
72
 
49
- // Save custom URL if provided
50
- if (options.url) {
51
- const { setApiBaseUrl } = await import('../lib/config.js');
52
- await setApiBaseUrl(options.url);
53
- console.log(chalk.dim(`Using API URL: ${options.url}`));
73
+ console.log(chalk.cyan('\nOpen this URL to authenticate:\n'));
74
+ console.log(` ${authUrl}\n`);
75
+
76
+ // Try to open browser automatically
77
+ try {
78
+ await open(authUrl);
79
+ console.log(chalk.dim('(Browser opened automatically)'));
80
+ } catch {
81
+ console.log(chalk.yellow('Could not open browser automatically. Please click or copy the URL above.'));
54
82
  }
55
83
 
56
- // Validate it
57
- console.log(chalk.cyan('\nValidating API key...'));
58
- const isValid = await validateApiKey();
84
+ console.log(chalk.dim('\nWaiting for authorization... (press Ctrl+C to cancel)\n'));
59
85
 
60
- if (isValid) {
61
- console.log(chalk.green('✓ Successfully logged in to cloud credential storage\n'));
62
- } else {
63
- console.log(chalk.yellow('⚠️ API key saved, but validation failed.'));
64
- console.log(chalk.yellow(' The key may be invalid or the service may be unavailable.\n'));
86
+ // Poll for authorization
87
+ const pollInterval = 2000; // 2 seconds
88
+ const timeout = 120000; // 2 minutes
89
+ const startTime = Date.now();
90
+
91
+ while (Date.now() - startTime < timeout) {
92
+ const statusRes = await fetch(`${baseUrl}/api/cli-auth/status?session=${encodeURIComponent(session)}`);
93
+
94
+ if (statusRes.ok) {
95
+ const data = await statusRes.json();
96
+
97
+ if (data.status === 'authorized') {
98
+ // Save the API key
99
+ await setApiKey(data.key);
100
+ await setApiBaseUrl(baseUrl);
101
+
102
+ console.log(chalk.green(`✓ Logged in as ${data.email}`));
103
+ console.log(chalk.dim(` API key saved to ~/.papercrane/config.json\n`));
104
+ return;
105
+ }
106
+ } else if (statusRes.status === 404) {
107
+ throw new Error('Session expired. Please try again.');
108
+ }
109
+
110
+ // Wait before next poll
111
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
65
112
  }
113
+
114
+ throw new Error('Authentication timed out');
66
115
  } catch (error) {
67
- console.error(chalk.red('Error:'), error.message);
116
+ if (error.message.includes('timed out')) {
117
+ console.error(chalk.red('\nAuthentication timed out. Please try again.'));
118
+ } else {
119
+ console.error(chalk.red('Error:'), error.message);
120
+ }
68
121
  process.exit(1);
69
122
  }
70
123
  });
@@ -189,6 +242,517 @@ program
189
242
  }
190
243
  });
191
244
 
245
+ // ============================================================================
246
+ // DASHBOARD GUIDE COMMAND
247
+ // ============================================================================
248
+
249
+ program
250
+ .command('dashboard-guide')
251
+ .description('Show how to build dashboards in cloud workspaces')
252
+ .action(async () => {
253
+ console.log(chalk.bold('\n📊 Building Dashboards in Papercrane Workspaces\n'));
254
+ console.log(chalk.dim('Workspaces are Next.js environments. Files you write become live routes.\n'));
255
+
256
+ console.log(chalk.bold('FILE STRUCTURE'));
257
+ console.log(chalk.dim('─'.repeat(60)));
258
+ console.log(`
259
+ Create two files for each dashboard page:
260
+
261
+ app/(dashboard)/my-dashboard/
262
+ ├── action.ts # Server-side data fetching
263
+ └── page.tsx # Client component with charts
264
+ `);
265
+
266
+ console.log(chalk.bold('1. action.ts - Server Action Pattern'));
267
+ console.log(chalk.dim('─'.repeat(60)));
268
+ console.log(`
269
+ ${chalk.cyan(`'use server'
270
+
271
+ import { authFetch } from '@/lib/papercrane'
272
+
273
+ export async function getData() {
274
+ // authFetch takes function path (dots, not slashes) and flat params
275
+ // Returns parsed result directly - no .json() needed
276
+ const result = await authFetch('integration.endpoint', { limit: 100 })
277
+ return result
278
+
279
+ // Example with Google Analytics:
280
+ // return authFetch('google-analytics.data.runReport', {
281
+ // property: '12345',
282
+ // startDate: '30daysAgo',
283
+ // endDate: 'today',
284
+ // metrics: ['activeUsers', 'sessions'],
285
+ // dimensions: ['date']
286
+ // })
287
+ }`)}
288
+ `);
289
+
290
+ console.log(chalk.bold('2. page.tsx - Client Component Pattern'));
291
+ console.log(chalk.dim('─'.repeat(60)));
292
+ console.log(`
293
+ ${chalk.cyan(`'use client'
294
+
295
+ import { useEffect, useState } from 'react'
296
+ import * as actions from './action'
297
+ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
298
+ import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
299
+ import { BarChart, Bar, XAxis, YAxis } from 'recharts'
300
+
301
+ // Use semantic chart colors: var(--chart-1) through var(--chart-5)
302
+ const chartConfig = {
303
+ value: { label: 'Value', color: 'var(--chart-1)' },
304
+ } satisfies ChartConfig
305
+
306
+ export default function MyDashboard() {
307
+ const [data, setData] = useState<any[]>([])
308
+ const [loading, setLoading] = useState(true)
309
+
310
+ useEffect(() => {
311
+ actions.getData()
312
+ .then(setData)
313
+ .finally(() => setLoading(false))
314
+ }, [])
315
+
316
+ if (loading) return <div className="p-8 bg-background text-foreground">Loading...</div>
317
+
318
+ return (
319
+ <div className="min-h-screen p-6 bg-background text-foreground">
320
+ <h1 className="text-2xl font-bold mb-6">My Dashboard</h1>
321
+
322
+ <Card>
323
+ <CardHeader>
324
+ <CardTitle>Chart Title</CardTitle>
325
+ </CardHeader>
326
+ <CardContent>
327
+ <ChartContainer config={chartConfig} className="h-[300px] w-full">
328
+ <BarChart data={data}>
329
+ <XAxis dataKey="name" stroke="var(--muted-foreground)" />
330
+ <YAxis stroke="var(--muted-foreground)" />
331
+ <ChartTooltip content={<ChartTooltipContent />} />
332
+ <Bar dataKey="value" fill="var(--color-value)" />
333
+ </BarChart>
334
+ </ChartContainer>
335
+ </CardContent>
336
+ </Card>
337
+ </div>
338
+ )
339
+ }`)}
340
+ `);
341
+
342
+ console.log(chalk.bold('SEMANTIC COLOR CLASSES'));
343
+ console.log(chalk.dim('─'.repeat(60)));
344
+ console.log(`
345
+ Use these Tailwind classes for theme-aware colors:
346
+
347
+ ${chalk.green('Backgrounds:')} bg-background, bg-card, bg-muted, bg-primary, bg-secondary
348
+ ${chalk.green('Text:')} text-foreground, text-muted-foreground, text-primary, text-secondary
349
+ ${chalk.green('Borders:')} border-border, border-input
350
+ ${chalk.green('Charts:')} var(--chart-1) through var(--chart-5) in ChartConfig
351
+ ${chalk.green('Axis/Labels:')} var(--muted-foreground) for XAxis/YAxis stroke
352
+ `);
353
+
354
+ console.log(chalk.bold('AVAILABLE COMPONENTS'));
355
+ console.log(chalk.dim('─'.repeat(60)));
356
+ console.log(`
357
+ ${chalk.green('UI Components:')}
358
+ Card, CardHeader, CardTitle, CardContent, CardDescription
359
+ Button, Input, Select, Table, Badge, Tabs
360
+
361
+ ${chalk.green('Chart Components:')}
362
+ ChartContainer, ChartTooltip, ChartTooltipContent (from @/components/ui/chart)
363
+ BarChart, LineChart, AreaChart, PieChart (from recharts)
364
+ XAxis, YAxis, Bar, Line, Area, Pie, Cell
365
+ `);
366
+
367
+ console.log(chalk.bold('QUICK START'));
368
+ console.log(chalk.dim('─'.repeat(60)));
369
+ console.log(`
370
+ 1. ${chalk.cyan('papercrane')} # List available integrations
371
+ 2. ${chalk.cyan('papercrane describe <path>')} # Explore endpoints
372
+ 3. ${chalk.cyan('papercrane call <path> \'{}\'')} # Test data fetching
373
+ 4. ${chalk.cyan('papercrane pull')} # Pull workspace files locally
374
+ 5. Edit files in ${chalk.cyan('~/.papercrane/workspaces/<id>/')}
375
+ 6. ${chalk.cyan('papercrane push')} # Push changes back
376
+
377
+ ${chalk.dim('The pull/push workflow is recommended - edit files locally, then sync.')}
378
+ ${chalk.dim('For single files, you can also use: papercrane files write <path> --json "..."')}
379
+ `);
380
+ console.log();
381
+ });
382
+
383
+ // ============================================================================
384
+ // WORKSPACE COMMANDS
385
+ // ============================================================================
386
+
387
+ const workspacesCmd = program.command('workspaces').description('Manage workspaces');
388
+
389
+ workspacesCmd
390
+ .command('list')
391
+ .description('List all workspaces')
392
+ .action(async () => {
393
+ try {
394
+ const loggedIn = await isLoggedIn();
395
+ if (!loggedIn) {
396
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
397
+ process.exit(1);
398
+ }
399
+
400
+ const workspaces = await listWorkspaces();
401
+ const defaultWs = await getDefaultWorkspace();
402
+
403
+ if (!workspaces || workspaces.length === 0) {
404
+ console.log(chalk.yellow('No workspaces found.'));
405
+ return;
406
+ }
407
+
408
+ console.log(chalk.bold('\nWorkspaces:\n'));
409
+ for (const ws of workspaces) {
410
+ const isDefault = ws.id === defaultWs;
411
+ const status = ws.status === 'ready' ? chalk.green('ready') : chalk.yellow(ws.status);
412
+ const defaultMarker = isDefault ? chalk.cyan(' (default)') : '';
413
+ const typeLabel = ws.type === 'external' ? chalk.dim('external') : chalk.dim('managed');
414
+ const createdDate = ws.createdAt ? new Date(ws.createdAt).toLocaleDateString() : '';
415
+ console.log(` ${chalk.bold(ws.id)} ${ws.name || '(unnamed)'} ${typeLabel} ${status} ${chalk.dim(createdDate)}${defaultMarker}`);
416
+ }
417
+ console.log();
418
+ } catch (error) {
419
+ console.error(chalk.red('Error:'), error.message);
420
+ process.exit(1);
421
+ }
422
+ });
423
+
424
+ workspacesCmd
425
+ .command('use <id>')
426
+ .description('Set the default workspace')
427
+ .action(async (id) => {
428
+ try {
429
+ const loggedIn = await isLoggedIn();
430
+ if (!loggedIn) {
431
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
432
+ process.exit(1);
433
+ }
434
+
435
+ const wsId = parseInt(id, 10);
436
+ if (isNaN(wsId)) {
437
+ console.error(chalk.red('Error: Invalid workspace ID'));
438
+ process.exit(1);
439
+ }
440
+
441
+ await setDefaultWorkspace(wsId);
442
+ console.log(chalk.green(`✓ Default workspace set to ${wsId}\n`));
443
+ } catch (error) {
444
+ console.error(chalk.red('Error:'), error.message);
445
+ process.exit(1);
446
+ }
447
+ });
448
+
449
+ // ============================================================================
450
+ // FILE COMMANDS
451
+ // ============================================================================
452
+
453
+ const filesCmd = program.command('files').description('Build dashboards by reading/writing files in cloud workspaces.\n\nWorkspaces are Next.js environments - files you write become live routes.\nRun "papercrane dashboard-guide" for the recommended dashboard pattern.');
454
+
455
+ filesCmd
456
+ .command('list [path]')
457
+ .description('List files in a workspace')
458
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
459
+ .action(async (path, options) => {
460
+ try {
461
+ const loggedIn = await isLoggedIn();
462
+ if (!loggedIn) {
463
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
464
+ process.exit(1);
465
+ }
466
+
467
+ const wsId = await resolveWorkspaceId(options.workspace);
468
+ const result = await getFileTree(wsId, path || '');
469
+
470
+ console.log(chalk.bold(`\nFiles in workspace ${wsId}${path ? ` (${path})` : ''}:\n`));
471
+ printFileTree(result.tree, ' ');
472
+ console.log();
473
+ } catch (error) {
474
+ console.error(chalk.red('Error:'), error.message);
475
+ process.exit(1);
476
+ }
477
+ });
478
+
479
+ filesCmd
480
+ .command('read <path>')
481
+ .description('Read file contents')
482
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
483
+ .option('--raw', 'Output raw content without formatting')
484
+ .action(async (path, options) => {
485
+ try {
486
+ const loggedIn = await isLoggedIn();
487
+ if (!loggedIn) {
488
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
489
+ process.exit(1);
490
+ }
491
+
492
+ const wsId = await resolveWorkspaceId(options.workspace);
493
+ const result = await readFile(wsId, path);
494
+
495
+ if (options.raw) {
496
+ process.stdout.write(result.content);
497
+ } else {
498
+ console.log(chalk.dim(`# ${result.path} (${result.size} bytes)\n`));
499
+ console.log(result.content);
500
+ }
501
+ } catch (error) {
502
+ console.error(chalk.red('Error:'), error.message);
503
+ process.exit(1);
504
+ }
505
+ });
506
+
507
+ filesCmd
508
+ .command('write <path> [content]')
509
+ .description('Write content to a file. Use --json for reliable escaping:\n\npapercrane files write page.tsx --json \'"use client";\\nexport default function() {}"\'')
510
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
511
+ .option('-f, --file <localPath>', 'Read content from a local file')
512
+ .option('--json <string>', 'Content as a JSON-encoded string (recommended for code)')
513
+ .option('--stdin', 'Read content from stdin')
514
+ .action(async (path, contentArg, options) => {
515
+ try {
516
+ const loggedIn = await isLoggedIn();
517
+ if (!loggedIn) {
518
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
519
+ process.exit(1);
520
+ }
521
+
522
+ let content;
523
+
524
+ if (options.json) {
525
+ // Parse JSON-encoded string
526
+ try {
527
+ content = JSON.parse(options.json);
528
+ if (typeof content !== 'string') {
529
+ console.error(chalk.red('Error: --json must be a JSON string, e.g., \'"hello\\nworld"\''));
530
+ process.exit(1);
531
+ }
532
+ } catch (e) {
533
+ console.error(chalk.red('Error: Invalid JSON string'));
534
+ console.error(chalk.dim(' Expected format: \'"line1\\nline2"\''));
535
+ process.exit(1);
536
+ }
537
+ } else if (options.file) {
538
+ // Read from local file
539
+ content = await fs.readFile(options.file, 'utf-8');
540
+ } else if (options.stdin) {
541
+ // Read from stdin
542
+ content = await readStdin();
543
+ } else if (contentArg) {
544
+ // Use positional argument
545
+ content = contentArg;
546
+ } else {
547
+ console.error(chalk.red('Error: Content required. Provide --json, --file, --stdin, or as argument'));
548
+ process.exit(1);
549
+ }
550
+
551
+ const wsId = await resolveWorkspaceId(options.workspace);
552
+ const result = await writeFile(wsId, path, content);
553
+
554
+ console.log(chalk.green(`✓ Written ${result.size} bytes to ${result.path}\n`));
555
+ } catch (error) {
556
+ console.error(chalk.red('Error:'), error.message);
557
+ process.exit(1);
558
+ }
559
+ });
560
+
561
+ filesCmd
562
+ .command('edit <path>')
563
+ .description('Edit a file with find/replace')
564
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
565
+ .option('--old <string>', 'Text to find')
566
+ .option('--new <string>', 'Text to replace with')
567
+ .option('--replace-all', 'Replace all occurrences')
568
+ .option('-p, --params <json>', 'Parameters as JSON: {"old_string": "...", "new_string": "...", "replace_all": false}')
569
+ .option('--stdin', 'Read parameters as JSON from stdin')
570
+ .action(async (path, options) => {
571
+ try {
572
+ const loggedIn = await isLoggedIn();
573
+ if (!loggedIn) {
574
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
575
+ process.exit(1);
576
+ }
577
+
578
+ let oldString, newString, replaceAll = false;
579
+
580
+ if (options.stdin) {
581
+ // Read JSON from stdin
582
+ const input = await readStdin();
583
+ const params = JSON.parse(input);
584
+ oldString = params.old_string;
585
+ newString = params.new_string;
586
+ replaceAll = params.replace_all || false;
587
+ } else if (options.params) {
588
+ // Parse JSON params
589
+ const params = JSON.parse(options.params);
590
+ oldString = params.old_string;
591
+ newString = params.new_string;
592
+ replaceAll = params.replace_all || false;
593
+ } else if (options.old !== undefined && options.new !== undefined) {
594
+ // Use --old and --new flags
595
+ oldString = options.old;
596
+ newString = options.new;
597
+ replaceAll = options.replaceAll || false;
598
+ } else {
599
+ console.error(chalk.red('Error: Must provide --old and --new, or --params, or --stdin'));
600
+ process.exit(1);
601
+ }
602
+
603
+ const wsId = await resolveWorkspaceId(options.workspace);
604
+ const result = await editFile(wsId, path, oldString, newString, replaceAll);
605
+
606
+ console.log(chalk.green(`✓ Made ${result.replacements} replacement(s) in ${result.path}\n`));
607
+ } catch (error) {
608
+ if (error.response?.data?.error) {
609
+ console.error(chalk.red('Error:'), error.response.data.error);
610
+ if (error.response.data.occurrences) {
611
+ console.error(chalk.dim(` Found ${error.response.data.occurrences} occurrences. Use --replace-all to replace all.`));
612
+ }
613
+ } else {
614
+ console.error(chalk.red('Error:'), error.message);
615
+ }
616
+ process.exit(1);
617
+ }
618
+ });
619
+
620
+ filesCmd
621
+ .command('delete <path>')
622
+ .description('Delete a file')
623
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
624
+ .action(async (path, options) => {
625
+ try {
626
+ const loggedIn = await isLoggedIn();
627
+ if (!loggedIn) {
628
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
629
+ process.exit(1);
630
+ }
631
+
632
+ const wsId = await resolveWorkspaceId(options.workspace);
633
+ const result = await deleteFile(wsId, path);
634
+
635
+ console.log(chalk.green(`✓ Deleted ${result.path}\n`));
636
+ } catch (error) {
637
+ console.error(chalk.red('Error:'), error.message);
638
+ process.exit(1);
639
+ }
640
+ });
641
+
642
+ // ============================================================================
643
+ // PULL / PUSH COMMANDS
644
+ // ============================================================================
645
+
646
+ program
647
+ .command('pull')
648
+ .description('Pull workspace files to local ~/.papercrane/workspaces/<id>/ for editing')
649
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
650
+ .action(async (options) => {
651
+ try {
652
+ const loggedIn = await isLoggedIn();
653
+ if (!loggedIn) {
654
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
655
+ process.exit(1);
656
+ }
657
+
658
+ const wsId = await resolveWorkspaceId(options.workspace);
659
+
660
+ console.log(chalk.cyan(`Pulling workspace ${wsId}...`));
661
+
662
+ const { localPath, files } = await pullWorkspace(wsId, (file, current, total) => {
663
+ process.stdout.write(`\r ${chalk.dim(`[${current}/${total}]`)} ${file}`);
664
+ });
665
+
666
+ console.log('\n');
667
+ console.log(chalk.green(`✓ Pulled ${files.length} files to ${localPath}\n`));
668
+ console.log(chalk.dim('Edit files locally, then run: papercrane push'));
669
+ } catch (error) {
670
+ console.error(chalk.red('Error:'), error.message);
671
+ process.exit(1);
672
+ }
673
+ });
674
+
675
+ program
676
+ .command('push')
677
+ .description('Push local files back to workspace')
678
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
679
+ .option('-p, --path <path>', 'Only push files under this path (e.g., "verdata-vrisk")')
680
+ .action(async (options) => {
681
+ try {
682
+ const loggedIn = await isLoggedIn();
683
+ if (!loggedIn) {
684
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
685
+ process.exit(1);
686
+ }
687
+
688
+ const wsId = await resolveWorkspaceId(options.workspace);
689
+ const localPath = getLocalWorkspacePath(wsId);
690
+ const pathFilter = options.path;
691
+
692
+ console.log(chalk.cyan(`Pushing to workspace ${wsId}...`));
693
+
694
+ const { files } = await pushWorkspace(wsId, (file, current, total) => {
695
+ process.stdout.write(`\r ${chalk.dim(`[${current}/${total}]`)} ${file}`);
696
+ }, pathFilter);
697
+
698
+ console.log('\n');
699
+ console.log(chalk.green(`✓ Pushed ${files.length} files from ${localPath}`));
700
+
701
+ // Print preview URL(s)
702
+ const { getApiBaseUrl } = await import('../lib/config.js');
703
+ const baseUrl = await getApiBaseUrl();
704
+
705
+ // Find unique dashboard directories that were pushed
706
+ const dashboards = [...new Set(files.map(f => f.split('/')[0]).filter(d => d && !d.includes('.')))];
707
+
708
+ if (dashboards.length > 0) {
709
+ console.log('');
710
+ console.log(chalk.dim('Preview:'));
711
+ for (const dashboard of dashboards) {
712
+ console.log(chalk.cyan(` ${baseUrl}/environments/${wsId}/${dashboard}`));
713
+ }
714
+ }
715
+ console.log('');
716
+ } catch (error) {
717
+ console.error(chalk.red('Error:'), error.message);
718
+ process.exit(1);
719
+ }
720
+ });
721
+
722
+ // Helper to read from stdin
723
+ function readStdin() {
724
+ return new Promise((resolve, reject) => {
725
+ let data = '';
726
+ process.stdin.setEncoding('utf-8');
727
+ process.stdin.on('data', chunk => data += chunk);
728
+ process.stdin.on('end', () => resolve(data));
729
+ process.stdin.on('error', reject);
730
+ });
731
+ }
732
+
733
+ // Helper to print file tree recursively
734
+ function printFileTree(tree, indent = '') {
735
+ if (!tree || !Array.isArray(tree)) return;
736
+
737
+ // Sort: folders first, then files
738
+ const sorted = [...tree].sort((a, b) => {
739
+ if (a.type === 'folder' && b.type !== 'folder') return -1;
740
+ if (a.type !== 'folder' && b.type === 'folder') return 1;
741
+ return a.name.localeCompare(b.name);
742
+ });
743
+
744
+ for (const item of sorted) {
745
+ if (item.type === 'folder') {
746
+ console.log(`${indent}${chalk.blue(item.name + '/')}`);
747
+ if (item.children) {
748
+ printFileTree(item.children, indent + ' ');
749
+ }
750
+ } else {
751
+ console.log(`${indent}${item.name}`);
752
+ }
753
+ }
754
+ }
755
+
192
756
  // Default action: show connected modules when no command is given
193
757
  program.action(async (_, cmd) => {
194
758
  // Reject unknown commands instead of silently falling through
@@ -197,12 +761,22 @@ program.action(async (_, cmd) => {
197
761
  console.error(chalk.red(`Unknown command: ${unknownArgs[0]}`));
198
762
  console.error();
199
763
  console.error('Available commands:');
200
- console.error(' papercrane List connected integrations and endpoints');
201
- console.error(' papercrane describe <path> Describe a module or endpoint');
202
- console.error(' papercrane call <path> [json] Call an endpoint');
203
- console.error(' papercrane add Add a new integration');
204
- console.error(' papercrane login Log in with API key');
205
- console.error(' papercrane logout Log out');
764
+ console.error(' papercrane List connected integrations and endpoints');
765
+ console.error(' papercrane describe <path> Describe a module or endpoint');
766
+ console.error(' papercrane call <path> [json] Call an endpoint');
767
+ console.error(' papercrane add Add a new integration');
768
+ console.error(' papercrane dashboard-guide Show how to build dashboards');
769
+ console.error(' papercrane workspaces list List workspaces');
770
+ console.error(' papercrane workspaces use <id> Set default workspace');
771
+ console.error(' papercrane files list [path] List files in workspace');
772
+ console.error(' papercrane files read <path> Read file contents');
773
+ console.error(' papercrane files write <path> Write file contents');
774
+ console.error(' papercrane files edit <path> Edit file with find/replace');
775
+ console.error(' papercrane files delete <path> Delete a file');
776
+ console.error(' papercrane pull Pull workspace files locally for editing');
777
+ console.error(' papercrane push Push local changes back to workspace');
778
+ console.error(' papercrane login Log in with API key');
779
+ console.error(' papercrane logout Log out');
206
780
  process.exit(1);
207
781
  }
208
782
 
@@ -0,0 +1,186 @@
1
+ import http from 'http';
2
+ import crypto from 'crypto';
3
+ import { URL } from 'url';
4
+
5
+ /**
6
+ * Generate a random state token for CSRF protection
7
+ * @returns {string}
8
+ */
9
+ export function generateState() {
10
+ return crypto.randomBytes(16).toString('hex');
11
+ }
12
+
13
+ /**
14
+ * Find an available port
15
+ * @returns {Promise<number>}
16
+ */
17
+ function findAvailablePort() {
18
+ return new Promise((resolve, reject) => {
19
+ const server = http.createServer();
20
+ server.listen(0, '127.0.0.1', () => {
21
+ const port = server.address().port;
22
+ server.close(() => resolve(port));
23
+ });
24
+ server.on('error', reject);
25
+ });
26
+ }
27
+
28
+ /**
29
+ * Generate HTML page with Papercrane styling
30
+ */
31
+ function generateHtmlPage(baseUrl, { title, message, isError = false }) {
32
+ const iconColor = isError ? '#e53e3e' : '#228be6';
33
+ const icon = isError
34
+ ? `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="${iconColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>`
35
+ : `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="${iconColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`;
36
+
37
+ return `<!DOCTYPE html>
38
+ <html lang="en">
39
+ <head>
40
+ <meta charset="UTF-8">
41
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
42
+ <title>${title} - Papercrane</title>
43
+ <style>
44
+ * {
45
+ margin: 0;
46
+ padding: 0;
47
+ box-sizing: border-box;
48
+ }
49
+ body {
50
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
51
+ background-color: #f8f9fa;
52
+ min-height: 100vh;
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ padding: 20px;
57
+ }
58
+ .card {
59
+ background: white;
60
+ border-radius: 8px;
61
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
62
+ border: 1px solid #e9ecef;
63
+ padding: 40px;
64
+ max-width: 400px;
65
+ width: 100%;
66
+ text-align: center;
67
+ }
68
+ .logo {
69
+ margin-bottom: 24px;
70
+ }
71
+ .logo img {
72
+ height: 36px;
73
+ }
74
+ .icon {
75
+ margin-bottom: 16px;
76
+ }
77
+ h1 {
78
+ font-size: 20px;
79
+ font-weight: 600;
80
+ color: #212529;
81
+ margin-bottom: 8px;
82
+ }
83
+ p {
84
+ color: #868e96;
85
+ font-size: 14px;
86
+ line-height: 1.5;
87
+ }
88
+ </style>
89
+ </head>
90
+ <body>
91
+ <div class="card">
92
+ <div class="logo">
93
+ <img src="${baseUrl}/papercrane_and_logo.svg" alt="Papercrane" onerror="this.style.display='none'">
94
+ </div>
95
+ <div class="icon">${icon}</div>
96
+ <h1>${title}</h1>
97
+ <p>${message}</p>
98
+ </div>
99
+ </body>
100
+ </html>`;
101
+ }
102
+
103
+ /**
104
+ * Start auth server and return port immediately
105
+ * @param {string} expectedState - The state token to validate
106
+ * @param {string} baseUrl - The Papercrane base URL (for logo)
107
+ * @param {number} timeoutMs - Timeout in milliseconds
108
+ * @returns {Promise<{port: number, waitForAuth: () => Promise<{key: string, email: string}>}>}
109
+ */
110
+ export async function createAuthServer(expectedState, baseUrl = '', timeoutMs = 120000) {
111
+ const port = await findAvailablePort();
112
+
113
+ let resolveAuth, rejectAuth;
114
+ const authPromise = new Promise((resolve, reject) => {
115
+ resolveAuth = resolve;
116
+ rejectAuth = reject;
117
+ });
118
+
119
+ const server = http.createServer((req, res) => {
120
+ const url = new URL(req.url, `http://localhost:${port}`);
121
+
122
+ if (url.pathname !== '/callback') {
123
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
124
+ res.end('Not Found');
125
+ return;
126
+ }
127
+
128
+ const key = url.searchParams.get('key');
129
+ const state = url.searchParams.get('state');
130
+ const email = url.searchParams.get('email');
131
+
132
+ if (state !== expectedState) {
133
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
134
+ res.end(generateHtmlPage(baseUrl, {
135
+ title: 'Authentication Failed',
136
+ message: 'Invalid state parameter. This could be a security issue. Please try again from your terminal.',
137
+ isError: true
138
+ }));
139
+ return;
140
+ }
141
+
142
+ if (!key) {
143
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
144
+ res.end(generateHtmlPage(baseUrl, {
145
+ title: 'Authentication Failed',
146
+ message: 'No API key received. Please try again from your terminal.',
147
+ isError: true
148
+ }));
149
+ return;
150
+ }
151
+
152
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
153
+ res.end(generateHtmlPage(baseUrl, {
154
+ title: 'CLI Authenticated',
155
+ message: 'You can close this window and return to your terminal.'
156
+ }));
157
+
158
+ server.close();
159
+ resolveAuth({ key, email: email || 'unknown' });
160
+ });
161
+
162
+ const timeout = setTimeout(() => {
163
+ server.close();
164
+ rejectAuth(new Error('Authentication timed out. Please try again.'));
165
+ }, timeoutMs);
166
+
167
+ server.on('close', () => clearTimeout(timeout));
168
+ server.on('error', (err) => {
169
+ clearTimeout(timeout);
170
+ rejectAuth(err);
171
+ });
172
+
173
+ await new Promise((resolve, reject) => {
174
+ server.listen(port, '127.0.0.1', resolve);
175
+ server.on('error', reject);
176
+ });
177
+
178
+ return {
179
+ port,
180
+ waitForAuth: () => authPromise,
181
+ close: () => {
182
+ clearTimeout(timeout);
183
+ server.close();
184
+ }
185
+ };
186
+ }
package/lib/config.js CHANGED
@@ -95,3 +95,31 @@ export async function clearConfig() {
95
95
  await ensureConfigDir();
96
96
  await fs.writeFile(CONFIG_FILE, JSON.stringify({}, null, 2));
97
97
  }
98
+
99
+ /**
100
+ * Get the default workspace ID from config
101
+ * @returns {Promise<number|null>}
102
+ */
103
+ export async function getDefaultWorkspace() {
104
+ const config = await loadConfig();
105
+ return config.defaultWorkspaceId || null;
106
+ }
107
+
108
+ /**
109
+ * Set the default workspace ID in config
110
+ * @param {number} workspaceId - The workspace ID to set as default
111
+ */
112
+ export async function setDefaultWorkspace(workspaceId) {
113
+ const config = await loadConfig();
114
+ config.defaultWorkspaceId = workspaceId;
115
+ await saveConfig(config);
116
+ }
117
+
118
+ /**
119
+ * Clear the default workspace from config
120
+ */
121
+ export async function clearDefaultWorkspace() {
122
+ const config = await loadConfig();
123
+ delete config.defaultWorkspaceId;
124
+ await saveConfig(config);
125
+ }
@@ -0,0 +1,327 @@
1
+ import axios from 'axios';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import { getApiKey, getApiBaseUrl, getDefaultWorkspace, setDefaultWorkspace } from './config.js';
5
+
6
+ /**
7
+ * Get authorization headers
8
+ * @returns {Promise<Object>}
9
+ */
10
+ async function getAuthHeaders() {
11
+ const apiKey = await getApiKey();
12
+ if (!apiKey) {
13
+ throw new Error('Not logged in. Run: papercrane login');
14
+ }
15
+ return {
16
+ 'Authorization': `Bearer ${apiKey}`,
17
+ 'Content-Type': 'application/json'
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Resolve workspace ID from option or default.
23
+ * If no default is set and only one workspace exists, auto-selects it.
24
+ * @param {number|string|null} wsOption - Workspace ID from command option
25
+ * @returns {Promise<number>}
26
+ */
27
+ export async function resolveWorkspaceId(wsOption) {
28
+ if (wsOption) {
29
+ return parseInt(wsOption, 10);
30
+ }
31
+
32
+ const defaultWs = await getDefaultWorkspace();
33
+ if (defaultWs) {
34
+ return defaultWs;
35
+ }
36
+
37
+ // No default set - check if there's only one workspace
38
+ const workspaces = await listWorkspaces();
39
+
40
+ if (workspaces.length === 0) {
41
+ throw new Error('No workspaces available. Create a workspace at https://papercrane.ai first.');
42
+ }
43
+
44
+ if (workspaces.length === 1) {
45
+ // Auto-select the only workspace
46
+ const wsId = workspaces[0].id;
47
+ await setDefaultWorkspace(wsId);
48
+ console.log(`Auto-selected workspace ${wsId} as default (only workspace available)`);
49
+ return wsId;
50
+ }
51
+
52
+ // Multiple workspaces - user must choose
53
+ const wsIds = workspaces.map(ws => ` ${ws.id}: ${ws.name || '(unnamed)'}`).join('\n');
54
+ throw new Error(`Multiple workspaces available. Use -w <id> or run: papercrane workspaces use <id>\n\nAvailable workspaces:\n${wsIds}`);
55
+ }
56
+
57
+ /**
58
+ * List all workspaces for the current user
59
+ * @returns {Promise<Array>}
60
+ */
61
+ export async function listWorkspaces() {
62
+ const headers = await getAuthHeaders();
63
+ const baseUrl = await getApiBaseUrl();
64
+
65
+ const response = await axios.get(`${baseUrl}/sdk/workspaces`, { headers });
66
+ return response.data.workspaces;
67
+ }
68
+
69
+ /**
70
+ * Get file tree for a workspace
71
+ * @param {number} workspaceId
72
+ * @param {string} path - Subdirectory path (optional)
73
+ * @returns {Promise<Object>}
74
+ */
75
+ export async function getFileTree(workspaceId, path = '') {
76
+ const headers = await getAuthHeaders();
77
+ const baseUrl = await getApiBaseUrl();
78
+
79
+ const url = path
80
+ ? `${baseUrl}/sdk/workspaces/${workspaceId}/files?path=${encodeURIComponent(path)}`
81
+ : `${baseUrl}/sdk/workspaces/${workspaceId}/files`;
82
+
83
+ const response = await axios.get(url, { headers });
84
+ return response.data;
85
+ }
86
+
87
+ /**
88
+ * Read file contents
89
+ * @param {number} workspaceId
90
+ * @param {string} path - File path
91
+ * @returns {Promise<Object>}
92
+ */
93
+ export async function readFile(workspaceId, path) {
94
+ const headers = await getAuthHeaders();
95
+ const baseUrl = await getApiBaseUrl();
96
+
97
+ const response = await axios.get(
98
+ `${baseUrl}/sdk/workspaces/${workspaceId}/files/read?path=${encodeURIComponent(path)}`,
99
+ { headers }
100
+ );
101
+ return response.data;
102
+ }
103
+
104
+ /**
105
+ * Write file contents
106
+ * @param {number} workspaceId
107
+ * @param {string} path - File path
108
+ * @param {string} content - File content
109
+ * @returns {Promise<Object>}
110
+ */
111
+ export async function writeFile(workspaceId, path, content) {
112
+ const headers = await getAuthHeaders();
113
+ const baseUrl = await getApiBaseUrl();
114
+
115
+ const response = await axios.post(
116
+ `${baseUrl}/sdk/workspaces/${workspaceId}/files/write`,
117
+ { path, content },
118
+ { headers }
119
+ );
120
+ return response.data;
121
+ }
122
+
123
+ /**
124
+ * Edit file with find/replace
125
+ * @param {number} workspaceId
126
+ * @param {string} path - File path
127
+ * @param {string} oldString - Text to find
128
+ * @param {string} newString - Text to replace with
129
+ * @param {boolean} replaceAll - Replace all occurrences
130
+ * @returns {Promise<Object>}
131
+ */
132
+ export async function editFile(workspaceId, path, oldString, newString, replaceAll = false) {
133
+ const headers = await getAuthHeaders();
134
+ const baseUrl = await getApiBaseUrl();
135
+
136
+ const response = await axios.post(
137
+ `${baseUrl}/sdk/workspaces/${workspaceId}/files/edit`,
138
+ { path, old_string: oldString, new_string: newString, replace_all: replaceAll },
139
+ { headers }
140
+ );
141
+ return response.data;
142
+ }
143
+
144
+ /**
145
+ * Delete a file
146
+ * @param {number} workspaceId
147
+ * @param {string} path - File path
148
+ * @returns {Promise<Object>}
149
+ */
150
+ export async function deleteFile(workspaceId, path) {
151
+ const headers = await getAuthHeaders();
152
+ const baseUrl = await getApiBaseUrl();
153
+
154
+ const response = await axios.delete(
155
+ `${baseUrl}/sdk/workspaces/${workspaceId}/files?path=${encodeURIComponent(path)}`,
156
+ { headers }
157
+ );
158
+ return response.data;
159
+ }
160
+
161
+ /**
162
+ * Get the local workspace directory path
163
+ * @param {number} workspaceId
164
+ * @returns {string}
165
+ */
166
+ export function getLocalWorkspacePath(workspaceId) {
167
+ return join(homedir(), '.papercrane', 'workspaces', String(workspaceId));
168
+ }
169
+
170
+ /**
171
+ * Recursively collect all file paths from a file tree
172
+ * @param {Array} tree - File tree from getFileTree
173
+ * @param {string} prefix - Current path prefix
174
+ * @returns {string[]} - Array of file paths
175
+ */
176
+ function collectFilePaths(tree, prefix = '') {
177
+ const paths = [];
178
+ for (const item of tree) {
179
+ const itemPath = prefix ? `${prefix}/${item.name}` : item.name;
180
+ if (item.type === 'folder' && item.children) {
181
+ paths.push(...collectFilePaths(item.children, itemPath));
182
+ } else if (item.type === 'file') {
183
+ paths.push(itemPath);
184
+ }
185
+ }
186
+ return paths;
187
+ }
188
+
189
+ /**
190
+ * Pull all files from workspace to local directory
191
+ * @param {number} workspaceId
192
+ * @param {function} onProgress - Callback for progress updates (path, index, total)
193
+ * @returns {Promise<{localPath: string, files: string[]}>}
194
+ */
195
+ export async function pullWorkspace(workspaceId, onProgress = null) {
196
+ const { mkdir, writeFile: fsWriteFile } = await import('fs/promises');
197
+ const { dirname } = await import('path');
198
+
199
+ const localPath = getLocalWorkspacePath(workspaceId);
200
+
201
+ // Get file tree
202
+ const { tree } = await getFileTree(workspaceId);
203
+ const filePaths = collectFilePaths(tree);
204
+
205
+ // Create local directory
206
+ await mkdir(localPath, { recursive: true });
207
+
208
+ // Download each file
209
+ const downloaded = [];
210
+ for (let i = 0; i < filePaths.length; i++) {
211
+ const filePath = filePaths[i];
212
+ if (onProgress) onProgress(filePath, i + 1, filePaths.length);
213
+
214
+ try {
215
+ const { content } = await readFile(workspaceId, filePath);
216
+ const localFilePath = join(localPath, filePath);
217
+
218
+ // Ensure directory exists
219
+ await mkdir(dirname(localFilePath), { recursive: true });
220
+
221
+ // Write file
222
+ await fsWriteFile(localFilePath, content, 'utf-8');
223
+ downloaded.push(filePath);
224
+ } catch (error) {
225
+ // Skip files that can't be read (e.g., binary files)
226
+ console.warn(`Skipping ${filePath}: ${error.message}`);
227
+ }
228
+ }
229
+
230
+ return { localPath, files: downloaded };
231
+ }
232
+
233
+ // Binary file extensions to skip during push
234
+ const BINARY_EXTENSIONS = new Set([
235
+ 'ico', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp',
236
+ 'woff', 'woff2', 'ttf', 'eot', 'otf',
237
+ 'pdf', 'zip', 'tar', 'gz', 'rar',
238
+ 'mp3', 'mp4', 'wav', 'avi', 'mov',
239
+ 'exe', 'dll', 'so', 'dylib'
240
+ ]);
241
+
242
+ /**
243
+ * Check if a file is binary based on extension
244
+ * @param {string} filename
245
+ * @returns {boolean}
246
+ */
247
+ function isBinaryFile(filename) {
248
+ const ext = filename.split('.').pop()?.toLowerCase();
249
+ return ext ? BINARY_EXTENSIONS.has(ext) : false;
250
+ }
251
+
252
+ /**
253
+ * Recursively collect all local files
254
+ * @param {string} dir - Directory to scan
255
+ * @param {string} prefix - Current path prefix
256
+ * @returns {Promise<string[]>}
257
+ */
258
+ async function collectLocalFiles(dir, prefix = '') {
259
+ const { readdir, stat } = await import('fs/promises');
260
+ const paths = [];
261
+
262
+ try {
263
+ const entries = await readdir(dir, { withFileTypes: true });
264
+ for (const entry of entries) {
265
+ // Skip hidden files and common non-source directories
266
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
267
+
268
+ const itemPath = prefix ? `${prefix}/${entry.name}` : entry.name;
269
+ const fullPath = join(dir, entry.name);
270
+
271
+ if (entry.isDirectory()) {
272
+ paths.push(...await collectLocalFiles(fullPath, itemPath));
273
+ } else if (!isBinaryFile(entry.name)) {
274
+ // Skip binary files
275
+ paths.push(itemPath);
276
+ }
277
+ }
278
+ } catch (error) {
279
+ // Directory doesn't exist or can't be read
280
+ }
281
+
282
+ return paths;
283
+ }
284
+
285
+ /**
286
+ * Push local files to workspace
287
+ * @param {number} workspaceId
288
+ * @param {function} onProgress - Callback for progress updates (path, index, total)
289
+ * @param {string} pathFilter - Only push files under this path (optional)
290
+ * @returns {Promise<{files: string[]}>}
291
+ */
292
+ export async function pushWorkspace(workspaceId, onProgress = null, pathFilter = null) {
293
+ const { readFile: fsReadFile } = await import('fs/promises');
294
+
295
+ const localPath = getLocalWorkspacePath(workspaceId);
296
+
297
+ // Collect local files
298
+ let filePaths = await collectLocalFiles(localPath);
299
+
300
+ // Apply path filter if specified
301
+ if (pathFilter) {
302
+ const normalizedFilter = pathFilter.replace(/^\/+|\/+$/g, ''); // Remove leading/trailing slashes
303
+ filePaths = filePaths.filter(p => p.startsWith(normalizedFilter + '/') || p === normalizedFilter);
304
+ }
305
+
306
+ if (filePaths.length === 0) {
307
+ const msg = pathFilter
308
+ ? `No files found matching '${pathFilter}' in ${localPath}`
309
+ : `No files found in ${localPath}. Run 'papercrane pull' first.`;
310
+ throw new Error(msg);
311
+ }
312
+
313
+ // Upload each file
314
+ const uploaded = [];
315
+ for (let i = 0; i < filePaths.length; i++) {
316
+ const filePath = filePaths[i];
317
+ if (onProgress) onProgress(filePath, i + 1, filePaths.length);
318
+
319
+ const localFilePath = join(localPath, filePath);
320
+ const content = await fsReadFile(localFilePath, 'utf-8');
321
+
322
+ await writeFile(workspaceId, filePath, content);
323
+ uploaded.push(filePath);
324
+ }
325
+
326
+ return { files: uploaded };
327
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papercraneai/cli",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "CLI tool for managing OAuth credentials for LLM integrations",
5
5
  "main": "index.js",
6
6
  "type": "module",