@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/bin/deploy.js +3 -2
- package/bin/init.js +55 -27
- package/bin/publish.js +128 -0
- package/bin/workspace.js +11 -5
- package/package.json +3 -1
- package/src/agent-dev.js +43 -2
- package/src/assemble.js +87 -0
- package/src/changelog.js +39 -0
- package/src/deploy.js +20 -159
- package/src/dev-dependencies.js +363 -0
- package/src/env.js +86 -0
- package/src/index.js +22 -5
- package/src/publish.js +97 -0
- package/src/server-routes.js +80 -10
- package/src/workspace.js +2 -2
package/src/deploy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createClient } from './client.js';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
127
|
-
const
|
|
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:
|
|
121
|
+
path: rel,
|
|
135
122
|
buffer: content,
|
|
136
|
-
filename: basename(
|
|
123
|
+
filename: basename(abs)
|
|
137
124
|
});
|
|
138
|
-
console.log(` ${
|
|
125
|
+
console.log(` ${rel} (${formatSize(content.length)}, chunked)`);
|
|
139
126
|
} else {
|
|
140
127
|
// Small file: direct JSON upload
|
|
141
|
-
const ext = '.' +
|
|
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/${
|
|
253
|
-
console.log(` ${
|
|
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 =
|
|
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
|
+
}
|