@entrinsik/vite-plugin-informer 2.2.0 → 2.3.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/init.js +40 -124
- package/package.json +44 -43
- package/src/agent-dev.js +422 -0
- package/src/deploy.js +71 -8
- package/src/index.js +16 -1
- package/src/server-routes.js +32 -4
package/bin/init.js
CHANGED
|
@@ -8,141 +8,59 @@ import { randomUUID } from 'node:crypto';
|
|
|
8
8
|
const cwd = process.cwd();
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Generate the default
|
|
11
|
+
* Generate the default informer.yaml content with helpful comments
|
|
12
12
|
*/
|
|
13
|
-
function
|
|
14
|
-
return `#
|
|
15
|
-
#
|
|
16
|
-
# This file controls which Informer APIs your
|
|
17
|
-
#
|
|
13
|
+
function generateInformerYaml() {
|
|
14
|
+
return `# Informer App Configuration
|
|
15
|
+
# =========================
|
|
16
|
+
# This file controls which Informer APIs your app can access and defines
|
|
17
|
+
# custom roles for role-based UIs.
|
|
18
18
|
#
|
|
19
|
-
#
|
|
19
|
+
# Without an access section, all API access is blocked (secure by default).
|
|
20
20
|
|
|
21
21
|
# ============================================================================
|
|
22
|
-
#
|
|
22
|
+
# DATA ACCESS
|
|
23
23
|
# ============================================================================
|
|
24
|
-
#
|
|
25
|
-
# Each entry generates:
|
|
26
|
-
# - POST /api/datasets/{id}/_search
|
|
27
|
-
# - GET /api/datasets/{id}/fields
|
|
24
|
+
# The access section controls which APIs the app can call when shared.
|
|
28
25
|
#
|
|
29
|
-
#
|
|
26
|
+
# access:
|
|
30
27
|
# datasets:
|
|
31
|
-
# - admin:sales-data
|
|
32
|
-
# - admin:
|
|
33
|
-
#
|
|
34
|
-
# With row-level security (filter injected server-side):
|
|
35
|
-
# datasets:
|
|
36
|
-
# - id: admin:orders
|
|
28
|
+
# - admin:sales-data # Full dataset access
|
|
29
|
+
# - id: admin:orders # With row-level security
|
|
37
30
|
# filter:
|
|
38
|
-
# region: $user.custom.region
|
|
39
|
-
# sales_rep: $user.username # User only sees their own records
|
|
40
|
-
#
|
|
41
|
-
datasets: []
|
|
42
|
-
|
|
43
|
-
# ============================================================================
|
|
44
|
-
# QUERIES
|
|
45
|
-
# ============================================================================
|
|
46
|
-
# Grant access to execute saved queries.
|
|
47
|
-
# Each entry generates:
|
|
48
|
-
# - POST /api/queries/{id}/_execute
|
|
31
|
+
# region: $user.custom.region
|
|
49
32
|
#
|
|
50
|
-
# Example:
|
|
51
33
|
# queries:
|
|
52
|
-
# - admin:
|
|
53
|
-
# - admin:monthly-report
|
|
54
|
-
#
|
|
55
|
-
queries: []
|
|
56
|
-
|
|
57
|
-
# ============================================================================
|
|
58
|
-
# INTEGRATIONS
|
|
59
|
-
# ============================================================================
|
|
60
|
-
# Grant access to make requests through integrations (Salesforce, REST APIs, etc.)
|
|
61
|
-
# Each entry generates:
|
|
62
|
-
# - POST /api/integrations/{id}/request
|
|
34
|
+
# - admin:monthly-summary
|
|
63
35
|
#
|
|
64
|
-
# Simple access:
|
|
65
36
|
# integrations:
|
|
66
37
|
# - salesforce
|
|
67
|
-
# - quickbooks
|
|
68
|
-
#
|
|
69
|
-
# With credential injection (headers/params expanded server-side, never exposed to JS):
|
|
70
|
-
# integrations:
|
|
71
|
-
# - id: partner-api
|
|
72
|
-
# headers:
|
|
73
|
-
# Authorization: Bearer $user.custom.partnerToken
|
|
74
|
-
# X-Client-ID: $tenant.id
|
|
75
|
-
# params:
|
|
76
|
-
# user_id: $user.custom.externalId
|
|
77
|
-
#
|
|
78
|
-
# With path restrictions (only allow specific endpoints):
|
|
79
|
-
# integrations:
|
|
80
|
-
# - id: salesforce
|
|
81
|
-
# paths:
|
|
82
|
-
# - /data/*/query
|
|
83
|
-
# - /data/*/sobjects/*
|
|
84
|
-
#
|
|
85
|
-
integrations: []
|
|
86
|
-
|
|
87
|
-
# ============================================================================
|
|
88
|
-
# DATASOURCES
|
|
89
|
-
# ============================================================================
|
|
90
|
-
# Grant access to run SQL queries against datasources.
|
|
91
|
-
# Each entry generates:
|
|
92
|
-
# - POST /api/datasources/{id}/_query
|
|
93
38
|
#
|
|
94
|
-
# Example:
|
|
95
39
|
# datasources:
|
|
96
40
|
# - postgres-main
|
|
97
|
-
# - mysql-analytics
|
|
98
|
-
#
|
|
99
|
-
datasources: []
|
|
100
|
-
|
|
101
|
-
# ============================================================================
|
|
102
|
-
# LIBRARIES
|
|
103
|
-
# ============================================================================
|
|
104
|
-
# Grant access to read files from other libraries.
|
|
105
|
-
# Each entry generates:
|
|
106
|
-
# - GET /api/libraries/{id}/contents/*
|
|
107
|
-
#
|
|
108
|
-
# Example:
|
|
109
|
-
# libraries:
|
|
110
|
-
# - admin:shared-assets
|
|
111
|
-
# - admin:common-templates
|
|
112
|
-
#
|
|
113
|
-
libraries: []
|
|
114
|
-
|
|
115
|
-
# ============================================================================
|
|
116
|
-
# RAW API ACCESS (Advanced)
|
|
117
|
-
# ============================================================================
|
|
118
|
-
# For edge cases not covered by resource types above.
|
|
119
|
-
# Specify exact method and path.
|
|
120
41
|
#
|
|
121
|
-
#
|
|
122
|
-
# apis:
|
|
42
|
+
# apis: # Raw API paths (advanced)
|
|
123
43
|
# - POST /api/custom/endpoint
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
44
|
+
|
|
45
|
+
access:
|
|
46
|
+
datasets: []
|
|
47
|
+
queries: []
|
|
48
|
+
integrations: []
|
|
127
49
|
|
|
128
50
|
# ============================================================================
|
|
129
|
-
#
|
|
51
|
+
# ROLES (optional)
|
|
130
52
|
# ============================================================================
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
# $user.username - Login name
|
|
135
|
-
# $user.email - Email address
|
|
136
|
-
# $user.displayName - Full name (e.g., "John Smith")
|
|
137
|
-
# $user.custom.xxx - Custom user field value
|
|
138
|
-
#
|
|
139
|
-
# Tenant variables:
|
|
140
|
-
# $tenant.id - Tenant identifier
|
|
141
|
-
#
|
|
142
|
-
# Report variables:
|
|
143
|
-
# $report.id - Report UUID
|
|
144
|
-
# $report.name - Report display name
|
|
53
|
+
# Define custom roles that publishers assign when sharing the app.
|
|
54
|
+
# Roles are available in client code via window.__INFORMER__.roles
|
|
55
|
+
# and in server handlers via request.roles.
|
|
145
56
|
#
|
|
57
|
+
# roles:
|
|
58
|
+
# - id: viewer
|
|
59
|
+
# name: Viewer
|
|
60
|
+
# description: Can view reports but not take actions
|
|
61
|
+
# - id: approver
|
|
62
|
+
# name: Approver
|
|
63
|
+
# description: Can approve or reject requests
|
|
146
64
|
`;
|
|
147
65
|
}
|
|
148
66
|
|
|
@@ -187,7 +105,7 @@ function packageNameToDisplayName(name) {
|
|
|
187
105
|
}
|
|
188
106
|
|
|
189
107
|
async function init() {
|
|
190
|
-
console.log('Initializing Informer
|
|
108
|
+
console.log('Initializing Informer App project...\n');
|
|
191
109
|
|
|
192
110
|
// 1. Check for package.json
|
|
193
111
|
const pkgPath = resolve(cwd, 'package.json');
|
|
@@ -210,9 +128,9 @@ async function init() {
|
|
|
210
128
|
// 3. Get report info from user
|
|
211
129
|
const defaultName = pkg.informer?.name || packageNameToDisplayName(pkg.name || basename(cwd));
|
|
212
130
|
|
|
213
|
-
console.log('Configure your
|
|
131
|
+
console.log('Configure your app:\n');
|
|
214
132
|
|
|
215
|
-
const reportName = await prompt('
|
|
133
|
+
const reportName = await prompt('App name', defaultName);
|
|
216
134
|
|
|
217
135
|
console.log('');
|
|
218
136
|
|
|
@@ -276,11 +194,11 @@ INFORMER_API_KEY=your-api-key
|
|
|
276
194
|
console.log('Created .env (update with your credentials)');
|
|
277
195
|
}
|
|
278
196
|
|
|
279
|
-
// 9. Create
|
|
280
|
-
const
|
|
281
|
-
if (!await exists(
|
|
282
|
-
await writeFile(
|
|
283
|
-
console.log('Created
|
|
197
|
+
// 9. Create informer.yaml if it doesn't exist
|
|
198
|
+
const informerYamlPath = resolve(cwd, 'informer.yaml');
|
|
199
|
+
if (!await exists(informerYamlPath)) {
|
|
200
|
+
await writeFile(informerYamlPath, generateInformerYaml());
|
|
201
|
+
console.log('Created informer.yaml (configure API access and roles)');
|
|
284
202
|
}
|
|
285
203
|
|
|
286
204
|
// 10. Add .env to .gitignore if not present
|
|
@@ -289,11 +207,9 @@ INFORMER_API_KEY=your-api-key
|
|
|
289
207
|
console.log('\nSetup complete!\n');
|
|
290
208
|
console.log('Next steps:');
|
|
291
209
|
console.log(' 1. Update .env with your Informer credentials');
|
|
292
|
-
console.log(' 2. Update
|
|
210
|
+
console.log(' 2. Update informer.yaml with the datasets/APIs your app needs');
|
|
293
211
|
console.log(' 3. Run: npm install');
|
|
294
212
|
console.log(' 4. Run: npm run dev');
|
|
295
|
-
console.log(' 5. Install the Claude skill: /plugin marketplace add entrinsik-org/claude-plugins');
|
|
296
|
-
console.log(' 6. Then: /plugin install magic-reports@entrinsik-plugins');
|
|
297
213
|
console.log('');
|
|
298
214
|
}
|
|
299
215
|
|
package/package.json
CHANGED
|
@@ -1,46 +1,47 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
},
|
|
10
|
-
"publishConfig": {
|
|
11
|
-
"registry": "https://docker.entrinsik.com/repository/entNPM/"
|
|
12
|
-
},
|
|
13
|
-
"author": "Entrinsik Inc.",
|
|
14
|
-
"license": "UNLICENSED",
|
|
15
|
-
"type": "module",
|
|
16
|
-
"types": "index.d.ts",
|
|
17
|
-
"main": "./src/index.js",
|
|
18
|
-
"engines": {
|
|
19
|
-
"node": ">=18.0.0"
|
|
20
|
-
},
|
|
21
|
-
"exports": {
|
|
22
|
-
".": {
|
|
23
|
-
"types": "./index.d.ts",
|
|
24
|
-
"default": "./src/index.js"
|
|
2
|
+
"name": "@entrinsik/vite-plugin-informer",
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"description": "Vite plugin and deploy tool for Informer App development",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/entrinsik-org/i5.git",
|
|
8
|
+
"directory": "packages/vite-plugin-informer"
|
|
25
9
|
},
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"registry": "https://docker.entrinsik.com/repository/entNPM/"
|
|
12
|
+
},
|
|
13
|
+
"author": "Entrinsik Inc.",
|
|
14
|
+
"license": "UNLICENSED",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"types": "index.d.ts",
|
|
17
|
+
"main": "./src/index.js",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18.0.0"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./index.d.ts",
|
|
24
|
+
"default": "./src/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./deploy": "./src/deploy.js",
|
|
27
|
+
"./workspace": "./src/workspace.js"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"informer-deploy": "./bin/deploy.js",
|
|
31
|
+
"informer-init": "./bin/init.js",
|
|
32
|
+
"informer-workspace": "./bin/workspace.js",
|
|
33
|
+
"create-magic-report": "./bin/init.js"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"bin",
|
|
38
|
+
"index.d.ts"
|
|
39
|
+
],
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"vite": ">=5.0.0"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"dotenv": "16.6.1",
|
|
45
|
+
"yaml": "^2.7.0"
|
|
46
|
+
}
|
|
46
47
|
}
|
package/src/agent-dev.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { readFile, readdir, stat, access } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse as parseUrl } from 'node:url';
|
|
4
|
+
import yaml from 'yaml';
|
|
5
|
+
const parseYaml = yaml.parse;
|
|
6
|
+
|
|
7
|
+
const MAX_STEPS = 20;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read and parse informer.yaml from the project root.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} projectRoot
|
|
13
|
+
* @returns {Promise<Object|null>} Parsed YAML or null
|
|
14
|
+
*/
|
|
15
|
+
async function loadInformerYaml(projectRoot) {
|
|
16
|
+
const yamlPath = join(projectRoot, 'informer.yaml');
|
|
17
|
+
try {
|
|
18
|
+
await access(yamlPath);
|
|
19
|
+
const content = await readFile(yamlPath, 'utf8');
|
|
20
|
+
return parseYaml(content);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Scan the tools/ directory for .js files that export a handler.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} projectRoot
|
|
30
|
+
* @returns {Promise<Array<{ name: string, filePath: string }>>}
|
|
31
|
+
*/
|
|
32
|
+
async function scanLocalTools(projectRoot) {
|
|
33
|
+
const toolsDir = join(projectRoot, 'tools');
|
|
34
|
+
try {
|
|
35
|
+
await access(toolsDir);
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tools = [];
|
|
41
|
+
await walkToolFiles(toolsDir, 'tools', tools);
|
|
42
|
+
return tools;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function walkToolFiles(dir, basePath, results) {
|
|
46
|
+
let items;
|
|
47
|
+
try {
|
|
48
|
+
items = await readdir(dir);
|
|
49
|
+
} catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
const full = join(dir, item);
|
|
55
|
+
const childPath = `${basePath}/${item}`;
|
|
56
|
+
const s = await stat(full);
|
|
57
|
+
|
|
58
|
+
if (s.isDirectory()) {
|
|
59
|
+
await walkToolFiles(full, childPath, results);
|
|
60
|
+
} else if (item.endsWith('.js')) {
|
|
61
|
+
const name = childPath
|
|
62
|
+
.replace(/^tools\//, '')
|
|
63
|
+
.replace(/\.js$/, '')
|
|
64
|
+
.replace(/\//g, '_');
|
|
65
|
+
results.push({ name, filePath: full });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read an SSE stream from a fetch response, collecting text and tool calls.
|
|
72
|
+
*
|
|
73
|
+
* @param {Response} response - Fetch response with SSE body
|
|
74
|
+
* @returns {Promise<{ text: string, toolCalls: Array<{ id: string, name: string, input: Object }>, usage: Object }>}
|
|
75
|
+
*/
|
|
76
|
+
async function readSSE(response) {
|
|
77
|
+
const reader = response.body.getReader();
|
|
78
|
+
const decoder = new TextDecoder();
|
|
79
|
+
let buffer = '';
|
|
80
|
+
let text = '';
|
|
81
|
+
const toolCalls = [];
|
|
82
|
+
const pendingInputs = new Map(); // toolCallId -> partial JSON string
|
|
83
|
+
let usage = {};
|
|
84
|
+
|
|
85
|
+
while (true) {
|
|
86
|
+
const { done, value } = await reader.read();
|
|
87
|
+
if (done) break;
|
|
88
|
+
|
|
89
|
+
buffer += decoder.decode(value, { stream: true });
|
|
90
|
+
const lines = buffer.split('\n');
|
|
91
|
+
buffer = lines.pop();
|
|
92
|
+
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (!line.startsWith('data: ')) continue;
|
|
95
|
+
const payload = line.slice(6);
|
|
96
|
+
if (payload === '[DONE]') break;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const event = JSON.parse(payload);
|
|
100
|
+
switch (event.type) {
|
|
101
|
+
case 'text-delta':
|
|
102
|
+
text += event.delta;
|
|
103
|
+
break;
|
|
104
|
+
case 'tool-input-start':
|
|
105
|
+
pendingInputs.set(event.toolCallId, '');
|
|
106
|
+
break;
|
|
107
|
+
case 'tool-input-delta':
|
|
108
|
+
if (pendingInputs.has(event.toolCallId)) {
|
|
109
|
+
pendingInputs.set(
|
|
110
|
+
event.toolCallId,
|
|
111
|
+
pendingInputs.get(event.toolCallId) + event.inputTextDelta
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
case 'tool-input-available':
|
|
116
|
+
toolCalls.push({
|
|
117
|
+
id: event.toolCallId,
|
|
118
|
+
name: event.toolName,
|
|
119
|
+
input: event.input || {}
|
|
120
|
+
});
|
|
121
|
+
pendingInputs.delete(event.toolCallId);
|
|
122
|
+
break;
|
|
123
|
+
case 'finish-step':
|
|
124
|
+
if (event.usage) usage = event.usage;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// Skip malformed SSE events
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { text, toolCalls, usage };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Create Connect middleware for local agent development.
|
|
138
|
+
*
|
|
139
|
+
* Provides:
|
|
140
|
+
* GET /api/_agent → list agents from informer.yaml
|
|
141
|
+
* POST /api/_agent/:name/_trigger → run agent locally with tool dispatch
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} viteServer - Vite dev server instance
|
|
144
|
+
* @param {Object} opts - Configuration
|
|
145
|
+
* @returns {Function} Connect middleware
|
|
146
|
+
*/
|
|
147
|
+
export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot }) {
|
|
148
|
+
|
|
149
|
+
// query() — proxies to the workspace _sql endpoint (same as server-routes.js)
|
|
150
|
+
async function query(sql, params) {
|
|
151
|
+
if (!devWorkspaceId) {
|
|
152
|
+
throw new Error('query() requires INFORMER_DEV_WORKSPACE. Run: npx informer-workspace init');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const resp = await globalThis.fetch(`${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`, {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
|
158
|
+
body: JSON.stringify({ sql, params: params || [] })
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (!resp.ok) {
|
|
162
|
+
const err = await resp.json().catch(() => ({}));
|
|
163
|
+
throw new Error(`query() failed: ${resp.status} ${err.message || resp.statusText}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const data = await resp.json();
|
|
167
|
+
return data.rows;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// fetch() — proxies API calls to the Informer server (same as server-routes.js)
|
|
171
|
+
async function apiFetch(path, opts = {}) {
|
|
172
|
+
const method = (opts.method || 'GET').toUpperCase();
|
|
173
|
+
const url = `${serverOrigin}/api/${path.replace(/^\/?(?:api\/)?/, '')}`;
|
|
174
|
+
const fetchOpts = {
|
|
175
|
+
method,
|
|
176
|
+
headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (opts.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
180
|
+
fetchOpts.body = JSON.stringify(opts.body);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const resp = await globalThis.fetch(url, fetchOpts);
|
|
184
|
+
let body;
|
|
185
|
+
try { body = await resp.json(); } catch { body = await resp.text(); }
|
|
186
|
+
return { status: resp.status, body };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// emit() — no-op in dev mode (logs to console)
|
|
190
|
+
function emit(event, payload) {
|
|
191
|
+
console.log(`[agent-dev] emit("${event}",`, JSON.stringify(payload), ')');
|
|
192
|
+
return { ok: true };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return async function agentDevMiddleware(req, res, next) {
|
|
196
|
+
try {
|
|
197
|
+
const parsed = parseUrl(req.url, true);
|
|
198
|
+
const routePath = parsed.pathname || '/';
|
|
199
|
+
|
|
200
|
+
// GET / → list agents
|
|
201
|
+
if (req.method === 'GET' && (routePath === '/' || routePath === '')) {
|
|
202
|
+
const yaml = await loadInformerYaml(projectRoot);
|
|
203
|
+
const agents = yaml && yaml.agents ? Object.entries(yaml.agents).map(([name, def]) => ({
|
|
204
|
+
name,
|
|
205
|
+
description: def.description || null,
|
|
206
|
+
model: def.model || 'go_everyday',
|
|
207
|
+
tools: def.tools || [],
|
|
208
|
+
on: def.on || []
|
|
209
|
+
})) : [];
|
|
210
|
+
|
|
211
|
+
res.statusCode = 200;
|
|
212
|
+
res.setHeader('Content-Type', 'application/json');
|
|
213
|
+
res.end(JSON.stringify(agents));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// POST /:name/_trigger → run agent
|
|
218
|
+
const triggerMatch = routePath.match(/^\/([^/]+)\/_trigger$/);
|
|
219
|
+
if (req.method === 'POST' && triggerMatch) {
|
|
220
|
+
const agentName = decodeURIComponent(triggerMatch[1]);
|
|
221
|
+
|
|
222
|
+
// Read request body
|
|
223
|
+
const chunks = [];
|
|
224
|
+
for await (const chunk of req) {
|
|
225
|
+
chunks.push(chunk);
|
|
226
|
+
}
|
|
227
|
+
const rawBody = Buffer.concat(chunks).toString('utf8');
|
|
228
|
+
let body = {};
|
|
229
|
+
if (rawBody) {
|
|
230
|
+
try { body = JSON.parse(rawBody); } catch { body = {}; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const triggerEvent = {
|
|
234
|
+
event: body.event || '_manual',
|
|
235
|
+
payload: body.payload || {}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Load agent definition
|
|
239
|
+
const yaml = await loadInformerYaml(projectRoot);
|
|
240
|
+
if (!yaml || !yaml.agents || !yaml.agents[agentName]) {
|
|
241
|
+
res.statusCode = 404;
|
|
242
|
+
res.setHeader('Content-Type', 'application/json');
|
|
243
|
+
res.end(JSON.stringify({ error: `Agent "${agentName}" not found in informer.yaml` }));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const agentDef = yaml.agents[agentName];
|
|
248
|
+
const model = agentDef.model || 'go_everyday';
|
|
249
|
+
const instructions = agentDef.instructions || '';
|
|
250
|
+
const agentToolNames = agentDef.tools || [];
|
|
251
|
+
|
|
252
|
+
// Load tool handlers via ssrLoadModule
|
|
253
|
+
const localTools = await scanLocalTools(projectRoot);
|
|
254
|
+
const toolMap = new Map(localTools.map(t => [t.name, t]));
|
|
255
|
+
|
|
256
|
+
const loadedTools = {};
|
|
257
|
+
for (const toolName of agentToolNames) {
|
|
258
|
+
const tool = toolMap.get(toolName);
|
|
259
|
+
if (!tool) {
|
|
260
|
+
console.warn(`[agent-dev] Tool "${toolName}" not found in tools/`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const mod = await viteServer.ssrLoadModule(tool.filePath);
|
|
265
|
+
if (typeof mod.handler !== 'function') {
|
|
266
|
+
console.warn(`[agent-dev] Tool "${toolName}" has no handler export`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
loadedTools[toolName] = {
|
|
271
|
+
handler: mod.handler,
|
|
272
|
+
description: mod.description || `Tool: ${toolName}`,
|
|
273
|
+
schema: mod.schema || { type: 'object', properties: {} }
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Build AI SDK tool definitions for _chat
|
|
278
|
+
const chatTools = {};
|
|
279
|
+
for (const [name, tool] of Object.entries(loadedTools)) {
|
|
280
|
+
// Ensure schema always has type: 'object' at top level (Anthropic API requires it)
|
|
281
|
+
const schema = tool.schema || {};
|
|
282
|
+
chatTools[name] = {
|
|
283
|
+
description: tool.description,
|
|
284
|
+
inputSchema: {
|
|
285
|
+
type: 'object',
|
|
286
|
+
...schema
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Run the AI loop
|
|
292
|
+
console.log(`[agent-dev] Triggering "${agentName}" (model: ${model}, tools: ${Object.keys(loadedTools).join(', ')})`);
|
|
293
|
+
for (const [name, def] of Object.entries(chatTools)) {
|
|
294
|
+
console.log(`[agent-dev] Tool "${name}" schema:`, JSON.stringify(def.inputSchema));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const messages = [
|
|
298
|
+
{
|
|
299
|
+
role: 'user',
|
|
300
|
+
parts: [{
|
|
301
|
+
type: 'text',
|
|
302
|
+
text: JSON.stringify({
|
|
303
|
+
event: triggerEvent.event,
|
|
304
|
+
payload: triggerEvent.payload,
|
|
305
|
+
timestamp: new Date().toISOString()
|
|
306
|
+
})
|
|
307
|
+
}]
|
|
308
|
+
}
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
const steps = [];
|
|
312
|
+
let totalTokens = 0;
|
|
313
|
+
|
|
314
|
+
for (let step = 0; step < MAX_STEPS; step++) {
|
|
315
|
+
// Call the AI model
|
|
316
|
+
const chatResp = await globalThis.fetch(`${serverOrigin}/api/models/${model}/_chat`, {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
|
319
|
+
body: JSON.stringify({
|
|
320
|
+
messages,
|
|
321
|
+
system: instructions,
|
|
322
|
+
tools: Object.keys(chatTools).length > 0 ? chatTools : undefined,
|
|
323
|
+
webSearch: !!agentDef.webSearch,
|
|
324
|
+
maxSteps: 1
|
|
325
|
+
})
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (!chatResp.ok) {
|
|
329
|
+
const err = await chatResp.json().catch(() => ({}));
|
|
330
|
+
throw new Error(`Model call failed: ${chatResp.status} ${err.message || chatResp.statusText}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const { text, toolCalls, usage } = await readSSE(chatResp);
|
|
334
|
+
totalTokens += (usage.promptTokens || 0) + (usage.completionTokens || 0);
|
|
335
|
+
|
|
336
|
+
// No tool calls — model is done
|
|
337
|
+
if (toolCalls.length === 0) {
|
|
338
|
+
if (text) {
|
|
339
|
+
steps.push({ type: 'text', text });
|
|
340
|
+
console.log(`[agent-dev] Step ${step}: text response (${text.length} chars)`);
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Execute tool calls locally
|
|
346
|
+
const assistantParts = [];
|
|
347
|
+
if (text) {
|
|
348
|
+
assistantParts.push({ type: 'text', text });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const toolResultParts = [];
|
|
352
|
+
|
|
353
|
+
for (const tc of toolCalls) {
|
|
354
|
+
console.log(`[agent-dev] Step ${step}: calling tool "${tc.name}"`, JSON.stringify(tc.input));
|
|
355
|
+
|
|
356
|
+
assistantParts.push({
|
|
357
|
+
type: 'tool-invocation',
|
|
358
|
+
toolCallId: tc.id,
|
|
359
|
+
toolName: tc.name,
|
|
360
|
+
args: tc.input,
|
|
361
|
+
state: 'result'
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const tool = loadedTools[tc.name];
|
|
365
|
+
let result;
|
|
366
|
+
|
|
367
|
+
if (tool) {
|
|
368
|
+
try {
|
|
369
|
+
result = await tool.handler(tc.input, { query, fetch: apiFetch, emit, context: triggerEvent });
|
|
370
|
+
} catch (err) {
|
|
371
|
+
console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
|
|
372
|
+
result = { error: err.message };
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
result = { error: `Unknown tool: ${tc.name}` };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
console.log(`[agent-dev] → result:`, JSON.stringify(result).slice(0, 200));
|
|
379
|
+
|
|
380
|
+
toolResultParts.push({
|
|
381
|
+
type: 'tool-result',
|
|
382
|
+
toolCallId: tc.id,
|
|
383
|
+
toolName: tc.name,
|
|
384
|
+
result: JSON.stringify(result)
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
steps.push({
|
|
388
|
+
type: 'tool_call',
|
|
389
|
+
toolCalls: [{ name: tc.name, args: tc.input, result }]
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Append assistant message with tool invocations + tool results
|
|
394
|
+
messages.push({ role: 'assistant', parts: assistantParts });
|
|
395
|
+
messages.push({ role: 'tool', parts: toolResultParts });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const runResult = {
|
|
399
|
+
agent: agentName,
|
|
400
|
+
trigger: triggerEvent.event,
|
|
401
|
+
status: 'completed',
|
|
402
|
+
tokens: totalTokens,
|
|
403
|
+
steps
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
console.log(`[agent-dev] Agent "${agentName}" completed: ${steps.length} steps, ${totalTokens} tokens`);
|
|
407
|
+
|
|
408
|
+
res.statusCode = 200;
|
|
409
|
+
res.setHeader('Content-Type', 'application/json');
|
|
410
|
+
res.end(JSON.stringify(runResult));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
next();
|
|
415
|
+
} catch (err) {
|
|
416
|
+
console.error('[agent-dev]', err);
|
|
417
|
+
res.statusCode = 500;
|
|
418
|
+
res.setHeader('Content-Type', 'application/json');
|
|
419
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
}
|
package/src/deploy.js
CHANGED
|
@@ -14,7 +14,7 @@ const CHUNK_THRESHOLD = 512 * 1024; // 512KB
|
|
|
14
14
|
const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml'];
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Deploy a built Vite project to Informer as an App
|
|
17
|
+
* Deploy a built Vite project to Informer as an App.
|
|
18
18
|
*
|
|
19
19
|
* Tries the new /api/apps endpoint first. If the server doesn't support it,
|
|
20
20
|
* falls back to the legacy /api/reports endpoint transparently.
|
|
@@ -149,7 +149,7 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
// 7. Upload config files from project root (
|
|
152
|
+
// 7. Upload config files from project root (informer.yaml, data-access.yaml)
|
|
153
153
|
const projectRoot = dirname(distDir);
|
|
154
154
|
let configCount = 0;
|
|
155
155
|
for (const configFile of ROOT_CONFIG_FILES) {
|
|
@@ -188,7 +188,30 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
188
188
|
// No migrations directory, skip
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
// 9. Upload
|
|
191
|
+
// 9. Upload tools/ directory from project root (if it exists)
|
|
192
|
+
const toolsDir = join(projectRoot, 'tools');
|
|
193
|
+
let toolsCount = 0;
|
|
194
|
+
try {
|
|
195
|
+
await access(toolsDir);
|
|
196
|
+
const toolFiles = await walkDir(toolsDir);
|
|
197
|
+
for (const filePath of toolFiles) {
|
|
198
|
+
const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
|
|
199
|
+
const content = await readFile(filePath);
|
|
200
|
+
const ext = '.' + relPath.split('.').pop();
|
|
201
|
+
const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
|
|
202
|
+
const payload = isText
|
|
203
|
+
? { content: content.toString('utf8'), encoding: 'utf8' }
|
|
204
|
+
: { content: content.toString('base64'), encoding: 'base64' };
|
|
205
|
+
|
|
206
|
+
await api.put(`${entityPath}/contents/${relPath}`, payload);
|
|
207
|
+
console.log(` ${relPath} (from project root)`);
|
|
208
|
+
toolsCount++;
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// No tools directory, skip
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 10. Upload server/ directory from project root (if it exists)
|
|
192
215
|
const serverDir = join(projectRoot, 'server');
|
|
193
216
|
let serverCount = 0;
|
|
194
217
|
try {
|
|
@@ -211,7 +234,30 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
211
234
|
// No server directory, skip
|
|
212
235
|
}
|
|
213
236
|
|
|
214
|
-
//
|
|
237
|
+
// 11. Upload webhooks/ directory from project root (if it exists)
|
|
238
|
+
const webhooksDir = join(projectRoot, 'webhooks');
|
|
239
|
+
let webhooksCount = 0;
|
|
240
|
+
try {
|
|
241
|
+
await access(webhooksDir);
|
|
242
|
+
const webhookFiles = await walkDir(webhooksDir);
|
|
243
|
+
for (const filePath of webhookFiles) {
|
|
244
|
+
const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
|
|
245
|
+
const content = await readFile(filePath);
|
|
246
|
+
const ext = '.' + relPath.split('.').pop();
|
|
247
|
+
const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
|
|
248
|
+
const payload = isText
|
|
249
|
+
? { content: content.toString('utf8'), encoding: 'utf8' }
|
|
250
|
+
: { content: content.toString('base64'), encoding: 'base64' };
|
|
251
|
+
|
|
252
|
+
await api.put(`${entityPath}/contents/${relPath}`, payload);
|
|
253
|
+
console.log(` ${relPath} (from project root)`);
|
|
254
|
+
webhooksCount++;
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// No webhooks directory, skip
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 12. Deploy: run migrations + scan/bundle server routes + webhooks + tools + agents
|
|
215
261
|
if (apiPrefix === 'apps') {
|
|
216
262
|
try {
|
|
217
263
|
console.log('Deploying...');
|
|
@@ -226,14 +272,31 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
226
272
|
console.log(` ${r}`);
|
|
227
273
|
}
|
|
228
274
|
}
|
|
275
|
+
if (result.webhooks && result.webhooks.length > 0) {
|
|
276
|
+
console.log(` Registered ${result.webhooks.length} webhook(s):`);
|
|
277
|
+
for (const r of result.webhooks) {
|
|
278
|
+
console.log(` ${r}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (result.tools && result.tools.length > 0) {
|
|
282
|
+
console.log(` Registered ${result.tools.length} tool(s): ${result.tools.join(', ')}`);
|
|
283
|
+
}
|
|
284
|
+
if (result.agents && result.agents.length > 0) {
|
|
285
|
+
console.log(` Deployed ${result.agents.length} agent(s): ${result.agents.join(', ')}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (err.status === 404) {
|
|
290
|
+
// Server may not support _deploy yet — ignore
|
|
291
|
+
} else {
|
|
292
|
+
const detail = err.body || err.message;
|
|
293
|
+
console.error(` Deploy failed: ${detail}`);
|
|
229
294
|
}
|
|
230
|
-
} catch {
|
|
231
|
-
// Server may not support _deploy yet — ignore
|
|
232
295
|
}
|
|
233
296
|
}
|
|
234
297
|
|
|
235
|
-
//
|
|
236
|
-
const totalFiles = entries.length + configCount + migrationsCount + serverCount;
|
|
298
|
+
// 13. Print URL and return UUID for saving
|
|
299
|
+
const totalFiles = entries.length + configCount + migrationsCount + toolsCount + serverCount + webhooksCount;
|
|
237
300
|
const base = baseUrl.replace(/\/+$/, '');
|
|
238
301
|
const entityUrl = apiPrefix === 'apps'
|
|
239
302
|
? `${base}/api/apps/${naturalId}/view`
|
package/src/index.js
CHANGED
|
@@ -4,10 +4,11 @@ import { readFile } from 'node:fs/promises';
|
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
5
|
import { createClient } from './client.js';
|
|
6
6
|
import { createMiddleware as createServerRoutes } from './server-routes.js';
|
|
7
|
+
import { createAgentMiddleware } from './agent-dev.js';
|
|
7
8
|
import { init, migrate } from './workspace.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
* Vite plugin for local
|
|
11
|
+
* Vite plugin for local Informer App development.
|
|
11
12
|
*
|
|
12
13
|
* - Proxies /api requests to the Informer server with Basic auth
|
|
13
14
|
* - Runs server/ route handlers locally via ssrLoadModule (if server/ dir exists)
|
|
@@ -129,6 +130,20 @@ export default function informer(options = {}) {
|
|
|
129
130
|
server.middlewares.use('/api/_server', serverRoutes);
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
// Mount agent dev middleware if tools/ or informer.yaml agents exist
|
|
134
|
+
const toolsDir = resolve(projectRoot, 'tools');
|
|
135
|
+
const yamlPath = resolve(projectRoot, 'informer.yaml');
|
|
136
|
+
|
|
137
|
+
if (existsSync(toolsDir) || existsSync(yamlPath)) {
|
|
138
|
+
const agentDev = createAgentMiddleware(server, {
|
|
139
|
+
serverOrigin,
|
|
140
|
+
authHeader,
|
|
141
|
+
devWorkspaceId,
|
|
142
|
+
projectRoot
|
|
143
|
+
});
|
|
144
|
+
server.middlewares.use('/api/_agent', agentDev);
|
|
145
|
+
}
|
|
146
|
+
|
|
132
147
|
},
|
|
133
148
|
|
|
134
149
|
transformIndexHtml: {
|
package/src/server-routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
1
2
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
3
|
import { join, relative, posix } from 'node:path';
|
|
3
4
|
import { parse as parseUrl } from 'node:url';
|
|
@@ -218,17 +219,24 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
218
219
|
return;
|
|
219
220
|
}
|
|
220
221
|
|
|
221
|
-
// Read request body
|
|
222
|
+
// Read request body — preserve raw bytes for HMAC verification
|
|
222
223
|
const chunks = [];
|
|
223
224
|
for await (const chunk of req) {
|
|
224
225
|
chunks.push(chunk);
|
|
225
226
|
}
|
|
226
|
-
const rawBody = Buffer.concat(chunks).toString('utf8');
|
|
227
|
+
const rawBody = Buffer.concat(chunks).toString('utf8') || null;
|
|
227
228
|
let body = null;
|
|
228
229
|
if (rawBody) {
|
|
229
230
|
try { body = JSON.parse(rawBody); } catch { body = rawBody; }
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
// crypto helper — mirrors the sandbox's crypto.hmac()
|
|
234
|
+
const cryptoHelper = {
|
|
235
|
+
hmac(algorithm, key, data, encoding) {
|
|
236
|
+
return createHmac(algorithm, key).update(data).digest(encoding || 'hex');
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
232
240
|
// Build request context
|
|
233
241
|
const request = {
|
|
234
242
|
method: req.method.toUpperCase(),
|
|
@@ -236,12 +244,32 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
236
244
|
params: match.params,
|
|
237
245
|
query: parsed.query || {},
|
|
238
246
|
body,
|
|
247
|
+
rawBody,
|
|
239
248
|
headers: req.headers,
|
|
240
|
-
roles: roles || []
|
|
249
|
+
roles: roles || [],
|
|
250
|
+
user: {
|
|
251
|
+
username: 'dev-user',
|
|
252
|
+
displayName: 'Dev User',
|
|
253
|
+
email: null,
|
|
254
|
+
timezone: null
|
|
255
|
+
}
|
|
241
256
|
};
|
|
242
257
|
|
|
258
|
+
// respond() — sends early response, handler continues in background
|
|
259
|
+
let responded = false;
|
|
260
|
+
function respond(earlyBody) {
|
|
261
|
+
if (responded) return;
|
|
262
|
+
responded = true;
|
|
263
|
+
res.statusCode = 200;
|
|
264
|
+
res.setHeader('Content-Type', 'application/json');
|
|
265
|
+
res.end(JSON.stringify(earlyBody));
|
|
266
|
+
}
|
|
267
|
+
|
|
243
268
|
// Call handler
|
|
244
|
-
const result = await handler({ query, fetch: apiFetch, env: {}, request });
|
|
269
|
+
const result = await handler({ query, fetch: apiFetch, respond, crypto: cryptoHelper, env: {}, request });
|
|
270
|
+
|
|
271
|
+
// If respond() was already called, the response is already sent
|
|
272
|
+
if (responded) return;
|
|
245
273
|
|
|
246
274
|
// Normalize response (mirrors app-sandbox.js buildInvokeScript logic)
|
|
247
275
|
let status, responseBody, responseHeaders;
|