@papercraneai/cli 1.8.3 → 1.9.0-beta.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
@@ -13,6 +13,65 @@ import { validateApiKey } from '../lib/cloud-client.js';
13
13
  import { listFunctions, getFunction, runFunction, formatDescribe, formatDescribeRoot, formatFlat, formatResult, formatUnconnected } from '../lib/function-client.js';
14
14
  import { listWorkspaces, resolveWorkspaceId, getLocalWorkspacePath, pullWorkspace, pushWorkspace } from '../lib/environment-client.js';
15
15
 
16
+ /**
17
+ * Resolve the current workspace directory from ~/.papercrane/current symlink.
18
+ * Returns null if no workspace is set up (does not exit).
19
+ */
20
+ async function resolveWorkspaceDir() {
21
+ const currentLink = path.join(os.homedir(), '.papercrane', 'current');
22
+ try {
23
+ await fs.readlink(currentLink);
24
+ return await fs.realpath(currentLink);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Load .env from workspace dir using dotenv.
32
+ */
33
+ async function loadDotenv(workspaceDir) {
34
+ try {
35
+ const dotenv = await import('dotenv');
36
+ dotenv.config({ path: path.join(workspaceDir, '.env') });
37
+ } catch {
38
+ // dotenv not available — env vars from shell still work
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Recursively collect all endpoint paths from a Node tree.
44
+ */
45
+ function collectPaths(node, prefix, paths) {
46
+ if (!node.next || typeof node.next === 'function') {
47
+ // Leaf or dynamic — add the node itself
48
+ paths.push({ path: prefix, isDynamic: typeof node.next === 'function' });
49
+ return;
50
+ }
51
+ for (const [key, child] of Object.entries(node.next)) {
52
+ const isDynamic = key.startsWith('$');
53
+ const childPath = `${prefix}.${key}`;
54
+ if (!child.next) {
55
+ paths.push({ path: childPath, isDynamic });
56
+ } else {
57
+ collectPaths(child, childPath, paths);
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Check if the workspace has any local handlers.
64
+ */
65
+ async function hasLocalHandlers(workspaceDir) {
66
+ if (!workspaceDir) return false;
67
+ try {
68
+ const { loadHandlers } = await import('../lib/handler-loader.js');
69
+ return loadHandlers(workspaceDir).size > 0;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
16
75
  /**
17
76
  * Check login and workspace setup. Exits with helpful message if not ready.
18
77
  */
@@ -486,8 +545,56 @@ program
486
545
  .description('Describe a module or endpoint. Use --flat to recursively list all endpoint paths')
487
546
  .option('-i, --instance <name>', 'Integration instance name', 'Default')
488
547
  .option('-f, --flat', 'List all endpoint paths under this module')
489
- .action(async (path, options) => {
548
+ .action(async (fnPath, options) => {
490
549
  try {
550
+ // Local handler path
551
+ if (fnPath.startsWith('local.')) {
552
+ const workspaceDir = await resolveWorkspaceDir();
553
+ if (!workspaceDir) {
554
+ console.error(chalk.red('No workspace set up. Run: papercrane scaffold'));
555
+ process.exit(1);
556
+ }
557
+
558
+ await loadDotenv(workspaceDir);
559
+ const { loadHandler } = await import('../lib/handler-loader.js');
560
+
561
+ const segments = fnPath.slice('local.'.length).split('.');
562
+ const handlerName = segments[0];
563
+ const methodPath = segments.slice(1);
564
+
565
+ const node = loadHandler(workspaceDir, handlerName);
566
+ if (!node) {
567
+ console.error(chalk.red(`Local handler not found: ${handlerName}`));
568
+ process.exit(1);
569
+ }
570
+
571
+ if (options.flat) {
572
+ const paths = [];
573
+ collectPaths(node, `local.${handlerName}`, paths);
574
+ if (paths.length > 0) {
575
+ console.log();
576
+ for (const p of paths) {
577
+ const display = p.isDynamic ? chalk.yellow(p.path) : chalk.green(p.path);
578
+ console.log(` ${display}`);
579
+ }
580
+ console.log();
581
+ } else {
582
+ console.log(chalk.dim('\nNo endpoints found.\n'));
583
+ }
584
+ } else {
585
+ const { resolve } = await import('../lib/resolver.js');
586
+ const ctx = { credentials: {}, pathParams: {}, cleanup: [] };
587
+ const result = await resolve(node, methodPath, ctx, {}, 'GET');
588
+
589
+ if (!result.ok) {
590
+ console.error(chalk.red('Error:'), result.error);
591
+ process.exit(1);
592
+ }
593
+ formatDescribe(result.info, fnPath);
594
+ }
595
+ return;
596
+ }
597
+
491
598
  const loggedIn = await isLoggedIn();
492
599
  if (!loggedIn) {
493
600
  console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
@@ -495,11 +602,11 @@ program
495
602
  }
496
603
 
497
604
  if (options.flat) {
498
- const data = await listFunctions(path, options.instance, true);
605
+ const data = await listFunctions(fnPath, options.instance, true);
499
606
  formatFlat(data);
500
607
  } else {
501
- const data = await getFunction(path, options.instance);
502
- formatDescribe(data, path);
608
+ const data = await getFunction(fnPath, options.instance);
609
+ formatDescribe(data, fnPath);
503
610
  }
504
611
  } catch (error) {
505
612
  console.error(chalk.red('Error:'), error.message);
@@ -513,14 +620,8 @@ program
513
620
  .option('-i, --instance <name>', 'Integration instance name', 'Default')
514
621
  .option('-p, --params <json>', 'Parameters as JSON string (alternative to positional)')
515
622
  .option('--raw', 'Output raw JSON without formatting')
516
- .action(async (path, paramsArg, options) => {
623
+ .action(async (fnPath, paramsArg, options) => {
517
624
  try {
518
- const loggedIn = await isLoggedIn();
519
- if (!loggedIn) {
520
- console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
521
- process.exit(1);
522
- }
523
-
524
625
  // Parse params - positional arg takes precedence, fall back to --params
525
626
  let params = {};
526
627
  const paramsJson = paramsArg || options.params;
@@ -534,7 +635,61 @@ program
534
635
  }
535
636
  }
536
637
 
537
- const data = await runFunction(path, params, options.instance);
638
+ // Local handler path — execute directly via jiti, no server needed
639
+ if (fnPath.startsWith('local.')) {
640
+ const workspaceDir = await resolveWorkspaceDir();
641
+ if (!workspaceDir) {
642
+ console.error(chalk.red('No workspace set up. Run: papercrane scaffold'));
643
+ process.exit(1);
644
+ }
645
+
646
+ await loadDotenv(workspaceDir);
647
+ const { resolve } = await import('../lib/resolver.js');
648
+ const { loadHandler } = await import('../lib/handler-loader.js');
649
+
650
+ const segments = fnPath.slice('local.'.length).split('.');
651
+ const handlerName = segments[0];
652
+ const methodPath = segments.slice(1);
653
+
654
+ const node = loadHandler(workspaceDir, handlerName);
655
+ if (!node) {
656
+ console.error(chalk.red(`Local handler not found: ${handlerName}`));
657
+ console.error(chalk.dim(` Expected file: app/_handlers/${handlerName}.ts`));
658
+ process.exit(1);
659
+ }
660
+
661
+ const ctx = { credentials: {}, pathParams: {}, cleanup: [] };
662
+ const result = await resolve(node, methodPath, ctx, params, 'POST');
663
+
664
+ for (const fn of ctx.cleanup || []) {
665
+ try { await fn(); } catch {}
666
+ }
667
+
668
+ if (!result.ok) {
669
+ if (result.schema) {
670
+ console.error(chalk.yellow('\nExpected parameters:'));
671
+ for (const [name, info] of Object.entries(result.schema)) {
672
+ const req = info.required ? chalk.red('required') : chalk.dim('optional');
673
+ console.error(` ${chalk.cyan(name)} (${info.type}) ${req}`);
674
+ if (info.description) console.error(` ${chalk.dim(info.description)}`);
675
+ }
676
+ console.error('');
677
+ }
678
+ console.error(chalk.red('Error:'), result.error);
679
+ process.exit(1);
680
+ }
681
+
682
+ formatResult({ result: result.result });
683
+ return;
684
+ }
685
+
686
+ const loggedIn = await isLoggedIn();
687
+ if (!loggedIn) {
688
+ console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
689
+ process.exit(1);
690
+ }
691
+
692
+ const data = await runFunction(fnPath, params, options.instance);
538
693
  formatResult(data);
539
694
  } catch (error) {
540
695
  console.error(chalk.red('Error:'), error.message);
@@ -784,6 +939,140 @@ Use these Tailwind classes for theme-aware colors:
784
939
  console.log();
785
940
  });
786
941
 
942
+ // ============================================================================
943
+ // LOCAL INTEGRATION GUIDE
944
+ // ============================================================================
945
+
946
+ program
947
+ .command('local-integration-guide')
948
+ .description('Show how to build local API integrations that dashboards can call')
949
+ .action(async () => {
950
+ console.log(`
951
+ LOCAL INTEGRATION GUIDE
952
+
953
+ Connect any API to Papercrane dashboards via handler files.
954
+
955
+ WORKFLOW
956
+ 1. Research the target API: auth mechanism, base URL, endpoints, exact field names
957
+ 2. Create app/_handlers/<name>.ts exporting a Node tree (see format below)
958
+ 3. Add credentials to .env at workspace root
959
+ 4. Test: papercrane call local.<name>.<endpoint> '{...}'
960
+ 5. Iterate: fix field names, URLs, auth until calls return real data
961
+ 6. Call from dashboards: authFetch('local.<name>.<endpoint>', params)
962
+
963
+ FILES
964
+ ~/.papercrane/current/
965
+ ├── .env # Credentials (not synced by push/pull)
966
+ └── app/_handlers/
967
+ └── <name>.ts # → local.<name>.* endpoints
968
+
969
+ AVAILABLE WITHOUT npm install
970
+ - zod (from @papercraneai/cli)
971
+ - fetch (Node built-in)
972
+ - crypto (Node built-in)
973
+ If you need a vendor SDK, the user must npm install it in the workspace.
974
+
975
+ HANDLER FORMAT
976
+ File must export a Node object (named or default export).
977
+ Filename becomes the handler name: my-api.ts → local.my-api.*
978
+
979
+ import { z } from 'zod'
980
+
981
+ export const myApi = {
982
+ description: 'Short description of this integration',
983
+
984
+ // Optional root handler — runs first on every call
985
+ handler: async (ctx, params) => {
986
+ const token = process.env.MY_API_TOKEN
987
+ if (!token) throw new Error('Set MY_API_TOKEN in .env')
988
+ ctx.baseUrl = 'https://api.example.com'
989
+ ctx.headers = { Authorization: 'Bearer ' + token }
990
+ },
991
+
992
+ next: {
993
+ // Leaf node (callable endpoint): local.my-api.list
994
+ list: {
995
+ description: 'List items',
996
+ params: z.object({
997
+ limit: z.number().optional().describe('Max results'),
998
+ }),
999
+ handler: async (ctx, params) => {
1000
+ const res = await fetch(ctx.baseUrl + '/items?' + new URLSearchParams(params), {
1001
+ headers: ctx.headers,
1002
+ })
1003
+ if (!res.ok) throw new Error('API error: ' + res.status)
1004
+ return res.json()
1005
+ },
1006
+ },
1007
+
1008
+ // Branch node (grouping): local.my-api.accounts.*
1009
+ accounts: {
1010
+ description: 'Account operations',
1011
+ next: {
1012
+ list: {
1013
+ description: 'List accounts',
1014
+ handler: async (ctx) => {
1015
+ const res = await fetch(ctx.baseUrl + '/accounts', { headers: ctx.headers })
1016
+ return res.json()
1017
+ },
1018
+ },
1019
+ },
1020
+ },
1021
+ },
1022
+ }
1023
+
1024
+ NODE TYPES
1025
+ Leaf — { description, params?, handler } — callable endpoint, no next
1026
+ Branch — { description, handler?, next } — groups children, handler sets up ctx
1027
+ Dynamic — key starts with $ — captures path segment
1028
+ $id → ctx.pathParams.id
1029
+ local.my-api.accounts.123.get → ctx.pathParams.id = '123'
1030
+
1031
+ CONTEXT (ctx)
1032
+ ctx.pathParams Values captured by $ segments
1033
+ ctx.cleanup Array of async teardown functions (push to close connections)
1034
+ ctx[anything] Add any property — common: ctx.client, ctx.headers, ctx.baseUrl
1035
+
1036
+ PARAMS
1037
+ Use z.object({...}) with .describe() on each field.
1038
+ Validated before handler runs. Shows in "papercrane describe" output.
1039
+
1040
+ CREDENTIALS
1041
+ Store in .env at workspace root. Read via process.env.KEY_NAME.
1042
+ .env is loaded automatically by both dev server and CLI.
1043
+ .env is NOT synced by push/pull — credentials stay local.
1044
+
1045
+ AUTH PATTERNS
1046
+ Bearer token:
1047
+ headers: { Authorization: 'Bearer ' + process.env.TOKEN }
1048
+
1049
+ HMAC signing:
1050
+ import crypto from 'crypto'
1051
+ const sig = crypto.createHmac('sha1', key).update(body).digest('base64')
1052
+ headers: { 'X-Signature': sig }
1053
+
1054
+ API key in query:
1055
+ url + '?api_key=' + process.env.API_KEY
1056
+
1057
+ ERRORS
1058
+ Just throw. Message is returned to caller, no stack trace exposed.
1059
+
1060
+ TESTING
1061
+ papercrane describe local.<name> # see endpoints
1062
+ papercrane describe local.<name> --flat # all paths
1063
+ papercrane describe local.<name>.<endpoint> # params + example
1064
+ papercrane call local.<name>.<endpoint> '{...}'
1065
+
1066
+ IMPORTANT
1067
+ - Read the vendor's actual API docs for exact field names, URL paths, and
1068
+ auth details before writing code. Getting field names wrong (e.g. "message"
1069
+ vs "messagetext", "url" vs "profile") is the #1 source of errors.
1070
+ - Test with papercrane call after writing each endpoint. Don't write the
1071
+ whole handler tree before testing — iterate one endpoint at a time.
1072
+ - Ask the user for credentials. They go in .env, not in the handler file.
1073
+ `);
1074
+ });
1075
+
787
1076
  // ============================================================================
788
1077
  // WORKSPACE COMMANDS
789
1078
  // ============================================================================
@@ -1074,6 +1363,7 @@ program.action(async (_, cmd) => {
1074
1363
  console.error(' papercrane call <path> [json] Call an endpoint');
1075
1364
  console.error(' papercrane add Add a new integration');
1076
1365
  console.error(' papercrane dashboard-guide Show how to build dashboards');
1366
+ console.error(' papercrane local-integration-guide Show how to build local API integrations');
1077
1367
  console.error(' papercrane scaffold Pre-scaffold workspace (no login required)');
1078
1368
  console.error(' papercrane workspaces list List workspaces');
1079
1369
  console.error(' papercrane workspaces use <id> Set default workspace');
@@ -1086,14 +1376,45 @@ program.action(async (_, cmd) => {
1086
1376
  }
1087
1377
 
1088
1378
  try {
1379
+ // Show cloud modules if logged in
1089
1380
  const loggedIn = await isLoggedIn();
1090
- if (!loggedIn) {
1381
+ if (loggedIn) {
1382
+ try {
1383
+ const data = await listFunctions('', 'Default');
1384
+ formatDescribeRoot(data);
1385
+ } catch (error) {
1386
+ console.error(chalk.red('Error loading cloud modules:'), error.message);
1387
+ }
1388
+ }
1389
+
1390
+ // Show local handlers if workspace has any
1391
+ const workspaceDir = await resolveWorkspaceDir();
1392
+ if (workspaceDir) {
1393
+ try {
1394
+ const { loadHandlers } = await import('../lib/handler-loader.js');
1395
+ const handlers = loadHandlers(workspaceDir);
1396
+ if (handlers.size > 0) {
1397
+ console.log(chalk.bold('Local modules:\n'));
1398
+ for (const [name, node] of handlers) {
1399
+ const displayPath = node.next ? `local.${name}.*` : `local.${name}`;
1400
+ console.log(` ${chalk.magenta(displayPath)}`);
1401
+ if (node.description) {
1402
+ console.log(` ${chalk.dim(node.description)}`);
1403
+ }
1404
+ }
1405
+ console.log();
1406
+ console.log(chalk.dim('Run "papercrane describe local.<name>" to see endpoints.'));
1407
+ console.log();
1408
+ }
1409
+ } catch {
1410
+ // Non-fatal — local handlers are optional
1411
+ }
1412
+ }
1413
+
1414
+ if (!loggedIn && (!workspaceDir || !(await hasLocalHandlers(workspaceDir)))) {
1091
1415
  console.error(chalk.red('Error: Not logged in. Run: papercrane login'));
1092
1416
  process.exit(1);
1093
1417
  }
1094
-
1095
- const data = await listFunctions('', 'Default');
1096
- formatDescribeRoot(data);
1097
1418
  } catch (error) {
1098
1419
  console.error(chalk.red('Error:'), error.message);
1099
1420
  process.exit(1);
package/lib/dev-server.js CHANGED
@@ -391,6 +391,103 @@ export async function startDevServer(workspaceDir, port) {
391
391
  // Serve theme CSS from CLI package
392
392
  server.use('/themes', express.static(path.join(cliRoot, 'public', 'themes')));
393
393
 
394
+ // ---- Local handler routes ----
395
+ server.use(express.json());
396
+
397
+ // POST /function/local.* — execute a local handler
398
+ server.post('/function/local.:rest(*)', async (req, res) => {
399
+ try {
400
+ const { resolve } = await import('./resolver.js');
401
+ const { loadHandler, clearHandlerCache } = await import('./handler-loader.js');
402
+
403
+ const segments = req.params.rest.split('.');
404
+ const handlerName = segments[0];
405
+ const methodPath = segments.slice(1);
406
+
407
+ // Clear cache on each request during dev for hot-reload
408
+ clearHandlerCache(projectDir);
409
+
410
+ const node = loadHandler(projectDir, handlerName);
411
+ if (!node) {
412
+ return res.status(404).json({ error: `Local handler not found: ${handlerName}` });
413
+ }
414
+
415
+ const ctx = { credentials: {}, pathParams: {}, cleanup: [] };
416
+ const params = req.body?.params || req.body || {};
417
+
418
+ const result = await resolve(node, methodPath, ctx, params, 'POST');
419
+
420
+ // Run cleanup functions
421
+ for (const fn of ctx.cleanup || []) {
422
+ try { await fn(); } catch {}
423
+ }
424
+
425
+ if (!result.ok) {
426
+ const response = { error: result.error };
427
+ if (result.schema) response.schema = result.schema;
428
+ return res.status(result.status || 500).json(response);
429
+ }
430
+
431
+ return res.json({ result: result.result });
432
+ } catch (err) {
433
+ return res.status(500).json({ error: err.message });
434
+ }
435
+ });
436
+
437
+ // GET /function/local.* — describe a local handler
438
+ server.get('/function/local.:rest(*)', async (req, res) => {
439
+ try {
440
+ const { resolve } = await import('./resolver.js');
441
+ const { loadHandler, clearHandlerCache } = await import('./handler-loader.js');
442
+
443
+ const segments = req.params.rest.split('.');
444
+ const handlerName = segments[0];
445
+ const methodPath = segments.slice(1);
446
+
447
+ clearHandlerCache(projectDir);
448
+
449
+ const node = loadHandler(projectDir, handlerName);
450
+ if (!node) {
451
+ return res.status(404).json({ error: `Local handler not found: ${handlerName}` });
452
+ }
453
+
454
+ const ctx = { credentials: {}, pathParams: {}, cleanup: [] };
455
+ const result = await resolve(node, methodPath, ctx, {}, 'GET');
456
+
457
+ for (const fn of ctx.cleanup || []) {
458
+ try { await fn(); } catch {}
459
+ }
460
+
461
+ if (!result.ok) {
462
+ return res.status(result.status || 500).json({ error: result.error });
463
+ }
464
+
465
+ return res.json(result.info);
466
+ } catch (err) {
467
+ return res.status(500).json({ error: err.message });
468
+ }
469
+ });
470
+
471
+ // GET /function/local — list all local handlers
472
+ server.get('/function/local', async (req, res) => {
473
+ try {
474
+ const { loadHandlers, clearHandlerCache } = await import('./handler-loader.js');
475
+ clearHandlerCache(projectDir);
476
+ const handlers = loadHandlers(projectDir);
477
+ const items = [];
478
+ for (const [name, node] of handlers) {
479
+ items.push({
480
+ path: `local.${name}`,
481
+ description: node.description,
482
+ isGroup: !!node.next,
483
+ });
484
+ }
485
+ return res.json({ items });
486
+ } catch (err) {
487
+ return res.status(500).json({ error: err.message });
488
+ }
489
+ });
490
+
394
491
  // Everything else -> Next.js
395
492
  server.all('/{*path}', (req, res) => handle(req, res));
396
493
 
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Handler loader — scans app/_handlers/*.ts in a workspace dir,
3
+ * loads each via jiti, and returns handler Node trees.
4
+ */
5
+
6
+ import path from 'path';
7
+ import fs from 'fs';
8
+ import { createRequire } from 'module';
9
+
10
+ const require = createRequire(import.meta.url);
11
+
12
+ // Cache: workspaceDir → Map<name, Node>
13
+ const cache = new Map();
14
+
15
+ /**
16
+ * Create a jiti instance rooted at the workspace dir so handler imports
17
+ * resolve from the workspace's node_modules.
18
+ */
19
+ function createJiti(workspaceDir) {
20
+ const { createJiti: createJitiFn } = require('jiti');
21
+ return createJitiFn(path.join(workspaceDir, '_virtual_entry.ts'), {
22
+ interopDefault: true,
23
+ moduleCache: false,
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Load all handlers from app/_handlers/*.ts in the given workspace dir.
29
+ * @param {string} workspaceDir - Absolute path to the workspace root
30
+ * @param {Object} [options]
31
+ * @param {boolean} [options.noCache] - Skip cache
32
+ * @returns {Map<string, Object>} Map of handler name → Node tree
33
+ */
34
+ export function loadHandlers(workspaceDir, { noCache = false } = {}) {
35
+ if (!noCache && cache.has(workspaceDir)) {
36
+ return cache.get(workspaceDir);
37
+ }
38
+
39
+ const handlersDir = path.join(workspaceDir, 'app', '_handlers');
40
+ const handlers = new Map();
41
+
42
+ if (!fs.existsSync(handlersDir)) {
43
+ cache.set(workspaceDir, handlers);
44
+ return handlers;
45
+ }
46
+
47
+ const files = fs.readdirSync(handlersDir).filter(f => f.endsWith('.ts') || f.endsWith('.js'));
48
+ const jiti = createJiti(workspaceDir);
49
+
50
+ for (const file of files) {
51
+ const name = path.basename(file, path.extname(file));
52
+ try {
53
+ const mod = jiti(path.join(handlersDir, file));
54
+ const node = extractNode(mod);
55
+ if (node) {
56
+ handlers.set(name, node);
57
+ }
58
+ } catch (err) {
59
+ // Log but don't fail — other handlers may still work
60
+ console.error(`Warning: Failed to load handler "${name}": ${err.message}`);
61
+ }
62
+ }
63
+
64
+ cache.set(workspaceDir, handlers);
65
+ return handlers;
66
+ }
67
+
68
+ /**
69
+ * Load a single handler by name.
70
+ * @param {string} workspaceDir - Absolute path to the workspace root
71
+ * @param {string} name - Handler name (filename without extension)
72
+ * @returns {Object|null} Node tree or null if not found
73
+ */
74
+ export function loadHandler(workspaceDir, name) {
75
+ const handlersDir = path.join(workspaceDir, 'app', '_handlers');
76
+
77
+ // Try .ts first, then .js
78
+ for (const ext of ['.ts', '.js']) {
79
+ const filePath = path.join(handlersDir, name + ext);
80
+ if (fs.existsSync(filePath)) {
81
+ const jiti = createJiti(workspaceDir);
82
+ const mod = jiti(filePath);
83
+ return extractNode(mod);
84
+ }
85
+ }
86
+
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Clear the handler cache (useful when files change).
92
+ * @param {string} [workspaceDir] - Clear for specific dir, or all if omitted
93
+ */
94
+ export function clearHandlerCache(workspaceDir) {
95
+ if (workspaceDir) {
96
+ cache.delete(workspaceDir);
97
+ } else {
98
+ cache.clear();
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Extract the first Node-shaped export from a module.
104
+ * A Node has at minimum a `description` string.
105
+ */
106
+ function extractNode(mod) {
107
+ if (!mod || typeof mod !== 'object') return null;
108
+
109
+ // Check if the module itself is a Node (default export)
110
+ if (typeof mod.description === 'string' && (mod.handler || mod.next)) {
111
+ return mod;
112
+ }
113
+
114
+ // Check named exports
115
+ for (const key of Object.keys(mod)) {
116
+ const val = mod[key];
117
+ if (val && typeof val === 'object' && typeof val.description === 'string' && (val.handler || val.next)) {
118
+ return val;
119
+ }
120
+ }
121
+
122
+ return null;
123
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Local resolver — adapted from apps/papercrane/src/server/lib/sdk-handlers/resolver.ts
3
+ * for CLI/dev-server use. No Express dependencies, no streaming.
4
+ */
5
+
6
+ import { createRequire } from 'module';
7
+ const require = createRequire(import.meta.url);
8
+
9
+ /**
10
+ * Convert Zod schema to JSON Schema.
11
+ * Supports both zod v4 (native toJSONSchema) and v3 (zod-to-json-schema).
12
+ */
13
+ function zodToJsonSchema(schema) {
14
+ try {
15
+ // Zod v4 has native toJSONSchema
16
+ if (typeof schema.toJSONSchema === 'function') {
17
+ return schema.toJSONSchema();
18
+ }
19
+ // Fallback to zod-to-json-schema for v3
20
+ const { zodToJsonSchema: convert } = require('zod-to-json-schema');
21
+ return convert(schema);
22
+ } catch {
23
+ return { type: 'object' };
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Check if next is a dynamic function
29
+ */
30
+ function isDynamicNext(next) {
31
+ return typeof next === 'function';
32
+ }
33
+
34
+ /**
35
+ * Resolve dynamic next to static Record<string, Node>.
36
+ */
37
+ async function resolveNext(node, ctx) {
38
+ if (!node.next) return undefined;
39
+
40
+ if (isDynamicNext(node.next)) {
41
+ if (node.handler) {
42
+ await node.handler(ctx, {});
43
+ }
44
+ return await node.next(ctx);
45
+ }
46
+
47
+ return node.next;
48
+ }
49
+
50
+ /**
51
+ * Find the next node given a path segment.
52
+ */
53
+ function findNext(next, segment) {
54
+ if (!next) return null;
55
+
56
+ if (segment in next) {
57
+ return { node: next[segment] };
58
+ }
59
+
60
+ const wildcardKey = Object.keys(next).find((k) => k.startsWith('$'));
61
+ if (wildcardKey) {
62
+ const paramName = wildcardKey.slice(1);
63
+ return { node: next[wildcardKey], paramName };
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Get a human-readable type name from a Zod schema field.
71
+ * Supports both zod v3 (_def.typeName) and v4 (.type property).
72
+ */
73
+ function getZodTypeName(field) {
74
+ // Zod v4: field.type is a string like 'string', 'number', etc.
75
+ if (typeof field.type === 'string') {
76
+ const t = field.type;
77
+ // For optional wrapper in v4, unwrap
78
+ if (t === 'optional' && field.unwrap) {
79
+ return getZodTypeName(field.unwrap());
80
+ }
81
+ if (t === 'nullable' && field.unwrap) {
82
+ return `${getZodTypeName(field.unwrap())} | null`;
83
+ }
84
+ if (t === 'array' && field.def?.element) {
85
+ return `${getZodTypeName(field.def.element)}[]`;
86
+ }
87
+ if (t === 'enum' && field.def?.entries) {
88
+ return Object.keys(field.def.entries).map((v) => `"${v}"`).join(' | ');
89
+ }
90
+ if (t === 'union' && field.def?.options) {
91
+ return field.def.options.map((o) => getZodTypeName(o)).join(' | ');
92
+ }
93
+ return t;
94
+ }
95
+
96
+ // Zod v3 fallback
97
+ const typeName = field._def?.typeName;
98
+ if (!typeName) return 'unknown';
99
+ if (typeName === 'ZodOptional' || typeName === 'ZodDefault') {
100
+ return getZodTypeName(field._def.innerType);
101
+ }
102
+ if (typeName === 'ZodNullable') {
103
+ return `${getZodTypeName(field._def.innerType)} | null`;
104
+ }
105
+ if (typeName === 'ZodArray') {
106
+ return `${getZodTypeName(field._def.type)}[]`;
107
+ }
108
+ if (typeName === 'ZodEnum') {
109
+ return field._def.values.map((v) => `"${v}"`).join(' | ');
110
+ }
111
+ if (typeName === 'ZodUnion') {
112
+ return field._def.options.map((o) => getZodTypeName(o)).join(' | ');
113
+ }
114
+ const map = {
115
+ ZodString: 'string',
116
+ ZodNumber: 'number',
117
+ ZodBoolean: 'boolean',
118
+ ZodObject: 'object',
119
+ ZodRecord: 'Record<string, any>',
120
+ };
121
+ return map[typeName] || typeName.replace('Zod', '').toLowerCase();
122
+ }
123
+
124
+ /**
125
+ * Convert a Zod schema to a simple field-level schema description.
126
+ * Supports both zod v3 (_def.typeName) and v4 (.shape property).
127
+ */
128
+ function zodToSimpleSchema(schema) {
129
+ try {
130
+ // Zod v4: schema.shape is a plain object
131
+ const shape = typeof schema.shape === 'object' && schema.shape
132
+ ? schema.shape
133
+ // Zod v3: schema._def.shape() is a function
134
+ : (schema._def?.typeName === 'ZodObject' ? schema._def.shape() : null);
135
+
136
+ if (!shape) return undefined;
137
+
138
+ const result = {};
139
+ for (const [key, value] of Object.entries(shape)) {
140
+ const field = value;
141
+ const entry = {
142
+ type: getZodTypeName(field),
143
+ required: typeof field.isOptional === 'function' ? !field.isOptional() : true,
144
+ };
145
+ // Zod v4: description is in field.description or field._zod?.def?.description
146
+ const desc = field.description || field._zod?.def?.description;
147
+ if (desc) {
148
+ entry.description = desc;
149
+ }
150
+ result[key] = entry;
151
+ }
152
+ return result;
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Format Zod validation errors into a readable string.
160
+ * Supports both zod v3 (.errors) and v4 (.issues).
161
+ */
162
+ function formatZodError(error) {
163
+ const items = error.errors || error.issues;
164
+ if (items && Array.isArray(items)) {
165
+ return items
166
+ .map((e) => {
167
+ const path = e.path && e.path.length > 0 ? `${e.path.join('.')}: ` : '';
168
+ return `${path}${e.message}`;
169
+ })
170
+ .join('; ');
171
+ }
172
+ return error.message || 'Validation failed';
173
+ }
174
+
175
+ /**
176
+ * Build node info for GET requests
177
+ */
178
+ function buildNodeInfo(node, inheritedScopes, resolvedNext) {
179
+ const resolvedScopes = node.scopes || inheritedScopes;
180
+
181
+ const info = {
182
+ description: node.description,
183
+ };
184
+
185
+ if (node.guide) {
186
+ info.guide = node.guide;
187
+ }
188
+
189
+ if (resolvedScopes && resolvedScopes.length > 0) {
190
+ info.scopes = resolvedScopes;
191
+ }
192
+
193
+ if (node.params) {
194
+ try {
195
+ info.params = zodToJsonSchema(node.params);
196
+ } catch (e) {
197
+ // skip
198
+ }
199
+ }
200
+
201
+ if (resolvedNext) {
202
+ info.next = Object.entries(resolvedNext).map(([name, child]) => ({
203
+ name,
204
+ description: child.description,
205
+ isDynamic: name.startsWith('$'),
206
+ isEndpoint: !child.next,
207
+ }));
208
+ }
209
+
210
+ return info;
211
+ }
212
+
213
+ /**
214
+ * Resolve a path through the node tree and execute/describe the target node.
215
+ *
216
+ * @param {Object} node - The root node to start from
217
+ * @param {string[]} path - Array of path segments (already split by '.')
218
+ * @param {Object} ctx - The context object (will be mutated by handlers)
219
+ * @param {Object} params - Request body params (used at leaf nodes)
220
+ * @param {'GET'|'POST'} method - HTTP method
221
+ * @param {string[]} [inheritedScopes] - Scopes inherited from ancestors
222
+ * @returns {Promise<Object>} ResolveResult
223
+ */
224
+ export async function resolve(node, path, ctx, params, method, inheritedScopes) {
225
+ try {
226
+ const atEndOfPath = path.length === 0;
227
+ const resolvedScopes = node.scopes || inheritedScopes;
228
+
229
+ if (!atEndOfPath) {
230
+ const needsHandler = method === 'POST' || isDynamicNext(node.next);
231
+ if (needsHandler && node.handler) {
232
+ await node.handler(ctx, params);
233
+ }
234
+
235
+ const nextNodes = isDynamicNext(node.next)
236
+ ? await node.next(ctx)
237
+ : node.next;
238
+
239
+ const [segment, ...rest] = path;
240
+ const found = findNext(nextNodes, segment);
241
+
242
+ if (!found) {
243
+ return { ok: false, error: `Unknown path segment: ${segment}`, status: 404 };
244
+ }
245
+
246
+ if (found.paramName) {
247
+ ctx.pathParams[found.paramName] = segment;
248
+ }
249
+
250
+ // $resolveFullPath captures all remaining segments
251
+ if (found.paramName === 'resolveFullPath') {
252
+ ctx.pathParams.resolveFullPath = [segment, ...rest];
253
+ ctx.resolvedScopes = found.node.scopes || resolvedScopes;
254
+
255
+ if (method === 'GET') {
256
+ return { ok: true, info: buildNodeInfo(found.node, resolvedScopes) };
257
+ }
258
+
259
+ if (!found.node.handler) {
260
+ return { ok: false, error: 'No handler defined for this function', status: 500 };
261
+ }
262
+
263
+ const result = await found.node.handler(ctx, params);
264
+ return { ok: true, result };
265
+ }
266
+
267
+ return resolve(found.node, rest, ctx, params, method, resolvedScopes);
268
+ }
269
+
270
+ ctx.resolvedScopes = resolvedScopes;
271
+
272
+ if (method === 'GET') {
273
+ const nextNodes = await resolveNext(node, ctx);
274
+ return { ok: true, info: buildNodeInfo(node, inheritedScopes, nextNodes) };
275
+ }
276
+
277
+ const isLeaf = !node.next;
278
+ if (!isLeaf) {
279
+ return {
280
+ ok: false,
281
+ error: 'Cannot execute branch node - must specify a complete path to a leaf function',
282
+ status: 400,
283
+ };
284
+ }
285
+
286
+ let validatedParams = params;
287
+ if (node.params) {
288
+ const schema = node.params;
289
+ const strictSchema = typeof schema.strict === 'function' ? schema.strict() : schema;
290
+ const parsed = strictSchema.safeParse(params);
291
+ if (!parsed.success) {
292
+ return {
293
+ ok: false,
294
+ error: formatZodError(parsed.error),
295
+ schema: zodToSimpleSchema(node.params),
296
+ status: 400,
297
+ };
298
+ }
299
+ validatedParams = parsed.data;
300
+ }
301
+
302
+ if (!node.handler) {
303
+ return { ok: false, error: 'No handler defined for this function', status: 500 };
304
+ }
305
+
306
+ const result = await node.handler(ctx, validatedParams);
307
+ return { ok: true, result };
308
+ } catch (err) {
309
+ const upstreamError = err.response?.data || err.response?.body;
310
+ return {
311
+ ok: false,
312
+ error: upstreamError || (err instanceof Error ? err.message : 'Unknown error occurred'),
313
+ status: err.response?.status || 500,
314
+ };
315
+ }
316
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papercraneai/cli",
3
- "version": "1.8.3",
3
+ "version": "1.9.0-beta.0",
4
4
  "description": "CLI tool for managing OAuth credentials for LLM integrations",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -69,10 +69,12 @@
69
69
  "cmdk": "^1.1.1",
70
70
  "commander": "^12.0.0",
71
71
  "date-fns": "^4.1.0",
72
+ "dotenv": "^16.4.5",
72
73
  "embla-carousel-react": "^8.6.0",
73
74
  "express": "^5.1.0",
74
75
  "https-proxy-agent": "^7.0.4",
75
76
  "input-otp": "^1.4.2",
77
+ "jiti": "^2.4.2",
76
78
  "inquirer": "^8.2.6",
77
79
  "lucide-react": "^0.559.0",
78
80
  "next": "16.1.7",
@@ -92,7 +94,8 @@
92
94
  "tw-animate-css": "^1.4.0",
93
95
  "typescript": "^5",
94
96
  "vaul": "^1.1.2",
95
- "zod": "^4.1.13"
97
+ "zod": "^4.1.13",
98
+ "zod-to-json-schema": "^3.24.5"
96
99
  },
97
100
  "overrides": {}
98
101
  }
@@ -28,6 +28,24 @@ export async function getAuthConfig(): Promise<AuthConfig> {
28
28
  }
29
29
 
30
30
  export async function authFetch(functionPath: string, params: Record<string, unknown> = {}) {
31
+ // Route local.* handlers to the dev server on localhost
32
+ if (functionPath.startsWith('local.')) {
33
+ const port = process.env.PAPERCRANE_LOCAL_PORT || '3100'
34
+ const res = await fetch(`http://localhost:${port}/function/${functionPath}`, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ params })
38
+ })
39
+
40
+ if (!res.ok) {
41
+ const error = await res.json().catch(() => ({ error: res.statusText }))
42
+ throw new Error(error.error || `Local handler error: ${res.status}`)
43
+ }
44
+
45
+ const { result } = await res.json()
46
+ return result
47
+ }
48
+
31
49
  const { apiKey, apiBaseUrl } = await getAuthConfig()
32
50
 
33
51
  const res = await fetch(`${apiBaseUrl}/function/${functionPath}`, {