@entrinsik/vite-plugin-informer 2.3.0 → 2.5.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
@@ -13,40 +13,66 @@ const cwd = process.cwd();
13
13
  function generateInformerYaml() {
14
14
  return `# Informer App Configuration
15
15
  # =========================
16
- # This file controls which Informer APIs your app can access and defines
17
- # custom roles for role-based UIs.
16
+ # This file controls the data your app depends on and defines custom
17
+ # roles for role-based UIs.
18
18
  #
19
- # Without an access section, all API access is blocked (secure by default).
19
+ # Without dependencies: or access:, all API access is blocked (secure
20
+ # by default).
20
21
 
21
22
  # ============================================================================
22
- # DATA ACCESS
23
+ # DEPENDENCIES (preferred)
23
24
  # ============================================================================
24
- # The access section controls which APIs the app can call when shared.
25
+ # Typed slots that the installer binds to actual resources at deploy
26
+ # time. Each slot becomes a property on the handler context object —
27
+ # call methods on it instead of building raw API URLs.
25
28
  #
26
- # access:
27
- # datasets:
28
- # - admin:sales-data # Full dataset access
29
- # - id: admin:orders # With row-level security
30
- # filter:
31
- # region: $user.custom.region
29
+ # Slot methods by target:
30
+ # dataset → context.<slot>.search(esQuery) / context.<slot>.fields()
31
+ # query → context.<slot>.execute(params)
32
+ # datasource → context.<slot>.query(payload)
33
+ # integration → context.<slot>.request({ method, path, body })
32
34
  #
33
- # queries:
34
- # - admin:monthly-summary
35
+ # defaultBinding must be a UUID (not a configId). Look up UUIDs via:
36
+ # GET /api/datasets-list → for target: dataset
37
+ # GET /api/queries-list → for target: query
38
+ # GET /api/datasources-list → for target: datasource
39
+ # GET /api/integrations-list → for target: integration
35
40
  #
36
- # integrations:
37
- # - salesforce
41
+ # Example:
38
42
  #
39
- # datasources:
40
- # - postgres-main
43
+ # dependencies:
44
+ # sales:
45
+ # target: dataset
46
+ # defaultBinding: 7d5a9b1e-0c83-4bde-9e2a-3a4b5c6d7e8f
47
+ # orders:
48
+ # target: dataset
49
+ # defaultBinding: 1f2e3d4c-5b6a-7980-1234-56789abcdef0
50
+ # options:
51
+ # filter:
52
+ # region: $user.custom.region # Row-level security
53
+ # monthly_summary:
54
+ # target: query
55
+ # defaultBinding: 9a8b7c6d-5e4f-3a2b-1c0d-fedcba987654
56
+ # salesforce:
57
+ # target: integration # No defaultBinding — installer picks
58
+ # options:
59
+ # headers:
60
+ # Authorization: Bearer $user.custom.sfToken
61
+
62
+ dependencies: {}
63
+
64
+ # ============================================================================
65
+ # ACCESS (raw API allowlist — for paths that don't fit the typed-slot model)
66
+ # ============================================================================
67
+ # Use access.apis for endpoints not covered by dependencies: slots,
68
+ # such as AI model routes or custom server endpoints.
41
69
  #
42
- # apis: # Raw API paths (advanced)
70
+ # access:
71
+ # apis:
72
+ # - POST /api/models/go_everyday/_object
73
+ # - POST /api/models/go_everyday/_chat
43
74
  # - POST /api/custom/endpoint
44
75
 
45
- access:
46
- datasets: []
47
- queries: []
48
- integrations: []
49
-
50
76
  # ============================================================================
51
77
  # ROLES (optional)
52
78
  # ============================================================================
@@ -111,7 +137,8 @@ async function init() {
111
137
  const pkgPath = resolve(cwd, 'package.json');
112
138
  if (!await exists(pkgPath)) {
113
139
  console.error('No package.json found. Run this in a Vite project directory.');
114
- console.error('Create one first with: npm create vite@latest');
140
+ console.error('Create one first with: npm create vite@latest . -- --template react');
141
+ console.error('(Use --template react-ts for TypeScript, or vanilla/vue/svelte/etc.)');
115
142
  process.exit(1);
116
143
  }
117
144
 
@@ -121,7 +148,8 @@ async function init() {
121
148
  const hasVite = pkg.devDependencies?.vite || pkg.dependencies?.vite;
122
149
  if (!hasVite) {
123
150
  console.error('Vite not found in dependencies.');
124
- console.error('Create a Vite project first: npm create vite@latest');
151
+ console.error('Create a Vite project first: npm create vite@latest . -- --template react');
152
+ console.error('(Use --template react-ts for TypeScript, or vanilla/vue/svelte/etc.)');
125
153
  process.exit(1);
126
154
  }
127
155
 
@@ -198,7 +226,7 @@ INFORMER_API_KEY=your-api-key
198
226
  const informerYamlPath = resolve(cwd, 'informer.yaml');
199
227
  if (!await exists(informerYamlPath)) {
200
228
  await writeFile(informerYamlPath, generateInformerYaml());
201
- console.log('Created informer.yaml (configure API access and roles)');
229
+ console.log('Created informer.yaml (declare your data dependencies and roles)');
202
230
  }
203
231
 
204
232
  // 10. Add .env to .gitignore if not present
@@ -207,7 +235,7 @@ INFORMER_API_KEY=your-api-key
207
235
  console.log('\nSetup complete!\n');
208
236
  console.log('Next steps:');
209
237
  console.log(' 1. Update .env with your Informer credentials');
210
- console.log(' 2. Update informer.yaml with the datasets/APIs your app needs');
238
+ console.log(' 2. Declare your data dependencies in informer.yaml (see comments inside)');
211
239
  console.log(' 3. Run: npm install');
212
240
  console.log(' 4. Run: npm run dev');
213
241
  console.log('');
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@entrinsik/vite-plugin-informer",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Vite plugin and deploy tool for Informer App development",
5
5
  "repository": {
6
6
  "type": "git",
package/src/agent-dev.js CHANGED
@@ -2,6 +2,7 @@ import { readFile, readdir, stat, access } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { parse as parseUrl } from 'node:url';
4
4
  import yaml from 'yaml';
5
+ import { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
5
6
  const parseYaml = yaml.parse;
6
7
 
7
8
  const MAX_STEPS = 20;
@@ -170,7 +171,12 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
170
171
  // fetch() — proxies API calls to the Informer server (same as server-routes.js)
171
172
  async function apiFetch(path, opts = {}) {
172
173
  const method = (opts.method || 'GET').toUpperCase();
173
- const url = `${serverOrigin}/api/${path.replace(/^\/?(?:api\/)?/, '')}`;
174
+ // Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath).
175
+ const apiPath = normalizeFetchPath(path);
176
+ if (!apiPath) {
177
+ return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` } };
178
+ }
179
+ const url = `${serverOrigin}${apiPath}`;
174
180
  const fetchOpts = {
175
181
  method,
176
182
  headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
@@ -192,6 +198,33 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
192
198
  return { ok: true };
193
199
  }
194
200
 
201
+ // notify/email — delivery is a console-logged no-op in dev, but required-field
202
+ // validation mirrors prod so a tool that passes here won't 500 in production.
203
+ const { notify, email } = buildDevMessaging('[agent-dev]');
204
+
205
+ // crypto helper — mirrors the prod sandbox crypto surface
206
+ const cryptoHelper = buildDevCrypto();
207
+
208
+ // log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
209
+ const logCall = (level, message, data) => {
210
+ const msg = typeof message === 'string' ? message : JSON.stringify(message);
211
+ const args = [`[app-log] [${level}] ${msg}`];
212
+ if (data) args.push(data);
213
+ console.log(...args);
214
+ };
215
+ const log = Object.assign(
216
+ (message, data) => logCall('info', message, data),
217
+ {
218
+ debug: (message, data) => logCall('debug', message, data),
219
+ info: (message, data) => logCall('info', message, data),
220
+ warn: (message, data) => logCall('warn', message, data),
221
+ error: (message, data) => logCall('error', message, data)
222
+ }
223
+ );
224
+
225
+ // markdown helper — passthrough in dev (production uses `marked`)
226
+ const markdown = (text) => text;
227
+
195
228
  return async function agentDevMiddleware(req, res, next) {
196
229
  try {
197
230
  const parsed = parseUrl(req.url, true);
@@ -249,6 +282,14 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
249
282
  const instructions = agentDef.instructions || '';
250
283
  const agentToolNames = agentDef.tools || [];
251
284
 
285
+ // Build the typed dependency context + env the same way the
286
+ // server-routes dev middleware does, so tools using
287
+ // `context.<slot>.<method>(...)` and `env` work locally and match
288
+ // the prod sandbox bag.
289
+ const deps = await loadDependencies(projectRoot);
290
+ const context = buildDevContext({ deps, apiFetch });
291
+ const env = await loadAppEnv(projectRoot);
292
+
252
293
  // Load tool handlers via ssrLoadModule
253
294
  const localTools = await scanLocalTools(projectRoot);
254
295
  const toolMap = new Map(localTools.map(t => [t.name, t]));
@@ -366,7 +407,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
366
407
 
367
408
  if (tool) {
368
409
  try {
369
- result = await tool.handler(tc.input, { query, fetch: apiFetch, emit, context: triggerEvent });
410
+ result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, notify, email, crypto: cryptoHelper, markdown, log, env });
370
411
  } catch (err) {
371
412
  console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
372
413
  result = { error: err.message };
package/src/deploy.js CHANGED
@@ -102,22 +102,9 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
102
102
  limit: 10
103
103
  });
104
104
 
105
- // 5. Clear existing files
105
+ // 5. Clear existing files in one server-side transaction
106
106
  console.log('Clearing existing files...');
107
- const files = await api.get(`${entityPath}/files?start=0&end=10000`);
108
- if (files && Array.isArray(files)) {
109
- // Delete non-directories first
110
- const nonDirs = files.filter(f => !f.directory);
111
- await Promise.all(nonDirs.map(f => api.del(`${entityPath}/files/${f.id}`)));
112
-
113
- // Then delete directories in reverse order (deepest first by path length)
114
- const dirs = files
115
- .filter(f => f.directory)
116
- .sort((a, b) => (b.path || '').length - (a.path || '').length);
117
- for (const d of dirs) {
118
- await api.del(`${entityPath}/files/${d.id}`);
119
- }
120
- }
107
+ await api.post(`${entityPath}/files/_clear`);
121
108
 
122
109
  // 6. Upload dist/ contents
123
110
  console.log(`Uploading files from ${distDir}...`);
@@ -0,0 +1,363 @@
1
+ import { readFile, access } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import yaml from 'yaml';
5
+
6
+ const parseYaml = yaml.parse;
7
+
8
+ const RANDOM_BYTES_MAX = 1024;
9
+
10
+ /**
11
+ * Build the dev-mode `crypto` helper. Mirrors the production host dispatcher in
12
+ * app-sandbox.js (cryptoRef) so handlers/tools see the same crypto surface
13
+ * locally. Methods are synchronous in dev (prod returns promises across the
14
+ * isolate boundary); `await` works either way.
15
+ *
16
+ * @returns {Object} crypto helper { hmac, hash, randomUUID, randomBytes, timingSafeEqual, verifyHmac, encrypt, decrypt, verify }
17
+ */
18
+ export function buildDevCrypto() {
19
+ return {
20
+ hmac: (algorithm, key, data, encoding) => crypto.createHmac(algorithm, key).update(data).digest(encoding || 'hex'),
21
+ hash: (algorithm, data, encoding) => crypto.createHash(algorithm).update(data).digest(encoding || 'hex'),
22
+ randomUUID: () => crypto.randomUUID(),
23
+ randomBytes: (length, encoding) => {
24
+ const n = Math.min(Math.max(parseInt(length, 10) || 0, 1), RANDOM_BYTES_MAX);
25
+ return crypto.randomBytes(n).toString(encoding || 'hex');
26
+ },
27
+ timingSafeEqual: (a, b) => {
28
+ if (a == null || b == null) {
29
+ throw new Error('crypto.timingSafeEqual requires two non-null values to compare');
30
+ }
31
+ const ba = Buffer.from(String(a));
32
+ const bb = Buffer.from(String(b));
33
+ if (ba.length !== bb.length) return false;
34
+ return crypto.timingSafeEqual(ba, bb);
35
+ },
36
+ verifyHmac: (algorithm, key, data, signature, encoding) => {
37
+ if (signature == null) {
38
+ throw new Error('crypto.verifyHmac requires a signature to compare against (got null/undefined)');
39
+ }
40
+ const expected = crypto.createHmac(algorithm, key).update(data).digest(encoding || 'hex');
41
+ const a = Buffer.from(String(signature));
42
+ const b = Buffer.from(expected);
43
+ if (a.length !== b.length) return false;
44
+ return crypto.timingSafeEqual(a, b);
45
+ },
46
+ encrypt: (plaintext, key) => {
47
+ const dk = crypto.createHash('sha256').update(String(key)).digest();
48
+ const iv = crypto.randomBytes(12);
49
+ const cipher = crypto.createCipheriv('aes-256-gcm', dk, iv);
50
+ const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
51
+ const tag = cipher.getAuthTag();
52
+ return `${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`;
53
+ },
54
+ decrypt: (payload, key) => {
55
+ const parts = String(payload).split(':');
56
+ if (parts.length !== 3) {
57
+ throw new Error('crypto.decrypt: malformed payload (expected iv:tag:ciphertext)');
58
+ }
59
+ const dk = crypto.createHash('sha256').update(String(key)).digest();
60
+ const iv = Buffer.from(parts[0], 'base64');
61
+ const tag = Buffer.from(parts[1], 'base64');
62
+ const ct = Buffer.from(parts[2], 'base64');
63
+ const decipher = crypto.createDecipheriv('aes-256-gcm', dk, iv);
64
+ decipher.setAuthTag(tag);
65
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
66
+ },
67
+ verify: (algorithm, data, signature, publicKey, signatureEncoding) => {
68
+ if (data == null || signature == null || publicKey == null) {
69
+ throw new Error('crypto.verify requires data, signature, and publicKey');
70
+ }
71
+ return crypto.verify(algorithm, Buffer.from(String(data)), publicKey, Buffer.from(String(signature), signatureEncoding || 'base64'));
72
+ }
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Canonicalize a guest-code fetch() path the same way the prod sandbox does
78
+ * (see normalizeFetchPath in app-sandbox.js). Returns the normalized `/api/...`
79
+ * path, or null for any non-canonical input — so dev rejects the same shapes
80
+ * prod 400s instead of silently accepting `//api/foo` / `\api\foo`.
81
+ *
82
+ * @param {string} urlPath
83
+ * @returns {string|null}
84
+ */
85
+ export function normalizeFetchPath(urlPath) {
86
+ if (typeof urlPath !== 'string' || !urlPath) return null;
87
+ // eslint-disable-next-line no-control-regex
88
+ if (/[\\\x00-\x1f\s]/.test(urlPath)) return null;
89
+ if (/^[a-z][a-z0-9+.-]*:/i.test(urlPath)) return null;
90
+ let p = urlPath;
91
+ if (p.startsWith('/')) p = p.slice(1);
92
+ if (p.startsWith('api/')) p = p.slice('api/'.length);
93
+ if (!p || p.startsWith('/') || p.startsWith('api/')) return null;
94
+ if (p.includes('//')) return null;
95
+ return `/api/${p}`;
96
+ }
97
+
98
+ /**
99
+ * Build dev-mode notify/email shims that mirror the PROD required-field
100
+ * validation (app-message-utils.js) so an app that passes locally also passes in
101
+ * production. Delivery is still a console-logged no-op in dev.
102
+ *
103
+ * @param {string} logPrefix - console log prefix (e.g. '[app]' or '[agent-dev]')
104
+ * @returns {{ notify: Function, email: Function }}
105
+ */
106
+ export function buildDevMessaging(logPrefix = '[app]') {
107
+ const notify = (usernameOrArray, message) => {
108
+ if (Array.isArray(usernameOrArray)) {
109
+ for (const item of usernameOrArray) {
110
+ if (!item || typeof item.username !== 'string') throw new Error('notify() bulk items require a username');
111
+ if (!item.title) throw new Error('notify() bulk items require a title');
112
+ }
113
+ console.log(`${logPrefix} notify (bulk)`, JSON.stringify(usernameOrArray));
114
+ return { ids: usernameOrArray.map(() => 'dev-message'), queued: usernameOrArray.length };
115
+ }
116
+ if (!usernameOrArray || typeof usernameOrArray !== 'string') {
117
+ throw new Error('notify() requires a username as the first argument');
118
+ }
119
+ if (!message || !message.title) {
120
+ throw new Error('notify() requires a message with at least a title');
121
+ }
122
+ console.log(`${logPrefix} notify`, usernameOrArray, JSON.stringify(message));
123
+ return { id: 'dev-message' };
124
+ };
125
+
126
+ const email = (toOrArray, message) => {
127
+ if (Array.isArray(toOrArray)) {
128
+ for (const item of toOrArray) {
129
+ if (!item || typeof item.to !== 'string') throw new Error('email() bulk items require a to address');
130
+ if (!item.subject) throw new Error('email() bulk items require a subject');
131
+ }
132
+ console.log(`${logPrefix} email (bulk)`, JSON.stringify(toOrArray));
133
+ return { ids: toOrArray.map(() => 'dev-message'), queued: toOrArray.length };
134
+ }
135
+ if (!toOrArray || typeof toOrArray !== 'string') {
136
+ throw new Error('email() requires an email address as the first argument');
137
+ }
138
+ if (!message || !message.subject) {
139
+ throw new Error('email() requires a message with at least a subject');
140
+ }
141
+ console.log(`${logPrefix} email`, toOrArray, JSON.stringify(message));
142
+ return { id: 'dev-message' };
143
+ };
144
+
145
+ return { notify, email };
146
+ }
147
+
148
+ // Mirrors the server-side patterns in
149
+ // packages/informer-server/modules/app/routes/deploy.js so dev and deploy
150
+ // reject the same shapes with the same wording. Keep these in lockstep.
151
+ const DEPENDENCY_NAME_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
152
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
153
+ const VALID_TARGETS = new Set(['dataset', 'query', 'datasource', 'integration']);
154
+ const VALID_RUN_AS = new Set(['user', 'owner']);
155
+
156
+ const METHOD_SURFACE = {
157
+ dataset: ['search', 'fields'],
158
+ query: ['execute'],
159
+ datasource: ['query'],
160
+ integration: ['request']
161
+ };
162
+
163
+ /**
164
+ * Load the `dependencies:` map from informer.yaml. Returns `{}` if the file
165
+ * is missing or the section is absent — both are valid (an app may have no
166
+ * declared dependencies).
167
+ *
168
+ * @param {string} projectRoot
169
+ * @returns {Promise<Object>} The raw dependencies object as authored
170
+ */
171
+ export async function loadDependencies(projectRoot) {
172
+ const yamlPath = join(projectRoot, 'informer.yaml');
173
+ try {
174
+ await access(yamlPath);
175
+ } catch {
176
+ return {};
177
+ }
178
+ const content = await readFile(yamlPath, 'utf8');
179
+ const parsed = parseYaml(content);
180
+ if (!parsed || typeof parsed !== 'object') return {};
181
+ return (parsed.dependencies && typeof parsed.dependencies === 'object') ? parsed.dependencies : {};
182
+ }
183
+
184
+ /**
185
+ * Load the app `env:` map from informer.yaml. In production the sandbox injects
186
+ * `app.defn.env`; dev has no app model, so the YAML's top-level `env:` block is
187
+ * the closest stand-in. Returns `{}` when missing so handlers/tools see the same
188
+ * empty object rather than undefined.
189
+ *
190
+ * @param {string} projectRoot
191
+ * @returns {Promise<Object>} The raw env object as authored
192
+ */
193
+ export async function loadAppEnv(projectRoot) {
194
+ const yamlPath = join(projectRoot, 'informer.yaml');
195
+ try {
196
+ await access(yamlPath);
197
+ } catch {
198
+ return {};
199
+ }
200
+ const content = await readFile(yamlPath, 'utf8');
201
+ const parsed = parseYaml(content);
202
+ if (!parsed || typeof parsed !== 'object') return {};
203
+ return (parsed.env && typeof parsed.env === 'object') ? parsed.env : {};
204
+ }
205
+
206
+ /**
207
+ * Validate the shape of a parsed `dependencies:` block. Returns an array of
208
+ * human-readable error strings (empty when valid). Wording mirrors deploy.js
209
+ * so devs see the same message at boot and at deploy.
210
+ *
211
+ * Skips "is the resource actually readable?" — that requires a network call
212
+ * against the configured server and would slow startup. Shape validation is
213
+ * the cheap win; the deploy path still does the read check.
214
+ *
215
+ * @param {Object} deps - The raw `dependencies:` object
216
+ * @returns {string[]} Error messages, one per problem
217
+ */
218
+ export function validateDependencies(deps) {
219
+ const errors = [];
220
+ for (const [name, decl] of Object.entries(deps || {})) {
221
+ if (!DEPENDENCY_NAME_PATTERN.test(name)) {
222
+ errors.push(`dependency "${name}": invalid name (must be lowercase dot-segmented, eg. "orders.list")`);
223
+ continue;
224
+ }
225
+ if (!decl || typeof decl !== 'object') {
226
+ errors.push(`dependency "${name}": must be an object`);
227
+ continue;
228
+ }
229
+ if (!decl.target || typeof decl.target !== 'string') {
230
+ errors.push(`dependency "${name}": missing required "target" field`);
231
+ continue;
232
+ }
233
+ if (!VALID_TARGETS.has(decl.target)) {
234
+ errors.push(`dependency "${name}": unknown target "${decl.target}" (expected one of: ${[...VALID_TARGETS].join(', ')})`);
235
+ }
236
+ const runAs = decl.runAs || 'user';
237
+ if (!VALID_RUN_AS.has(runAs)) {
238
+ errors.push(`dependency "${name}": runAs must be 'user' or 'owner' (got "${runAs}")`);
239
+ }
240
+ if (decl.defaultBinding != null) {
241
+ if (typeof decl.defaultBinding !== 'string') {
242
+ errors.push(`dependency "${name}": defaultBinding must be a string UUID`);
243
+ } else if (!UUID_PATTERN.test(decl.defaultBinding)) {
244
+ errors.push(`dependency "${name}": defaultBinding must be a UUID (got "${decl.defaultBinding}")`);
245
+ }
246
+ }
247
+ }
248
+ return errors;
249
+ }
250
+
251
+ /**
252
+ * Build the dev-mode `context` object passed to server route handlers, so
253
+ * `await context.myDep.method(...)` works the same locally as it does after
254
+ * deploy. Mirrors the production sandbox's typed-proxy DI context built in
255
+ * `app-sandbox.js`.
256
+ *
257
+ * Dev-mode differences from production:
258
+ * - There is no installer step locally, so each dep is auto-bound to its
259
+ * manifest `defaultBinding` UUID. Deps without one return an unbound
260
+ * proxy whose methods throw a pointed message — same shape as the
261
+ * prod `makeUnboundProxy` behavior, just with a dev-flavored hint.
262
+ * - `runAs` is informational only. Every dev call goes through the
263
+ * vite plugin's auth header (the same identity that handles /api proxy
264
+ * requests), regardless of whether the manifest declares user or owner.
265
+ * - Driver option-merging (dataset filters, query default parameters,
266
+ * integration paths/headers) is not replicated here. Dev is a thin
267
+ * pass-through — exotic prod semantics surface as integration-test work,
268
+ * not as silent dev parity.
269
+ *
270
+ * @param {Object} args
271
+ * @param {Object} args.deps - The raw `dependencies:` object
272
+ * @param {Function} args.apiFetch - The dev-server fetch helper
273
+ * (path, { method, body }) => { status, body }
274
+ * @returns {Object} An object keyed by dependency name, values are typed
275
+ * proxies with methods matching the target's production method surface.
276
+ */
277
+ export function buildDevContext({ deps, apiFetch }) {
278
+ const context = {};
279
+ for (const [name, decl] of Object.entries(deps || {})) {
280
+ if (!decl || typeof decl !== 'object') continue;
281
+ const target = decl.target;
282
+ if (!VALID_TARGETS.has(target)) continue;
283
+
284
+ const targetId = (typeof decl.defaultBinding === 'string' && UUID_PATTERN.test(decl.defaultBinding))
285
+ ? decl.defaultBinding
286
+ : null;
287
+
288
+ context[name] = targetId
289
+ ? makeDevProxy({ name, target, targetId, apiFetch })
290
+ : makeUnboundDevProxy({ name, target });
291
+ }
292
+ return context;
293
+ }
294
+
295
+ function makeDevProxy({ name, target, targetId, apiFetch }) {
296
+ switch (target) {
297
+ case 'dataset':
298
+ return {
299
+ async search(payload) {
300
+ return await devCall(apiFetch, 'POST', `datasets/${targetId}/_search`, payload || {}, name, 'dataset');
301
+ },
302
+ async fields() {
303
+ return await devCall(apiFetch, 'GET', `datasets/${targetId}/fields`, null, name, 'dataset');
304
+ }
305
+ };
306
+ case 'query':
307
+ return {
308
+ async execute(payload) {
309
+ return await devCall(apiFetch, 'POST', `queries/${targetId}/_execute`, payload || {}, name, 'query');
310
+ }
311
+ };
312
+ case 'datasource':
313
+ return {
314
+ async query(payload) {
315
+ return await devCall(apiFetch, 'POST', `datasources/${targetId}/_query`, payload || {}, name, 'datasource');
316
+ }
317
+ };
318
+ case 'integration':
319
+ return {
320
+ async request(payload) {
321
+ return await devCall(apiFetch, 'POST', `integrations/${targetId}/request`, payload || {}, name, 'integration');
322
+ }
323
+ };
324
+ default:
325
+ return {};
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Build an unbound dev proxy whose every method throws a clear error pointing
331
+ * at the missing `defaultBinding`. The method-surface keys are populated so
332
+ * a typo on the method name throws TypeError immediately, not a silent
333
+ * `undefined is not a function` later — matching the prod unbound-proxy
334
+ * contract.
335
+ */
336
+ function makeUnboundDevProxy({ name, target }) {
337
+ const methods = METHOD_SURFACE[target] || [];
338
+ const proxy = {};
339
+ for (const method of methods) {
340
+ proxy[method] = async () => {
341
+ throw new Error(
342
+ `Dependency "${name}" is not bound in dev — add \`defaultBinding: <uuid>\` to its entry in informer.yaml`
343
+ );
344
+ };
345
+ }
346
+ return proxy;
347
+ }
348
+
349
+ async function devCall(apiFetch, method, path, body, depName, resourceType) {
350
+ const opts = { method };
351
+ if (body !== null && body !== undefined) opts.body = body;
352
+ const { status, body: responseBody } = await apiFetch(path, opts);
353
+ if (status >= 400) {
354
+ const message = responseBody && typeof responseBody === 'object' && responseBody.message
355
+ ? responseBody.message
356
+ : String(status);
357
+ const err = new Error(`Dependency "${depName}" (${resourceType}) call failed: ${message}`);
358
+ err.status = status;
359
+ err.body = responseBody;
360
+ throw err;
361
+ }
362
+ return responseBody;
363
+ }
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,8 +1,9 @@
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 { loadDependencies, validateDependencies } from './dev-dependencies.js';
6
+ import { loadEnv, envWritePath } from './env.js';
6
7
  import { createMiddleware as createServerRoutes } from './server-routes.js';
7
8
  import { createAgentMiddleware } from './agent-dev.js';
8
9
  import { init, migrate } from './workspace.js';
@@ -23,12 +24,14 @@ export default function informer(options = {}) {
23
24
  let authHeader = null;
24
25
  let serverOrigin = null;
25
26
  let devWorkspaceId = null;
27
+ let activeMode = null;
26
28
 
27
29
  return {
28
30
  name: 'vite-plugin-informer',
29
31
 
30
- config(_, { command }) {
31
- dotenv.config();
32
+ config(_, { command, mode }) {
33
+ activeMode = mode;
34
+ loadEnv({ mode });
32
35
 
33
36
  isDev = command === 'serve';
34
37
 
@@ -57,7 +60,8 @@ export default function informer(options = {}) {
57
60
  changeOrigin: true,
58
61
  headers: {
59
62
  Authorization: authHeader
60
- }
63
+ },
64
+ ...options.proxy
61
65
  }
62
66
  }
63
67
  };
@@ -106,7 +110,7 @@ export default function informer(options = {}) {
106
110
  api,
107
111
  slug,
108
112
  migrationsDir,
109
- envPath: resolve(projectRoot, '.env')
113
+ envPath: envWritePath({ mode: activeMode })
110
114
  });
111
115
  devWorkspaceId = result.workspaceId;
112
116
  }
@@ -116,6 +120,19 @@ export default function informer(options = {}) {
116
120
  }
117
121
  }
118
122
 
123
+ // Surface manifest-level dependency declaration errors at boot,
124
+ // not at `npx informer publish` time. Matches the deploy.js
125
+ // validation so devs see the same wording pre-deploy.
126
+ try {
127
+ const deps = await loadDependencies(projectRoot);
128
+ const errors = validateDependencies(deps);
129
+ for (const message of errors) {
130
+ console.error(`[informer] informer.yaml: ${message}`);
131
+ }
132
+ } catch (err) {
133
+ console.warn(`[informer] Could not validate informer.yaml dependencies: ${err.message}`);
134
+ }
135
+
119
136
  // Mount server-side route handlers if a server/ directory exists
120
137
  const serverDir = resolve(projectRoot, 'server');
121
138
 
@@ -1,10 +1,14 @@
1
- import { createHmac } from 'node:crypto';
2
1
  import { readdir, stat } from 'node:fs/promises';
3
2
  import { join, relative, posix } from 'node:path';
4
3
  import { parse as parseUrl } from 'node:url';
4
+ import { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
5
5
 
6
6
  const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
7
7
 
8
+ // Strict base64 — see view-api.js for the rationale. Mirror kept identical
9
+ // to keep dev and prod behavior aligned.
10
+ const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
11
+
8
12
  /**
9
13
  * Convert a file path under server/ to a route path.
10
14
  *
@@ -171,7 +175,13 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
171
175
  // fetch() implementation — proxies API calls to the Informer server
172
176
  async function apiFetch(path, opts = {}) {
173
177
  const method = (opts.method || 'GET').toUpperCase();
174
- const url = `${serverOrigin}/api/${path.replace(/^\/?(?:api\/)?/, '')}`;
178
+ // Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath);
179
+ // reject non-canonical shapes here instead of silently accepting them.
180
+ const apiPath = normalizeFetchPath(path);
181
+ if (!apiPath) {
182
+ return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` } };
183
+ }
184
+ const url = `${serverOrigin}${apiPath}`;
175
185
  const fetchOpts = {
176
186
  method,
177
187
  headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
@@ -230,13 +240,40 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
230
240
  try { body = JSON.parse(rawBody); } catch { body = rawBody; }
231
241
  }
232
242
 
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');
243
+ // crypto helper — mirrors the prod sandbox crypto surface
244
+ const cryptoHelper = buildDevCrypto();
245
+
246
+ // log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
247
+ const logCall = (level, message, data) => {
248
+ const msg = typeof message === 'string' ? message : JSON.stringify(message);
249
+ const args = [`[app-log] [${level}] ${msg}`];
250
+ if (data) args.push(data);
251
+ console.log(...args);
252
+ };
253
+ const log = Object.assign(
254
+ (message, data) => logCall('info', message, data),
255
+ {
256
+ debug: (message, data) => logCall('debug', message, data),
257
+ info: (message, data) => logCall('info', message, data),
258
+ warn: (message, data) => logCall('warn', message, data),
259
+ error: (message, data) => logCall('error', message, data)
237
260
  }
261
+ );
262
+
263
+ // markdown helper — passthrough in dev (production uses `marked`)
264
+ const markdown = (text) => text;
265
+
266
+ // emit helper — no-op in dev (logs to console)
267
+ const emit = (event, payload) => {
268
+ console.log(`[app-event] emit("${event}",`, JSON.stringify(payload), ')');
269
+ return { ok: true };
238
270
  };
239
271
 
272
+ // notify/email — delivery is a console-logged no-op in dev, but the
273
+ // required-field validation mirrors prod so an app that passes here
274
+ // won't 500 in production.
275
+ const { notify, email } = buildDevMessaging('[app]');
276
+
240
277
  // Build request context
241
278
  const request = {
242
279
  method: req.method.toUpperCase(),
@@ -265,20 +302,39 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
265
302
  res.end(JSON.stringify(earlyBody));
266
303
  }
267
304
 
268
- // Call handler
269
- const result = await handler({ query, fetch: apiFetch, respond, crypto: cryptoHelper, env: {}, request });
305
+ // Build the dependency-injection context the same way the prod
306
+ // sandbox does, so handlers using `await context.myDep.method(...)`
307
+ // work locally. Loaded per request so edits to informer.yaml take
308
+ // effect without a dev-server restart.
309
+ const deps = await loadDependencies(projectRoot);
310
+ const context = buildDevContext({ deps, apiFetch });
311
+ const env = await loadAppEnv(projectRoot);
312
+
313
+ // Call handler — bag mirrors the prod sandbox (see app-sandbox.js).
314
+ const result = await handler({ request, context, query, fetch: apiFetch, respond, emit, notify, email, crypto: cryptoHelper, markdown, log, env });
270
315
 
271
316
  // If respond() was already called, the response is already sent
272
317
  if (responded) return;
273
318
 
274
- // Normalize response (mirrors app-sandbox.js buildInvokeScript logic)
275
- let status, responseBody, responseHeaders;
319
+ // Normalize response (mirrors app-sandbox.js buildInvokeScript logic).
320
+ // The encoding allow-list and contract checks are enforced inside the
321
+ // ivm in production; replicate them here so handlers see the same
322
+ // errors in dev (where there's no isolate to attribute the throw to).
323
+ // If you change this, mirror it in modules/app/routes/view-api.js.
324
+ let status, responseBody, responseHeaders, encoding;
276
325
 
277
326
  if (result === undefined || result === null) {
278
327
  status = 204;
279
328
  responseBody = null;
280
329
  responseHeaders = {};
281
330
  } else if (typeof result === 'object' && typeof result.status === 'number') {
331
+ encoding = typeof result.encoding === 'string' ? result.encoding : null;
332
+ if (encoding !== null && encoding !== 'base64') {
333
+ throw new Error(`Unknown response encoding: ${JSON.stringify(encoding)} (expected 'base64' or omitted)`);
334
+ }
335
+ if (encoding === 'base64' && typeof result.body !== 'string') {
336
+ throw new Error(`encoding: 'base64' requires body to be a base64-encoded string, got ${typeof result.body}`);
337
+ }
282
338
  status = result.status || 200;
283
339
  responseBody = result.body !== undefined ? result.body : null;
284
340
  responseHeaders = result.headers || {};
@@ -295,6 +351,20 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
295
351
 
296
352
  if (responseBody === null) {
297
353
  res.end();
354
+ } else if (encoding === 'base64' && typeof responseBody === 'string') {
355
+ if (!BASE64_RE.test(responseBody)) {
356
+ throw new Error('Handler returned malformed base64 body');
357
+ }
358
+ res.end(Buffer.from(responseBody, 'base64'));
359
+ } else if (typeof responseBody === 'string') {
360
+ // Pre-PR the dev middleware always JSON.stringify'd the body, so
361
+ // a handler returning { body: 'hello' } emitted "hello" (with
362
+ // quotes) — diverging from prod which passed strings verbatim.
363
+ // This branch fixes that parity.
364
+ if (!res.getHeader('content-type')) {
365
+ res.setHeader('Content-Type', 'application/json');
366
+ }
367
+ res.end(responseBody);
298
368
  } else {
299
369
  if (!res.getHeader('content-type')) {
300
370
  res.setHeader('Content-Type', 'application/json');
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
  }