keystone-cli 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/README.md +136 -0
- package/logo.png +0 -0
- package/package.json +45 -0
- package/src/cli.ts +775 -0
- package/src/db/workflow-db.test.ts +99 -0
- package/src/db/workflow-db.ts +265 -0
- package/src/expression/evaluator.test.ts +247 -0
- package/src/expression/evaluator.ts +517 -0
- package/src/parser/agent-parser.test.ts +123 -0
- package/src/parser/agent-parser.ts +59 -0
- package/src/parser/config-schema.ts +54 -0
- package/src/parser/schema.ts +157 -0
- package/src/parser/workflow-parser.test.ts +212 -0
- package/src/parser/workflow-parser.ts +228 -0
- package/src/runner/llm-adapter.test.ts +329 -0
- package/src/runner/llm-adapter.ts +306 -0
- package/src/runner/llm-executor.test.ts +537 -0
- package/src/runner/llm-executor.ts +256 -0
- package/src/runner/mcp-client.test.ts +122 -0
- package/src/runner/mcp-client.ts +123 -0
- package/src/runner/mcp-manager.test.ts +143 -0
- package/src/runner/mcp-manager.ts +85 -0
- package/src/runner/mcp-server.test.ts +242 -0
- package/src/runner/mcp-server.ts +436 -0
- package/src/runner/retry.test.ts +52 -0
- package/src/runner/retry.ts +58 -0
- package/src/runner/shell-executor.test.ts +123 -0
- package/src/runner/shell-executor.ts +166 -0
- package/src/runner/step-executor.test.ts +465 -0
- package/src/runner/step-executor.ts +354 -0
- package/src/runner/timeout.test.ts +20 -0
- package/src/runner/timeout.ts +30 -0
- package/src/runner/tool-integration.test.ts +198 -0
- package/src/runner/workflow-runner.test.ts +358 -0
- package/src/runner/workflow-runner.ts +955 -0
- package/src/ui/dashboard.tsx +165 -0
- package/src/utils/auth-manager.test.ts +152 -0
- package/src/utils/auth-manager.ts +88 -0
- package/src/utils/config-loader.test.ts +52 -0
- package/src/utils/config-loader.ts +85 -0
- package/src/utils/mermaid.test.ts +51 -0
- package/src/utils/mermaid.ts +87 -0
- package/src/utils/redactor.test.ts +66 -0
- package/src/utils/redactor.ts +60 -0
- package/src/utils/workflow-registry.test.ts +108 -0
- package/src/utils/workflow-registry.ts +121 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { Redactor } from './redactor';
|
|
3
|
+
|
|
4
|
+
describe('Redactor', () => {
|
|
5
|
+
const secrets = {
|
|
6
|
+
API_KEY: 'sk-123456789',
|
|
7
|
+
PASSWORD: 'p4ssw0rd-unique',
|
|
8
|
+
TOKEN: 'tkn-abc-123',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const redactor = new Redactor(secrets);
|
|
12
|
+
|
|
13
|
+
it('should redact secrets from a string', () => {
|
|
14
|
+
const text = 'Your API key is sk-123456789 and your password is p4ssw0rd-unique.';
|
|
15
|
+
const redacted = redactor.redact(text);
|
|
16
|
+
expect(redacted).toBe('Your API key is ***REDACTED*** and your password is ***REDACTED***.');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should handle special regex characters in secrets', () => {
|
|
20
|
+
const specialRedactor = new Redactor({ S1: 'secret.with*special+chars' });
|
|
21
|
+
const text = 'My secret is secret.with*special+chars';
|
|
22
|
+
expect(specialRedactor.redact(text)).toBe('My secret is ***REDACTED***');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should redact longer secrets first', () => {
|
|
26
|
+
// If we have 'abc' and 'abc-longer' as secrets
|
|
27
|
+
const redactor2 = new Redactor({ S1: 'abc', S2: 'abc-longer' });
|
|
28
|
+
const text = 'Value: abc-longer';
|
|
29
|
+
expect(redactor2.redact(text)).toBe('Value: ***REDACTED***');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should redact complex values recursively', () => {
|
|
33
|
+
const value = {
|
|
34
|
+
nested: {
|
|
35
|
+
key: 'sk-123456789',
|
|
36
|
+
list: ['p4ssw0rd-unique', 'safe'],
|
|
37
|
+
},
|
|
38
|
+
array: ['tkn-abc-123', 'def'],
|
|
39
|
+
};
|
|
40
|
+
const redacted = redactor.redactValue(value);
|
|
41
|
+
expect(redacted).toEqual({
|
|
42
|
+
nested: {
|
|
43
|
+
key: '***REDACTED***',
|
|
44
|
+
list: ['***REDACTED***', 'safe'],
|
|
45
|
+
},
|
|
46
|
+
array: ['***REDACTED***', 'def'],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should handle non-string values gracefully', () => {
|
|
51
|
+
expect(redactor.redactValue(123)).toBe(123);
|
|
52
|
+
expect(redactor.redactValue(true)).toBe(true);
|
|
53
|
+
expect(redactor.redactValue(null)).toBe(null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should return input as is if not a string for redact', () => {
|
|
57
|
+
// @ts-ignore
|
|
58
|
+
expect(redactor.redact(123)).toBe(123);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should ignore secrets shorter than 3 characters', () => {
|
|
62
|
+
const shortRedactor = new Redactor({ S1: 'a', S2: '12', S3: 'abc' });
|
|
63
|
+
const text = 'a and 12 are safe, but abc is a secret';
|
|
64
|
+
expect(shortRedactor.redact(text)).toBe('a and 12 are safe, but ***REDACTED*** is a secret');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redactor for masking secrets in output strings
|
|
3
|
+
*
|
|
4
|
+
* This utility helps prevent secret leakage by replacing secret values
|
|
5
|
+
* with masked strings before they are logged or stored in the database.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class Redactor {
|
|
9
|
+
private secrets: string[];
|
|
10
|
+
|
|
11
|
+
constructor(secrets: Record<string, string>) {
|
|
12
|
+
// Extract all secret values (not keys) and sort by length descending
|
|
13
|
+
// to ensure longer secrets are matched first.
|
|
14
|
+
// Filter out very short secrets (length < 3) to avoid redacting common words/numbers.
|
|
15
|
+
this.secrets = Object.values(secrets)
|
|
16
|
+
.filter((value) => value && value.length >= 3)
|
|
17
|
+
.sort((a, b) => b.length - a.length);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Redact all secrets from a string
|
|
22
|
+
*/
|
|
23
|
+
redact(text: string): string {
|
|
24
|
+
if (!text || typeof text !== 'string') {
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let redacted = text;
|
|
29
|
+
for (const secret of this.secrets) {
|
|
30
|
+
// Use a global replace to handle multiple occurrences
|
|
31
|
+
// Escape special regex characters in the secret
|
|
32
|
+
const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
33
|
+
redacted = redacted.replace(new RegExp(escaped, 'g'), '***REDACTED***');
|
|
34
|
+
}
|
|
35
|
+
return redacted;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Redact secrets from any value (string, object, array)
|
|
40
|
+
*/
|
|
41
|
+
redactValue(value: unknown): unknown {
|
|
42
|
+
if (typeof value === 'string') {
|
|
43
|
+
return this.redact(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
return value.map((item) => this.redactValue(item));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (value !== null && typeof value === 'object') {
|
|
51
|
+
const redacted: Record<string, unknown> = {};
|
|
52
|
+
for (const [key, val] of Object.entries(value)) {
|
|
53
|
+
redacted[key] = this.redactValue(val);
|
|
54
|
+
}
|
|
55
|
+
return redacted;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test';
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { ConfigSchema } from '../parser/config-schema.ts';
|
|
6
|
+
import { ConfigLoader } from './config-loader.ts';
|
|
7
|
+
import { WorkflowRegistry } from './workflow-registry.ts';
|
|
8
|
+
|
|
9
|
+
describe('WorkflowRegistry', () => {
|
|
10
|
+
const tempWorkflowsDir = join(
|
|
11
|
+
process.cwd(),
|
|
12
|
+
`temp-workflows-${Math.random().toString(36).substring(7)}`
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
try {
|
|
17
|
+
mkdirSync(tempWorkflowsDir, { recursive: true });
|
|
18
|
+
} catch (e) {}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
try {
|
|
23
|
+
rmSync(tempWorkflowsDir, { recursive: true, force: true });
|
|
24
|
+
} catch (e) {}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should list workflows in the workflows directory', () => {
|
|
28
|
+
const keystoneWorkflowsDir = join(tempWorkflowsDir, '.keystone', 'workflows');
|
|
29
|
+
mkdirSync(keystoneWorkflowsDir, { recursive: true });
|
|
30
|
+
|
|
31
|
+
const workflowContent = `
|
|
32
|
+
name: registry-test
|
|
33
|
+
steps:
|
|
34
|
+
- id: s1
|
|
35
|
+
type: shell
|
|
36
|
+
run: echo 1
|
|
37
|
+
`;
|
|
38
|
+
const filePath = join(keystoneWorkflowsDir, 'registry-test.yaml');
|
|
39
|
+
writeFileSync(filePath, workflowContent);
|
|
40
|
+
|
|
41
|
+
// Mock homedir and cwd to use our temp dir
|
|
42
|
+
const homedirSpy = spyOn(os, 'homedir').mockReturnValue(tempWorkflowsDir);
|
|
43
|
+
const cwdSpy = spyOn(process, 'cwd').mockReturnValue(tempWorkflowsDir);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const workflows = WorkflowRegistry.listWorkflows();
|
|
47
|
+
const testWorkflow = workflows.find((w) => w.name === 'registry-test');
|
|
48
|
+
expect(testWorkflow).toBeDefined();
|
|
49
|
+
expect(testWorkflow?.name).toBe('registry-test');
|
|
50
|
+
} finally {
|
|
51
|
+
homedirSpy.mockRestore();
|
|
52
|
+
cwdSpy.mockRestore();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should resolve a workflow name to a path', () => {
|
|
57
|
+
const keystoneWorkflowsDir = join(tempWorkflowsDir, '.keystone', 'workflows');
|
|
58
|
+
mkdirSync(keystoneWorkflowsDir, { recursive: true });
|
|
59
|
+
|
|
60
|
+
const fileName = 'resolve-test.yaml';
|
|
61
|
+
const filePath = join(keystoneWorkflowsDir, fileName);
|
|
62
|
+
writeFileSync(filePath, 'name: resolve-test\nsteps: []');
|
|
63
|
+
|
|
64
|
+
const cwdSpy = spyOn(process, 'cwd').mockReturnValue(tempWorkflowsDir);
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const resolved = WorkflowRegistry.resolvePath('resolve-test');
|
|
68
|
+
expect(resolved.endsWith(fileName)).toBe(true);
|
|
69
|
+
} finally {
|
|
70
|
+
cwdSpy.mockRestore();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should resolve an absolute or relative path directly', () => {
|
|
75
|
+
const filePath = join(tempWorkflowsDir, 'direct-path.yaml');
|
|
76
|
+
writeFileSync(filePath, 'name: direct\nsteps: []');
|
|
77
|
+
|
|
78
|
+
expect(WorkflowRegistry.resolvePath(filePath)).toBe(filePath);
|
|
79
|
+
rmSync(filePath);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should look in the home directory .keystone folder', () => {
|
|
83
|
+
const mockHome = join(tempWorkflowsDir, 'mock-home');
|
|
84
|
+
const keystoneDir = join(mockHome, '.keystone', 'workflows');
|
|
85
|
+
mkdirSync(keystoneDir, { recursive: true });
|
|
86
|
+
|
|
87
|
+
const workflowPath = join(keystoneDir, 'home-test.yaml');
|
|
88
|
+
writeFileSync(workflowPath, 'name: home-test\nsteps: []');
|
|
89
|
+
|
|
90
|
+
const homedirSpy = spyOn(os, 'homedir').mockReturnValue(mockHome);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const resolved = WorkflowRegistry.resolvePath('home-test');
|
|
94
|
+
expect(resolved).toBe(workflowPath);
|
|
95
|
+
|
|
96
|
+
const workflows = WorkflowRegistry.listWorkflows();
|
|
97
|
+
expect(workflows.some((w) => w.name === 'home-test')).toBe(true);
|
|
98
|
+
} finally {
|
|
99
|
+
homedirSpy.mockRestore();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should throw if workflow not found', () => {
|
|
104
|
+
expect(() => WorkflowRegistry.resolvePath('non-existent')).toThrow(
|
|
105
|
+
/Workflow "non-existent" not found/
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, extname, join } from 'node:path';
|
|
4
|
+
import type { Workflow } from '../parser/schema.ts';
|
|
5
|
+
import { WorkflowParser } from '../parser/workflow-parser.ts';
|
|
6
|
+
import { ConfigLoader } from './config-loader.ts';
|
|
7
|
+
|
|
8
|
+
export class WorkflowRegistry {
|
|
9
|
+
private static getSearchPaths(): string[] {
|
|
10
|
+
const paths = new Set<string>();
|
|
11
|
+
|
|
12
|
+
paths.add(join(process.cwd(), '.keystone', 'workflows'));
|
|
13
|
+
paths.add(join(homedir(), '.keystone', 'workflows'));
|
|
14
|
+
|
|
15
|
+
return Array.from(paths);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* List all available workflows with their metadata
|
|
20
|
+
*/
|
|
21
|
+
static listWorkflows(): Array<{
|
|
22
|
+
name: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
inputs?: Record<string, unknown>;
|
|
25
|
+
}> {
|
|
26
|
+
const workflows: Array<{
|
|
27
|
+
name: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
inputs?: Record<string, unknown>;
|
|
30
|
+
}> = [];
|
|
31
|
+
const seen = new Set<string>();
|
|
32
|
+
|
|
33
|
+
for (const dir of WorkflowRegistry.getSearchPaths()) {
|
|
34
|
+
if (!existsSync(dir)) continue;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const files = readdirSync(dir);
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
if (!file.endsWith('.yaml') && !file.endsWith('.yml')) continue;
|
|
40
|
+
|
|
41
|
+
const fullPath = join(dir, file);
|
|
42
|
+
if (statSync(fullPath).isDirectory()) continue;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Parse strictly to get metadata
|
|
46
|
+
const workflow = WorkflowParser.loadWorkflow(fullPath);
|
|
47
|
+
|
|
48
|
+
// Deduplicate by name
|
|
49
|
+
if (seen.has(workflow.name)) continue;
|
|
50
|
+
seen.add(workflow.name);
|
|
51
|
+
|
|
52
|
+
workflows.push({
|
|
53
|
+
name: workflow.name,
|
|
54
|
+
description: workflow.description,
|
|
55
|
+
inputs: workflow.inputs,
|
|
56
|
+
});
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// Skip invalid workflows during listing
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.warn(`Failed to scan directory ${dir}:`, e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return workflows;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a workflow name to a file path
|
|
71
|
+
*/
|
|
72
|
+
static resolvePath(name: string): string {
|
|
73
|
+
// 1. Check if it's already a path
|
|
74
|
+
if (existsSync(name) && (name.endsWith('.yaml') || name.endsWith('.yml'))) {
|
|
75
|
+
return name;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const searchPaths = WorkflowRegistry.getSearchPaths();
|
|
79
|
+
|
|
80
|
+
// 2. Search by filename in standard dirs
|
|
81
|
+
for (const dir of searchPaths) {
|
|
82
|
+
if (!existsSync(dir)) continue;
|
|
83
|
+
|
|
84
|
+
// Check exact filename match (name.yaml)
|
|
85
|
+
const pathYaml = join(dir, `${name}.yaml`);
|
|
86
|
+
if (existsSync(pathYaml)) return pathYaml;
|
|
87
|
+
|
|
88
|
+
const pathYml = join(dir, `${name}.yml`);
|
|
89
|
+
if (existsSync(pathYml)) return pathYml;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Search by internal workflow name
|
|
93
|
+
for (const dir of searchPaths) {
|
|
94
|
+
if (!existsSync(dir)) continue;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const files = readdirSync(dir);
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
if (!file.endsWith('.yaml') && !file.endsWith('.yml')) continue;
|
|
100
|
+
|
|
101
|
+
const fullPath = join(dir, file);
|
|
102
|
+
const stats = statSync(fullPath);
|
|
103
|
+
if (stats.isDirectory()) continue;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const workflow = WorkflowParser.loadWorkflow(fullPath);
|
|
107
|
+
if (workflow.name === name) {
|
|
108
|
+
return fullPath;
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// Skip invalid workflows
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
// Skip errors scanning directories
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
throw new Error(`Workflow "${name}" not found in: ${searchPaths.join(', ')}`);
|
|
120
|
+
}
|
|
121
|
+
}
|