@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 CHANGED
@@ -8,141 +8,59 @@ import { randomUUID } from 'node:crypto';
8
8
  const cwd = process.cwd();
9
9
 
10
10
  /**
11
- * Generate the default data-access.yaml content with helpful comments
11
+ * Generate the default informer.yaml content with helpful comments
12
12
  */
13
- function generateDataAccessYaml() {
14
- return `# Data Access Configuration
15
- # ========================
16
- # This file controls which Informer APIs your report can access.
17
- # Without this file, all API access is blocked (secure by default).
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
- # Documentation: https://docs.entrinsik.com/informer/magic-reports/data-access
19
+ # Without an access section, all API access is blocked (secure by default).
20
20
 
21
21
  # ============================================================================
22
- # DATASETS
22
+ # DATA ACCESS
23
23
  # ============================================================================
24
- # Grant access to dataset search and field metadata.
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
- # Simple access (full dataset):
26
+ # access:
30
27
  # datasets:
31
- # - admin:sales-data
32
- # - admin:customers
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 # User only sees their 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:daily-summary
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
- # Example:
122
- # apis:
42
+ # apis: # Raw API paths (advanced)
123
43
  # - POST /api/custom/endpoint
124
- # - GET /api/special/resource
125
- #
126
- apis: []
44
+
45
+ access:
46
+ datasets: []
47
+ queries: []
48
+ integrations: []
127
49
 
128
50
  # ============================================================================
129
- # VARIABLE REFERENCE
51
+ # ROLES (optional)
130
52
  # ============================================================================
131
- # Variables are expanded server-side, keeping sensitive values secure.
132
- #
133
- # User variables:
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 Magic Report project...\n');
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 Magic Report:\n');
131
+ console.log('Configure your app:\n');
214
132
 
215
- const reportName = await prompt('Report name', defaultName);
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 data-access.yaml if it doesn't exist
280
- const dataAccessPath = resolve(cwd, 'data-access.yaml');
281
- if (!await exists(dataAccessPath)) {
282
- await writeFile(dataAccessPath, generateDataAccessYaml());
283
- console.log('Created data-access.yaml (configure API access for your report)');
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 data-access.yaml with the datasets/APIs your report needs');
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
- "name": "@entrinsik/vite-plugin-informer",
3
- "version": "2.2.0",
4
- "description": "Vite plugin and publish tool for local Magic Report development",
5
- "repository": {
6
- "type": "git",
7
- "url": "https://github.com/entrinsik-org/i5.git",
8
- "directory": "packages/vite-plugin-informer"
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
- "./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
- }
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
  }
@@ -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 (or legacy Magic Report).
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 (e.g., data-access.yaml)
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 server/ directory from project root (if it exists)
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
- // 10. Deploy: run migrations + scan/bundle server routes
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
- // 11. Print URL and return UUID for saving
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 Magic Report development.
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: {
@@ -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;