@entrinsik/vite-plugin-informer 2.3.0 → 2.5.0-beta.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/src/deploy.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createClient } from './client.js';
2
- import { readdir, readFile, stat, access } from 'node:fs/promises';
3
- import { join, relative, posix, basename, dirname } from 'node:path';
2
+ import { collectAppFiles } from './assemble.js';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { basename, dirname } from 'node:path';
4
5
 
5
6
  const TEXT_EXTENSIONS = new Set([
6
7
  '.html', '.css', '.js', '.mjs', '.json', '.svg',
@@ -10,9 +11,6 @@ const TEXT_EXTENSIONS = new Set([
10
11
  // Files above this size use chunked upload via Flow.js protocol
11
12
  const CHUNK_THRESHOLD = 512 * 1024; // 512KB
12
13
 
13
- // Config files to upload from project root (if they exist)
14
- const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml'];
15
-
16
14
  /**
17
15
  * Deploy a built Vite project to Informer as an App.
18
16
  *
@@ -102,159 +100,40 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
102
100
  limit: 10
103
101
  });
104
102
 
105
- // 5. Clear existing files
103
+ // 5. Clear existing files in one server-side transaction
106
104
  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
- }
105
+ await api.post(`${entityPath}/files/_clear`);
121
106
 
122
- // 6. Upload dist/ contents
123
- console.log(`Uploading files from ${distDir}...`);
124
- const entries = await walkDir(distDir);
107
+ // 6. Upload the app-library file set: dist output at the library root, plus
108
+ // informer.yaml / data-access.yaml and the server/tools/migrations/webhooks
109
+ // source trees. Sourced from the shared collectAppFiles() so a deploy and a
110
+ // marketplace publish package byte-identical contents.
111
+ console.log('Uploading files...');
112
+ const files = await collectAppFiles({ distDir, projectRoot: dirname(distDir) });
125
113
 
126
- for (const filePath of entries) {
127
- const relPath = posix.normalize(relative(distDir, filePath).split('\\').join('/'));
128
- const content = await readFile(filePath);
114
+ for (const { abs, rel } of files) {
115
+ const content = await readFile(abs);
129
116
 
130
117
  if (content.length > CHUNK_THRESHOLD) {
131
118
  // Large file: chunked upload via Flow.js protocol
132
119
  await api.uploadChunked({
133
120
  entityPath,
134
- path: relPath,
121
+ path: rel,
135
122
  buffer: content,
136
- filename: basename(filePath)
123
+ filename: basename(abs)
137
124
  });
138
- console.log(` ${relPath} (${formatSize(content.length)}, chunked)`);
125
+ console.log(` ${rel} (${formatSize(content.length)}, chunked)`);
139
126
  } else {
140
127
  // Small file: direct JSON upload
141
- const ext = '.' + relPath.split('.').pop();
142
- const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
143
- const payload = isText
144
- ? { content: content.toString('utf8'), encoding: 'utf8' }
145
- : { content: content.toString('base64'), encoding: 'base64' };
146
-
147
- await api.put(`${entityPath}/contents/${relPath}`, payload);
148
- console.log(` ${relPath}`);
149
- }
150
- }
151
-
152
- // 7. Upload config files from project root (informer.yaml, data-access.yaml)
153
- const projectRoot = dirname(distDir);
154
- let configCount = 0;
155
- for (const configFile of ROOT_CONFIG_FILES) {
156
- const configPath = join(projectRoot, configFile);
157
- try {
158
- await access(configPath);
159
- const content = await readFile(configPath, 'utf8');
160
- await api.put(`${entityPath}/contents/${configFile}`, {
161
- content,
162
- encoding: 'utf8'
163
- });
164
- console.log(` ${configFile} (from project root)`);
165
- configCount++;
166
- } catch {
167
- // File doesn't exist, skip
168
- }
169
- }
170
-
171
- // 8. Upload migrations/ directory from project root (if it exists)
172
- const migrationsDir = join(projectRoot, 'migrations');
173
- let migrationsCount = 0;
174
- try {
175
- await access(migrationsDir);
176
- const migrationFiles = await walkDir(migrationsDir);
177
- for (const filePath of migrationFiles) {
178
- const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
179
- const content = await readFile(filePath, 'utf8');
180
- await api.put(`${entityPath}/contents/${relPath}`, {
181
- content,
182
- encoding: 'utf8'
183
- });
184
- console.log(` ${relPath} (from project root)`);
185
- migrationsCount++;
186
- }
187
- } catch {
188
- // No migrations directory, skip
189
- }
190
-
191
- // 9. Upload tools/ directory from project root (if it exists)
192
- const toolsDir = join(projectRoot, 'tools');
193
- let toolsCount = 0;
194
- try {
195
- await access(toolsDir);
196
- const toolFiles = await walkDir(toolsDir);
197
- for (const filePath of toolFiles) {
198
- const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
199
- const content = await readFile(filePath);
200
- const ext = '.' + relPath.split('.').pop();
201
- const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
202
- const payload = isText
203
- ? { content: content.toString('utf8'), encoding: 'utf8' }
204
- : { content: content.toString('base64'), encoding: 'base64' };
205
-
206
- await api.put(`${entityPath}/contents/${relPath}`, payload);
207
- console.log(` ${relPath} (from project root)`);
208
- toolsCount++;
209
- }
210
- } catch {
211
- // No tools directory, skip
212
- }
213
-
214
- // 10. Upload server/ directory from project root (if it exists)
215
- const serverDir = join(projectRoot, 'server');
216
- let serverCount = 0;
217
- try {
218
- await access(serverDir);
219
- const serverFiles = await walkDir(serverDir);
220
- for (const filePath of serverFiles) {
221
- const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
222
- const content = await readFile(filePath);
223
- const ext = '.' + relPath.split('.').pop();
224
- const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
225
- const payload = isText
226
- ? { content: content.toString('utf8'), encoding: 'utf8' }
227
- : { content: content.toString('base64'), encoding: 'base64' };
228
-
229
- await api.put(`${entityPath}/contents/${relPath}`, payload);
230
- console.log(` ${relPath} (from project root)`);
231
- serverCount++;
232
- }
233
- } catch {
234
- // No server directory, skip
235
- }
236
-
237
- // 11. Upload webhooks/ directory from project root (if it exists)
238
- const webhooksDir = join(projectRoot, 'webhooks');
239
- let webhooksCount = 0;
240
- try {
241
- await access(webhooksDir);
242
- const webhookFiles = await walkDir(webhooksDir);
243
- for (const filePath of webhookFiles) {
244
- const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
245
- const content = await readFile(filePath);
246
- const ext = '.' + relPath.split('.').pop();
128
+ const ext = '.' + rel.split('.').pop();
247
129
  const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
248
130
  const payload = isText
249
131
  ? { content: content.toString('utf8'), encoding: 'utf8' }
250
132
  : { content: content.toString('base64'), encoding: 'base64' };
251
133
 
252
- await api.put(`${entityPath}/contents/${relPath}`, payload);
253
- console.log(` ${relPath} (from project root)`);
254
- webhooksCount++;
134
+ await api.put(`${entityPath}/contents/${rel}`, payload);
135
+ console.log(` ${rel}`);
255
136
  }
256
- } catch {
257
- // No webhooks directory, skip
258
137
  }
259
138
 
260
139
  // 12. Deploy: run migrations + scan/bundle server routes + webhooks + tools + agents
@@ -296,7 +175,7 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
296
175
  }
297
176
 
298
177
  // 13. Print URL and return UUID for saving
299
- const totalFiles = entries.length + configCount + migrationsCount + toolsCount + serverCount + webhooksCount;
178
+ const totalFiles = files.length;
300
179
  const base = baseUrl.replace(/\/+$/, '');
301
180
  const entityUrl = apiPrefix === 'apps'
302
181
  ? `${base}/api/apps/${naturalId}/view`
@@ -306,24 +185,6 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
306
185
  return { id: entity.id, url: entityUrl };
307
186
  }
308
187
 
309
- /**
310
- * Recursively walk a directory, returning file paths (not directories).
311
- */
312
- async function walkDir(dir) {
313
- const results = [];
314
- const items = await readdir(dir);
315
- for (const item of items) {
316
- const full = join(dir, item);
317
- const s = await stat(full);
318
- if (s.isDirectory()) {
319
- results.push(...await walkDir(full));
320
- } else {
321
- results.push(full);
322
- }
323
- }
324
- return results;
325
- }
326
-
327
188
  function formatSize(bytes) {
328
189
  if (bytes < 1024) return `${bytes} B`;
329
190
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -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
+ }