@entrinsik/vite-plugin-informer 2.1.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 +105 -10
- package/src/index.js +80 -33
- package/src/server-routes.js +312 -0
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,20 +188,115 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
188
188
|
// No migrations directory, skip
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
// 9.
|
|
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)
|
|
215
|
+
const serverDir = join(projectRoot, 'server');
|
|
216
|
+
let serverCount = 0;
|
|
217
|
+
try {
|
|
218
|
+
await access(serverDir);
|
|
219
|
+
const serverFiles = await walkDir(serverDir);
|
|
220
|
+
for (const filePath of serverFiles) {
|
|
221
|
+
const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
|
|
222
|
+
const content = await readFile(filePath);
|
|
223
|
+
const ext = '.' + relPath.split('.').pop();
|
|
224
|
+
const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
|
|
225
|
+
const payload = isText
|
|
226
|
+
? { content: content.toString('utf8'), encoding: 'utf8' }
|
|
227
|
+
: { content: content.toString('base64'), encoding: 'base64' };
|
|
228
|
+
|
|
229
|
+
await api.put(`${entityPath}/contents/${relPath}`, payload);
|
|
230
|
+
console.log(` ${relPath} (from project root)`);
|
|
231
|
+
serverCount++;
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
// No server directory, skip
|
|
235
|
+
}
|
|
236
|
+
|
|
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
|
|
192
261
|
if (apiPrefix === 'apps') {
|
|
193
262
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
263
|
+
console.log('Deploying...');
|
|
264
|
+
const result = await api.post(`${entityPath}/_deploy`);
|
|
265
|
+
if (result) {
|
|
266
|
+
if (result.migrated && result.migrated.length > 0) {
|
|
267
|
+
console.log(` Ran ${result.migrated.length} migration(s): ${result.migrated.join(', ')}`);
|
|
268
|
+
}
|
|
269
|
+
if (result.routes && result.routes.length > 0) {
|
|
270
|
+
console.log(` Registered ${result.routes.length} server route(s):`);
|
|
271
|
+
for (const r of result.routes) {
|
|
272
|
+
console.log(` ${r}`);
|
|
273
|
+
}
|
|
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}`);
|
|
197
294
|
}
|
|
198
|
-
} catch {
|
|
199
|
-
// Server may not support _migrate yet — ignore
|
|
200
295
|
}
|
|
201
296
|
}
|
|
202
297
|
|
|
203
|
-
//
|
|
204
|
-
const totalFiles = entries.length + configCount + migrationsCount;
|
|
298
|
+
// 13. Print URL and return UUID for saving
|
|
299
|
+
const totalFiles = entries.length + configCount + migrationsCount + toolsCount + serverCount + webhooksCount;
|
|
205
300
|
const base = baseUrl.replace(/\/+$/, '');
|
|
206
301
|
const entityUrl = apiPrefix === 'apps'
|
|
207
302
|
? `${base}/api/apps/${naturalId}/view`
|
package/src/index.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import dotenv from 'dotenv';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { createClient } from './client.js';
|
|
6
|
+
import { createMiddleware as createServerRoutes } from './server-routes.js';
|
|
7
|
+
import { createAgentMiddleware } from './agent-dev.js';
|
|
8
|
+
import { init, migrate } from './workspace.js';
|
|
2
9
|
|
|
3
10
|
/**
|
|
4
|
-
* Vite plugin for local
|
|
11
|
+
* Vite plugin for local Informer App development.
|
|
5
12
|
*
|
|
6
13
|
* - Proxies /api requests to the Informer server with Basic auth
|
|
7
|
-
* -
|
|
14
|
+
* - Runs server/ route handlers locally via ssrLoadModule (if server/ dir exists)
|
|
8
15
|
* - Injects window.__INFORMER__ context mock in dev mode
|
|
9
16
|
* - Sets base to './' so built assets use relative paths
|
|
10
17
|
*
|
|
@@ -60,43 +67,83 @@ export default function informer(options = {}) {
|
|
|
60
67
|
return cfg;
|
|
61
68
|
},
|
|
62
69
|
|
|
63
|
-
configureServer(server) {
|
|
64
|
-
if (!isDev || !
|
|
70
|
+
async configureServer(server) {
|
|
71
|
+
if (!isDev || !serverOrigin) return;
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
server.middlewares.use('/api/_query', async (req, res, next) => {
|
|
69
|
-
if (req.method !== 'POST') return next();
|
|
73
|
+
const projectRoot = process.cwd();
|
|
74
|
+
const migrationsDir = resolve(projectRoot, 'migrations');
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
// Auto-provision workspace if migrations/ exists
|
|
77
|
+
if (existsSync(migrationsDir)) {
|
|
78
|
+
const api = createClient({
|
|
79
|
+
baseUrl: serverOrigin,
|
|
80
|
+
apiKey: process.env.INFORMER_API_KEY,
|
|
81
|
+
user: process.env.INFORMER_USER,
|
|
82
|
+
pass: process.env.INFORMER_PASS
|
|
83
|
+
});
|
|
77
84
|
|
|
78
|
-
// Forward to workspace _sql endpoint
|
|
79
|
-
const url = `${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`;
|
|
80
85
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
// Verify existing workspace or create a new one
|
|
87
|
+
let needsInit = !devWorkspaceId;
|
|
88
|
+
if (devWorkspaceId) {
|
|
89
|
+
const ds = await api.get(`datasources/${devWorkspaceId}`);
|
|
90
|
+
if (ds) {
|
|
91
|
+
await migrate({ api, workspaceId: devWorkspaceId, migrationsDir });
|
|
92
|
+
} else {
|
|
93
|
+
console.warn(`[informer] Workspace ${devWorkspaceId} not found on server. Re-creating...`);
|
|
94
|
+
needsInit = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (needsInit) {
|
|
99
|
+
const pkg = JSON.parse(await readFile(resolve(projectRoot, 'package.json'), 'utf8'));
|
|
100
|
+
let slug = pkg.name;
|
|
101
|
+
if (slug && slug.startsWith('@') && slug.includes('/')) {
|
|
102
|
+
slug = slug.split('/')[1];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = await init({
|
|
106
|
+
api,
|
|
107
|
+
slug,
|
|
108
|
+
migrationsDir,
|
|
109
|
+
envPath: resolve(projectRoot, '.env')
|
|
110
|
+
});
|
|
111
|
+
devWorkspaceId = result.workspaceId;
|
|
112
|
+
}
|
|
94
113
|
} catch (err) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
114
|
+
console.warn(`[informer] Workspace setup failed: ${err.message}`);
|
|
115
|
+
console.warn('[informer] query() will not work. Run: npx informer-workspace init');
|
|
98
116
|
}
|
|
99
|
-
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Mount server-side route handlers if a server/ directory exists
|
|
120
|
+
const serverDir = resolve(projectRoot, 'server');
|
|
121
|
+
|
|
122
|
+
if (existsSync(serverDir)) {
|
|
123
|
+
const serverRoutes = createServerRoutes(server, {
|
|
124
|
+
serverOrigin,
|
|
125
|
+
authHeader,
|
|
126
|
+
devWorkspaceId,
|
|
127
|
+
projectRoot,
|
|
128
|
+
roles: (options.mock && options.mock.roles) || []
|
|
129
|
+
});
|
|
130
|
+
server.middlewares.use('/api/_server', serverRoutes);
|
|
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
|
+
|
|
100
147
|
},
|
|
101
148
|
|
|
102
149
|
transformIndexHtml: {
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, posix } from 'node:path';
|
|
4
|
+
import { parse as parseUrl } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Convert a file path under server/ to a route path.
|
|
10
|
+
*
|
|
11
|
+
* Examples:
|
|
12
|
+
* server/orders/index.js -> /orders
|
|
13
|
+
* server/orders/[id].js -> /orders/:id
|
|
14
|
+
* server/orders/[id]/approve.js -> /orders/:id/approve
|
|
15
|
+
* server/index.js -> /
|
|
16
|
+
*/
|
|
17
|
+
function filePathToRoute(filePath) {
|
|
18
|
+
let route = filePath
|
|
19
|
+
.replace(/^server\//, '')
|
|
20
|
+
.replace(/\.js$/, '');
|
|
21
|
+
|
|
22
|
+
route = route.replace(/\[([^\]]+)\]/g, ':$1');
|
|
23
|
+
|
|
24
|
+
route = route.replace(/\/index$/, '');
|
|
25
|
+
|
|
26
|
+
if (route === 'index' || route === '') {
|
|
27
|
+
return '/';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!route.startsWith('/')) {
|
|
31
|
+
route = '/' + route;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (route.length > 1 && route.endsWith('/')) {
|
|
35
|
+
route = route.slice(0, -1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return route;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Match an incoming request against a route table.
|
|
43
|
+
*
|
|
44
|
+
* @param {Array<{ method: string, path: string }>} routes
|
|
45
|
+
* @param {string} method - HTTP method
|
|
46
|
+
* @param {string} requestPath - e.g. "/orders/123"
|
|
47
|
+
* @returns {{ route: Object, params: Object }|null}
|
|
48
|
+
*/
|
|
49
|
+
function matchRoute(routes, method, requestPath) {
|
|
50
|
+
const upperMethod = method.toUpperCase();
|
|
51
|
+
const candidates = routes.filter(r => r.method === upperMethod);
|
|
52
|
+
|
|
53
|
+
const normalizedPath = requestPath.length > 1 && requestPath.endsWith('/')
|
|
54
|
+
? requestPath.slice(0, -1)
|
|
55
|
+
: requestPath;
|
|
56
|
+
|
|
57
|
+
const requestSegments = normalizedPath.split('/').filter(Boolean);
|
|
58
|
+
|
|
59
|
+
let bestMatch = null;
|
|
60
|
+
let bestScore = -1;
|
|
61
|
+
|
|
62
|
+
for (const route of candidates) {
|
|
63
|
+
const routeSegments = route.path.split('/').filter(Boolean);
|
|
64
|
+
|
|
65
|
+
if (routeSegments.length !== requestSegments.length) continue;
|
|
66
|
+
|
|
67
|
+
const params = {};
|
|
68
|
+
let score = 0;
|
|
69
|
+
let matched = true;
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < routeSegments.length; i++) {
|
|
72
|
+
const routeSeg = routeSegments[i];
|
|
73
|
+
const reqSeg = requestSegments[i];
|
|
74
|
+
|
|
75
|
+
if (routeSeg.startsWith(':')) {
|
|
76
|
+
params[routeSeg.slice(1)] = decodeURIComponent(reqSeg);
|
|
77
|
+
} else if (routeSeg === reqSeg) {
|
|
78
|
+
score++;
|
|
79
|
+
} else {
|
|
80
|
+
matched = false;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (matched && score > bestScore) {
|
|
86
|
+
bestMatch = { route, params };
|
|
87
|
+
bestScore = score;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (requestSegments.length === 0) {
|
|
92
|
+
const rootRoute = candidates.find(r => r.path === '/');
|
|
93
|
+
if (rootRoute) {
|
|
94
|
+
return { route: rootRoute, params: {} };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return bestMatch;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Recursively scan the server/ directory for .js handler files.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} serverDir - Absolute path to server/ directory
|
|
105
|
+
* @returns {Promise<Array<{ path: string, filePath: string }>>}
|
|
106
|
+
*/
|
|
107
|
+
async function scanRoutes(serverDir) {
|
|
108
|
+
const files = await walkJsFiles(serverDir, 'server');
|
|
109
|
+
return files.map(({ relPath, absPath }) => ({
|
|
110
|
+
path: filePathToRoute(relPath),
|
|
111
|
+
filePath: absPath
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function walkJsFiles(dir, basePath) {
|
|
116
|
+
const results = [];
|
|
117
|
+
let items;
|
|
118
|
+
try {
|
|
119
|
+
items = await readdir(dir);
|
|
120
|
+
} catch {
|
|
121
|
+
return results;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const item of items) {
|
|
125
|
+
const full = join(dir, item);
|
|
126
|
+
const childPath = `${basePath}/${item}`;
|
|
127
|
+
const s = await stat(full);
|
|
128
|
+
|
|
129
|
+
if (s.isDirectory()) {
|
|
130
|
+
results.push(...await walkJsFiles(full, childPath));
|
|
131
|
+
} else if (item.endsWith('.js')) {
|
|
132
|
+
results.push({ relPath: childPath, absPath: full });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return results;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Create Connect middleware for dev-mode server route execution.
|
|
141
|
+
*
|
|
142
|
+
* @param {Object} viteServer - Vite dev server instance
|
|
143
|
+
* @param {{ serverOrigin: string, authHeader: string, devWorkspaceId: string|null, projectRoot: string }} opts
|
|
144
|
+
* @returns {Function} Connect middleware
|
|
145
|
+
*/
|
|
146
|
+
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles }) {
|
|
147
|
+
const serverDir = join(projectRoot, 'server');
|
|
148
|
+
|
|
149
|
+
// query() implementation — proxies to the workspace _sql endpoint
|
|
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
|
+
const detail = err.message || resp.statusText;
|
|
164
|
+
throw new Error(`query() failed: ${resp.status} ${detail} (${serverOrigin}/api/datasources/${devWorkspaceId}/_sql)`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const data = await resp.json();
|
|
168
|
+
return data.rows;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// fetch() implementation — proxies API calls to the Informer server
|
|
172
|
+
async function apiFetch(path, opts = {}) {
|
|
173
|
+
const method = (opts.method || 'GET').toUpperCase();
|
|
174
|
+
const url = `${serverOrigin}/api/${path.replace(/^\/?(?:api\/)?/, '')}`;
|
|
175
|
+
const fetchOpts = {
|
|
176
|
+
method,
|
|
177
|
+
headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (opts.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
181
|
+
fetchOpts.body = JSON.stringify(opts.body);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const resp = await globalThis.fetch(url, fetchOpts);
|
|
185
|
+
let body;
|
|
186
|
+
try { body = await resp.json(); } catch { body = await resp.text(); }
|
|
187
|
+
return { status: resp.status, body };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return async function serverRoutesMiddleware(req, res, next) {
|
|
191
|
+
try {
|
|
192
|
+
// The URL has already had /api/_server stripped by Vite's middleware.use()
|
|
193
|
+
const parsed = parseUrl(req.url, true);
|
|
194
|
+
const routePath = parsed.pathname || '/';
|
|
195
|
+
|
|
196
|
+
// Scan server/ directory for handler files
|
|
197
|
+
const scannedRoutes = await scanRoutes(serverDir);
|
|
198
|
+
|
|
199
|
+
// Build route table with all valid methods per file
|
|
200
|
+
const routeTable = [];
|
|
201
|
+
for (const scanned of scannedRoutes) {
|
|
202
|
+
for (const method of VALID_METHODS) {
|
|
203
|
+
routeTable.push({ method, path: scanned.path, filePath: scanned.filePath });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Match request against route table
|
|
208
|
+
const match = matchRoute(routeTable, req.method, routePath);
|
|
209
|
+
if (!match) return next();
|
|
210
|
+
|
|
211
|
+
// Load handler via Vite's ssrLoadModule (ESM + HMR)
|
|
212
|
+
const mod = await viteServer.ssrLoadModule(match.route.filePath);
|
|
213
|
+
const handler = mod[req.method.toUpperCase()];
|
|
214
|
+
|
|
215
|
+
if (typeof handler !== 'function') {
|
|
216
|
+
res.statusCode = 404;
|
|
217
|
+
res.setHeader('Content-Type', 'application/json');
|
|
218
|
+
res.end(JSON.stringify({ error: 'Method not found' }));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Read request body — preserve raw bytes for HMAC verification
|
|
223
|
+
const chunks = [];
|
|
224
|
+
for await (const chunk of req) {
|
|
225
|
+
chunks.push(chunk);
|
|
226
|
+
}
|
|
227
|
+
const rawBody = Buffer.concat(chunks).toString('utf8') || null;
|
|
228
|
+
let body = null;
|
|
229
|
+
if (rawBody) {
|
|
230
|
+
try { body = JSON.parse(rawBody); } catch { body = rawBody; }
|
|
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
|
+
|
|
240
|
+
// Build request context
|
|
241
|
+
const request = {
|
|
242
|
+
method: req.method.toUpperCase(),
|
|
243
|
+
path: routePath,
|
|
244
|
+
params: match.params,
|
|
245
|
+
query: parsed.query || {},
|
|
246
|
+
body,
|
|
247
|
+
rawBody,
|
|
248
|
+
headers: req.headers,
|
|
249
|
+
roles: roles || [],
|
|
250
|
+
user: {
|
|
251
|
+
username: 'dev-user',
|
|
252
|
+
displayName: 'Dev User',
|
|
253
|
+
email: null,
|
|
254
|
+
timezone: null
|
|
255
|
+
}
|
|
256
|
+
};
|
|
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
|
+
|
|
268
|
+
// Call handler
|
|
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;
|
|
273
|
+
|
|
274
|
+
// Normalize response (mirrors app-sandbox.js buildInvokeScript logic)
|
|
275
|
+
let status, responseBody, responseHeaders;
|
|
276
|
+
|
|
277
|
+
if (result === undefined || result === null) {
|
|
278
|
+
status = 204;
|
|
279
|
+
responseBody = null;
|
|
280
|
+
responseHeaders = {};
|
|
281
|
+
} else if (typeof result === 'object' && typeof result.status === 'number') {
|
|
282
|
+
status = result.status || 200;
|
|
283
|
+
responseBody = result.body !== undefined ? result.body : null;
|
|
284
|
+
responseHeaders = result.headers || {};
|
|
285
|
+
} else {
|
|
286
|
+
status = 200;
|
|
287
|
+
responseBody = result;
|
|
288
|
+
responseHeaders = { 'content-type': 'application/json' };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
res.statusCode = status;
|
|
292
|
+
for (const [key, value] of Object.entries(responseHeaders)) {
|
|
293
|
+
res.setHeader(key, value);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (responseBody === null) {
|
|
297
|
+
res.end();
|
|
298
|
+
} else {
|
|
299
|
+
if (!res.getHeader('content-type')) {
|
|
300
|
+
res.setHeader('Content-Type', 'application/json');
|
|
301
|
+
}
|
|
302
|
+
res.end(JSON.stringify(responseBody));
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
viteServer.ssrFixStacktrace(err);
|
|
306
|
+
console.error('[server-routes]', err);
|
|
307
|
+
res.statusCode = 500;
|
|
308
|
+
res.setHeader('Content-Type', 'application/json');
|
|
309
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|