@entrinsik/vite-plugin-informer 2.5.0 → 2.6.0-beta.1

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.
@@ -150,16 +150,25 @@ export function buildDevMessaging(logPrefix = '[app]') {
150
150
  // reject the same shapes with the same wording. Keep these in lockstep.
151
151
  const DEPENDENCY_NAME_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
152
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']);
153
+ const VALID_TARGETS = new Set(['dataset', 'query', 'datasource', 'integration', 'app']);
154
154
  const VALID_RUN_AS = new Set(['user', 'owner']);
155
155
 
156
+ // Targets that accept `defaultBinding:` in the manifest — mirrors
157
+ // DEFAULT_BINDING_LOOKUP in deploy.js. All resolve the target under read_access.
158
+ const DEFAULT_BINDABLE_TARGETS = new Set(['dataset', 'query', 'datasource', 'integration', 'app']);
159
+
156
160
  const METHOD_SURFACE = {
157
161
  dataset: ['search', 'fields'],
158
162
  query: ['execute'],
159
163
  datasource: ['query'],
160
- integration: ['request']
164
+ integration: ['request'],
165
+ app: ['request']
161
166
  };
162
167
 
168
+ // Cross-app request() method allow-list — mirrors REQUEST_METHODS in
169
+ // entity-type/app.js so dev rejects the same shapes prod 400s.
170
+ const REQUEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
171
+
163
172
  /**
164
173
  * Load the `dependencies:` map from informer.yaml. Returns `{}` if the file
165
174
  * is missing or the section is absent — both are valid (an app may have no
@@ -238,7 +247,11 @@ export function validateDependencies(deps) {
238
247
  errors.push(`dependency "${name}": runAs must be 'user' or 'owner' (got "${runAs}")`);
239
248
  }
240
249
  if (decl.defaultBinding != null) {
241
- if (typeof decl.defaultBinding !== 'string') {
250
+ if (!DEFAULT_BINDABLE_TARGETS.has(decl.target)) {
251
+ // Same rejection the deploy performs — surface it at dev boot
252
+ // rather than letting the deploy be the first to say no.
253
+ errors.push(`dependency "${name}": target "${decl.target}" does not support defaultBinding (bind it via PUT /api/apps/<id>/dependencies/${name})`);
254
+ } else if (typeof decl.defaultBinding !== 'string') {
242
255
  errors.push(`dependency "${name}": defaultBinding must be a string UUID`);
243
256
  } else if (!UUID_PATTERN.test(decl.defaultBinding)) {
244
257
  errors.push(`dependency "${name}": defaultBinding must be a UUID (got "${decl.defaultBinding}")`);
@@ -248,6 +261,68 @@ export function validateDependencies(deps) {
248
261
  return errors;
249
262
  }
250
263
 
264
+ /**
265
+ * Validate the plugin's `devBindings` option against the declared dependencies.
266
+ * Returns human-readable error strings (empty when valid). Runs at dev boot so a
267
+ * typo'd slot ("kanbann") is caught loudly rather than silently ignored until
268
+ * the first call reports the slot as unbound — telling the developer to add the
269
+ * thing they think they already added.
270
+ *
271
+ * A binding key must name a declared `target: app` dependency; its value must be
272
+ * a non-empty `owner:slug` string (shorthand for `{ app }`) or an object with a
273
+ * non-empty `app` (the target app, for request()).
274
+ *
275
+ * @param {Object} deps - The raw `dependencies:` object from informer.yaml
276
+ * @param {Object} devBindings - The plugin's `devBindings` option
277
+ * @returns {string[]} Error messages, one per problem
278
+ */
279
+ export function validateDevBindings(deps, devBindings) {
280
+ const errors = [];
281
+ const declared = (deps && typeof deps === 'object') ? deps : {};
282
+ for (const [name, binding] of Object.entries(devBindings || {})) {
283
+ const decl = declared[name];
284
+ if (!decl || typeof decl !== 'object') {
285
+ errors.push(`devBindings."${name}": no dependency "${name}" is declared in informer.yaml`);
286
+ continue;
287
+ }
288
+ if (decl.target !== 'app') {
289
+ errors.push(`devBindings."${name}": only "target: app" dependencies take a devBinding (got "${decl.target}")`);
290
+ continue;
291
+ }
292
+ if (typeof binding === 'string') {
293
+ if (!binding.trim()) {
294
+ errors.push(`devBindings."${name}": binding string must be a non-empty "owner:slug"`);
295
+ }
296
+ continue;
297
+ }
298
+ if (!binding || typeof binding !== 'object') {
299
+ errors.push(`devBindings."${name}": must be an "owner:slug" string or an object { app }`);
300
+ continue;
301
+ }
302
+ if (binding.app == null) {
303
+ errors.push(`devBindings."${name}": needs "app" (the target app, for request())`);
304
+ } else if (typeof binding.app !== 'string' || !binding.app.trim()) {
305
+ errors.push(`devBindings."${name}".app: must be a non-empty string`);
306
+ }
307
+ }
308
+ return errors;
309
+ }
310
+
311
+ /**
312
+ * Normalize a devBindings entry to `{ app }`. A bare string is shorthand for
313
+ * `{ app }`. Every consumer of a binding — the `.d.ts` generator and the dev
314
+ * proxy alike — resolves `.app` from this result, never from the raw entry, so
315
+ * the string and object forms behave identically: a shorthand binding gets both
316
+ * generated types and a working request().
317
+ *
318
+ * @param {string|{app?: string}|null|undefined} binding
319
+ * @returns {{ app: string|null }}
320
+ */
321
+ export function resolveAppBinding(binding) {
322
+ if (typeof binding === 'string') return { app: binding };
323
+ return { app: (binding && binding.app) || null };
324
+ }
325
+
251
326
  /**
252
327
  * Build the dev-mode `context` object passed to server route handlers, so
253
328
  * `await context.myDep.method(...)` works the same locally as it does after
@@ -274,13 +349,28 @@ export function validateDependencies(deps) {
274
349
  * @returns {Object} An object keyed by dependency name, values are typed
275
350
  * proxies with methods matching the target's production method surface.
276
351
  */
277
- export function buildDevContext({ deps, apiFetch }) {
352
+ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = null }) {
278
353
  const context = {};
279
354
  for (const [name, decl] of Object.entries(deps || {})) {
280
355
  if (!decl || typeof decl !== 'object') continue;
281
356
  const target = decl.target;
282
357
  if (!VALID_TARGETS.has(target)) continue;
283
358
 
359
+ // App slots bind from the plugin's `devBindings` (a human `owner:slug`
360
+ // pointing request() at the target app), falling back to the manifest
361
+ // `defaultBinding` UUID like every other target. request() is the only
362
+ // surface.
363
+ if (target === 'app') {
364
+ const fallback = (typeof decl.defaultBinding === 'string' && UUID_PATTERN.test(decl.defaultBinding))
365
+ ? decl.defaultBinding
366
+ : null;
367
+ const binding = devBindings[name] != null ? devBindings[name] : fallback;
368
+ context[name] = binding
369
+ ? makeAppDevProxy({ name, binding, appFetch })
370
+ : makeUnboundDevProxy({ name, target });
371
+ continue;
372
+ }
373
+
284
374
  const targetId = (typeof decl.defaultBinding === 'string' && UUID_PATTERN.test(decl.defaultBinding))
285
375
  ? decl.defaultBinding
286
376
  : null;
@@ -292,6 +382,94 @@ export function buildDevContext({ deps, apiFetch }) {
292
382
  return context;
293
383
  }
294
384
 
385
+ /**
386
+ * Dev proxy for a `target: app` slot. `request()` is the only surface.
387
+ *
388
+ * Production runs `request()` through the target's own /view/_/ dispatch, and
389
+ * dev injects into the same route:
390
+ *
391
+ * request(payload) -> /api/apps/<app>/view/_/<url>
392
+ * The same route prod injects into, so the target's own handlers, authz
393
+ * and roles run. This dispatch accepts only the token/session auth
394
+ * strategies (not basic auth), so it uses `appFetch`: an INFORMER_APP_TOKEN
395
+ * Bearer, or the INFORMER_API_KEY Bearer reused when the plugin already
396
+ * runs in API-key mode. `appFetch` stamps `x-informer-app-depth: 1`, so the
397
+ * target runs one hop deep and enforces the same one-hop guard as prod.
398
+ * Non-JSON success responses follow the prod envelope contract: text/HTML
399
+ * returns a text envelope; binary is rejected (dev can't emulate it yet).
400
+ *
401
+ * @param {Object} args
402
+ * @param {string} args.name - dependency slot name
403
+ * @param {string|{app?: string}} args.binding - devBindings entry (or the
404
+ * manifest defaultBinding UUID). A bare string is shorthand for `{ app }`.
405
+ * @param {Function|null} args.appFetch - token-authed fetch, or null when
406
+ * INFORMER_APP_TOKEN is unset.
407
+ */
408
+ function makeAppDevProxy({ name, binding, appFetch }) {
409
+ const { app } = resolveAppBinding(binding);
410
+
411
+ return {
412
+ async request(payload) {
413
+ if (!app) {
414
+ throw new Error(
415
+ `Dependency "${name}" (app): dev request() needs the target app — set devBindings.${name}.app (e.g. 'admin:kanban')`
416
+ );
417
+ }
418
+ if (!appFetch) {
419
+ throw new Error(
420
+ `Dependency "${name}" (app): dev request() needs a Bearer credential — the target's /view/_/ dispatch does not accept basic auth. Run in API-key mode (INFORMER_API_KEY), or create a token (Admin → Tokens, or POST /api/tokens) and set INFORMER_APP_TOKEN in .env`
421
+ );
422
+ }
423
+ const { method = 'GET', url, params, data } = payload || {};
424
+ const httpMethod = String(method).toUpperCase();
425
+ if (!REQUEST_METHODS.includes(httpMethod)) {
426
+ throw new Error(`Dependency "${name}" (app): unsupported method "${method}"`);
427
+ }
428
+ if (!url || typeof url !== 'string') {
429
+ throw new Error(`Dependency "${name}" (app): request({ url }) requires the target route path`);
430
+ }
431
+ const path = url.replace(/^\/+/, '');
432
+ const search = params ? `?${new URLSearchParams(params).toString()}` : '';
433
+ // Path-traversal guard (mirrors entity-type/app.js): the path is
434
+ // interpolated into the target's /view/_/ dispatch and fetch resolves
435
+ // dot segments before the request lands, so `../../x` would escape the
436
+ // dispatch and reach arbitrary /api routes. Resolve it the way fetch
437
+ // will and confirm it still lives under the dispatch prefix.
438
+ const targetPrefix = `/api/apps/${encodeURIComponent(app)}/view/_/`;
439
+ let resolvedPath;
440
+ try {
441
+ resolvedPath = new URL(`${targetPrefix}${path}${search}`, 'http://localhost').pathname;
442
+ } catch {
443
+ throw new Error(`Dependency "${name}" (app): request({ url }) is malformed`);
444
+ }
445
+ if (!resolvedPath.startsWith(targetPrefix)) {
446
+ throw new Error(`Dependency "${name}" (app): request({ url }) must not escape the target app with ".." path segments`);
447
+ }
448
+ const { status, body, contentType } = await appFetch(
449
+ `apps/${encodeURIComponent(app)}/view/_/${path}${search}`,
450
+ { method: httpMethod, body: data }
451
+ );
452
+ if (status >= 400) {
453
+ throw dependencyCallError(name, 'app', status, body);
454
+ }
455
+ // Success-envelope contract mirrors entity-type/app.js: JSON → parsed
456
+ // body; non-JSON text/HTML → text envelope; binary → not emulatable
457
+ // via resp.text() without corruption, so fail loudly rather than
458
+ // silently hand back mangled bytes.
459
+ if (isBinaryContentType(contentType)) {
460
+ throw new Error(
461
+ `Dependency "${name}" (app): the dev proxy can't return binary responses yet (upstream content-type "${contentType}"). Test binary endpoints against a deployed build.`
462
+ );
463
+ }
464
+ // (An empty content-type gets the text envelope too, matching prod.)
465
+ if (!(contentType || '').includes('json')) {
466
+ return { status, body, contentType: contentType || '', headers: { 'content-type': contentType || '' } };
467
+ }
468
+ return body;
469
+ }
470
+ };
471
+ }
472
+
295
473
  function makeDevProxy({ name, target, targetId, apiFetch }) {
296
474
  switch (target) {
297
475
  case 'dataset':
@@ -335,29 +513,81 @@ function makeDevProxy({ name, target, targetId, apiFetch }) {
335
513
  */
336
514
  function makeUnboundDevProxy({ name, target }) {
337
515
  const methods = METHOD_SURFACE[target] || [];
516
+ // App slots bind via devBindings (a dev-local owner:slug) or a manifest
517
+ // defaultBinding UUID, so point at whichever the reader is likelier to want.
518
+ const hint = target === 'app'
519
+ ? `add \`devBindings: { ${name}: { app: '<owner:slug>' } }\` to the informer() plugin options in vite.config.js, or \`defaultBinding: <uuid>\` to its entry in informer.yaml`
520
+ : 'add `defaultBinding: <uuid>` to its entry in informer.yaml';
338
521
  const proxy = {};
339
522
  for (const method of methods) {
340
523
  proxy[method] = async () => {
341
- throw new Error(
342
- `Dependency "${name}" is not bound in dev — add \`defaultBinding: <uuid>\` to its entry in informer.yaml`
524
+ // Mirror the prod unbound-proxy contract (unbound-proxy.js): a 422
525
+ // with data.errorCode 'dependency_unbound', so guest code doing
526
+ // `catch (err) { if (err.data?.errorCode === 'dependency_unbound') }`
527
+ // fires in dev exactly as it does in production.
528
+ throw dependencyError(
529
+ `Dependency "${name}" is not bound in dev — ${hint}`,
530
+ 422,
531
+ { errorCode: 'dependency_unbound', dependencyName: name, resourceType: target }
343
532
  );
344
533
  };
345
534
  }
346
535
  return proxy;
347
536
  }
348
537
 
538
+ /**
539
+ * Build an Error carrying the same shape production reconstructs across the V8
540
+ * isolate boundary (see the invoke-script wrapper in app-sandbox.js): a numeric
541
+ * `statusCode`, a structured `data` (with `errorCode`), and the boom-style
542
+ * `output.payload.data` mirror. Guest code branches on `err.statusCode` and
543
+ * `err.data.errorCode`, so dev must attach the same fields or those branches
544
+ * silently never fire locally — the exact dev/prod drift this feature removes.
545
+ */
546
+ function dependencyError(message, statusCode, data) {
547
+ const err = new Error(message);
548
+ err.statusCode = statusCode;
549
+ err.data = data;
550
+ err.output = { statusCode, payload: { statusCode, message, data } };
551
+ return err;
552
+ }
553
+
554
+ /**
555
+ * Shape a >=400 dev-proxy response into the prod boundary-error contract
556
+ * (unwrapInject in inject-helpers.js): the dependency name is baked into the
557
+ * message and `data` carries { errorCode, dependencyName, resourceType,
558
+ * upstreamStatus } — propagating any structured `errorCode` the upstream boom
559
+ * body already carried (e.g. a target app's own `dependency_unbound`).
560
+ */
561
+ function dependencyCallError(depName, resourceType, status, responseBody) {
562
+ const isObj = responseBody && typeof responseBody === 'object';
563
+ const message = isObj && responseBody.message ? responseBody.message : String(status);
564
+ const data = {
565
+ errorCode: (isObj && responseBody.data && responseBody.data.errorCode) || null,
566
+ dependencyName: depName,
567
+ resourceType,
568
+ upstreamStatus: status
569
+ };
570
+ const err = dependencyError(`Dependency "${depName}" (${resourceType}): ${message}`, status, data);
571
+ err.body = responseBody;
572
+ return err;
573
+ }
574
+
575
+ /**
576
+ * Local mirror of inject-helpers.js isBinaryContentType (can't import across the
577
+ * package boundary). Text, JSON and SSE are safe to carry as strings; anything
578
+ * else is bytes that `resp.text()` would corrupt.
579
+ */
580
+ function isBinaryContentType(contentType) {
581
+ const ct = (contentType || '').toLowerCase();
582
+ return Boolean(ct) && !ct.startsWith('text/') && !ct.includes('json') && !ct.includes('event-stream');
583
+ }
584
+
349
585
  async function devCall(apiFetch, method, path, body, depName, resourceType) {
350
586
  const opts = { method };
351
587
  if (body !== null && body !== undefined) opts.body = body;
352
588
  const { status, body: responseBody } = await apiFetch(path, opts);
353
589
  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;
590
+ throw dependencyCallError(depName, resourceType, status, responseBody);
361
591
  }
362
592
  return responseBody;
363
593
  }
package/src/index.js CHANGED
@@ -1,13 +1,39 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { readFile } from 'node:fs/promises';
2
+ import { readFile, writeFile, mkdir } 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
+ import { loadDependencies, validateDependencies, validateDevBindings, resolveAppBinding } from './dev-dependencies.js';
6
+ import { buildDeclarations } from './openapi-to-dts.js';
6
7
  import { loadEnv, envWritePath } from './env.js';
7
8
  import { createMiddleware as createServerRoutes } from './server-routes.js';
8
9
  import { createAgentMiddleware } from './agent-dev.js';
9
10
  import { init, migrate } from './workspace.js';
10
11
 
12
+ /**
13
+ * Write generated app-dependency type declarations under .informer/ and keep the
14
+ * directory out of git (idempotent).
15
+ */
16
+ async function writeAppDepTypes (projectRoot, dts) {
17
+ const dir = resolve(projectRoot, '.informer');
18
+ await mkdir(dir, { recursive: true });
19
+ await writeFile(resolve(dir, 'app-deps.d.ts'), dts, 'utf8');
20
+
21
+ const giPath = resolve(projectRoot, '.gitignore');
22
+ let gi = '';
23
+ try {
24
+ gi = await readFile(giPath, 'utf8');
25
+ } catch (err) {
26
+ // Only a missing file is benign; a real read error (EACCES…) must not be
27
+ // swallowed, or we'd clobber the user's .gitignore with a single line.
28
+ if (err.code !== 'ENOENT') throw err;
29
+ }
30
+ const ignored = gi.split(/\r?\n/).some((l) => l.trim() === '.informer/' || l.trim() === '.informer');
31
+ if (!ignored) {
32
+ const sep = gi && !gi.endsWith('\n') ? '\n' : '';
33
+ await writeFile(giPath, `${gi}${sep}.informer/\n`, 'utf8');
34
+ }
35
+ }
36
+
11
37
  /**
12
38
  * Vite plugin for local Informer App development.
13
39
  *
@@ -16,7 +42,12 @@ import { init, migrate } from './workspace.js';
16
42
  * - Injects window.__INFORMER__ context mock in dev mode
17
43
  * - Sets base to './' so built assets use relative paths
18
44
  *
19
- * @param {{ mock?: object }} options
45
+ * @param {Object} [options]
46
+ * @param {{ report?: object, theme?: 'light'|'dark', roles?: string[] }} [options.mock]
47
+ * window.__INFORMER__ mock injected in dev.
48
+ * @param {Object} [options.devBindings] - dev bindings for `target: app` deps,
49
+ * which can't be defaultBound in the manifest. See AppDevBinding in index.d.ts.
50
+ * @param {Object} [options.proxy] - extra Vite proxy options merged onto /api.
20
51
  * @returns {import('vite').Plugin}
21
52
  */
22
53
  export default function informer(options = {}) {
@@ -24,6 +55,11 @@ export default function informer(options = {}) {
24
55
  let authHeader = null;
25
56
  let serverOrigin = null;
26
57
  let devWorkspaceId = null;
58
+ // Optional separate credential for cross-app request() — the target's
59
+ // /view/_/ dispatch accepts only the token/session strategies, not basic
60
+ // auth. In API-key mode the INFORMER_API_KEY Bearer is reused instead, so
61
+ // this is only needed under basic auth (INFORMER_USER/PASS).
62
+ let appToken = null;
27
63
  let activeMode = null;
28
64
 
29
65
  return {
@@ -46,6 +82,7 @@ export default function informer(options = {}) {
46
82
  const pass = process.env.INFORMER_PASS;
47
83
 
48
84
  devWorkspaceId = process.env.INFORMER_DEV_WORKSPACE || null;
85
+ appToken = process.env.INFORMER_APP_TOKEN || null;
49
86
 
50
87
  if (baseUrl) {
51
88
  serverOrigin = baseUrl.replace(/\/+$/, '');
@@ -77,15 +114,18 @@ export default function informer(options = {}) {
77
114
  const projectRoot = process.cwd();
78
115
  const migrationsDir = resolve(projectRoot, 'migrations');
79
116
 
117
+ // One API client for the whole dev-server setup — createClient just
118
+ // builds an auth header (no I/O), so it's hoisted out of the two
119
+ // branches that each used to rebuild an identical one.
120
+ const api = createClient({
121
+ baseUrl: serverOrigin,
122
+ apiKey: process.env.INFORMER_API_KEY,
123
+ user: process.env.INFORMER_USER,
124
+ pass: process.env.INFORMER_PASS
125
+ });
126
+
80
127
  // Auto-provision workspace if migrations/ exists
81
128
  if (existsSync(migrationsDir)) {
82
- const api = createClient({
83
- baseUrl: serverOrigin,
84
- apiKey: process.env.INFORMER_API_KEY,
85
- user: process.env.INFORMER_USER,
86
- pass: process.env.INFORMER_PASS
87
- });
88
-
89
129
  try {
90
130
  // Verify existing workspace or create a new one
91
131
  let needsInit = !devWorkspaceId;
@@ -120,17 +160,91 @@ export default function informer(options = {}) {
120
160
  }
121
161
  }
122
162
 
163
+ // Read the manifest dependencies once for both consumers below —
164
+ // boot-time validation and app-dependency type generation (this
165
+ // used to parse informer.yaml twice). loadDependencies returns {}
166
+ // when there's no manifest and only throws on a malformed/unreadable
167
+ // one; the validators null-guard, so {} is a safe fallback.
168
+ let deps = {};
169
+ try {
170
+ deps = await loadDependencies(projectRoot);
171
+ } catch (err) {
172
+ console.warn(`[informer] Could not read informer.yaml dependencies: ${err.message}`);
173
+ }
174
+
123
175
  // Surface manifest-level dependency declaration errors at boot,
124
176
  // not at `npx informer publish` time. Matches the deploy.js
125
177
  // validation so devs see the same wording pre-deploy.
178
+ for (const message of validateDependencies(deps)) {
179
+ console.error(`[informer] informer.yaml: ${message}`);
180
+ }
181
+ // Catch a typo'd or mis-targeted devBinding at boot rather than
182
+ // letting the first call report the slot as unbound.
183
+ for (const message of validateDevBindings(deps, options.devBindings || {})) {
184
+ console.error(`[informer] vite.config.js: ${message}`);
185
+ }
186
+
187
+ // Generate .d.ts types for bound `target: app` deps from their
188
+ // published OpenAPI docs, so server/ handlers get typed
189
+ // context.<slot>.request()/query() autocomplete.
126
190
  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}`);
191
+ // A bare string devBinding is shorthand for { app } — request()
192
+ // works under it, so its types must generate too, not just for
193
+ // the object form.
194
+ const boundAppRef = (name) =>
195
+ resolveAppBinding(options.devBindings && options.devBindings[name]).app;
196
+ const appSlots = Object.entries(deps).filter(
197
+ ([name, decl]) => decl && decl.target === 'app' && boundAppRef(name)
198
+ );
199
+ if (appSlots.length) {
200
+ const specs = {};
201
+ let transientFailure = false; // a slot we couldn't reach (vs. a definitive "no doc")
202
+ for (const [name] of appSlots) {
203
+ const appRef = boundAppRef(name);
204
+ let spec;
205
+ try {
206
+ spec = await api.get(`apps/${appRef}/openapi.json`);
207
+ } catch (err) {
208
+ // 401/403 are as definitive as a 404 — the binding is
209
+ // wrong or unshared, and retrying changes nothing, so
210
+ // don't imply otherwise or mark the run transient.
211
+ if (err.status === 401 || err.status === 403) {
212
+ console.warn(`[informer] ${name}: ${appRef} returned ${err.status} — your credentials can't read that app; check the app ref and that it's shared with you`);
213
+ continue;
214
+ }
215
+ // Network blip or a server-side failure: don't drop the
216
+ // slot — preserve its last-good types below.
217
+ transientFailure = true;
218
+ console.warn(`[informer] ${name}: could not reach ${appRef} OpenAPI (${err.message}) — keeping existing types if present`);
219
+ continue;
220
+ }
221
+ if (spec === null) {
222
+ // 404: app ref not found, not shared with you, or a server older
223
+ // than the openapi endpoint. A deploy doesn't fix any of these.
224
+ console.warn(`[informer] ${name}: ${appRef} returned 404 — check the app ref exists and is shared with you (or the server predates /openapi.json)`);
225
+ continue;
226
+ }
227
+ if (!spec.paths) {
228
+ console.warn(`[informer] ${name}: ${appRef} has no server routes to type yet — deploy its server/ handlers`);
229
+ continue;
230
+ }
231
+ specs[name] = spec;
232
+ }
233
+ const slotNames = Object.keys(specs);
234
+ if (slotNames.length) {
235
+ // A transient failure would otherwise rewrite the file without the
236
+ // unreachable slot, making its types vanish on a network blip. If we
237
+ // already have a generated file, keep it intact instead.
238
+ if (transientFailure && existsSync(resolve(projectRoot, '.informer', 'app-deps.d.ts'))) {
239
+ console.warn('[informer] some app dependencies were unreachable — keeping the last-good .informer/app-deps.d.ts');
240
+ } else {
241
+ await writeAppDepTypes(projectRoot, buildDeclarations(specs));
242
+ console.log(`[informer] wrote app-dependency types → .informer/app-deps.d.ts (${slotNames.join(', ')})`);
243
+ }
244
+ }
131
245
  }
132
246
  } catch (err) {
133
- console.warn(`[informer] Could not validate informer.yaml dependencies: ${err.message}`);
247
+ console.warn(`[informer] app-dependency type generation skipped: ${err.message}`);
134
248
  }
135
249
 
136
250
  // Mount server-side route handlers if a server/ directory exists
@@ -142,7 +256,12 @@ export default function informer(options = {}) {
142
256
  authHeader,
143
257
  devWorkspaceId,
144
258
  projectRoot,
145
- roles: (options.mock && options.mock.roles) || []
259
+ roles: (options.mock && options.mock.roles) || [],
260
+ // Dev-only bindings for `target: app` slots (override the
261
+ // manifest defaultBinding for local dev). Shape:
262
+ // devBindings: { kanban: { app: 'admin:kanban' } }
263
+ devBindings: options.devBindings || {},
264
+ appToken
146
265
  });
147
266
  server.middlewares.use('/api/_server', serverRoutes);
148
267
  }
@@ -156,7 +275,9 @@ export default function informer(options = {}) {
156
275
  serverOrigin,
157
276
  authHeader,
158
277
  devWorkspaceId,
159
- projectRoot
278
+ projectRoot,
279
+ devBindings: options.devBindings || {},
280
+ appToken
160
281
  });
161
282
  server.middlewares.use('/api/_agent', agentDev);
162
283
  }