@entrinsik/vite-plugin-informer 2.4.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/init.js +55 -27
- package/bin/publish.js +128 -0
- package/package.json +3 -1
- package/src/agent-dev.js +22 -9
- 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/index.js +14 -0
- package/src/publish.js +97 -0
- package/src/server-routes.js +56 -12
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
|
|
17
|
-
#
|
|
16
|
+
# This file controls the data your app depends on and defines custom
|
|
17
|
+
# roles for role-based UIs.
|
|
18
18
|
#
|
|
19
|
-
# Without
|
|
19
|
+
# Without dependencies: or access:, all API access is blocked (secure
|
|
20
|
+
# by default).
|
|
20
21
|
|
|
21
22
|
# ============================================================================
|
|
22
|
-
#
|
|
23
|
+
# DEPENDENCIES (preferred)
|
|
23
24
|
# ============================================================================
|
|
24
|
-
#
|
|
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
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
-
#
|
|
34
|
-
#
|
|
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
|
-
#
|
|
37
|
-
# - salesforce
|
|
41
|
+
# Example:
|
|
38
42
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
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
|
-
#
|
|
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 (
|
|
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.
|
|
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/publish.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile, access } from 'node:fs/promises';
|
|
4
|
+
import { resolve, join } from 'node:path';
|
|
5
|
+
import { collectAppFiles } from '../src/assemble.js';
|
|
6
|
+
import { extractNotes } from '../src/changelog.js';
|
|
7
|
+
import { publish } from '../src/publish.js';
|
|
8
|
+
import { loadEnv, parseModeArg } from '../src/env.js';
|
|
9
|
+
|
|
10
|
+
const mode = parseModeArg(process.argv);
|
|
11
|
+
loadEnv({ mode });
|
|
12
|
+
|
|
13
|
+
// --- Target + auth. You already have API keys sorted — just point these at the
|
|
14
|
+
// License Manager that fronts the marketplace cloud-api. ---
|
|
15
|
+
const marketplaceUrl = process.env.INFORMER_MARKETPLACE_URL;
|
|
16
|
+
const token = process.env.INFORMER_PUBLISH_TOKEN || process.env.INFORMER_API_KEY;
|
|
17
|
+
|
|
18
|
+
if (!marketplaceUrl || !token) {
|
|
19
|
+
console.error('Missing required environment variables.');
|
|
20
|
+
console.error(' INFORMER_MARKETPLACE_URL=https://<license-manager-host>/cloud-api');
|
|
21
|
+
console.error(' INFORMER_PUBLISH_TOKEN=lmpub_<vendor publish key>');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// --- App + listing metadata from package.json "informer" block ---
|
|
26
|
+
const projectRoot = resolve('.');
|
|
27
|
+
let pkg;
|
|
28
|
+
try {
|
|
29
|
+
pkg = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
console.error('Could not read package.json in current directory.');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const inf = pkg.informer || {};
|
|
36
|
+
const name = inf.name || pkg.name;
|
|
37
|
+
const slug = inf.slug || defaultSlug(name);
|
|
38
|
+
|
|
39
|
+
// Version: prefer the git tag (CI: GITHUB_REF_NAME=vX.Y.Z), then --version, then package.json.
|
|
40
|
+
const version = argValue('--version') || tagVersion(process.env.GITHUB_REF_NAME) || pkg.version;
|
|
41
|
+
|
|
42
|
+
if (!name || !slug || !version) {
|
|
43
|
+
console.error('Need a name, slug, and version. Set package.json "informer.name"/"informer.slug" and tag a release (vX.Y.Z) or pass --version.');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Release notes: the section for this version, else the cumulative Unreleased block.
|
|
48
|
+
const changeNotes = await extractNotes(join(projectRoot, 'CHANGELOG.md'), version);
|
|
49
|
+
|
|
50
|
+
// Provenance (GitHub Actions). All optional — best-effort audit trail.
|
|
51
|
+
const sourceRepo = process.env.GITHUB_REPOSITORY || undefined;
|
|
52
|
+
const sourceCommit = process.env.GITHUB_SHA || undefined;
|
|
53
|
+
const ciRun = process.env.GITHUB_RUN_ID
|
|
54
|
+
? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
55
|
+
: undefined;
|
|
56
|
+
|
|
57
|
+
// Assemble the same file set a deploy produces.
|
|
58
|
+
let files;
|
|
59
|
+
try {
|
|
60
|
+
files = await collectAppFiles({ distDir: join(projectRoot, 'dist'), projectRoot });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error(err.message);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const icon = await resolveIcon(projectRoot, inf.icon);
|
|
67
|
+
const channel = version.includes('-') ? 'beta' : 'stable';
|
|
68
|
+
|
|
69
|
+
console.log(`Publishing ${name} v${version} (${channel}) — ${files.length} files${changeNotes ? '' : ' — no release notes found'}`);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const result = await publish({
|
|
73
|
+
marketplaceUrl,
|
|
74
|
+
token,
|
|
75
|
+
files,
|
|
76
|
+
icon,
|
|
77
|
+
fields: {
|
|
78
|
+
name,
|
|
79
|
+
slug,
|
|
80
|
+
version,
|
|
81
|
+
shortDescription: inf.shortDescription,
|
|
82
|
+
description: inf.description || pkg.description,
|
|
83
|
+
categories: inf.categories,
|
|
84
|
+
documentationUrl: inf.documentationUrl,
|
|
85
|
+
requires: inf.requires,
|
|
86
|
+
metadata: inf.metadata,
|
|
87
|
+
changeNotes,
|
|
88
|
+
sourceRepo,
|
|
89
|
+
sourceCommit,
|
|
90
|
+
ciRun
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
console.log(`Published ${slug} v${version} (${result.channel || channel}).`);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error(err.message);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- helpers ---
|
|
100
|
+
|
|
101
|
+
function argValue(flag) {
|
|
102
|
+
const i = process.argv.indexOf(flag);
|
|
103
|
+
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function tagVersion(ref) {
|
|
107
|
+
return ref ? ref.replace(/^v/, '') : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function defaultSlug(value) {
|
|
111
|
+
if (!value) return undefined;
|
|
112
|
+
const base = value.startsWith('@') && value.includes('/') ? value.split('/')[1] : value;
|
|
113
|
+
return base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function resolveIcon(root, configured) {
|
|
117
|
+
const candidates = [configured, 'favicon.svg', join('public', 'favicon.svg'), join('dist', 'favicon.svg')].filter(Boolean);
|
|
118
|
+
for (const rel of candidates) {
|
|
119
|
+
const abs = resolve(root, rel);
|
|
120
|
+
try {
|
|
121
|
+
await access(abs);
|
|
122
|
+
return { abs, filename: rel.split(/[/\\]/).pop() };
|
|
123
|
+
} catch {
|
|
124
|
+
// try next candidate
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entrinsik/vite-plugin-informer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0-beta.0",
|
|
4
4
|
"description": "Vite plugin and deploy tool for Informer App development",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
"default": "./src/index.js"
|
|
25
25
|
},
|
|
26
26
|
"./deploy": "./src/deploy.js",
|
|
27
|
+
"./publish": "./src/publish.js",
|
|
27
28
|
"./workspace": "./src/workspace.js"
|
|
28
29
|
},
|
|
29
30
|
"bin": {
|
|
30
31
|
"informer-deploy": "./bin/deploy.js",
|
|
32
|
+
"informer-publish": "./bin/publish.js",
|
|
31
33
|
"informer-init": "./bin/init.js",
|
|
32
34
|
"informer-workspace": "./bin/workspace.js",
|
|
33
35
|
"create-magic-report": "./bin/init.js"
|
package/src/agent-dev.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { createHmac } from 'node:crypto';
|
|
2
1
|
import { readFile, readdir, stat, access } from 'node:fs/promises';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { parse as parseUrl } from 'node:url';
|
|
5
4
|
import yaml from 'yaml';
|
|
5
|
+
import { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
|
|
6
6
|
const parseYaml = yaml.parse;
|
|
7
7
|
|
|
8
8
|
const MAX_STEPS = 20;
|
|
@@ -171,7 +171,12 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
171
171
|
// fetch() — proxies API calls to the Informer server (same as server-routes.js)
|
|
172
172
|
async function apiFetch(path, opts = {}) {
|
|
173
173
|
const method = (opts.method || 'GET').toUpperCase();
|
|
174
|
-
|
|
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}`;
|
|
175
180
|
const fetchOpts = {
|
|
176
181
|
method,
|
|
177
182
|
headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
|
|
@@ -193,12 +198,12 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
193
198
|
return { ok: true };
|
|
194
199
|
}
|
|
195
200
|
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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();
|
|
202
207
|
|
|
203
208
|
// log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
|
|
204
209
|
const logCall = (level, message, data) => {
|
|
@@ -277,6 +282,14 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
277
282
|
const instructions = agentDef.instructions || '';
|
|
278
283
|
const agentToolNames = agentDef.tools || [];
|
|
279
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
|
+
|
|
280
293
|
// Load tool handlers via ssrLoadModule
|
|
281
294
|
const localTools = await scanLocalTools(projectRoot);
|
|
282
295
|
const toolMap = new Map(localTools.map(t => [t.name, t]));
|
|
@@ -394,7 +407,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
394
407
|
|
|
395
408
|
if (tool) {
|
|
396
409
|
try {
|
|
397
|
-
result = await tool.handler(tc.input, { query, fetch: apiFetch, emit, crypto: cryptoHelper, markdown, log,
|
|
410
|
+
result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, notify, email, crypto: cryptoHelper, markdown, log, env });
|
|
398
411
|
} catch (err) {
|
|
399
412
|
console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
|
|
400
413
|
result = { error: err.message };
|
package/src/assemble.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readdir, stat, access } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, posix } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The canonical set of files that make up a deployable Informer app library.
|
|
6
|
+
*
|
|
7
|
+
* This is the SINGLE SOURCE OF TRUTH for what an app "is" on disk, shared by
|
|
8
|
+
* both `informer-deploy` (uploads each file to a live instance) and
|
|
9
|
+
* `informer-publish` (tars the same set for the marketplace). Keeping the
|
|
10
|
+
* enumeration in one place is what guarantees a published artifact is
|
|
11
|
+
* byte-identical to what a normal deploy produces.
|
|
12
|
+
*
|
|
13
|
+
* Layout rule: the Vite build output (`dist/`) lands at the library ROOT
|
|
14
|
+
* (index.html, assets/…), while the source trees keep their directory prefix
|
|
15
|
+
* (server/…, tools/…) — matching how an app's library is structured in Informer.
|
|
16
|
+
*/
|
|
17
|
+
const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml'];
|
|
18
|
+
const SOURCE_DIRS = ['migrations', 'tools', 'server', 'webhooks'];
|
|
19
|
+
|
|
20
|
+
async function exists(path) {
|
|
21
|
+
try {
|
|
22
|
+
await access(path);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Recursively walk a directory, returning absolute file paths (not dirs). */
|
|
30
|
+
async function walkDir(dir) {
|
|
31
|
+
const results = [];
|
|
32
|
+
const items = await readdir(dir);
|
|
33
|
+
for (const item of items) {
|
|
34
|
+
const full = join(dir, item);
|
|
35
|
+
const s = await stat(full);
|
|
36
|
+
if (s.isDirectory()) {
|
|
37
|
+
results.push(...await walkDir(full));
|
|
38
|
+
} else {
|
|
39
|
+
results.push(full);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Normalise a path to forward slashes for use as a library/tar entry name. */
|
|
46
|
+
function toLibraryPath(p) {
|
|
47
|
+
return posix.normalize(p.split('\\').join('/'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Collect every file that belongs in the app library, each as { abs, rel },
|
|
52
|
+
* where `rel` is the path it should occupy in the library (and in the tar).
|
|
53
|
+
*
|
|
54
|
+
* @param {{ distDir: string, projectRoot: string }} opts
|
|
55
|
+
* @returns {Promise<Array<{ abs: string, rel: string }>>}
|
|
56
|
+
*/
|
|
57
|
+
export async function collectAppFiles({ distDir, projectRoot }) {
|
|
58
|
+
if (!await exists(distDir)) {
|
|
59
|
+
throw new Error(`Build output not found at "${distDir}" — run the build first.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const files = [];
|
|
63
|
+
|
|
64
|
+
// dist/** -> library root
|
|
65
|
+
for (const abs of await walkDir(distDir)) {
|
|
66
|
+
files.push({ abs, rel: toLibraryPath(relative(distDir, abs)) });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// root config files -> library root
|
|
70
|
+
for (const name of ROOT_CONFIG_FILES) {
|
|
71
|
+
const abs = join(projectRoot, name);
|
|
72
|
+
if (await exists(abs)) files.push({ abs, rel: name });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// source trees -> keep their prefix (server/…, migrations/…, tools/…, webhooks/…)
|
|
76
|
+
for (const dir of SOURCE_DIRS) {
|
|
77
|
+
const root = join(projectRoot, dir);
|
|
78
|
+
if (!await exists(root)) continue;
|
|
79
|
+
for (const abs of await walkDir(root)) {
|
|
80
|
+
files.push({ abs, rel: toLibraryPath(relative(projectRoot, abs)) });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return files;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { ROOT_CONFIG_FILES, SOURCE_DIRS };
|
package/src/changelog.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract release notes for a version from a Keep-a-Changelog style CHANGELOG.md.
|
|
5
|
+
*
|
|
6
|
+
* Looks for the section header whose name matches `version` (e.g. `## [1.4.0]`,
|
|
7
|
+
* `## 1.4.0`, `## v1.4.0`); if absent, falls back to `## [Unreleased]`. Returns
|
|
8
|
+
* the section body trimmed, or '' if nothing matches / the file is missing.
|
|
9
|
+
*
|
|
10
|
+
* This is what makes the dual notes flow work: betas ship the cumulative
|
|
11
|
+
* `Unreleased` block, while a stable release promotes `Unreleased` to a
|
|
12
|
+
* versioned heading before tagging so its exact section is matched.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} changelogPath
|
|
15
|
+
* @param {string} version
|
|
16
|
+
* @returns {Promise<string>}
|
|
17
|
+
*/
|
|
18
|
+
export async function extractNotes(changelogPath, version) {
|
|
19
|
+
let text;
|
|
20
|
+
try {
|
|
21
|
+
text = await readFile(changelogPath, 'utf8');
|
|
22
|
+
} catch {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
return sectionBody(text, version) ?? sectionBody(text, 'Unreleased') ?? '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Body of the first `## [name]` / `## name` section, up to the next `## `. */
|
|
29
|
+
function sectionBody(text, name) {
|
|
30
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
31
|
+
const header = new RegExp(`^##\\s+\\[?v?${escaped}\\]?.*$`, 'im');
|
|
32
|
+
const match = header.exec(text);
|
|
33
|
+
if (!match) return null;
|
|
34
|
+
|
|
35
|
+
const after = text.slice(match.index + match[0].length);
|
|
36
|
+
const next = after.search(/^##\s/m);
|
|
37
|
+
const body = (next === -1 ? after : after.slice(0, next)).trim();
|
|
38
|
+
return body || null;
|
|
39
|
+
}
|
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/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { createClient } from './client.js';
|
|
5
|
+
import { loadDependencies, validateDependencies } from './dev-dependencies.js';
|
|
5
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';
|
|
@@ -119,6 +120,19 @@ export default function informer(options = {}) {
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
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
|
+
|
|
122
136
|
// Mount server-side route handlers if a server/ directory exists
|
|
123
137
|
const serverDir = resolve(projectRoot, 'server');
|
|
124
138
|
|
package/src/publish.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, copyFile, rm, readFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const execFileP = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
class PublishError extends Error {
|
|
10
|
+
constructor(status, body, url) {
|
|
11
|
+
super(`Marketplace publish failed: ${status}${body ? ` — ${body}` : ''}${url ? ` (${url})` : ''}`);
|
|
12
|
+
this.name = 'PublishError';
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.body = body;
|
|
15
|
+
this.url = url;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { PublishError };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Stage the assembled files into a temp dir (preserving their library-relative
|
|
23
|
+
* layout) and tar.gz them. System `tar` keeps the archive format exactly what
|
|
24
|
+
* the server's node-tar expects; COPYFILE_DISABLE suppresses macOS AppleDouble
|
|
25
|
+
* (`._*`) junk from leaking into the archive.
|
|
26
|
+
*
|
|
27
|
+
* @param {Array<{abs: string, rel: string}>} files
|
|
28
|
+
* @returns {Promise<Buffer>}
|
|
29
|
+
*/
|
|
30
|
+
async function buildArchive(files) {
|
|
31
|
+
const stage = await mkdtemp(join(tmpdir(), 'informer-publish-'));
|
|
32
|
+
const archivePath = `${stage}.tgz`;
|
|
33
|
+
try {
|
|
34
|
+
for (const { abs, rel } of files) {
|
|
35
|
+
const dest = join(stage, rel);
|
|
36
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
37
|
+
await copyFile(abs, dest);
|
|
38
|
+
}
|
|
39
|
+
await execFileP('tar', ['-czf', archivePath, '-C', stage, '.'], {
|
|
40
|
+
env: { ...process.env, COPYFILE_DISABLE: '1' }
|
|
41
|
+
});
|
|
42
|
+
return await readFile(archivePath);
|
|
43
|
+
} finally {
|
|
44
|
+
await rm(stage, { recursive: true, force: true });
|
|
45
|
+
await rm(archivePath, { force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Publish an assembled app to the marketplace (License Manager `/packs/publish`).
|
|
51
|
+
*
|
|
52
|
+
* @param {Object} opts
|
|
53
|
+
* @param {string} opts.marketplaceUrl - base URL fronting the cloud-api
|
|
54
|
+
* @param {string} opts.token - vendor publish token (sent as Bearer)
|
|
55
|
+
* @param {Array<{abs: string, rel: string}>} opts.files - assembled app files
|
|
56
|
+
* @param {Object} opts.fields - publish metadata; non-string values are JSON-encoded
|
|
57
|
+
* (the endpoint parses categories/requires/metadata as JSON strings)
|
|
58
|
+
* @param {{abs: string, filename: string}} [opts.icon] - optional listing icon
|
|
59
|
+
* @returns {Promise<Object>} the publish response
|
|
60
|
+
*/
|
|
61
|
+
export async function publish({ marketplaceUrl, token, files, fields, icon }) {
|
|
62
|
+
const archive = await buildArchive(files);
|
|
63
|
+
|
|
64
|
+
const form = new FormData();
|
|
65
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
66
|
+
if (value === undefined || value === null || value === '') continue;
|
|
67
|
+
form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
|
|
68
|
+
}
|
|
69
|
+
form.append(
|
|
70
|
+
'archive',
|
|
71
|
+
new Blob([archive], { type: 'application/gzip' }),
|
|
72
|
+
`${fields.slug}-${fields.version}.tgz`
|
|
73
|
+
);
|
|
74
|
+
if (icon) {
|
|
75
|
+
const iconBuffer = await readFile(icon.abs);
|
|
76
|
+
form.append('icon', new Blob([iconBuffer]), icon.filename);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const url = `${marketplaceUrl.replace(/\/+$/, '')}/packs/publish`;
|
|
80
|
+
const res = await fetch(url, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
83
|
+
body: form
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
let body = '';
|
|
88
|
+
try {
|
|
89
|
+
body = await res.text();
|
|
90
|
+
} catch {
|
|
91
|
+
body = '';
|
|
92
|
+
}
|
|
93
|
+
throw new PublishError(res.status, body, url);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return await res.json();
|
|
97
|
+
}
|
package/src/server-routes.js
CHANGED
|
@@ -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
|
-
|
|
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,12 +240,8 @@ 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
|
|
234
|
-
const cryptoHelper =
|
|
235
|
-
hmac(algorithm, key, data, encoding) {
|
|
236
|
-
return createHmac(algorithm, key).update(data).digest(encoding || 'hex');
|
|
237
|
-
}
|
|
238
|
-
};
|
|
243
|
+
// crypto helper — mirrors the prod sandbox crypto surface
|
|
244
|
+
const cryptoHelper = buildDevCrypto();
|
|
239
245
|
|
|
240
246
|
// log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
|
|
241
247
|
const logCall = (level, message, data) => {
|
|
@@ -263,6 +269,11 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
263
269
|
return { ok: true };
|
|
264
270
|
};
|
|
265
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
|
+
|
|
266
277
|
// Build request context
|
|
267
278
|
const request = {
|
|
268
279
|
method: req.method.toUpperCase(),
|
|
@@ -291,20 +302,39 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
291
302
|
res.end(JSON.stringify(earlyBody));
|
|
292
303
|
}
|
|
293
304
|
|
|
294
|
-
//
|
|
295
|
-
|
|
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 });
|
|
296
315
|
|
|
297
316
|
// If respond() was already called, the response is already sent
|
|
298
317
|
if (responded) return;
|
|
299
318
|
|
|
300
|
-
// Normalize response (mirrors app-sandbox.js buildInvokeScript logic)
|
|
301
|
-
|
|
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;
|
|
302
325
|
|
|
303
326
|
if (result === undefined || result === null) {
|
|
304
327
|
status = 204;
|
|
305
328
|
responseBody = null;
|
|
306
329
|
responseHeaders = {};
|
|
307
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
|
+
}
|
|
308
338
|
status = result.status || 200;
|
|
309
339
|
responseBody = result.body !== undefined ? result.body : null;
|
|
310
340
|
responseHeaders = result.headers || {};
|
|
@@ -321,6 +351,20 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
321
351
|
|
|
322
352
|
if (responseBody === null) {
|
|
323
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);
|
|
324
368
|
} else {
|
|
325
369
|
if (!res.getHeader('content-type')) {
|
|
326
370
|
res.setHeader('Content-Type', 'application/json');
|