jetic-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/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +36 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/dev.d.ts +3 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +478 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +91 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/inspect.d.ts +3 -0
- package/dist/commands/inspect.d.ts.map +1 -0
- package/dist/commands/inspect.js +73 -0
- package/dist/commands/inspect.js.map +1 -0
- package/dist/commands/memory.d.ts +3 -0
- package/dist/commands/memory.d.ts.map +1 -0
- package/dist/commands/memory.js +96 -0
- package/dist/commands/memory.js.map +1 -0
- package/dist/commands/scan.d.ts +3 -0
- package/dist/commands/scan.d.ts.map +1 -0
- package/dist/commands/scan.js +54 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/simulate-workflow.d.ts +46 -0
- package/dist/commands/simulate-workflow.d.ts.map +1 -0
- package/dist/commands/simulate-workflow.js +671 -0
- package/dist/commands/simulate-workflow.js.map +1 -0
- package/dist/commands/simulate.d.ts +3 -0
- package/dist/commands/simulate.d.ts.map +1 -0
- package/dist/commands/simulate.js +482 -0
- package/dist/commands/simulate.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/package.json +30 -0
- package/src/commands/config.ts +36 -0
- package/src/commands/dev.ts +451 -0
- package/src/commands/init.ts +64 -0
- package/src/commands/inspect.ts +47 -0
- package/src/commands/memory.ts +71 -0
- package/src/commands/scan.ts +22 -0
- package/src/commands/simulate-workflow.ts +794 -0
- package/src/commands/simulate.ts +512 -0
- package/src/index.ts +26 -0
- package/tsconfig.json +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jetic-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"bin": {
|
|
6
|
+
"jetic": "./dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"clean": "rm -rf dist"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@faker-js/faker": "^9.0.0",
|
|
14
|
+
"commander": "^12.0.0",
|
|
15
|
+
"zod": "^3.23.8",
|
|
16
|
+
"@ai-sdk/openai": "^4.0.41",
|
|
17
|
+
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
18
|
+
"ai": "^7.0.65",
|
|
19
|
+
"@jetic/core": "workspace:*",
|
|
20
|
+
"@jetic/model": "workspace:*",
|
|
21
|
+
"@jetic/scanner": "workspace:*",
|
|
22
|
+
"@jetic/memory": "workspace:*",
|
|
23
|
+
"@jetic/simulator": "workspace:*",
|
|
24
|
+
"@jetic/dashboard": "workspace:*",
|
|
25
|
+
"express": "^4.21.2"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/express": "^5.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { loadConfig, saveConfig } from '@jetic/core';
|
|
3
|
+
|
|
4
|
+
export const configCommand = new Command('config')
|
|
5
|
+
.description('Manage Jetic configuration')
|
|
6
|
+
.action(() => {
|
|
7
|
+
const config = loadConfig();
|
|
8
|
+
|
|
9
|
+
console.log('\n\x1b[1mJetic Configuration\x1b[0m\n');
|
|
10
|
+
const tableData = [
|
|
11
|
+
{ Key: 'Project Root', Value: config.projectRoot },
|
|
12
|
+
{ Key: 'Jetic Directory', Value: config.jeticDir },
|
|
13
|
+
{ Key: 'AI Provider', Value: config.ai?.provider || 'Not set' },
|
|
14
|
+
{ Key: 'AI Model', Value: config.ai?.model || 'Not set' },
|
|
15
|
+
{ Key: 'API Key Env Var', Value: config.ai?.apiKeyEnvVar || 'Not set' }
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
console.table(tableData, ['Key', 'Value']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
configCommand
|
|
22
|
+
.command('ai')
|
|
23
|
+
.description('Configure the AI provider and model')
|
|
24
|
+
.requiredOption('-p, --provider <provider>', 'AI Provider (e.g. openai)')
|
|
25
|
+
.requiredOption('-m, --model <model>', 'Model name (e.g. gpt-4o)')
|
|
26
|
+
.requiredOption('-k, --key-env <envvar>', 'Environment variable containing the API key (e.g. OPENAI_API_KEY)')
|
|
27
|
+
.action((options) => {
|
|
28
|
+
const config = loadConfig();
|
|
29
|
+
config.ai = {
|
|
30
|
+
provider: options.provider,
|
|
31
|
+
model: options.model,
|
|
32
|
+
apiKeyEnvVar: options.keyEnv
|
|
33
|
+
};
|
|
34
|
+
saveConfig(config);
|
|
35
|
+
console.log('AI configuration saved successfully.');
|
|
36
|
+
});
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { JeticMemory } from '@jetic/memory';
|
|
6
|
+
|
|
7
|
+
export const devCommand = new Command('dev')
|
|
8
|
+
.description('Start the Jetic local dashboard')
|
|
9
|
+
.option('-p, --port <number>', 'Port to run the dashboard on', '8787')
|
|
10
|
+
.action(async (options) => {
|
|
11
|
+
const port = parseInt(options.port, 10);
|
|
12
|
+
const app = express();
|
|
13
|
+
|
|
14
|
+
app.use(express.json());
|
|
15
|
+
|
|
16
|
+
// Allow dashboard dev server to call the API during development
|
|
17
|
+
app.use((_req, res, next) => {
|
|
18
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
19
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
|
|
20
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
21
|
+
if (_req.method === 'OPTIONS') return res.sendStatus(204);
|
|
22
|
+
next();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// ─── Model API ───────────────────────────────────────────────────────
|
|
26
|
+
app.get('/api/model', (_req, res) => {
|
|
27
|
+
const modelPath = path.join(process.cwd(), '.jetic', 'model.json');
|
|
28
|
+
try {
|
|
29
|
+
if (!fs.existsSync(modelPath)) return res.json(null);
|
|
30
|
+
res.json(JSON.parse(fs.readFileSync(modelPath, 'utf8')));
|
|
31
|
+
} catch (err: any) {
|
|
32
|
+
res.status(500).json({ error: err.message });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
app.put('/api/model/endpoint/:id', (req, res) => {
|
|
37
|
+
const modelPath = path.join(process.cwd(), '.jetic', 'model.json');
|
|
38
|
+
try {
|
|
39
|
+
if (!fs.existsSync(modelPath)) return res.status(404).json({ error: 'model.json not found' });
|
|
40
|
+
const model = JSON.parse(fs.readFileSync(modelPath, 'utf8'));
|
|
41
|
+
const idx = model.endpoints.findIndex((e: any) => e.id === req.params.id);
|
|
42
|
+
if (idx === -1) return res.status(404).json({ error: 'Endpoint not found' });
|
|
43
|
+
model.endpoints[idx] = { ...model.endpoints[idx], ...req.body };
|
|
44
|
+
fs.writeFileSync(modelPath, JSON.stringify(model, null, 2), 'utf8');
|
|
45
|
+
res.json({ ok: true, endpoint: model.endpoints[idx] });
|
|
46
|
+
} catch (err: any) {
|
|
47
|
+
res.status(500).json({ error: err.message });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
app.post('/api/model/scan', async (_req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
const { loadConfig, writeJsonSync, ensureDirSync } = await import('@jetic/core');
|
|
54
|
+
const { ExpressScanner } = await import('@jetic/scanner');
|
|
55
|
+
const config = loadConfig();
|
|
56
|
+
ensureDirSync(config.jeticDir);
|
|
57
|
+
const scanner = new ExpressScanner(config);
|
|
58
|
+
const model = await scanner.scan();
|
|
59
|
+
const modelPath = path.join(config.jeticDir, 'model.json');
|
|
60
|
+
writeJsonSync(modelPath, model);
|
|
61
|
+
res.json({ ok: true, endpointCount: model.endpoints.length, model });
|
|
62
|
+
} catch (err: any) {
|
|
63
|
+
res.status(500).json({ error: err.message });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ─── Source viewer ────────────────────────────────────────────────────
|
|
68
|
+
app.get('/api/model/source', (req, res) => {
|
|
69
|
+
const file = req.query.file as string;
|
|
70
|
+
const line = parseInt(req.query.line as string, 10) || 1;
|
|
71
|
+
if (!file) return res.status(400).json({ error: 'file is required' });
|
|
72
|
+
try {
|
|
73
|
+
// Resolve relative to cwd
|
|
74
|
+
const resolved = path.isAbsolute(file) ? file : path.join(process.cwd(), file);
|
|
75
|
+
if (!fs.existsSync(resolved)) {
|
|
76
|
+
return res.status(404).json({ error: `File not found: ${resolved}` });
|
|
77
|
+
}
|
|
78
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
79
|
+
const lines = content.split('\n');
|
|
80
|
+
// Return ~40 lines centred on the handler line
|
|
81
|
+
const start = Math.max(0, line - 5);
|
|
82
|
+
const end = Math.min(lines.length, line + 35);
|
|
83
|
+
const snippet = lines.slice(start, end).join('\n');
|
|
84
|
+
res.json({ source: snippet, startLine: start + 1, totalLines: lines.length, file: resolved });
|
|
85
|
+
} catch (err: any) {
|
|
86
|
+
res.status(500).json({ error: err.message });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ─── Related files ────────────────────────────────────────────────────
|
|
91
|
+
app.get('/api/model/related', (req, res) => {
|
|
92
|
+
const id = req.query.id as string;
|
|
93
|
+
if (!id) return res.status(400).json({ error: 'id is required' });
|
|
94
|
+
try {
|
|
95
|
+
const modelPath = path.join(process.cwd(), '.jetic', 'model.json');
|
|
96
|
+
if (!fs.existsSync(modelPath)) return res.json({ files: [] });
|
|
97
|
+
const model = JSON.parse(fs.readFileSync(modelPath, 'utf8'));
|
|
98
|
+
const endpoint = (model.endpoints || []).find((e: any) => e.id === id);
|
|
99
|
+
if (!endpoint || !endpoint.source?.file) return res.json({ files: [] });
|
|
100
|
+
|
|
101
|
+
const sourceFile = path.isAbsolute(endpoint.source.file)
|
|
102
|
+
? endpoint.source.file
|
|
103
|
+
: path.join(process.cwd(), endpoint.source.file);
|
|
104
|
+
|
|
105
|
+
if (!fs.existsSync(sourceFile)) return res.json({ files: [] });
|
|
106
|
+
|
|
107
|
+
// Parse import/require statements from the source file
|
|
108
|
+
const content = fs.readFileSync(sourceFile, 'utf8');
|
|
109
|
+
const importRegex = /(?:import\s+.*?from\s+['"](.+?)['"]|require\(['"](.+?)['"]\))/g;
|
|
110
|
+
const relatedFiles: string[] = [];
|
|
111
|
+
const sourceDir = path.dirname(sourceFile);
|
|
112
|
+
let match: RegExpExecArray | null;
|
|
113
|
+
|
|
114
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
115
|
+
const importPath = match[1] || match[2];
|
|
116
|
+
if (!importPath || importPath.startsWith('@') || !importPath.startsWith('.')) continue;
|
|
117
|
+
const exts = ['', '.ts', '.js', '.tsx', '.jsx'];
|
|
118
|
+
for (const ext of exts) {
|
|
119
|
+
const candidate = path.resolve(sourceDir, importPath + ext);
|
|
120
|
+
if (fs.existsSync(candidate)) {
|
|
121
|
+
// Return path relative to cwd
|
|
122
|
+
relatedFiles.push(path.relative(process.cwd(), candidate));
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Also include the source file itself if not already there
|
|
129
|
+
const relSelf = path.relative(process.cwd(), sourceFile);
|
|
130
|
+
const files = [relSelf, ...relatedFiles.filter(f => f !== relSelf)];
|
|
131
|
+
res.json({ files });
|
|
132
|
+
} catch (err: any) {
|
|
133
|
+
res.status(500).json({ error: err.message });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
// ─── Workflows API ───────────────────────────────────────────────────
|
|
139
|
+
app.get('/api/workflows', (_req, res) => {
|
|
140
|
+
const jeticDir = path.join(process.cwd(), '.jetic');
|
|
141
|
+
try {
|
|
142
|
+
if (!fs.existsSync(jeticDir)) return res.json([]);
|
|
143
|
+
const files = fs.readdirSync(jeticDir).filter(f => f.endsWith('.json') && f !== 'model.json' && f !== 'memory.json');
|
|
144
|
+
const workflows: any[] = [];
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
try {
|
|
147
|
+
const data = JSON.parse(fs.readFileSync(path.join(jeticDir, file), 'utf8'));
|
|
148
|
+
if (data.steps && Array.isArray(data.steps)) workflows.push({ _file: file, ...data });
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
res.json(workflows);
|
|
152
|
+
} catch (err: any) {
|
|
153
|
+
res.status(500).json({ error: err.message });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ─── Workflow run – Server-Sent Events stream ─────────────────────────
|
|
158
|
+
app.post('/api/workflows/run', async (req, res) => {
|
|
159
|
+
const { file } = req.body as { file: string };
|
|
160
|
+
if (!file) { return res.status(400).json({ error: 'file is required' }); }
|
|
161
|
+
|
|
162
|
+
const jeticDir = path.join(process.cwd(), '.jetic');
|
|
163
|
+
const wfPath = path.join(jeticDir, file);
|
|
164
|
+
if (!fs.existsSync(wfPath)) { return res.status(404).json({ error: 'workflow file not found' }); }
|
|
165
|
+
|
|
166
|
+
let workflow: any;
|
|
167
|
+
try { workflow = JSON.parse(fs.readFileSync(wfPath, 'utf8')); }
|
|
168
|
+
catch (e: any) { return res.status(400).json({ error: e.message }); }
|
|
169
|
+
|
|
170
|
+
// ── Pre-load async imports BEFORE opening the SSE stream so that
|
|
171
|
+
// the async yield doesn't trigger req 'close' before the loop runs ──
|
|
172
|
+
const { JeticMemory: Mem } = await import('@jetic/memory');
|
|
173
|
+
const { faker } = await import('@faker-js/faker');
|
|
174
|
+
|
|
175
|
+
// SSE headers – opened AFTER imports so aborted stays false
|
|
176
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
177
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
178
|
+
res.setHeader('Connection', 'keep-alive');
|
|
179
|
+
res.flushHeaders();
|
|
180
|
+
|
|
181
|
+
const send = (type: string, data: any) => {
|
|
182
|
+
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Resolve base URL from model
|
|
186
|
+
let baseUrl = 'http://localhost:4000';
|
|
187
|
+
const modelPath = path.join(jeticDir, 'model.json');
|
|
188
|
+
if (fs.existsSync(modelPath)) {
|
|
189
|
+
try {
|
|
190
|
+
const model = JSON.parse(fs.readFileSync(modelPath, 'utf8'));
|
|
191
|
+
const localEnv = (model.environments || []).find((e: any) => e.name === 'local');
|
|
192
|
+
if (localEnv) baseUrl = localEnv.baseUrl;
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
send('start', { name: workflow.name, totalSteps: workflow.steps.length, baseUrl });
|
|
197
|
+
|
|
198
|
+
// Register close handler AFTER flushing start — any close before this is irrelevant
|
|
199
|
+
let aborted = false;
|
|
200
|
+
req.on('close', () => { aborted = true; });
|
|
201
|
+
|
|
202
|
+
// ── inline the minimal executor (avoids importing CLI internals) ──
|
|
203
|
+
function deepGet(obj: any, dotPath: string): any {
|
|
204
|
+
return dotPath.replace(/\[(\d+)\]/g, '.$1').split('.').reduce((o, k) => o?.[k], obj);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function resolveTemplate(value: string): Promise<string> {
|
|
208
|
+
return value.replace(/\{\{([^}]+)\}\}/g, (_, expr) => {
|
|
209
|
+
expr = expr.trim();
|
|
210
|
+
if (expr.startsWith('faker.')) {
|
|
211
|
+
try {
|
|
212
|
+
let fn: any = faker;
|
|
213
|
+
for (const part of expr.split('.').slice(1)) fn = fn[part];
|
|
214
|
+
return typeof fn === 'function' ? String(fn()) : String(fn);
|
|
215
|
+
} catch { return expr; }
|
|
216
|
+
}
|
|
217
|
+
return _; // memory refs handled below via async
|
|
218
|
+
});
|
|
219
|
+
// Note: memory template refs ({{scope:key}}) require async — handled separately
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function resolveMemoryTemplates(value: string): Promise<string> {
|
|
223
|
+
const matches = [...value.matchAll(/\{\{([^}]+)\}\}/g)];
|
|
224
|
+
let result = value;
|
|
225
|
+
for (const [placeholder, expr] of matches.map(m => [m[0], m[1].trim()])) {
|
|
226
|
+
if (expr.includes(':') && !expr.startsWith('faker.')) {
|
|
227
|
+
const [scope, key] = expr.split(':', 2);
|
|
228
|
+
const mem = new Mem({ scope });
|
|
229
|
+
const val = await mem.get(key);
|
|
230
|
+
result = result.replace(placeholder, val != null ? String(val) : '');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function resolveStr(v: string): Promise<string> {
|
|
237
|
+
let s = await resolveTemplate(v);
|
|
238
|
+
s = await resolveMemoryTemplates(s);
|
|
239
|
+
return s;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function executeStep(step: any): Promise<any> {
|
|
243
|
+
const startTime = Date.now();
|
|
244
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
245
|
+
const bodyExtra: Record<string, any> = {};
|
|
246
|
+
|
|
247
|
+
// Resolve inject
|
|
248
|
+
for (const [target, memKeyOrTpl] of Object.entries(step.inject ?? {}) as [string, string][]) {
|
|
249
|
+
const strVal = memKeyOrTpl.includes('{{')
|
|
250
|
+
? await resolveStr(memKeyOrTpl)
|
|
251
|
+
: await (async () => {
|
|
252
|
+
const [scope, key] = memKeyOrTpl.includes(':') ? memKeyOrTpl.split(':', 2) : ['workflow', memKeyOrTpl];
|
|
253
|
+
const val = await new Mem({ scope }).get(key);
|
|
254
|
+
return val != null ? String(val) : '';
|
|
255
|
+
})();
|
|
256
|
+
|
|
257
|
+
if (!strVal) continue;
|
|
258
|
+
if (target.startsWith('body:')) bodyExtra[target.slice(5)] = strVal;
|
|
259
|
+
else headers[target.startsWith('header:') ? target.slice(7) : target] = strVal;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Resolve body templates
|
|
263
|
+
const resolvedBody: Record<string, any> = {};
|
|
264
|
+
for (const [k, v] of Object.entries(step.body ?? {})) {
|
|
265
|
+
resolvedBody[k] = typeof v === 'string' ? await resolveStr(v) : v;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const requestBody = { ...bodyExtra, ...resolvedBody };
|
|
269
|
+
|
|
270
|
+
// captureInput
|
|
271
|
+
for (const [memKey, bodyField] of Object.entries(step.captureInput ?? {}) as [string, string][]) {
|
|
272
|
+
const val = requestBody[bodyField];
|
|
273
|
+
if (val != null) {
|
|
274
|
+
const [scope, key] = memKey.includes(':') ? memKey.split(':', 2) : ['workflow', memKey];
|
|
275
|
+
await new Mem({ scope }).set(key, val);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Resolve path params
|
|
280
|
+
let resolvedPath = step.path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_: string, p: string) =>
|
|
281
|
+
requestBody[p] !== undefined ? String(requestBody[p]) : `:${p}`
|
|
282
|
+
);
|
|
283
|
+
// Also resolve {{}} in path
|
|
284
|
+
resolvedPath = await resolveStr(resolvedPath);
|
|
285
|
+
|
|
286
|
+
const finalUrl = `${baseUrl.replace(/\/$/, '')}${resolvedPath}`;
|
|
287
|
+
const expectedStatus = step.expectStatus ?? 200;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const isBodyMethod = !['GET', 'HEAD'].includes(step.method.toUpperCase());
|
|
291
|
+
const fetchOpts: RequestInit = { method: step.method.toUpperCase(), headers };
|
|
292
|
+
if (isBodyMethod && Object.keys(requestBody).length > 0) {
|
|
293
|
+
fetchOpts.body = JSON.stringify(requestBody);
|
|
294
|
+
} else if (!isBodyMethod && Object.keys(requestBody).length > 0) {
|
|
295
|
+
const params = new URLSearchParams();
|
|
296
|
+
for (const [k, v] of Object.entries(requestBody)) if (v != null) params.set(k, String(v));
|
|
297
|
+
// Don't append query params for now — keep simple
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const response = await fetch(finalUrl, fetchOpts);
|
|
301
|
+
const durationMs = Date.now() - startTime;
|
|
302
|
+
|
|
303
|
+
let responseBody: any = null;
|
|
304
|
+
const ct = response.headers.get('content-type') ?? '';
|
|
305
|
+
if (ct.includes('application/json')) {
|
|
306
|
+
try { responseBody = await response.json(); } catch {}
|
|
307
|
+
} else {
|
|
308
|
+
responseBody = await response.text();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const passed = response.status === expectedStatus ||
|
|
312
|
+
(response.status >= 200 && response.status < 300 && expectedStatus >= 200 && expectedStatus < 300);
|
|
313
|
+
|
|
314
|
+
// Capture
|
|
315
|
+
const captured: string[] = [];
|
|
316
|
+
if (passed) {
|
|
317
|
+
for (const [memKey, responsePath] of Object.entries(step.capture ?? {}) as [string, string][]) {
|
|
318
|
+
const val = deepGet(responseBody, responsePath);
|
|
319
|
+
if (val != null) {
|
|
320
|
+
const [scope, key] = memKey.includes(':') ? memKey.split(':', 2) : ['workflow', memKey];
|
|
321
|
+
await new Mem({ scope }).set(key, val);
|
|
322
|
+
captured.push(`${memKey} ← ${responsePath}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return { status: response.status, passed, durationMs, captured, injected: headers, responseBody, error: null };
|
|
328
|
+
} catch (err: any) {
|
|
329
|
+
return { status: 0, passed: false, durationMs: Date.now() - startTime, captured: [], injected: headers, responseBody: null, error: err.message };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Execute steps, emitting SSE events ──
|
|
334
|
+
let passed = 0; let failed = 0;
|
|
335
|
+
for (let i = 0; i < workflow.steps.length; i++) {
|
|
336
|
+
if (aborted) break;
|
|
337
|
+
const step = workflow.steps[i];
|
|
338
|
+
send('step_start', { index: i, step: { name: step.name, method: step.method, path: step.path } });
|
|
339
|
+
|
|
340
|
+
const result = await executeStep(step);
|
|
341
|
+
|
|
342
|
+
if (result.passed) passed++; else failed++;
|
|
343
|
+
send('step_result', {
|
|
344
|
+
index: i,
|
|
345
|
+
step: { name: step.name, method: step.method, path: step.path, description: step.description },
|
|
346
|
+
status: result.status,
|
|
347
|
+
passed: result.passed,
|
|
348
|
+
durationMs: result.durationMs,
|
|
349
|
+
captured: result.captured,
|
|
350
|
+
injected: result.injected,
|
|
351
|
+
responseBody: result.responseBody,
|
|
352
|
+
error: result.error,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Only abort the chain if the step explicitly failed AND continueOnFailure is not set
|
|
356
|
+
// AND the failure is a hard error (not just a bad status code)
|
|
357
|
+
if (!result.passed && !step.continueOnFailure && result.error) {
|
|
358
|
+
send('aborted', { index: i, reason: result.error ?? `Status ${result.status} ≠ ${step.expectStatus ?? 200}` });
|
|
359
|
+
res.end();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
send('done', { passed, failed, total: workflow.steps.length });
|
|
365
|
+
res.end();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Memory API
|
|
369
|
+
app.get('/api/memory', (_req, res) => {
|
|
370
|
+
try {
|
|
371
|
+
const allMemory = JeticMemory.getAllMemory();
|
|
372
|
+
const entries: Array<{ key: string; value: any }> = [];
|
|
373
|
+
for (const scope in allMemory) {
|
|
374
|
+
for (const key in allMemory[scope]) {
|
|
375
|
+
entries.push({ key: `${scope}:${key}`, value: allMemory[scope][key] });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
res.json(entries);
|
|
379
|
+
} catch (err: any) {
|
|
380
|
+
res.status(500).json({ error: err.message });
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
app.post('/api/memory', async (req, res) => {
|
|
385
|
+
const { key: rawKey, value } = req.body as { key: string; value: string };
|
|
386
|
+
if (!rawKey || value === undefined) {
|
|
387
|
+
return res.status(400).json({ error: 'key and value are required' });
|
|
388
|
+
}
|
|
389
|
+
const parts = rawKey.split(':');
|
|
390
|
+
const scope = parts.length > 1 ? parts[0] : 'global';
|
|
391
|
+
const key = parts.length > 1 ? parts.slice(1).join(':') : parts[0];
|
|
392
|
+
const memory = new JeticMemory({ scope });
|
|
393
|
+
await memory.set(key, value);
|
|
394
|
+
res.json({ ok: true });
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
app.delete('/api/memory', async (req, res) => {
|
|
398
|
+
const { key: rawKey } = req.body as { key: string };
|
|
399
|
+
if (!rawKey) {
|
|
400
|
+
return res.status(400).json({ error: 'key is required' });
|
|
401
|
+
}
|
|
402
|
+
const parts = rawKey.split(':');
|
|
403
|
+
const scope = parts.length > 1 ? parts[0] : 'global';
|
|
404
|
+
const key = parts.length > 1 ? parts.slice(1).join(':') : parts[0];
|
|
405
|
+
const memory = new JeticMemory({ scope });
|
|
406
|
+
await memory.delete(key);
|
|
407
|
+
res.json({ ok: true });
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Locate the dashboard static files (bundled in dist/dashboard or resolved via workspace)
|
|
411
|
+
try {
|
|
412
|
+
let dashboardDistPath = path.join(__dirname, 'dashboard');
|
|
413
|
+
if (!fs.existsSync(dashboardDistPath)) {
|
|
414
|
+
dashboardDistPath = path.join(__dirname, '..', 'dashboard');
|
|
415
|
+
}
|
|
416
|
+
if (!fs.existsSync(dashboardDistPath)) {
|
|
417
|
+
try {
|
|
418
|
+
const dashboardPackagePath = require.resolve('@jetic/dashboard/package.json');
|
|
419
|
+
dashboardDistPath = path.join(path.dirname(dashboardPackagePath), 'dist');
|
|
420
|
+
} catch {}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!fs.existsSync(dashboardDistPath)) {
|
|
424
|
+
console.warn(`Dashboard build not found at ${dashboardDistPath}. Please build the dashboard first.`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Serve static files
|
|
428
|
+
app.use(express.static(dashboardDistPath));
|
|
429
|
+
|
|
430
|
+
// SPA fallback
|
|
431
|
+
app.get('*', (_req, res) => {
|
|
432
|
+
const indexPath = path.join(dashboardDistPath, 'index.html');
|
|
433
|
+
if (fs.existsSync(indexPath)) {
|
|
434
|
+
res.sendFile(indexPath);
|
|
435
|
+
} else {
|
|
436
|
+
res.status(404).send('Dashboard not built yet.');
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
} catch (e) {
|
|
440
|
+
console.error('Could not find dashboard static files. Ensure it is built.', e);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
app.listen(port, () => {
|
|
444
|
+
const url = `http://localhost:${port}`;
|
|
445
|
+
console.log(`\n🚀 Jetic Studio is running at ${url}\n`);
|
|
446
|
+
|
|
447
|
+
const { exec } = require('child_process');
|
|
448
|
+
const start = (process.platform == 'darwin' ? 'open' : process.platform == 'win32' ? 'start' : 'xdg-open');
|
|
449
|
+
exec(`${start} ${url}`);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { loadConfig, saveConfig, ensureDirSync } from '@jetic/core';
|
|
3
|
+
import * as readline from 'readline/promises';
|
|
4
|
+
import { stdin as input, stdout as output } from 'process';
|
|
5
|
+
|
|
6
|
+
export const initCommand = new Command('init')
|
|
7
|
+
.description('Initialize Jetic in the current directory')
|
|
8
|
+
.action(async () => {
|
|
9
|
+
const config = loadConfig();
|
|
10
|
+
ensureDirSync(config.jeticDir);
|
|
11
|
+
|
|
12
|
+
console.log('\x1b[36mWelcome to Jetic Initialization\x1b[0m\n');
|
|
13
|
+
|
|
14
|
+
const rl = readline.createInterface({ input, output });
|
|
15
|
+
|
|
16
|
+
const providerInput = await rl.question('AI Provider [default: openrouter]: ');
|
|
17
|
+
const modelInput = await rl.question('Model name [default: meta-llama/llama-3.1-8b-instruct]: ');
|
|
18
|
+
const apiKeyInput = await rl.question('API Key: ');
|
|
19
|
+
|
|
20
|
+
rl.close();
|
|
21
|
+
|
|
22
|
+
const provider = providerInput.trim() || 'openrouter';
|
|
23
|
+
const envVarName = `${provider.toUpperCase()}_API_KEY`;
|
|
24
|
+
|
|
25
|
+
config.ai = {
|
|
26
|
+
provider: provider,
|
|
27
|
+
model: modelInput.trim() || 'meta-llama/llama-3.1-8b-instruct',
|
|
28
|
+
apiKeyEnvVar: envVarName
|
|
29
|
+
};
|
|
30
|
+
saveConfig(config);
|
|
31
|
+
|
|
32
|
+
if (apiKeyInput.trim()) {
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const envPath = require('path').join(process.cwd(), '.env');
|
|
35
|
+
const envContent = `\n${envVarName}=${apiKeyInput.trim()}\n`;
|
|
36
|
+
fs.appendFileSync(envPath, envContent);
|
|
37
|
+
console.log(`\n\x1b[32m✓\x1b[0m Saved API key to .env file as ${envVarName}`);
|
|
38
|
+
console.log(`(Alternatively, you can run: set ${envVarName}=${apiKeyInput.trim()})\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const banner = `
|
|
42
|
+
\x1b[36m
|
|
43
|
+
__ __ _ _____
|
|
44
|
+
\\ \\ / /__| | ___ ___ _ __ ___ ___ |_ _|__
|
|
45
|
+
\\ \\ /\\ / / _ \\ |/ __/ _ \\| '_ \` _ \\ / _ \\ | |/ _ \\
|
|
46
|
+
\\ V V / __/ | (_| (_) | | | | | | __/ | | (_) |
|
|
47
|
+
\\_/\\_/ \\___|_|\\___\\___/|_| |_| |_|\\___| |_|\\___/
|
|
48
|
+
|
|
49
|
+
_ _____ _____ ___ ___
|
|
50
|
+
| | ____|_ _|_ _/ __|
|
|
51
|
+
_ | | _| | | | | |
|
|
52
|
+
| |_| | |___ | | | | |__
|
|
53
|
+
\\___/|_____| |_| |___\\___|
|
|
54
|
+
\x1b[0m`;
|
|
55
|
+
|
|
56
|
+
console.log(banner);
|
|
57
|
+
console.log(`\x1b[32m✓\x1b[0m Successfully initialized Jetic in \x1b[1m${config.jeticDir}\x1b[0m\n`);
|
|
58
|
+
console.log('Available Commands:');
|
|
59
|
+
console.log(' \x1b[36mjetic init\x1b[0m Initialize Jetic in the current directory');
|
|
60
|
+
console.log(' \x1b[36mjetic scan\x1b[0m Scan the project for API routes and generate behavioral model');
|
|
61
|
+
console.log(' \x1b[36mjetic inspect\x1b[0m Inspect discovered endpoints and details');
|
|
62
|
+
console.log(' \x1b[36mjetic config\x1b[0m Manage Jetic AI provider and settings');
|
|
63
|
+
console.log('');
|
|
64
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { loadConfig, readJsonSync } from '@jetic/core';
|
|
3
|
+
import { BehavioralModel } from '@jetic/model';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
|
|
6
|
+
export const inspectCommand = new Command('inspect')
|
|
7
|
+
.description('Inspect the discovered API model')
|
|
8
|
+
.action(() => {
|
|
9
|
+
const config = loadConfig();
|
|
10
|
+
const modelPath = path.join(config.jeticDir, 'model.json');
|
|
11
|
+
const model = readJsonSync<BehavioralModel>(modelPath);
|
|
12
|
+
|
|
13
|
+
if (!model) {
|
|
14
|
+
console.error('No behavioral model found. Run `jetic scan` first.');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
console.log(`Model Version: ${model.version}`);
|
|
19
|
+
console.log(`Endpoints (${model.endpoints.length}):\n`);
|
|
20
|
+
|
|
21
|
+
for (const ep of model.endpoints) {
|
|
22
|
+
console.log(`${ep.method} ${ep.path}`);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
inspectCommand
|
|
27
|
+
.command('endpoint <method> <path>')
|
|
28
|
+
.description('Inspect a specific endpoint')
|
|
29
|
+
.action((method, urlPath) => {
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
const modelPath = path.join(config.jeticDir, 'model.json');
|
|
32
|
+
const model = readJsonSync<BehavioralModel>(modelPath);
|
|
33
|
+
|
|
34
|
+
if (!model) return;
|
|
35
|
+
|
|
36
|
+
const ep = model.endpoints.find(
|
|
37
|
+
e => e.method.toLowerCase() === method.toLowerCase() && e.path === urlPath
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (!ep) {
|
|
41
|
+
console.error('Endpoint not found');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log(`${ep.method} ${ep.path}\n`);
|
|
46
|
+
console.log(`Source:\n ${ep.source.file}:${ep.source.line}`);
|
|
47
|
+
});
|