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