@entrinsik/vite-plugin-informer 2.2.0 → 2.4.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/deploy.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import dotenv from 'dotenv';
4
3
  import { readFile, writeFile } from 'node:fs/promises';
5
4
  import { resolve } from 'node:path';
6
5
  import { deploy } from '../src/deploy.js';
6
+ import { loadEnv, parseModeArg } from '../src/env.js';
7
7
 
8
- dotenv.config();
8
+ const mode = parseModeArg(process.argv);
9
+ loadEnv({ mode });
9
10
 
10
11
  const baseUrl = process.env.INFORMER_URL;
11
12
  const apiKey = process.env.INFORMER_API_KEY;
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/bin/workspace.js CHANGED
@@ -1,22 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import dotenv from 'dotenv';
4
3
  import { readFile } from 'node:fs/promises';
5
4
  import { resolve } from 'node:path';
6
5
  import { createClient } from '../src/client.js';
6
+ import { loadEnv, envWritePath, parseModeArg } from '../src/env.js';
7
7
  import { init, migrate, reset } from '../src/workspace.js';
8
8
 
9
- dotenv.config();
9
+ const mode = parseModeArg(process.argv);
10
+ loadEnv({ mode });
10
11
 
11
- const command = process.argv[2];
12
+ // Strip --mode <value> from argv before parsing the command
13
+ const args = process.argv.slice(2).filter((arg, i, arr) => arg !== '--mode' && arr[i - 1] !== '--mode');
14
+ const command = args[0];
12
15
 
13
16
  if (!command || !['init', 'migrate', 'reset'].includes(command)) {
14
- console.error('Usage: informer-workspace <init|migrate|reset>');
17
+ console.error('Usage: informer-workspace <init|migrate|reset> [--mode <name>]');
15
18
  console.error('');
16
19
  console.error('Commands:');
17
20
  console.error(' init Create a dev workspace datasource and run migrations');
18
21
  console.error(' migrate Run pending migrations against the dev workspace');
19
22
  console.error(' reset Drop all tables and re-run all migrations');
23
+ console.error('');
24
+ console.error('Options:');
25
+ console.error(' --mode <name> Load .env.<name> (e.g. --mode test, --mode production)');
20
26
  process.exit(1);
21
27
  }
22
28
 
@@ -33,7 +39,7 @@ if (!baseUrl || (!apiKey && (!user || !pass))) {
33
39
 
34
40
  const api = createClient({ baseUrl, apiKey, user, pass });
35
41
  const migrationsDir = resolve('migrations');
36
- const envPath = resolve('.env');
42
+ const envPath = envWritePath({ mode });
37
43
 
38
44
  try {
39
45
  if (command === 'init') {
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.4.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,450 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { readFile, readdir, stat, access } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { parse as parseUrl } from 'node:url';
5
+ import yaml from 'yaml';
6
+ const parseYaml = yaml.parse;
7
+
8
+ const MAX_STEPS = 20;
9
+
10
+ /**
11
+ * Read and parse informer.yaml from the project root.
12
+ *
13
+ * @param {string} projectRoot
14
+ * @returns {Promise<Object|null>} Parsed YAML or null
15
+ */
16
+ async function loadInformerYaml(projectRoot) {
17
+ const yamlPath = join(projectRoot, 'informer.yaml');
18
+ try {
19
+ await access(yamlPath);
20
+ const content = await readFile(yamlPath, 'utf8');
21
+ return parseYaml(content);
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Scan the tools/ directory for .js files that export a handler.
29
+ *
30
+ * @param {string} projectRoot
31
+ * @returns {Promise<Array<{ name: string, filePath: string }>>}
32
+ */
33
+ async function scanLocalTools(projectRoot) {
34
+ const toolsDir = join(projectRoot, 'tools');
35
+ try {
36
+ await access(toolsDir);
37
+ } catch {
38
+ return [];
39
+ }
40
+
41
+ const tools = [];
42
+ await walkToolFiles(toolsDir, 'tools', tools);
43
+ return tools;
44
+ }
45
+
46
+ async function walkToolFiles(dir, basePath, results) {
47
+ let items;
48
+ try {
49
+ items = await readdir(dir);
50
+ } catch {
51
+ return;
52
+ }
53
+
54
+ for (const item of items) {
55
+ const full = join(dir, item);
56
+ const childPath = `${basePath}/${item}`;
57
+ const s = await stat(full);
58
+
59
+ if (s.isDirectory()) {
60
+ await walkToolFiles(full, childPath, results);
61
+ } else if (item.endsWith('.js')) {
62
+ const name = childPath
63
+ .replace(/^tools\//, '')
64
+ .replace(/\.js$/, '')
65
+ .replace(/\//g, '_');
66
+ results.push({ name, filePath: full });
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Read an SSE stream from a fetch response, collecting text and tool calls.
73
+ *
74
+ * @param {Response} response - Fetch response with SSE body
75
+ * @returns {Promise<{ text: string, toolCalls: Array<{ id: string, name: string, input: Object }>, usage: Object }>}
76
+ */
77
+ async function readSSE(response) {
78
+ const reader = response.body.getReader();
79
+ const decoder = new TextDecoder();
80
+ let buffer = '';
81
+ let text = '';
82
+ const toolCalls = [];
83
+ const pendingInputs = new Map(); // toolCallId -> partial JSON string
84
+ let usage = {};
85
+
86
+ while (true) {
87
+ const { done, value } = await reader.read();
88
+ if (done) break;
89
+
90
+ buffer += decoder.decode(value, { stream: true });
91
+ const lines = buffer.split('\n');
92
+ buffer = lines.pop();
93
+
94
+ for (const line of lines) {
95
+ if (!line.startsWith('data: ')) continue;
96
+ const payload = line.slice(6);
97
+ if (payload === '[DONE]') break;
98
+
99
+ try {
100
+ const event = JSON.parse(payload);
101
+ switch (event.type) {
102
+ case 'text-delta':
103
+ text += event.delta;
104
+ break;
105
+ case 'tool-input-start':
106
+ pendingInputs.set(event.toolCallId, '');
107
+ break;
108
+ case 'tool-input-delta':
109
+ if (pendingInputs.has(event.toolCallId)) {
110
+ pendingInputs.set(
111
+ event.toolCallId,
112
+ pendingInputs.get(event.toolCallId) + event.inputTextDelta
113
+ );
114
+ }
115
+ break;
116
+ case 'tool-input-available':
117
+ toolCalls.push({
118
+ id: event.toolCallId,
119
+ name: event.toolName,
120
+ input: event.input || {}
121
+ });
122
+ pendingInputs.delete(event.toolCallId);
123
+ break;
124
+ case 'finish-step':
125
+ if (event.usage) usage = event.usage;
126
+ break;
127
+ }
128
+ } catch {
129
+ // Skip malformed SSE events
130
+ }
131
+ }
132
+ }
133
+
134
+ return { text, toolCalls, usage };
135
+ }
136
+
137
+ /**
138
+ * Create Connect middleware for local agent development.
139
+ *
140
+ * Provides:
141
+ * GET /api/_agent → list agents from informer.yaml
142
+ * POST /api/_agent/:name/_trigger → run agent locally with tool dispatch
143
+ *
144
+ * @param {Object} viteServer - Vite dev server instance
145
+ * @param {Object} opts - Configuration
146
+ * @returns {Function} Connect middleware
147
+ */
148
+ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot }) {
149
+
150
+ // query() — proxies to the workspace _sql endpoint (same as server-routes.js)
151
+ async function query(sql, params) {
152
+ if (!devWorkspaceId) {
153
+ throw new Error('query() requires INFORMER_DEV_WORKSPACE. Run: npx informer-workspace init');
154
+ }
155
+
156
+ const resp = await globalThis.fetch(`${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`, {
157
+ method: 'POST',
158
+ headers: { 'Content-Type': 'application/json', Authorization: authHeader },
159
+ body: JSON.stringify({ sql, params: params || [] })
160
+ });
161
+
162
+ if (!resp.ok) {
163
+ const err = await resp.json().catch(() => ({}));
164
+ throw new Error(`query() failed: ${resp.status} ${err.message || resp.statusText}`);
165
+ }
166
+
167
+ const data = await resp.json();
168
+ return data.rows;
169
+ }
170
+
171
+ // fetch() — proxies API calls to the Informer server (same as server-routes.js)
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
+ // emit() — no-op in dev mode (logs to console)
191
+ function emit(event, payload) {
192
+ console.log(`[agent-dev] emit("${event}",`, JSON.stringify(payload), ')');
193
+ return { ok: true };
194
+ }
195
+
196
+ // crypto helper — mirrors the sandbox's crypto.hmac()
197
+ const cryptoHelper = {
198
+ hmac(algorithm, key, data, encoding) {
199
+ return createHmac(algorithm, key).update(data).digest(encoding || 'hex');
200
+ }
201
+ };
202
+
203
+ // log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
204
+ const logCall = (level, message, data) => {
205
+ const msg = typeof message === 'string' ? message : JSON.stringify(message);
206
+ const args = [`[app-log] [${level}] ${msg}`];
207
+ if (data) args.push(data);
208
+ console.log(...args);
209
+ };
210
+ const log = Object.assign(
211
+ (message, data) => logCall('info', message, data),
212
+ {
213
+ debug: (message, data) => logCall('debug', message, data),
214
+ info: (message, data) => logCall('info', message, data),
215
+ warn: (message, data) => logCall('warn', message, data),
216
+ error: (message, data) => logCall('error', message, data)
217
+ }
218
+ );
219
+
220
+ // markdown helper — passthrough in dev (production uses `marked`)
221
+ const markdown = (text) => text;
222
+
223
+ return async function agentDevMiddleware(req, res, next) {
224
+ try {
225
+ const parsed = parseUrl(req.url, true);
226
+ const routePath = parsed.pathname || '/';
227
+
228
+ // GET / → list agents
229
+ if (req.method === 'GET' && (routePath === '/' || routePath === '')) {
230
+ const yaml = await loadInformerYaml(projectRoot);
231
+ const agents = yaml && yaml.agents ? Object.entries(yaml.agents).map(([name, def]) => ({
232
+ name,
233
+ description: def.description || null,
234
+ model: def.model || 'go_everyday',
235
+ tools: def.tools || [],
236
+ on: def.on || []
237
+ })) : [];
238
+
239
+ res.statusCode = 200;
240
+ res.setHeader('Content-Type', 'application/json');
241
+ res.end(JSON.stringify(agents));
242
+ return;
243
+ }
244
+
245
+ // POST /:name/_trigger → run agent
246
+ const triggerMatch = routePath.match(/^\/([^/]+)\/_trigger$/);
247
+ if (req.method === 'POST' && triggerMatch) {
248
+ const agentName = decodeURIComponent(triggerMatch[1]);
249
+
250
+ // Read request body
251
+ const chunks = [];
252
+ for await (const chunk of req) {
253
+ chunks.push(chunk);
254
+ }
255
+ const rawBody = Buffer.concat(chunks).toString('utf8');
256
+ let body = {};
257
+ if (rawBody) {
258
+ try { body = JSON.parse(rawBody); } catch { body = {}; }
259
+ }
260
+
261
+ const triggerEvent = {
262
+ event: body.event || '_manual',
263
+ payload: body.payload || {}
264
+ };
265
+
266
+ // Load agent definition
267
+ const yaml = await loadInformerYaml(projectRoot);
268
+ if (!yaml || !yaml.agents || !yaml.agents[agentName]) {
269
+ res.statusCode = 404;
270
+ res.setHeader('Content-Type', 'application/json');
271
+ res.end(JSON.stringify({ error: `Agent "${agentName}" not found in informer.yaml` }));
272
+ return;
273
+ }
274
+
275
+ const agentDef = yaml.agents[agentName];
276
+ const model = agentDef.model || 'go_everyday';
277
+ const instructions = agentDef.instructions || '';
278
+ const agentToolNames = agentDef.tools || [];
279
+
280
+ // Load tool handlers via ssrLoadModule
281
+ const localTools = await scanLocalTools(projectRoot);
282
+ const toolMap = new Map(localTools.map(t => [t.name, t]));
283
+
284
+ const loadedTools = {};
285
+ for (const toolName of agentToolNames) {
286
+ const tool = toolMap.get(toolName);
287
+ if (!tool) {
288
+ console.warn(`[agent-dev] Tool "${toolName}" not found in tools/`);
289
+ continue;
290
+ }
291
+
292
+ const mod = await viteServer.ssrLoadModule(tool.filePath);
293
+ if (typeof mod.handler !== 'function') {
294
+ console.warn(`[agent-dev] Tool "${toolName}" has no handler export`);
295
+ continue;
296
+ }
297
+
298
+ loadedTools[toolName] = {
299
+ handler: mod.handler,
300
+ description: mod.description || `Tool: ${toolName}`,
301
+ schema: mod.schema || { type: 'object', properties: {} }
302
+ };
303
+ }
304
+
305
+ // Build AI SDK tool definitions for _chat
306
+ const chatTools = {};
307
+ for (const [name, tool] of Object.entries(loadedTools)) {
308
+ // Ensure schema always has type: 'object' at top level (Anthropic API requires it)
309
+ const schema = tool.schema || {};
310
+ chatTools[name] = {
311
+ description: tool.description,
312
+ inputSchema: {
313
+ type: 'object',
314
+ ...schema
315
+ }
316
+ };
317
+ }
318
+
319
+ // Run the AI loop
320
+ console.log(`[agent-dev] Triggering "${agentName}" (model: ${model}, tools: ${Object.keys(loadedTools).join(', ')})`);
321
+ for (const [name, def] of Object.entries(chatTools)) {
322
+ console.log(`[agent-dev] Tool "${name}" schema:`, JSON.stringify(def.inputSchema));
323
+ }
324
+
325
+ const messages = [
326
+ {
327
+ role: 'user',
328
+ parts: [{
329
+ type: 'text',
330
+ text: JSON.stringify({
331
+ event: triggerEvent.event,
332
+ payload: triggerEvent.payload,
333
+ timestamp: new Date().toISOString()
334
+ })
335
+ }]
336
+ }
337
+ ];
338
+
339
+ const steps = [];
340
+ let totalTokens = 0;
341
+
342
+ for (let step = 0; step < MAX_STEPS; step++) {
343
+ // Call the AI model
344
+ const chatResp = await globalThis.fetch(`${serverOrigin}/api/models/${model}/_chat`, {
345
+ method: 'POST',
346
+ headers: { 'Content-Type': 'application/json', Authorization: authHeader },
347
+ body: JSON.stringify({
348
+ messages,
349
+ system: instructions,
350
+ tools: Object.keys(chatTools).length > 0 ? chatTools : undefined,
351
+ webSearch: !!agentDef.webSearch,
352
+ maxSteps: 1
353
+ })
354
+ });
355
+
356
+ if (!chatResp.ok) {
357
+ const err = await chatResp.json().catch(() => ({}));
358
+ throw new Error(`Model call failed: ${chatResp.status} ${err.message || chatResp.statusText}`);
359
+ }
360
+
361
+ const { text, toolCalls, usage } = await readSSE(chatResp);
362
+ totalTokens += (usage.promptTokens || 0) + (usage.completionTokens || 0);
363
+
364
+ // No tool calls — model is done
365
+ if (toolCalls.length === 0) {
366
+ if (text) {
367
+ steps.push({ type: 'text', text });
368
+ console.log(`[agent-dev] Step ${step}: text response (${text.length} chars)`);
369
+ }
370
+ break;
371
+ }
372
+
373
+ // Execute tool calls locally
374
+ const assistantParts = [];
375
+ if (text) {
376
+ assistantParts.push({ type: 'text', text });
377
+ }
378
+
379
+ const toolResultParts = [];
380
+
381
+ for (const tc of toolCalls) {
382
+ console.log(`[agent-dev] Step ${step}: calling tool "${tc.name}"`, JSON.stringify(tc.input));
383
+
384
+ assistantParts.push({
385
+ type: 'tool-invocation',
386
+ toolCallId: tc.id,
387
+ toolName: tc.name,
388
+ args: tc.input,
389
+ state: 'result'
390
+ });
391
+
392
+ const tool = loadedTools[tc.name];
393
+ let result;
394
+
395
+ if (tool) {
396
+ try {
397
+ result = await tool.handler(tc.input, { query, fetch: apiFetch, emit, crypto: cryptoHelper, markdown, log, context: triggerEvent });
398
+ } catch (err) {
399
+ console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
400
+ result = { error: err.message };
401
+ }
402
+ } else {
403
+ result = { error: `Unknown tool: ${tc.name}` };
404
+ }
405
+
406
+ console.log(`[agent-dev] → result:`, JSON.stringify(result).slice(0, 200));
407
+
408
+ toolResultParts.push({
409
+ type: 'tool-result',
410
+ toolCallId: tc.id,
411
+ toolName: tc.name,
412
+ result: JSON.stringify(result)
413
+ });
414
+
415
+ steps.push({
416
+ type: 'tool_call',
417
+ toolCalls: [{ name: tc.name, args: tc.input, result }]
418
+ });
419
+ }
420
+
421
+ // Append assistant message with tool invocations + tool results
422
+ messages.push({ role: 'assistant', parts: assistantParts });
423
+ messages.push({ role: 'tool', parts: toolResultParts });
424
+ }
425
+
426
+ const runResult = {
427
+ agent: agentName,
428
+ trigger: triggerEvent.event,
429
+ status: 'completed',
430
+ tokens: totalTokens,
431
+ steps
432
+ };
433
+
434
+ console.log(`[agent-dev] Agent "${agentName}" completed: ${steps.length} steps, ${totalTokens} tokens`);
435
+
436
+ res.statusCode = 200;
437
+ res.setHeader('Content-Type', 'application/json');
438
+ res.end(JSON.stringify(runResult));
439
+ return;
440
+ }
441
+
442
+ next();
443
+ } catch (err) {
444
+ console.error('[agent-dev]', err);
445
+ res.statusCode = 500;
446
+ res.setHeader('Content-Type', 'application/json');
447
+ res.end(JSON.stringify({ error: err.message }));
448
+ }
449
+ };
450
+ }
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/env.js ADDED
@@ -0,0 +1,86 @@
1
+ import dotenv from 'dotenv';
2
+ import { existsSync } from 'node:fs';
3
+ import { resolve, dirname, parse as parsePath } from 'node:path';
4
+
5
+ /**
6
+ * Build an ordered list of .env file paths for dotenv to load.
7
+ *
8
+ * Priority (first match wins in dotenv):
9
+ * 1. <cwd>/.env.<mode> (mode-specific, e.g. .env.test)
10
+ * 2. <cwd>/.env (local defaults)
11
+ * 3. <parent>/.env.<mode> (walk up for monorepo shared config)
12
+ * 4. <parent>/.env
13
+ * ... continues up to first parent that contains a .env file
14
+ *
15
+ * When mode is 'development' (Vite default), mode-specific files are skipped
16
+ * since .env already serves that purpose.
17
+ *
18
+ * @param {{ mode?: string, cwd?: string }} options
19
+ * @returns {string[]} Ordered file paths (may include non-existent files — dotenv ignores those)
20
+ */
21
+ export function buildEnvPaths({ mode, cwd } = {}) {
22
+ const dir = cwd || process.cwd();
23
+ const useMode = mode && mode !== 'development';
24
+
25
+ // Local project .env files (highest priority)
26
+ const paths = [];
27
+ if (useMode) paths.push(resolve(dir, `.env.${mode}`));
28
+ paths.push(resolve(dir, '.env'));
29
+
30
+ // Walk up to parent directories for monorepo fallback
31
+ let current = dirname(dir);
32
+ const { root } = parsePath(dir);
33
+
34
+ while (current !== root) {
35
+ if (useMode) paths.push(resolve(current, `.env.${mode}`));
36
+ paths.push(resolve(current, '.env'));
37
+
38
+ // Stop at the first ancestor that has any .env file
39
+ const hasEnv = useMode
40
+ ? existsSync(resolve(current, `.env.${mode}`)) || existsSync(resolve(current, '.env'))
41
+ : existsSync(resolve(current, '.env'));
42
+
43
+ if (hasEnv) break;
44
+
45
+ current = dirname(current);
46
+ }
47
+
48
+ return paths;
49
+ }
50
+
51
+ /**
52
+ * Load environment variables using mode-aware, parent-walking dotenv paths.
53
+ *
54
+ * @param {{ mode?: string, cwd?: string }} options
55
+ */
56
+ export function loadEnv({ mode, cwd } = {}) {
57
+ const paths = buildEnvPaths({ mode, cwd });
58
+ dotenv.config({ path: paths });
59
+ }
60
+
61
+ /**
62
+ * Determine the .env file path to write workspace IDs into.
63
+ * Writes to .env.<mode> when a non-default mode is active, otherwise .env.
64
+ *
65
+ * @param {{ mode?: string, cwd?: string }} options
66
+ * @returns {string}
67
+ */
68
+ export function envWritePath({ mode, cwd } = {}) {
69
+ const dir = cwd || process.cwd();
70
+ const useMode = mode && mode !== 'development';
71
+ return resolve(dir, useMode ? `.env.${mode}` : '.env');
72
+ }
73
+
74
+ /**
75
+ * Parse --mode <name> from a process.argv array.
76
+ *
77
+ * @param {string[]} argv
78
+ * @returns {string|null}
79
+ */
80
+ export function parseModeArg(argv) {
81
+ const idx = argv.indexOf('--mode');
82
+ if (idx !== -1 && idx + 1 < argv.length) {
83
+ return argv[idx + 1];
84
+ }
85
+ return null;
86
+ }
package/src/index.js CHANGED
@@ -1,13 +1,14 @@
1
- import dotenv from 'dotenv';
2
1
  import { existsSync } from 'node:fs';
3
2
  import { readFile } from 'node:fs/promises';
4
3
  import { resolve } from 'node:path';
5
4
  import { createClient } from './client.js';
5
+ import { loadEnv, envWritePath } from './env.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)
@@ -22,12 +23,14 @@ export default function informer(options = {}) {
22
23
  let authHeader = null;
23
24
  let serverOrigin = null;
24
25
  let devWorkspaceId = null;
26
+ let activeMode = null;
25
27
 
26
28
  return {
27
29
  name: 'vite-plugin-informer',
28
30
 
29
- config(_, { command }) {
30
- dotenv.config();
31
+ config(_, { command, mode }) {
32
+ activeMode = mode;
33
+ loadEnv({ mode });
31
34
 
32
35
  isDev = command === 'serve';
33
36
 
@@ -56,7 +59,8 @@ export default function informer(options = {}) {
56
59
  changeOrigin: true,
57
60
  headers: {
58
61
  Authorization: authHeader
59
- }
62
+ },
63
+ ...options.proxy
60
64
  }
61
65
  }
62
66
  };
@@ -105,7 +109,7 @@ export default function informer(options = {}) {
105
109
  api,
106
110
  slug,
107
111
  migrationsDir,
108
- envPath: resolve(projectRoot, '.env')
112
+ envPath: envWritePath({ mode: activeMode })
109
113
  });
110
114
  devWorkspaceId = result.workspaceId;
111
115
  }
@@ -129,6 +133,20 @@ export default function informer(options = {}) {
129
133
  server.middlewares.use('/api/_server', serverRoutes);
130
134
  }
131
135
 
136
+ // Mount agent dev middleware if tools/ or informer.yaml agents exist
137
+ const toolsDir = resolve(projectRoot, 'tools');
138
+ const yamlPath = resolve(projectRoot, 'informer.yaml');
139
+
140
+ if (existsSync(toolsDir) || existsSync(yamlPath)) {
141
+ const agentDev = createAgentMiddleware(server, {
142
+ serverOrigin,
143
+ authHeader,
144
+ devWorkspaceId,
145
+ projectRoot
146
+ });
147
+ server.middlewares.use('/api/_agent', agentDev);
148
+ }
149
+
132
150
  },
133
151
 
134
152
  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,50 @@ 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
+
240
+ // log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
241
+ const logCall = (level, message, data) => {
242
+ const msg = typeof message === 'string' ? message : JSON.stringify(message);
243
+ const args = [`[app-log] [${level}] ${msg}`];
244
+ if (data) args.push(data);
245
+ console.log(...args);
246
+ };
247
+ const log = Object.assign(
248
+ (message, data) => logCall('info', message, data),
249
+ {
250
+ debug: (message, data) => logCall('debug', message, data),
251
+ info: (message, data) => logCall('info', message, data),
252
+ warn: (message, data) => logCall('warn', message, data),
253
+ error: (message, data) => logCall('error', message, data)
254
+ }
255
+ );
256
+
257
+ // markdown helper — passthrough in dev (production uses `marked`)
258
+ const markdown = (text) => text;
259
+
260
+ // emit helper — no-op in dev (logs to console)
261
+ const emit = (event, payload) => {
262
+ console.log(`[app-event] emit("${event}",`, JSON.stringify(payload), ')');
263
+ return { ok: true };
264
+ };
265
+
232
266
  // Build request context
233
267
  const request = {
234
268
  method: req.method.toUpperCase(),
@@ -236,12 +270,32 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
236
270
  params: match.params,
237
271
  query: parsed.query || {},
238
272
  body,
273
+ rawBody,
239
274
  headers: req.headers,
240
- roles: roles || []
275
+ roles: roles || [],
276
+ user: {
277
+ username: 'dev-user',
278
+ displayName: 'Dev User',
279
+ email: null,
280
+ timezone: null
281
+ }
241
282
  };
242
283
 
284
+ // respond() — sends early response, handler continues in background
285
+ let responded = false;
286
+ function respond(earlyBody) {
287
+ if (responded) return;
288
+ responded = true;
289
+ res.statusCode = 200;
290
+ res.setHeader('Content-Type', 'application/json');
291
+ res.end(JSON.stringify(earlyBody));
292
+ }
293
+
243
294
  // Call handler
244
- const result = await handler({ query, fetch: apiFetch, env: {}, request });
295
+ const result = await handler({ query, fetch: apiFetch, respond, emit, crypto: cryptoHelper, markdown, log, env: {}, request });
296
+
297
+ // If respond() was already called, the response is already sent
298
+ if (responded) return;
245
299
 
246
300
  // Normalize response (mirrors app-sandbox.js buildInvokeScript logic)
247
301
  let status, responseBody, responseHeaders;
package/src/workspace.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readdir, readFile, writeFile, access } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { join, basename } from 'node:path';
3
3
 
4
4
  /**
5
5
  * Execute raw SQL against a workspace datasource via the _sql route.
@@ -122,7 +122,7 @@ export async function init({ api, slug, migrationsDir, envPath }) {
122
122
  }
123
123
 
124
124
  await writeFile(envPath, envContent);
125
- console.log(`Saved INFORMER_DEV_WORKSPACE=${workspaceId} to .env`);
125
+ console.log(`Saved INFORMER_DEV_WORKSPACE=${workspaceId} to ${basename(envPath)}`);
126
126
 
127
127
  return { workspaceId, ...result };
128
128
  }