@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.
- package/bin/publish.js +147 -0
- package/index.d.ts +25 -1
- package/package.json +6 -4
- package/src/agent-dev.js +41 -9
- package/src/assemble.js +96 -0
- package/src/changelog.js +39 -0
- package/src/deploy.js +18 -144
- package/src/dev-dependencies.js +243 -13
- package/src/index.js +138 -17
- package/src/openapi-to-dts.js +168 -0
- package/src/publish.js +103 -0
- package/src/server-routes.js +56 -10
package/src/dev-dependencies.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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]
|
|
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
|
}
|