@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 +617 -43
- package/lib/auth-server.js +186 -0
- package/lib/config.js +28 -0
- package/lib/environment-client.js +327 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
20
|
-
.option('--api-key <key>', 'API key for
|
|
21
|
-
.option('--url <url>', 'API base URL (
|
|
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
|
-
|
|
26
|
+
const { getApiBaseUrl, setApiBaseUrl } = await import('../lib/config.js');
|
|
25
27
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
await setApiKey(apiKey);
|
|
71
|
+
const authUrl = `${baseUrl}/cli-auth?session=${session}`;
|
|
48
72
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
201
|
-
console.error(' papercrane describe <path>
|
|
202
|
-
console.error(' papercrane call <path> [json]
|
|
203
|
-
console.error(' papercrane add
|
|
204
|
-
console.error(' papercrane
|
|
205
|
-
console.error(' papercrane
|
|
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
|
+
}
|