@papercraneai/cli 1.0.0 → 1.2.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,94 @@
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
- });
32
-
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
- });
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();
39
36
  }
40
37
 
41
- if (!apiKey) {
42
- console.error(chalk.red('Error: API key is required'));
43
- process.exit(1);
38
+ // If --api-key provided, use direct login flow
39
+ if (options.apiKey) {
40
+ await setApiKey(options.apiKey);
41
+
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;
44
52
  }
45
53
 
46
- // Save the API key
47
- await setApiKey(apiKey);
54
+ // Browser-based login flow
55
+ const { createAuthServer, generateState } = await import('../lib/auth-server.js');
56
+ const open = (await import('open')).default;
48
57
 
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}`));
58
+ const state = generateState();
59
+ const authServer = await createAuthServer(state, baseUrl, 120000);
60
+
61
+ const authUrl = `${baseUrl}/cli-auth?port=${authServer.port}&state=${state}`;
62
+
63
+ console.log(chalk.cyan('Opening browser for authentication...'));
64
+ console.log(chalk.dim(` ${authUrl}\n`));
65
+
66
+ // Try to open browser
67
+ try {
68
+ await open(authUrl);
69
+ } catch {
70
+ console.log(chalk.yellow('Could not open browser automatically.'));
71
+ console.log(chalk.yellow('Please open this URL manually:\n'));
72
+ console.log(chalk.cyan(` ${authUrl}\n`));
54
73
  }
55
74
 
56
- // Validate it
57
- console.log(chalk.cyan('\nValidating API key...'));
58
- const isValid = await validateApiKey();
75
+ console.log(chalk.dim('Waiting for authorization... (press Ctrl+C to cancel)\n'));
76
+
77
+ // Wait for callback
78
+ const { key, email } = await authServer.waitForAuth();
79
+
80
+ // Save the API key
81
+ await setApiKey(key);
82
+
83
+ // Also save the URL to config if not already there
84
+ await setApiBaseUrl(baseUrl);
59
85
 
60
- if (isValid) {
61
- console.log(chalk.green('✓ Successfully logged in to cloud credential storage\n'));
86
+ console.log(chalk.green(`✓ Logged in as ${email}`));
87
+ console.log(chalk.dim(` API key saved to ~/.papercrane/config.json\n`));
88
+ } catch (error) {
89
+ if (error.message.includes('timed out')) {
90
+ console.error(chalk.red('\nAuthentication timed out. Please try again.'));
62
91
  } 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'));
92
+ console.error(chalk.red('Error:'), error.message);
65
93
  }
66
- } catch (error) {
67
- console.error(chalk.red('Error:'), error.message);
68
94
  process.exit(1);
69
95
  }
70
96
  });
@@ -155,12 +181,12 @@ program
155
181
  });
156
182
 
157
183
  // ============================================================================
158
- // CONNECT COMMAND
184
+ // ADD COMMAND
159
185
  // ============================================================================
160
186
 
161
187
  program
162
- .command('connect [integration]')
163
- .description('List available integrations, or connect a specific one')
188
+ .command('add [integration]')
189
+ .description('Add a new integration via OAuth')
164
190
  .action(async (integration) => {
165
191
  try {
166
192
  if (integration) {
@@ -170,7 +196,7 @@ program
170
196
  // Construct the connect URL
171
197
  const connectUrl = `${baseUrl}/integrations?connect=${integration}`;
172
198
 
173
- console.log(chalk.bold(`\nConnect ${integration}:\n`));
199
+ console.log(chalk.bold(`\nAdd ${integration}:\n`));
174
200
  console.log(` ${chalk.cyan(connectUrl)}\n`);
175
201
  console.log(chalk.dim('Open this URL in your browser to authenticate.\n'));
176
202
  } else {
@@ -189,8 +215,544 @@ program
189
215
  }
190
216
  });
191
217
 
218
+ // ============================================================================
219
+ // DASHBOARD GUIDE COMMAND
220
+ // ============================================================================
221
+
222
+ program
223
+ .command('dashboard-guide')
224
+ .description('Show how to build dashboards in cloud workspaces')
225
+ .action(async () => {
226
+ console.log(chalk.bold('\n📊 Building Dashboards in Papercrane Workspaces\n'));
227
+ console.log(chalk.dim('Workspaces are Next.js environments. Files you write become live routes.\n'));
228
+
229
+ console.log(chalk.bold('FILE STRUCTURE'));
230
+ console.log(chalk.dim('─'.repeat(60)));
231
+ console.log(`
232
+ Create two files for each dashboard page:
233
+
234
+ app/(dashboard)/my-dashboard/
235
+ ├── action.ts # Server-side data fetching
236
+ └── page.tsx # Client component with charts
237
+ `);
238
+
239
+ console.log(chalk.bold('1. action.ts - Server Action Pattern'));
240
+ console.log(chalk.dim('─'.repeat(60)));
241
+ console.log(`
242
+ ${chalk.cyan(`'use server'
243
+
244
+ import { authFetch } from '@/lib/papercrane'
245
+
246
+ export async function getData() {
247
+ // authFetch takes function path (dots, not slashes) and flat params
248
+ // Returns parsed result directly - no .json() needed
249
+ const result = await authFetch('integration.endpoint', { limit: 100 })
250
+ return result
251
+
252
+ // Example with Google Analytics:
253
+ // return authFetch('google-analytics.data.runReport', {
254
+ // property: '12345',
255
+ // startDate: '30daysAgo',
256
+ // endDate: 'today',
257
+ // metrics: ['activeUsers', 'sessions'],
258
+ // dimensions: ['date']
259
+ // })
260
+ }`)}
261
+ `);
262
+
263
+ console.log(chalk.bold('2. page.tsx - Client Component Pattern'));
264
+ console.log(chalk.dim('─'.repeat(60)));
265
+ console.log(`
266
+ ${chalk.cyan(`'use client'
267
+
268
+ import { useEffect, useState } from 'react'
269
+ import * as actions from './action'
270
+ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
271
+ import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
272
+ import { BarChart, Bar, XAxis, YAxis } from 'recharts'
273
+
274
+ // Use semantic chart colors: var(--chart-1) through var(--chart-5)
275
+ const chartConfig = {
276
+ value: { label: 'Value', color: 'var(--chart-1)' },
277
+ } satisfies ChartConfig
278
+
279
+ export default function MyDashboard() {
280
+ const [data, setData] = useState<any[]>([])
281
+ const [loading, setLoading] = useState(true)
282
+
283
+ useEffect(() => {
284
+ actions.getData()
285
+ .then(setData)
286
+ .finally(() => setLoading(false))
287
+ }, [])
288
+
289
+ if (loading) return <div className="p-8 bg-background text-foreground">Loading...</div>
290
+
291
+ return (
292
+ <div className="min-h-screen p-6 bg-background text-foreground">
293
+ <h1 className="text-2xl font-bold mb-6">My Dashboard</h1>
294
+
295
+ <Card>
296
+ <CardHeader>
297
+ <CardTitle>Chart Title</CardTitle>
298
+ </CardHeader>
299
+ <CardContent>
300
+ <ChartContainer config={chartConfig} className="h-[300px] w-full">
301
+ <BarChart data={data}>
302
+ <XAxis dataKey="name" stroke="var(--muted-foreground)" />
303
+ <YAxis stroke="var(--muted-foreground)" />
304
+ <ChartTooltip content={<ChartTooltipContent />} />
305
+ <Bar dataKey="value" fill="var(--color-value)" />
306
+ </BarChart>
307
+ </ChartContainer>
308
+ </CardContent>
309
+ </Card>
310
+ </div>
311
+ )
312
+ }`)}
313
+ `);
314
+
315
+ console.log(chalk.bold('SEMANTIC COLOR CLASSES'));
316
+ console.log(chalk.dim('─'.repeat(60)));
317
+ console.log(`
318
+ Use these Tailwind classes for theme-aware colors:
319
+
320
+ ${chalk.green('Backgrounds:')} bg-background, bg-card, bg-muted, bg-primary, bg-secondary
321
+ ${chalk.green('Text:')} text-foreground, text-muted-foreground, text-primary, text-secondary
322
+ ${chalk.green('Borders:')} border-border, border-input
323
+ ${chalk.green('Charts:')} var(--chart-1) through var(--chart-5) in ChartConfig
324
+ ${chalk.green('Axis/Labels:')} var(--muted-foreground) for XAxis/YAxis stroke
325
+ `);
326
+
327
+ console.log(chalk.bold('AVAILABLE COMPONENTS'));
328
+ console.log(chalk.dim('─'.repeat(60)));
329
+ console.log(`
330
+ ${chalk.green('UI Components:')}
331
+ Card, CardHeader, CardTitle, CardContent, CardDescription
332
+ Button, Input, Select, Table, Badge, Tabs
333
+
334
+ ${chalk.green('Chart Components:')}
335
+ ChartContainer, ChartTooltip, ChartTooltipContent (from @/components/ui/chart)
336
+ BarChart, LineChart, AreaChart, PieChart (from recharts)
337
+ XAxis, YAxis, Bar, Line, Area, Pie, Cell
338
+ `);
339
+
340
+ console.log(chalk.bold('QUICK START'));
341
+ console.log(chalk.dim('─'.repeat(60)));
342
+ console.log(`
343
+ 1. ${chalk.cyan('papercrane')} # List available integrations
344
+ 2. ${chalk.cyan('papercrane describe <path>')} # Explore endpoints
345
+ 3. ${chalk.cyan('papercrane call <path> \'{}\'')} # Test data fetching
346
+ 4. ${chalk.cyan('papercrane pull')} # Pull workspace files locally
347
+ 5. Edit files in ${chalk.cyan('~/.papercrane/workspaces/<id>/')}
348
+ 6. ${chalk.cyan('papercrane push')} # Push changes back
349
+
350
+ ${chalk.dim('The pull/push workflow is recommended - edit files locally, then sync.')}
351
+ ${chalk.dim('For single files, you can also use: papercrane files write <path> --json "..."')}
352
+ `);
353
+ console.log();
354
+ });
355
+
356
+ // ============================================================================
357
+ // WORKSPACE COMMANDS
358
+ // ============================================================================
359
+
360
+ const workspacesCmd = program.command('workspaces').description('Manage workspaces');
361
+
362
+ workspacesCmd
363
+ .command('list')
364
+ .description('List all workspaces')
365
+ .action(async () => {
366
+ try {
367
+ const loggedIn = await isLoggedIn();
368
+ if (!loggedIn) {
369
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
370
+ process.exit(1);
371
+ }
372
+
373
+ const workspaces = await listWorkspaces();
374
+ const defaultWs = await getDefaultWorkspace();
375
+
376
+ if (!workspaces || workspaces.length === 0) {
377
+ console.log(chalk.yellow('No workspaces found.'));
378
+ return;
379
+ }
380
+
381
+ console.log(chalk.bold('\nWorkspaces:\n'));
382
+ for (const ws of workspaces) {
383
+ const isDefault = ws.id === defaultWs;
384
+ const status = ws.status === 'ready' ? chalk.green('ready') : chalk.yellow(ws.status);
385
+ const defaultMarker = isDefault ? chalk.cyan(' (default)') : '';
386
+ const typeLabel = ws.type === 'external' ? chalk.dim('external') : chalk.dim('managed');
387
+ const createdDate = ws.createdAt ? new Date(ws.createdAt).toLocaleDateString() : '';
388
+ console.log(` ${chalk.bold(ws.id)} ${ws.name || '(unnamed)'} ${typeLabel} ${status} ${chalk.dim(createdDate)}${defaultMarker}`);
389
+ }
390
+ console.log();
391
+ } catch (error) {
392
+ console.error(chalk.red('Error:'), error.message);
393
+ process.exit(1);
394
+ }
395
+ });
396
+
397
+ workspacesCmd
398
+ .command('use <id>')
399
+ .description('Set the default workspace')
400
+ .action(async (id) => {
401
+ try {
402
+ const loggedIn = await isLoggedIn();
403
+ if (!loggedIn) {
404
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
405
+ process.exit(1);
406
+ }
407
+
408
+ const wsId = parseInt(id, 10);
409
+ if (isNaN(wsId)) {
410
+ console.error(chalk.red('Error: Invalid workspace ID'));
411
+ process.exit(1);
412
+ }
413
+
414
+ await setDefaultWorkspace(wsId);
415
+ console.log(chalk.green(`✓ Default workspace set to ${wsId}\n`));
416
+ } catch (error) {
417
+ console.error(chalk.red('Error:'), error.message);
418
+ process.exit(1);
419
+ }
420
+ });
421
+
422
+ // ============================================================================
423
+ // FILE COMMANDS
424
+ // ============================================================================
425
+
426
+ 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.');
427
+
428
+ filesCmd
429
+ .command('list [path]')
430
+ .description('List files in a workspace')
431
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
432
+ .action(async (path, options) => {
433
+ try {
434
+ const loggedIn = await isLoggedIn();
435
+ if (!loggedIn) {
436
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
437
+ process.exit(1);
438
+ }
439
+
440
+ const wsId = await resolveWorkspaceId(options.workspace);
441
+ const result = await getFileTree(wsId, path || '');
442
+
443
+ console.log(chalk.bold(`\nFiles in workspace ${wsId}${path ? ` (${path})` : ''}:\n`));
444
+ printFileTree(result.tree, ' ');
445
+ console.log();
446
+ } catch (error) {
447
+ console.error(chalk.red('Error:'), error.message);
448
+ process.exit(1);
449
+ }
450
+ });
451
+
452
+ filesCmd
453
+ .command('read <path>')
454
+ .description('Read file contents')
455
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
456
+ .option('--raw', 'Output raw content without formatting')
457
+ .action(async (path, options) => {
458
+ try {
459
+ const loggedIn = await isLoggedIn();
460
+ if (!loggedIn) {
461
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
462
+ process.exit(1);
463
+ }
464
+
465
+ const wsId = await resolveWorkspaceId(options.workspace);
466
+ const result = await readFile(wsId, path);
467
+
468
+ if (options.raw) {
469
+ process.stdout.write(result.content);
470
+ } else {
471
+ console.log(chalk.dim(`# ${result.path} (${result.size} bytes)\n`));
472
+ console.log(result.content);
473
+ }
474
+ } catch (error) {
475
+ console.error(chalk.red('Error:'), error.message);
476
+ process.exit(1);
477
+ }
478
+ });
479
+
480
+ filesCmd
481
+ .command('write <path> [content]')
482
+ .description('Write content to a file. Use --json for reliable escaping:\n\npapercrane files write page.tsx --json \'"use client";\\nexport default function() {}"\'')
483
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
484
+ .option('-f, --file <localPath>', 'Read content from a local file')
485
+ .option('--json <string>', 'Content as a JSON-encoded string (recommended for code)')
486
+ .option('--stdin', 'Read content from stdin')
487
+ .action(async (path, contentArg, options) => {
488
+ try {
489
+ const loggedIn = await isLoggedIn();
490
+ if (!loggedIn) {
491
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
492
+ process.exit(1);
493
+ }
494
+
495
+ let content;
496
+
497
+ if (options.json) {
498
+ // Parse JSON-encoded string
499
+ try {
500
+ content = JSON.parse(options.json);
501
+ if (typeof content !== 'string') {
502
+ console.error(chalk.red('Error: --json must be a JSON string, e.g., \'"hello\\nworld"\''));
503
+ process.exit(1);
504
+ }
505
+ } catch (e) {
506
+ console.error(chalk.red('Error: Invalid JSON string'));
507
+ console.error(chalk.dim(' Expected format: \'"line1\\nline2"\''));
508
+ process.exit(1);
509
+ }
510
+ } else if (options.file) {
511
+ // Read from local file
512
+ content = await fs.readFile(options.file, 'utf-8');
513
+ } else if (options.stdin) {
514
+ // Read from stdin
515
+ content = await readStdin();
516
+ } else if (contentArg) {
517
+ // Use positional argument
518
+ content = contentArg;
519
+ } else {
520
+ console.error(chalk.red('Error: Content required. Provide --json, --file, --stdin, or as argument'));
521
+ process.exit(1);
522
+ }
523
+
524
+ const wsId = await resolveWorkspaceId(options.workspace);
525
+ const result = await writeFile(wsId, path, content);
526
+
527
+ console.log(chalk.green(`✓ Written ${result.size} bytes to ${result.path}\n`));
528
+ } catch (error) {
529
+ console.error(chalk.red('Error:'), error.message);
530
+ process.exit(1);
531
+ }
532
+ });
533
+
534
+ filesCmd
535
+ .command('edit <path>')
536
+ .description('Edit a file with find/replace')
537
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
538
+ .option('--old <string>', 'Text to find')
539
+ .option('--new <string>', 'Text to replace with')
540
+ .option('--replace-all', 'Replace all occurrences')
541
+ .option('-p, --params <json>', 'Parameters as JSON: {"old_string": "...", "new_string": "...", "replace_all": false}')
542
+ .option('--stdin', 'Read parameters as JSON from stdin')
543
+ .action(async (path, options) => {
544
+ try {
545
+ const loggedIn = await isLoggedIn();
546
+ if (!loggedIn) {
547
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
548
+ process.exit(1);
549
+ }
550
+
551
+ let oldString, newString, replaceAll = false;
552
+
553
+ if (options.stdin) {
554
+ // Read JSON from stdin
555
+ const input = await readStdin();
556
+ const params = JSON.parse(input);
557
+ oldString = params.old_string;
558
+ newString = params.new_string;
559
+ replaceAll = params.replace_all || false;
560
+ } else if (options.params) {
561
+ // Parse JSON params
562
+ const params = JSON.parse(options.params);
563
+ oldString = params.old_string;
564
+ newString = params.new_string;
565
+ replaceAll = params.replace_all || false;
566
+ } else if (options.old !== undefined && options.new !== undefined) {
567
+ // Use --old and --new flags
568
+ oldString = options.old;
569
+ newString = options.new;
570
+ replaceAll = options.replaceAll || false;
571
+ } else {
572
+ console.error(chalk.red('Error: Must provide --old and --new, or --params, or --stdin'));
573
+ process.exit(1);
574
+ }
575
+
576
+ const wsId = await resolveWorkspaceId(options.workspace);
577
+ const result = await editFile(wsId, path, oldString, newString, replaceAll);
578
+
579
+ console.log(chalk.green(`✓ Made ${result.replacements} replacement(s) in ${result.path}\n`));
580
+ } catch (error) {
581
+ if (error.response?.data?.error) {
582
+ console.error(chalk.red('Error:'), error.response.data.error);
583
+ if (error.response.data.occurrences) {
584
+ console.error(chalk.dim(` Found ${error.response.data.occurrences} occurrences. Use --replace-all to replace all.`));
585
+ }
586
+ } else {
587
+ console.error(chalk.red('Error:'), error.message);
588
+ }
589
+ process.exit(1);
590
+ }
591
+ });
592
+
593
+ filesCmd
594
+ .command('delete <path>')
595
+ .description('Delete a file')
596
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
597
+ .action(async (path, options) => {
598
+ try {
599
+ const loggedIn = await isLoggedIn();
600
+ if (!loggedIn) {
601
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
602
+ process.exit(1);
603
+ }
604
+
605
+ const wsId = await resolveWorkspaceId(options.workspace);
606
+ const result = await deleteFile(wsId, path);
607
+
608
+ console.log(chalk.green(`✓ Deleted ${result.path}\n`));
609
+ } catch (error) {
610
+ console.error(chalk.red('Error:'), error.message);
611
+ process.exit(1);
612
+ }
613
+ });
614
+
615
+ // ============================================================================
616
+ // PULL / PUSH COMMANDS
617
+ // ============================================================================
618
+
619
+ program
620
+ .command('pull')
621
+ .description('Pull workspace files to local ~/.papercrane/workspaces/<id>/ for editing')
622
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
623
+ .action(async (options) => {
624
+ try {
625
+ const loggedIn = await isLoggedIn();
626
+ if (!loggedIn) {
627
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
628
+ process.exit(1);
629
+ }
630
+
631
+ const wsId = await resolveWorkspaceId(options.workspace);
632
+
633
+ console.log(chalk.cyan(`Pulling workspace ${wsId}...`));
634
+
635
+ const { localPath, files } = await pullWorkspace(wsId, (file, current, total) => {
636
+ process.stdout.write(`\r ${chalk.dim(`[${current}/${total}]`)} ${file}`);
637
+ });
638
+
639
+ console.log('\n');
640
+ console.log(chalk.green(`✓ Pulled ${files.length} files to ${localPath}\n`));
641
+ console.log(chalk.dim('Edit files locally, then run: papercrane push'));
642
+ } catch (error) {
643
+ console.error(chalk.red('Error:'), error.message);
644
+ process.exit(1);
645
+ }
646
+ });
647
+
648
+ program
649
+ .command('push')
650
+ .description('Push local files back to workspace')
651
+ .option('-w, --workspace <id>', 'Workspace ID (uses default if not specified)')
652
+ .option('-p, --path <path>', 'Only push files under this path (e.g., "verdata-vrisk")')
653
+ .action(async (options) => {
654
+ try {
655
+ const loggedIn = await isLoggedIn();
656
+ if (!loggedIn) {
657
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
658
+ process.exit(1);
659
+ }
660
+
661
+ const wsId = await resolveWorkspaceId(options.workspace);
662
+ const localPath = getLocalWorkspacePath(wsId);
663
+ const pathFilter = options.path;
664
+
665
+ console.log(chalk.cyan(`Pushing to workspace ${wsId}...`));
666
+
667
+ const { files } = await pushWorkspace(wsId, (file, current, total) => {
668
+ process.stdout.write(`\r ${chalk.dim(`[${current}/${total}]`)} ${file}`);
669
+ }, pathFilter);
670
+
671
+ console.log('\n');
672
+ console.log(chalk.green(`✓ Pushed ${files.length} files from ${localPath}`));
673
+
674
+ // Print preview URL(s)
675
+ const { getApiBaseUrl } = await import('../lib/config.js');
676
+ const baseUrl = await getApiBaseUrl();
677
+
678
+ // Find unique dashboard directories that were pushed
679
+ const dashboards = [...new Set(files.map(f => f.split('/')[0]).filter(d => d && !d.includes('.')))];
680
+
681
+ if (dashboards.length > 0) {
682
+ console.log('');
683
+ console.log(chalk.dim('Preview:'));
684
+ for (const dashboard of dashboards) {
685
+ console.log(chalk.cyan(` ${baseUrl}/environments/${wsId}/${dashboard}`));
686
+ }
687
+ }
688
+ console.log('');
689
+ } catch (error) {
690
+ console.error(chalk.red('Error:'), error.message);
691
+ process.exit(1);
692
+ }
693
+ });
694
+
695
+ // Helper to read from stdin
696
+ function readStdin() {
697
+ return new Promise((resolve, reject) => {
698
+ let data = '';
699
+ process.stdin.setEncoding('utf-8');
700
+ process.stdin.on('data', chunk => data += chunk);
701
+ process.stdin.on('end', () => resolve(data));
702
+ process.stdin.on('error', reject);
703
+ });
704
+ }
705
+
706
+ // Helper to print file tree recursively
707
+ function printFileTree(tree, indent = '') {
708
+ if (!tree || !Array.isArray(tree)) return;
709
+
710
+ // Sort: folders first, then files
711
+ const sorted = [...tree].sort((a, b) => {
712
+ if (a.type === 'folder' && b.type !== 'folder') return -1;
713
+ if (a.type !== 'folder' && b.type === 'folder') return 1;
714
+ return a.name.localeCompare(b.name);
715
+ });
716
+
717
+ for (const item of sorted) {
718
+ if (item.type === 'folder') {
719
+ console.log(`${indent}${chalk.blue(item.name + '/')}`);
720
+ if (item.children) {
721
+ printFileTree(item.children, indent + ' ');
722
+ }
723
+ } else {
724
+ console.log(`${indent}${item.name}`);
725
+ }
726
+ }
727
+ }
728
+
192
729
  // Default action: show connected modules when no command is given
193
- program.action(async () => {
730
+ program.action(async (_, cmd) => {
731
+ // Reject unknown commands instead of silently falling through
732
+ const unknownArgs = cmd.args;
733
+ if (unknownArgs.length > 0) {
734
+ console.error(chalk.red(`Unknown command: ${unknownArgs[0]}`));
735
+ console.error();
736
+ console.error('Available commands:');
737
+ console.error(' papercrane List connected integrations and endpoints');
738
+ console.error(' papercrane describe <path> Describe a module or endpoint');
739
+ console.error(' papercrane call <path> [json] Call an endpoint');
740
+ console.error(' papercrane add Add a new integration');
741
+ console.error(' papercrane dashboard-guide Show how to build dashboards');
742
+ console.error(' papercrane workspaces list List workspaces');
743
+ console.error(' papercrane workspaces use <id> Set default workspace');
744
+ console.error(' papercrane files list [path] List files in workspace');
745
+ console.error(' papercrane files read <path> Read file contents');
746
+ console.error(' papercrane files write <path> Write file contents');
747
+ console.error(' papercrane files edit <path> Edit file with find/replace');
748
+ console.error(' papercrane files delete <path> Delete a file');
749
+ console.error(' papercrane pull Pull workspace files locally for editing');
750
+ console.error(' papercrane push Push local changes back to workspace');
751
+ console.error(' papercrane login Log in with API key');
752
+ console.error(' papercrane logout Log out');
753
+ process.exit(1);
754
+ }
755
+
194
756
  try {
195
757
  const loggedIn = await isLoggedIn();
196
758
  if (!loggedIn) {
@@ -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
+ }
@@ -239,7 +239,7 @@ export function formatDescribeRoot(data) {
239
239
  console.log()
240
240
  } else {
241
241
  console.log(chalk.dim("\nNo connected modules.\n"))
242
- console.log(chalk.dim('Run "papercrane connect" to see available integrations.\n'))
242
+ console.log(chalk.dim('Run "papercrane add" to see available integrations.\n'))
243
243
  }
244
244
  return
245
245
  }
@@ -345,7 +345,7 @@ export function formatFlat(data) {
345
345
  }
346
346
 
347
347
  /**
348
- * Format unconnected integrations for the `connect` command.
348
+ * Format unconnected integrations for the `add` command.
349
349
  * @param {Object} data - Response from listFunctions (mode=list at root)
350
350
  */
351
351
  export function formatUnconnected(data) {
@@ -356,7 +356,7 @@ export function formatUnconnected(data) {
356
356
  console.log(` ${chalk.dim(item.displayName)}`)
357
357
  })
358
358
  console.log()
359
- console.log(chalk.dim('Run "papercrane connect <name>" to get started.\n'))
359
+ console.log(chalk.dim('Run "papercrane add <name>" to get started.\n'))
360
360
  } else {
361
361
  console.log(chalk.dim("\nAll integrations are already connected.\n"))
362
362
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papercraneai/cli",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "CLI tool for managing OAuth credentials for LLM integrations",
5
5
  "main": "index.js",
6
6
  "type": "module",