@carto-knowledge/runner 0.1.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/.turbo/turbo-typecheck.log +6 -0
- package/bun.lock +40 -0
- package/package.json +30 -0
- package/src/allowlist.test.ts +78 -0
- package/src/allowlist.ts +75 -0
- package/src/envelope.ts +62 -0
- package/src/index.ts +12 -0
- package/src/runner.test.ts +69 -0
- package/src/runner.ts +169 -0
- package/src/runtime.ts +90 -0
- package/src/tokenizer.test.ts +83 -0
- package/src/tokenizer.ts +75 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
|
|
2
|
+
> @carto/runner@0.1.0 typecheck /Users/pureicis/dev/carto/frontend_v1/packages/carto-runner
|
|
3
|
+
> bun x tsc --noEmit
|
|
4
|
+
|
|
5
|
+
../carto-commands/src/librarian/chat.ts(39,21): error TS2339: Property 'folderId' does not exist on type 'CreateConversationRequest'.
|
|
6
|
+
ELIFECYCLE Command failed with exit code 2.
|
package/bun.lock
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"workspaces": {
|
|
4
|
+
"": {
|
|
5
|
+
"name": "@carto/runner",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@carto-knowledge/commands": "file:../carto-commands",
|
|
8
|
+
"@carto-knowledge/core": "file:../carto-core",
|
|
9
|
+
"clipanion": "^4.0.0-rc.4",
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@types/bun": "latest",
|
|
13
|
+
"typescript": "^5.4.0",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
"packages": {
|
|
18
|
+
"@carto-knowledge/commands": ["@carto-knowledge/commands@file:../carto-commands", { "dependencies": { "@carto-knowledge/core": "file:../carto-core", "clipanion": "^4.0.0-rc.4" }, "devDependencies": { "@types/bun": "latest", "@types/node": "^22.0.0", "typescript": "^5.4.0" } }],
|
|
19
|
+
|
|
20
|
+
"@carto-knowledge/core": ["@carto-knowledge/core@file:../carto-core", { "dependencies": { "zod": "^3.23.0" }, "devDependencies": { "@types/bun": "latest", "typescript": "^5.4.0" } }],
|
|
21
|
+
|
|
22
|
+
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
|
23
|
+
|
|
24
|
+
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
|
|
25
|
+
|
|
26
|
+
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
|
27
|
+
|
|
28
|
+
"clipanion": ["clipanion@4.0.0-rc.4", "", { "dependencies": { "typanion": "^3.8.0" } }, "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q=="],
|
|
29
|
+
|
|
30
|
+
"typanion": ["typanion@3.14.0", "", {}, "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug=="],
|
|
31
|
+
|
|
32
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
33
|
+
|
|
34
|
+
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
|
35
|
+
|
|
36
|
+
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
|
37
|
+
|
|
38
|
+
"@carto-knowledge/commands/@carto-knowledge/core": ["@carto-knowledge/core@file:../carto-core", {}],
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@carto-knowledge/runner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./src/index.ts",
|
|
10
|
+
"types": "./src/index.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "bun test",
|
|
18
|
+
"test:watch": "bun test --watch",
|
|
19
|
+
"typecheck": "bun x tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@carto-knowledge/core": "^0.1.0",
|
|
23
|
+
"@carto-knowledge/commands": "^0.1.0",
|
|
24
|
+
"clipanion": "^4.0.0-rc.4"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/bun": "latest",
|
|
28
|
+
"typescript": "^5.4.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// ABOUTME: Tests for command allowlist validation
|
|
2
|
+
// ABOUTME: Validates command filtering for tool-mode execution
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from 'bun:test';
|
|
5
|
+
import { checkAllowlist, DEFAULT_ALLOWLIST, WRITE_ALLOWLIST } from './allowlist';
|
|
6
|
+
|
|
7
|
+
describe('checkAllowlist', () => {
|
|
8
|
+
it('allows exact match', () => {
|
|
9
|
+
const result = checkAllowlist('folder tree', { commands: ['folder tree'] });
|
|
10
|
+
expect(result.allowed).toBe(true);
|
|
11
|
+
expect(result.error).toBeUndefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('allows case-insensitive match', () => {
|
|
15
|
+
const result = checkAllowlist('FOLDER TREE', { commands: ['folder tree'] });
|
|
16
|
+
expect(result.allowed).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('rejects non-matching command', () => {
|
|
20
|
+
const result = checkAllowlist('folder delete', { commands: ['folder tree'] });
|
|
21
|
+
expect(result.allowed).toBe(false);
|
|
22
|
+
expect(result.error?.code).toBe('COMMAND_NOT_ALLOWED');
|
|
23
|
+
expect(result.error?.message).toContain('folder delete');
|
|
24
|
+
expect(result.error?.message).toContain('folder tree');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('allows prefix match', () => {
|
|
28
|
+
const result = checkAllowlist('folder tree', { commands: ['folder'] });
|
|
29
|
+
expect(result.allowed).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('handles empty allowlist', () => {
|
|
33
|
+
const result = checkAllowlist('anything', { commands: [] });
|
|
34
|
+
expect(result.allowed).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('trims whitespace', () => {
|
|
38
|
+
const result = checkAllowlist(' folder tree ', { commands: ['folder tree'] });
|
|
39
|
+
expect(result.allowed).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('allows multiple commands in allowlist', () => {
|
|
43
|
+
const result = checkAllowlist('item search', { commands: ['folder tree', 'item search', 'librarian chat'] });
|
|
44
|
+
expect(result.allowed).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('does not allow partial command name match', () => {
|
|
48
|
+
// 'fold' should not match 'folder tree'
|
|
49
|
+
const result = checkAllowlist('fold', { commands: ['folder tree'] });
|
|
50
|
+
expect(result.allowed).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('DEFAULT_ALLOWLIST', () => {
|
|
55
|
+
it('includes read-only operations', () => {
|
|
56
|
+
expect(DEFAULT_ALLOWLIST.commands).toContain('folder tree');
|
|
57
|
+
expect(DEFAULT_ALLOWLIST.commands).toContain('folder list');
|
|
58
|
+
expect(DEFAULT_ALLOWLIST.commands).toContain('item search');
|
|
59
|
+
expect(DEFAULT_ALLOWLIST.commands).toContain('item get');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does not include write operations', () => {
|
|
63
|
+
expect(DEFAULT_ALLOWLIST.commands).not.toContain('folder create');
|
|
64
|
+
expect(DEFAULT_ALLOWLIST.commands).not.toContain('item create');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('WRITE_ALLOWLIST', () => {
|
|
69
|
+
it('includes all read operations', () => {
|
|
70
|
+
for (const cmd of DEFAULT_ALLOWLIST.commands) {
|
|
71
|
+
expect(WRITE_ALLOWLIST.commands).toContain(cmd);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('includes write operations', () => {
|
|
76
|
+
expect(WRITE_ALLOWLIST.commands).toContain('folder create');
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/allowlist.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// ABOUTME: Validates commands against an allowlist for tool-mode execution
|
|
2
|
+
// ABOUTME: Prevents unauthorized command execution in Worker context
|
|
3
|
+
|
|
4
|
+
export interface AllowlistConfig {
|
|
5
|
+
commands: string[]; // e.g., ['folder tree', 'item search']
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AllowlistResult {
|
|
9
|
+
allowed: boolean;
|
|
10
|
+
error?: {
|
|
11
|
+
code: 'COMMAND_NOT_ALLOWED';
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Checks if a command matches the allowlist.
|
|
18
|
+
*
|
|
19
|
+
* Defensive validation:
|
|
20
|
+
* - Normalizes command path (lowercase, trim)
|
|
21
|
+
* - Supports prefix matching for subcommands
|
|
22
|
+
*/
|
|
23
|
+
export function checkAllowlist(
|
|
24
|
+
commandPath: string,
|
|
25
|
+
config: AllowlistConfig
|
|
26
|
+
): AllowlistResult {
|
|
27
|
+
const normalizedPath = commandPath.toLowerCase().trim();
|
|
28
|
+
|
|
29
|
+
// Check exact match
|
|
30
|
+
if (config.commands.some(cmd => cmd.toLowerCase() === normalizedPath)) {
|
|
31
|
+
return { allowed: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check prefix match (e.g., 'folder' allows 'folder tree', 'folder create')
|
|
35
|
+
if (config.commands.some(cmd => normalizedPath.startsWith(cmd.toLowerCase() + ' '))) {
|
|
36
|
+
return { allowed: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
allowed: false,
|
|
41
|
+
error: {
|
|
42
|
+
code: 'COMMAND_NOT_ALLOWED',
|
|
43
|
+
message: `Command '${commandPath}' is not in the allowlist. ` +
|
|
44
|
+
`Allowed commands: ${config.commands.join(', ')}`,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Default allowlist for Worker tool mode.
|
|
51
|
+
* Read-only operations by default.
|
|
52
|
+
*/
|
|
53
|
+
export const DEFAULT_ALLOWLIST: AllowlistConfig = {
|
|
54
|
+
commands: [
|
|
55
|
+
'folder tree',
|
|
56
|
+
'folder list',
|
|
57
|
+
'item search',
|
|
58
|
+
'item get',
|
|
59
|
+
'librarian chat',
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Extended allowlist including write operations.
|
|
65
|
+
* Use when agent needs to modify data.
|
|
66
|
+
*/
|
|
67
|
+
export const WRITE_ALLOWLIST: AllowlistConfig = {
|
|
68
|
+
commands: [
|
|
69
|
+
...DEFAULT_ALLOWLIST.commands,
|
|
70
|
+
'folder create',
|
|
71
|
+
'folder move',
|
|
72
|
+
'item create',
|
|
73
|
+
'item assign',
|
|
74
|
+
],
|
|
75
|
+
};
|
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// ABOUTME: Wraps CLI command outputs in structured JSON envelopes
|
|
2
|
+
// ABOUTME: Provides consistent response format for Mastra tool integration
|
|
3
|
+
|
|
4
|
+
export interface CommandError {
|
|
5
|
+
code: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CommandOutput<T = unknown> {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
command: string;
|
|
12
|
+
result?: T;
|
|
13
|
+
error?: CommandError;
|
|
14
|
+
requestId?: string;
|
|
15
|
+
schemaVersion: 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Creates a successful output envelope.
|
|
20
|
+
*/
|
|
21
|
+
export function successEnvelope<T>(
|
|
22
|
+
command: string,
|
|
23
|
+
result: T,
|
|
24
|
+
requestId?: string
|
|
25
|
+
): CommandOutput<T> {
|
|
26
|
+
return {
|
|
27
|
+
ok: true,
|
|
28
|
+
command,
|
|
29
|
+
result,
|
|
30
|
+
requestId,
|
|
31
|
+
schemaVersion: 1,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates an error output envelope.
|
|
37
|
+
*/
|
|
38
|
+
export function errorEnvelope(
|
|
39
|
+
command: string,
|
|
40
|
+
error: CommandError,
|
|
41
|
+
requestId?: string
|
|
42
|
+
): CommandOutput<never> {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
command,
|
|
46
|
+
error,
|
|
47
|
+
requestId,
|
|
48
|
+
schemaVersion: 1,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Common error codes for CLI operations.
|
|
54
|
+
*/
|
|
55
|
+
export const ErrorCodes = {
|
|
56
|
+
COMMAND_NOT_ALLOWED: 'COMMAND_NOT_ALLOWED',
|
|
57
|
+
COMMAND_NOT_FOUND: 'COMMAND_NOT_FOUND',
|
|
58
|
+
INVALID_ARGUMENTS: 'INVALID_ARGUMENTS',
|
|
59
|
+
AUTH_ERROR: 'AUTH_ERROR',
|
|
60
|
+
EXECUTION_ERROR: 'EXECUTION_ERROR',
|
|
61
|
+
PARSE_ERROR: 'PARSE_ERROR',
|
|
62
|
+
} as const;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// ABOUTME: Public API for @carto/runner package
|
|
2
|
+
// ABOUTME: Exports in-process runner and supporting utilities
|
|
3
|
+
|
|
4
|
+
export { runInProcess } from './runner';
|
|
5
|
+
export type { RunOptions } from './runner';
|
|
6
|
+
export type { CommandOutput } from './envelope';
|
|
7
|
+
export { successEnvelope, errorEnvelope, ErrorCodes } from './envelope';
|
|
8
|
+
export { tokenize, extractCommandPath } from './tokenizer';
|
|
9
|
+
export { checkAllowlist, DEFAULT_ALLOWLIST, WRITE_ALLOWLIST } from './allowlist';
|
|
10
|
+
export type { AllowlistConfig } from './allowlist';
|
|
11
|
+
export { WorkerRuntime, CliRuntime } from './runtime';
|
|
12
|
+
export type { CartoRuntime } from './runtime';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// ABOUTME: Tests for in-process CLI runner
|
|
2
|
+
// ABOUTME: Validates command execution without subprocess spawning
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from 'bun:test';
|
|
5
|
+
import { runInProcess } from './runner';
|
|
6
|
+
|
|
7
|
+
describe('runInProcess', () => {
|
|
8
|
+
const baseOptions = {
|
|
9
|
+
authToken: 'test-token',
|
|
10
|
+
apiBaseUrl: 'https://api.carto.so',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
it('returns error for empty command', async () => {
|
|
14
|
+
const result = await runInProcess('', baseOptions);
|
|
15
|
+
expect(result.ok).toBe(false);
|
|
16
|
+
expect(result.error?.code).toBe('INVALID_ARGUMENTS');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns error for whitespace-only command', async () => {
|
|
20
|
+
const result = await runInProcess(' ', baseOptions);
|
|
21
|
+
expect(result.ok).toBe(false);
|
|
22
|
+
expect(result.error?.code).toBe('INVALID_ARGUMENTS');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns error for missing auth token', async () => {
|
|
26
|
+
const result = await runInProcess('folder tree', {
|
|
27
|
+
...baseOptions,
|
|
28
|
+
authToken: '',
|
|
29
|
+
});
|
|
30
|
+
expect(result.ok).toBe(false);
|
|
31
|
+
expect(result.error?.code).toBe('AUTH_ERROR');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns error for disallowed command', async () => {
|
|
35
|
+
const result = await runInProcess('admin delete-all', {
|
|
36
|
+
...baseOptions,
|
|
37
|
+
allowlist: { commands: ['folder tree'] },
|
|
38
|
+
});
|
|
39
|
+
expect(result.ok).toBe(false);
|
|
40
|
+
expect(result.error?.code).toBe('COMMAND_NOT_ALLOWED');
|
|
41
|
+
expect(result.error?.message).toContain('admin delete-all');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('includes requestId in response', async () => {
|
|
45
|
+
const result = await runInProcess('folder tree', {
|
|
46
|
+
...baseOptions,
|
|
47
|
+
requestId: 'req-123',
|
|
48
|
+
allowlist: { commands: ['folder tree'] },
|
|
49
|
+
});
|
|
50
|
+
expect(result.requestId).toBe('req-123');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('extracts correct command path from disallowed command error', async () => {
|
|
54
|
+
// Test with a disallowed command to verify path extraction without network
|
|
55
|
+
const result = await runInProcess('folder tree --library test', {
|
|
56
|
+
...baseOptions,
|
|
57
|
+
allowlist: { commands: ['item search'] }, // folder tree not allowed
|
|
58
|
+
});
|
|
59
|
+
expect(result.command).toBe('folder tree');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('includes schemaVersion in error response', async () => {
|
|
63
|
+
const result = await runInProcess('folder tree', {
|
|
64
|
+
...baseOptions,
|
|
65
|
+
allowlist: { commands: [] }, // Empty allowlist to trigger error
|
|
66
|
+
});
|
|
67
|
+
expect(result.schemaVersion).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
});
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// ABOUTME: Executes CLI commands in-process without subprocess spawning
|
|
2
|
+
// ABOUTME: Designed for Cloudflare Worker compatibility
|
|
3
|
+
|
|
4
|
+
import { Cli } from 'clipanion';
|
|
5
|
+
import { tokenize, extractCommandPath } from './tokenizer';
|
|
6
|
+
import { checkAllowlist, AllowlistConfig, DEFAULT_ALLOWLIST } from './allowlist';
|
|
7
|
+
import { successEnvelope, errorEnvelope, CommandOutput, ErrorCodes } from './envelope';
|
|
8
|
+
import { WorkerRuntime } from './runtime';
|
|
9
|
+
import { allCommands } from '@carto-knowledge/commands';
|
|
10
|
+
import { CartoClient } from '@carto-knowledge/core';
|
|
11
|
+
|
|
12
|
+
export interface RunOptions {
|
|
13
|
+
/** Correlation ID for tracing */
|
|
14
|
+
requestId?: string;
|
|
15
|
+
|
|
16
|
+
/** Auth token for API calls */
|
|
17
|
+
authToken: string;
|
|
18
|
+
|
|
19
|
+
/** Base URL for Carto API */
|
|
20
|
+
apiBaseUrl: string;
|
|
21
|
+
|
|
22
|
+
/** Command allowlist (defaults to read-only operations) */
|
|
23
|
+
allowlist?: AllowlistConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Executes a CLI command in-process.
|
|
28
|
+
*
|
|
29
|
+
* Designed for Worker runtime:
|
|
30
|
+
* - No subprocess spawning
|
|
31
|
+
* - Captures output to memory streams
|
|
32
|
+
* - Forces JSON output and non-interactive mode
|
|
33
|
+
* - Validates against allowlist
|
|
34
|
+
*
|
|
35
|
+
* @param commandLine - Command string, e.g., "folder tree --library my-lib"
|
|
36
|
+
* @param options - Execution options including auth and allowlist
|
|
37
|
+
* @returns Structured command output envelope
|
|
38
|
+
*/
|
|
39
|
+
export async function runInProcess(
|
|
40
|
+
commandLine: string,
|
|
41
|
+
options: RunOptions
|
|
42
|
+
): Promise<CommandOutput> {
|
|
43
|
+
const { requestId, authToken, apiBaseUrl, allowlist = DEFAULT_ALLOWLIST } = options;
|
|
44
|
+
|
|
45
|
+
// Defensive: Validate inputs
|
|
46
|
+
if (!commandLine || typeof commandLine !== 'string') {
|
|
47
|
+
return errorEnvelope('', {
|
|
48
|
+
code: ErrorCodes.INVALID_ARGUMENTS,
|
|
49
|
+
message: 'commandLine must be a non-empty string',
|
|
50
|
+
}, requestId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!authToken) {
|
|
54
|
+
return errorEnvelope('', {
|
|
55
|
+
code: ErrorCodes.AUTH_ERROR,
|
|
56
|
+
message: 'authToken is required',
|
|
57
|
+
}, requestId);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Parse command line
|
|
61
|
+
const tokens = tokenize(commandLine.trim());
|
|
62
|
+
|
|
63
|
+
if (tokens.length === 0) {
|
|
64
|
+
return errorEnvelope('', {
|
|
65
|
+
code: ErrorCodes.INVALID_ARGUMENTS,
|
|
66
|
+
message: 'Empty command line',
|
|
67
|
+
}, requestId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const commandPath = extractCommandPath(tokens);
|
|
71
|
+
|
|
72
|
+
// Check allowlist
|
|
73
|
+
const allowlistResult = checkAllowlist(commandPath, allowlist);
|
|
74
|
+
if (!allowlistResult.allowed) {
|
|
75
|
+
return errorEnvelope(commandPath, allowlistResult.error!, requestId);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Force tool-mode flags
|
|
79
|
+
const augmentedTokens = [
|
|
80
|
+
...tokens,
|
|
81
|
+
'--format', 'json',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// Create CLI instance
|
|
85
|
+
const cli = new Cli({
|
|
86
|
+
binaryName: 'carto',
|
|
87
|
+
enableCapture: true,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Register all commands
|
|
91
|
+
for (const CommandClass of allCommands) {
|
|
92
|
+
cli.register(CommandClass);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Capture output to memory using simple buffers (Worker-compatible)
|
|
96
|
+
let stdoutData = '';
|
|
97
|
+
let stderrData = '';
|
|
98
|
+
|
|
99
|
+
const stdout = {
|
|
100
|
+
write(chunk: string | Uint8Array): boolean {
|
|
101
|
+
stdoutData += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const stderr = {
|
|
107
|
+
write(chunk: string | Uint8Array): boolean {
|
|
108
|
+
stderrData += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
|
|
109
|
+
return true;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Create runtime and client
|
|
114
|
+
const runtime = new WorkerRuntime(authToken, apiBaseUrl);
|
|
115
|
+
const client = new CartoClient({
|
|
116
|
+
baseUrl: apiBaseUrl,
|
|
117
|
+
getAuthToken: async () => authToken,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Create context matching CartoCommandContext
|
|
121
|
+
// Type assertion needed because we use minimal stream stubs for Worker compatibility
|
|
122
|
+
// Clipanion only uses the write() method at runtime
|
|
123
|
+
const context = {
|
|
124
|
+
stdin: process.stdin,
|
|
125
|
+
stdout,
|
|
126
|
+
stderr,
|
|
127
|
+
env: {},
|
|
128
|
+
colorDepth: 1,
|
|
129
|
+
client,
|
|
130
|
+
runtime,
|
|
131
|
+
} as unknown as Parameters<typeof cli.run>[1];
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const exitCode = await cli.run(augmentedTokens, context);
|
|
135
|
+
const output = stdoutData.trim();
|
|
136
|
+
const errorOutput = stderrData.trim();
|
|
137
|
+
|
|
138
|
+
if (exitCode === 0) {
|
|
139
|
+
// Parse JSON output from command
|
|
140
|
+
try {
|
|
141
|
+
const result = output ? JSON.parse(output) : null;
|
|
142
|
+
return successEnvelope(commandPath, result, requestId);
|
|
143
|
+
} catch {
|
|
144
|
+
// Command succeeded but output wasn't JSON (shouldn't happen in tool mode)
|
|
145
|
+
return successEnvelope(commandPath, { text: output }, requestId);
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
// Command failed
|
|
149
|
+
return errorEnvelope(commandPath, {
|
|
150
|
+
code: ErrorCodes.EXECUTION_ERROR,
|
|
151
|
+
message: errorOutput || output || `Command exited with code ${exitCode}`,
|
|
152
|
+
}, requestId);
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
// Unexpected error during execution
|
|
156
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
157
|
+
console.error(`[carto-cli] Execution error for '${commandPath}':`, error);
|
|
158
|
+
|
|
159
|
+
return errorEnvelope(commandPath, {
|
|
160
|
+
code: ErrorCodes.EXECUTION_ERROR,
|
|
161
|
+
message,
|
|
162
|
+
}, requestId);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Re-export for convenience
|
|
167
|
+
export { DEFAULT_ALLOWLIST, WRITE_ALLOWLIST } from './allowlist';
|
|
168
|
+
export type { AllowlistConfig } from './allowlist';
|
|
169
|
+
export type { CommandOutput } from './envelope';
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// ABOUTME: Abstracts runtime differences between CLI and Worker execution
|
|
2
|
+
// ABOUTME: Provides consistent interface for auth, logging, and config
|
|
3
|
+
|
|
4
|
+
export interface CartoRuntime {
|
|
5
|
+
// Authentication
|
|
6
|
+
getAuthToken(): Promise<string>;
|
|
7
|
+
|
|
8
|
+
// API access
|
|
9
|
+
apiBaseUrl: string;
|
|
10
|
+
|
|
11
|
+
// Environment flags
|
|
12
|
+
isInteractive: boolean;
|
|
13
|
+
isToolMode: boolean;
|
|
14
|
+
|
|
15
|
+
// Logging (respects --quiet, routes to correct streams)
|
|
16
|
+
log(message: string): void;
|
|
17
|
+
warn(message: string): void;
|
|
18
|
+
error(message: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Worker runtime - receives auth from request context.
|
|
23
|
+
* Non-interactive, always tool mode.
|
|
24
|
+
*/
|
|
25
|
+
export class WorkerRuntime implements CartoRuntime {
|
|
26
|
+
isInteractive = false;
|
|
27
|
+
isToolMode = true;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
private authToken: string,
|
|
31
|
+
public apiBaseUrl: string,
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
async getAuthToken(): Promise<string> {
|
|
35
|
+
return this.authToken;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
log(message: string): void {
|
|
39
|
+
console.log(`[carto-cli] ${message}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
warn(message: string): void {
|
|
43
|
+
console.warn(`[carto-cli] ${message}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
error(message: string): void {
|
|
47
|
+
console.error(`[carto-cli] ${message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* CLI runtime - reads from config file or env vars.
|
|
53
|
+
* Interactive when terminal attached.
|
|
54
|
+
*
|
|
55
|
+
* Compatible with both Bun and Node.js runtimes.
|
|
56
|
+
*/
|
|
57
|
+
export class CliRuntime implements CartoRuntime {
|
|
58
|
+
isInteractive: boolean;
|
|
59
|
+
isToolMode = false;
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
private config: { apiBaseUrl: string; authToken?: string },
|
|
63
|
+
) {
|
|
64
|
+
// Works in both Bun and Node.js
|
|
65
|
+
this.isInteractive = process.stdout.isTTY ?? false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get apiBaseUrl(): string {
|
|
69
|
+
return this.config.apiBaseUrl;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async getAuthToken(): Promise<string> {
|
|
73
|
+
if (!this.config.authToken) {
|
|
74
|
+
throw new Error('Not authenticated. Run `carto auth login` first.');
|
|
75
|
+
}
|
|
76
|
+
return this.config.authToken;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
log(message: string): void {
|
|
80
|
+
console.log(message);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
warn(message: string): void {
|
|
84
|
+
console.warn(message);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
error(message: string): void {
|
|
88
|
+
console.error(message);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// ABOUTME: Tests for command line tokenizer
|
|
2
|
+
// ABOUTME: Validates parsing of quoted strings and shell-like arguments
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from 'bun:test';
|
|
5
|
+
import { tokenize, extractCommandPath } from './tokenizer';
|
|
6
|
+
|
|
7
|
+
describe('tokenize', () => {
|
|
8
|
+
it('splits simple command', () => {
|
|
9
|
+
expect(tokenize('folder tree')).toEqual(['folder', 'tree']);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('handles single quotes', () => {
|
|
13
|
+
expect(tokenize("--name 'My Folder'")).toEqual(['--name', 'My Folder']);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('handles double quotes', () => {
|
|
17
|
+
expect(tokenize('--name "My Folder"')).toEqual(['--name', 'My Folder']);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('handles mixed quotes', () => {
|
|
21
|
+
expect(tokenize(`--a "foo" --b 'bar'`)).toEqual(['--a', 'foo', '--b', 'bar']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('handles escaped quotes', () => {
|
|
25
|
+
expect(tokenize(`--name "Test\\'s Folder"`)).toEqual(['--name', "Test's Folder"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles empty input', () => {
|
|
29
|
+
expect(tokenize('')).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('handles multiple spaces', () => {
|
|
33
|
+
expect(tokenize('folder tree')).toEqual(['folder', 'tree']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('handles tabs', () => {
|
|
37
|
+
expect(tokenize('folder\ttree')).toEqual(['folder', 'tree']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('handles leading and trailing whitespace', () => {
|
|
41
|
+
expect(tokenize(' folder tree ')).toEqual(['folder', 'tree']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('handles quoted string with equals sign', () => {
|
|
45
|
+
expect(tokenize('--filter="status=active"')).toEqual(['--filter=status=active']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('handles empty quoted string', () => {
|
|
49
|
+
expect(tokenize('--name ""')).toEqual(['--name', '']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('handles complex real-world command', () => {
|
|
53
|
+
expect(tokenize('folder create --library my-lib --name "Research Papers" --color blue')).toEqual([
|
|
54
|
+
'folder', 'create', '--library', 'my-lib', '--name', 'Research Papers', '--color', 'blue'
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('extractCommandPath', () => {
|
|
60
|
+
it('extracts command before options', () => {
|
|
61
|
+
expect(extractCommandPath(['folder', 'tree', '--library', 'x'])).toBe('folder tree');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('handles no options', () => {
|
|
65
|
+
expect(extractCommandPath(['folder', 'tree'])).toBe('folder tree');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('handles single command', () => {
|
|
69
|
+
expect(extractCommandPath(['help'])).toBe('help');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('handles empty tokens', () => {
|
|
73
|
+
expect(extractCommandPath([])).toBe('');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('stops at first option', () => {
|
|
77
|
+
expect(extractCommandPath(['item', 'search', '-q', 'hello', '--format', 'json'])).toBe('item search');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('handles three-level command', () => {
|
|
81
|
+
expect(extractCommandPath(['librarian', 'chat', 'start', '--library', 'x'])).toBe('librarian chat start');
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/tokenizer.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// ABOUTME: Tokenizes command line strings for in-process CLI execution
|
|
2
|
+
// ABOUTME: Handles quoted strings and shell-like argument parsing
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tokenizes a command line string, handling quoted strings.
|
|
6
|
+
*
|
|
7
|
+
* Examples:
|
|
8
|
+
* - "folder tree" => ['folder', 'tree']
|
|
9
|
+
* - "--name 'My Folder'" => ['--name', 'My Folder']
|
|
10
|
+
* - '--name "My Folder"' => ['--name', 'My Folder']
|
|
11
|
+
* - "folder create --name 'Test's Folder'" => handles escaped quotes
|
|
12
|
+
*/
|
|
13
|
+
export function tokenize(commandLine: string): string[] {
|
|
14
|
+
const tokens: string[] = [];
|
|
15
|
+
let current = '';
|
|
16
|
+
let inQuote: string | null = null;
|
|
17
|
+
let escaped = false;
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < commandLine.length; i++) {
|
|
20
|
+
const char = commandLine[i];
|
|
21
|
+
|
|
22
|
+
if (escaped) {
|
|
23
|
+
current += char;
|
|
24
|
+
escaped = false;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (char === '\\') {
|
|
29
|
+
escaped = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (inQuote) {
|
|
34
|
+
if (char === inQuote) {
|
|
35
|
+
// Empty quoted string should still produce a token
|
|
36
|
+
tokens.push(current);
|
|
37
|
+
current = '';
|
|
38
|
+
inQuote = null;
|
|
39
|
+
} else {
|
|
40
|
+
current += char;
|
|
41
|
+
}
|
|
42
|
+
} else if (char === '"' || char === "'") {
|
|
43
|
+
inQuote = char;
|
|
44
|
+
} else if (char === ' ' || char === '\t') {
|
|
45
|
+
if (current) {
|
|
46
|
+
tokens.push(current);
|
|
47
|
+
current = '';
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
current += char;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (current) {
|
|
55
|
+
tokens.push(current);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return tokens;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extracts the command path from tokens (before any options).
|
|
63
|
+
*
|
|
64
|
+
* Examples:
|
|
65
|
+
* - ['folder', 'tree', '--library', 'x'] => 'folder tree'
|
|
66
|
+
* - ['item', 'search', '-q', 'hello'] => 'item search'
|
|
67
|
+
*/
|
|
68
|
+
export function extractCommandPath(tokens: string[]): string {
|
|
69
|
+
const path: string[] = [];
|
|
70
|
+
for (const token of tokens) {
|
|
71
|
+
if (token.startsWith('-')) break;
|
|
72
|
+
path.push(token);
|
|
73
|
+
}
|
|
74
|
+
return path.join(' ');
|
|
75
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"declarationMap": true,
|
|
12
|
+
"types": ["bun-types"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"]
|
|
15
|
+
}
|