@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 +337 -16
- package/lib/dev-server.js +97 -0
- package/lib/handler-loader.js +123 -0
- package/lib/resolver.js +316 -0
- package/package.json +5 -2
- package/runtime-lib/papercrane.ts +18 -0
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 (
|
|
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(
|
|
605
|
+
const data = await listFunctions(fnPath, options.instance, true);
|
|
499
606
|
formatFlat(data);
|
|
500
607
|
} else {
|
|
501
|
-
const data = await getFunction(
|
|
502
|
-
formatDescribe(data,
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|
package/lib/resolver.js
ADDED
|
@@ -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.
|
|
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}`, {
|