@entrinsik/vite-plugin-informer 2.7.0 → 2.11.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/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # @entrinsik/vite-plugin-informer
2
+
3
+ Vite plugin and deploy tool for Informer App development: a local dev server wired to a live Informer, and a deploy pipeline that assembles your project and ships it to an App's library.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ npm create vite@latest my-app -- --template react
9
+ cd my-app
10
+ npm install -D @entrinsik/vite-plugin-informer
11
+ npx informer-init # wires vite config, informer.yaml, .env, deploy scripts
12
+ npm run dev # local dev against the Informer in .env
13
+ npm run deploy # assemble + upload + deploy
14
+ ```
15
+
16
+ ## What a deploy ships
17
+
18
+ The assembler uploads your built frontend plus these source trees, matching how an App's library is structured in Informer:
19
+
20
+ | Folder | Becomes |
21
+ |---|---|
22
+ | `server/` | API routes, dispatched under the App's `/api` surface |
23
+ | `webhooks/` | Publicly callable webhook routes |
24
+ | `tools/` | AI tools, private to the App's agents |
25
+ | `mcp/` | Tools exposed over the App's MCP endpoint |
26
+ | `channels/` | Live channel handlers: `join` / `joined` / `leave` and event-named handlers for `channel.send()`; `npm run dev` runs them locally, and the `channels:` block in `informer.yaml` declares relays only (each entry needs `on`) |
27
+ | `migrations/` | Workspace database migrations, run in order on deploy |
28
+ | `embeddings/` | Declarative embedding use cases (see below) |
29
+ | `lib/`, `shared/` | Modules importable by the trees above |
30
+
31
+ `informer.yaml` declares dependencies, raw API access, roles, and (optionally) a minimum Informer version.
32
+
33
+ ## Server compatibility
34
+
35
+ A deploy begins by asking the target server what it is (`GET /api/about`, one unauthenticated request) and adapts to the answer, so one version of this plugin can drive a fleet on mixed releases.
36
+
37
+ Everything below needs **Informer 2026.1.3**: App Channels (`channels/`, the `channels:` block, `broadcast()`), the embedding pump (`embeddings/`, `embed()`), staged uploads/downloads (`uploads`/`downloads` on the handler bag, `__INFORMER__.upload()`), and the `platform` descriptor itself.
38
+
39
+ Against an older server the CLI:
40
+
41
+ - **Leaves `channels/` and `embeddings/` on your disk.** Those releases don't recognise the folders as server-side, so anything uploaded there is served to any App viewer — your workspace SQL and channel authorization logic included. The rest of the App deploys normally; the warning names what was skipped and why.
42
+ - **Names every feature that would be inert**, so a green deploy can't be mistaken for a working one.
43
+ - **Enforces `requires: { informer: … }` itself.** Releases before 2026.1.3 ignore the block, so without this an App that declared a floor would deploy clean onto a server that can't run it. The deploy is refused before the App's library is touched.
44
+
45
+ A server whose version isn't comparable (a `dev` build, or an `/about` behind a proxy) is treated as current and warned about — gating it would break deploys to builds made from source.
46
+
47
+ App Channels phase 2 (plugin 2.11.0: `channel.send()`, the `joined` export and event-named exports in `channels/` files, wildcard subscriptions, frame `seq` and replay, `connected`) needs a 2026.1.3 server that carries I5-13027. The version probe cannot tell such a server from an earlier 2026.1.3 build, so the deploy is not gated: an earlier build refuses a `channels/` file that exports `joined` or an event name, and accepts a `channels:` entry without `on` that a current server rejects. The dev server runs the phase 2 contract regardless.
48
+
49
+ Feature-detect at runtime with optional chaining, since `platform` is absent entirely before 2026.1.3:
50
+
51
+ ```javascript
52
+ if (platform?.capabilities?.channels) broadcast('tickets', { … });
53
+ ```
54
+
55
+ A marketplace publish is unaffected: the archive always carries every source tree, because the server that installs it isn't known at publish time.
56
+
57
+ ## Embeddings
58
+
59
+ Apps can maintain vector embeddings over their own workspace data declaratively. You ship an `embeddings/` folder with **one file per use case**; the Informer platform acts as an *embedding pump* that asks your App what is pending, chunks and embeds the content in billed batches, and hands the vectors back for your App to store in its own workspace tables. You never call an embedding provider yourself, and the platform never keeps a copy of your corpus — your own tables are the ledger.
60
+
61
+ Each use case file exports `GET` and `POST` (a missing handler fails the deploy) and, optionally, a `config` literal; omitted keys take the defaults below, and a use case with no triggers in its config only runs on deploy and on a manual run:
62
+
63
+ ```javascript
64
+ // embeddings/tickets.js
65
+ export const config = { chunking: 'prose', on: ['ticket.created'], cron: '0 * * * *', revision: 1 };
66
+
67
+ // GET: the rows that still need embedding. The query IS the watermark:
68
+ // never embedded, stale, or written under an older revision.
69
+ export async function GET({ query, batch }) {
70
+ return await query(`
71
+ SELECT t.id, t.subject || ' ' || t.body AS content
72
+ FROM tickets t
73
+ LEFT JOIN ticket_embeddings e ON e.ticket_id = t.id AND e.seq = 0
74
+ WHERE e.ticket_id IS NULL OR e.embedded_at < t.updated_at OR e.revision <> $2
75
+ ORDER BY t.id
76
+ LIMIT $1
77
+ `, [batch.limit, batch.revision]);
78
+ }
79
+
80
+ // POST: store the finished vectors wherever your migrations put them.
81
+ export async function POST({ query, batch }) {
82
+ for (const doc of batch.docs) {
83
+ await query('DELETE FROM ticket_embeddings WHERE ticket_id = $1', [doc.id]);
84
+ for (const c of doc.chunks) {
85
+ await query(
86
+ 'INSERT INTO ticket_embeddings (ticket_id, seq, embedding, content, revision) VALUES ($1, $2, $3::vector, $4, $5)',
87
+ [doc.id, c.seq, JSON.stringify(c.embedding), c.content, batch.revision]
88
+ );
89
+ }
90
+ }
91
+ return { stored: batch.docs.length };
92
+ }
93
+ ```
94
+
95
+ How it works, briefly:
96
+
97
+ - **Your storage is the ledger.** Because `GET` anti-joins your own embedding table, the pump is idempotent and crash-tolerant: a run that dies mid-way finds the same rows pending next time.
98
+ - **Triggers converge.** Deploy pokes each use case (initial backfill), `emit()` events matching `on:` poke, `cron` desugars into a scheduled poke, and the platform sweep runs whatever is poked. A manual run claims the same single-flight lease directly, so nothing double-embeds.
99
+ - **Failures are bounded.** A run that fails for a reason the next attempt might not hit (a provider blip, a handler timeout) retries with a doubling delay and is parked after five consecutive failures; a run that cannot succeed as-is (a rejected key, an exhausted budget, a contract violation) is parked at once. Every park keeps the error on the use case's status row; the next trigger gives it one more attempt, and a successful run resets the count.
100
+ - **`revision` is the invalidation.** The string the pump hands you couples your config (chunking profile, budgets, `config.revision`) to the resolved platform embedding model. Store it beside each vector and compare it in `GET`: bumping the config, changing chunking, or an admin repointing the embedding model all surface as pending re-embed work automatically.
101
+ - **Unembeddable documents are tombstoned** platform-side by content hash and re-reported with `skipped: true` on every run — stamp them in your own table and exclude them from `GET`.
102
+ - **Workspace pgvector.** Apps with the embeddings capability get the `vector` extension provisioned where the server may create extensions (otherwise the deploy log names the cause and a migration using the `vector` type fails), so migrations can declare real `vector(...)` columns; `ON DELETE CASCADE` foreign keys are the cleanup story.
103
+ - **Query time.** Every handler bag (`server/`, `webhooks/`, `tools/`, `mcp/`) gets `embed(name, text)`, which returns `{ embedding, revision }`. `name` must be a declared use case. Filter on the revision as well as ordering by distance — an admin repointing the embedding model puts your query vectors in a new space while the stored rows are still in the old one, and an unfiltered `ORDER BY` then returns near-random neighbours with no error:
104
+
105
+ ```javascript
106
+ const { embedding, revision } = await embed('tickets', q);
107
+ const hits = await query(
108
+ `SELECT ticket_id, content
109
+ FROM ticket_embeddings
110
+ WHERE revision = $2
111
+ ORDER BY embedding <=> $1::vector
112
+ LIMIT 10`,
113
+ [JSON.stringify(embedding), revision]
114
+ );
115
+ ```
116
+ - **Billing.** Embedding calls bill to the App, one usage entry per batch of up to 128 chunks regardless of how many provider requests run underneath; pump compute meters against the App's compute budget.
117
+ - **`config` is read as a literal.** Deploy lifts `export const config = { … }` out of the source and evaluates it with none of the file's imports in scope, so it must be self-contained: no imported constants, no spreads of imported objects, no `export { config }` re-export. Other modules may import the literal from the use-case file; the reverse fails the deploy. The same self-contained-literal rule applies to `export const config`, `schema`, and `description` in `server/`, `webhooks/`, `tools/`, and `mcp/`; there a `config` violation fails the deploy while a `schema` or `description` violation is a deploy warning (nothing is published for that export).
118
+
119
+ ### Dev loop
120
+
121
+ `npm run deploy` uploads `embeddings/` like any other source tree — there is no manifest block to add (plugin ≥ 2.10.0; earlier versions never upload the folder, so the use case silently does not exist on the server). `npm run dev` never runs the pump, and its `embed()` throws an explanation rather than embedding (the dev mirror reports `platform.capabilities.embeddings: false`), so a search route is exercised against a deployed App. Feature-detect with `platform?.capabilities?.embeddings` — `typeof embed === 'function'` is true on both, since the binding exists either way. Then, in the App admin panel, the **Embeddings** tab lists each use case with its pump status (queued, running, up to date, last run, last error, skipped docs) and a **Run now** action; the same surface exists as API routes (`GET /apps/{id}/embeddings`, `POST /apps/{id}/embeddings/{name}/_run`). Deploy, run, read the error, fix, repeat.
122
+
123
+ ### Upgrading an App that already has an `embeddings/` folder
124
+
125
+ `embeddings/` is a server-side folder from this release on, like `server/`: its files are uploaded with the library, never served to browsers, and scanned as pump handlers. An App that kept anything else there (assets, data files) loses those files in the browser after redeploying with plugin 2.10.0 or later. A `.js` file there that exports `GET` or `POST` but not both fails the deploy; one that exports neither is never scanned as a use case at all and lands as a warning on an otherwise successful deploy. Move such content elsewhere before upgrading; the deploy names every stray entry as a warning.
126
+
127
+ ### Older Informer releases
128
+
129
+ The `embeddings/` folder, `embed()`, and the pump routes exist only on Informer releases that ship the feature. An App that must also run on older servers feature-detects with optional chaining (`platform?.capabilities?.embeddings` on the handler bag, `window.__INFORMER__.platform?.capabilities?.embeddings` in the browser) — releases before 2026.1.3 inject no `platform` descriptor at all, so a bare `platform.capabilities.embeddings` throws there instead of reporting `false`. It gates its vector DDL with a `-- requires: embeddings` header so the migration is skipped where pgvector is absent, and states a floor in `informer.yaml` (`requires: { informer: '>=…' }`) only if it cannot work without the feature.
130
+
131
+ Full reference: the **Embeddings** article under Developer → API → App in the Informer docs.
package/bin/ci.js CHANGED
File without changes
package/bin/workspace.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { readFile } from 'node:fs/promises';
4
- import { resolve } from 'node:path';
4
+ import { resolve, basename } from 'node:path';
5
5
  import { createClient } from '../src/client.js';
6
- import { loadEnv, envWritePath, parseModeArg } from '../src/env.js';
6
+ import { loadEnv, envWritePath, localEnvValue, parseModeArg } from '../src/env.js';
7
7
  import { init, migrate, reset } from '../src/workspace.js';
8
8
 
9
9
  const mode = parseModeArg(process.argv);
@@ -41,12 +41,22 @@ const api = createClient({ baseUrl, apiKey, user, pass });
41
41
  const migrationsDir = resolve('migrations');
42
42
  const envPath = envWritePath({ mode });
43
43
 
44
+ // loadEnv walks up to a parent .env (monorepo config), so process.env may
45
+ // carry ANOTHER app's workspace id. Only this app's own env file decides
46
+ // whether it is initialized; migrate/reset fall back to the inherited value.
47
+ const localWorkspaceId = localEnvValue('INFORMER_DEV_WORKSPACE', { mode });
48
+ const inheritedWorkspaceId = process.env.INFORMER_DEV_WORKSPACE || null;
49
+ if (!localWorkspaceId && inheritedWorkspaceId && command === 'init') {
50
+ console.log(`Ignoring INFORMER_DEV_WORKSPACE=${inheritedWorkspaceId} inherited from the environment: ${basename(envPath)} does not define it.`);
51
+ }
52
+ const workspaceId = localWorkspaceId || inheritedWorkspaceId;
53
+
44
54
  try {
45
55
  if (command === 'init') {
46
56
  // Check if already initialized
47
- if (process.env.INFORMER_DEV_WORKSPACE) {
48
- console.error(`Workspace already initialized: ${process.env.INFORMER_DEV_WORKSPACE}`);
49
- console.error('Run workspace:reset to start fresh, or remove INFORMER_DEV_WORKSPACE from .env to re-initialize.');
57
+ if (localWorkspaceId) {
58
+ console.error(`Workspace already initialized: ${localWorkspaceId}`);
59
+ console.error(`Run workspace:reset to start fresh, or remove INFORMER_DEV_WORKSPACE from ${basename(envPath)} to re-initialize.`);
50
60
  process.exit(1);
51
61
  }
52
62
 
@@ -72,7 +82,6 @@ try {
72
82
  await init({ api, slug, migrationsDir, envPath });
73
83
 
74
84
  } else if (command === 'migrate') {
75
- const workspaceId = process.env.INFORMER_DEV_WORKSPACE;
76
85
  if (!workspaceId) {
77
86
  console.error('No dev workspace found. Run workspace:init first.');
78
87
  process.exit(1);
@@ -80,7 +89,6 @@ try {
80
89
  await migrate({ api, workspaceId, migrationsDir });
81
90
 
82
91
  } else if (command === 'reset') {
83
- const workspaceId = process.env.INFORMER_DEV_WORKSPACE;
84
92
  if (!workspaceId) {
85
93
  console.error('No dev workspace found. Run workspace:init first.');
86
94
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entrinsik/vite-plugin-informer",
3
- "version": "2.7.0",
3
+ "version": "2.11.0",
4
4
  "description": "Vite plugin and deploy tool for Informer App development",
5
5
  "scripts": {
6
6
  "test": "node --test"
package/src/agent-dev.js CHANGED
@@ -2,7 +2,18 @@ import { readFile, readdir, stat, access } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { parse as parseUrl } from 'node:url';
4
4
  import yaml from 'yaml';
5
- import { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
5
+ import { manifestBlock, buildDevContext, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
6
+ import { createDevChannels, createDevEmit } from './dev-channels.js';
7
+ import { devPlatform } from './dev-platform.js';
8
+
9
+ // embed() is present on a real install even without the embeddings
10
+ // capability, where it throws a written explanation. Mirroring that here
11
+ // keeps the dev failure the same lesson as the deployed one, instead of a
12
+ // bare "embed is not a function" that reads like a missing binding.
13
+ const embed = async () => {
14
+ throw new Error('embed() is not available in the dev mirror: the embeddings capability needs a real Informer (platform.capabilities.embeddings is false)');
15
+ };
16
+
6
17
  const parseYaml = yaml.parse;
7
18
 
8
19
  const MAX_STEPS = 20;
@@ -149,9 +160,11 @@ async function readSSE(response) {
149
160
  *
150
161
  * @param {Object} viteServer - Vite dev server instance
151
162
  * @param {Object} opts - Configuration
163
+ * @param {ReturnType<typeof createDevChannels>} [opts.channels] - the dev channels hub
164
+ * behind `broadcast()` and the `channels:` relay (a private one when omitted)
152
165
  * @returns {Function} Connect middleware
153
166
  */
154
- export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken }) {
167
+ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels = createDevChannels() }) {
155
168
 
156
169
  // query() — proxies to the workspace _sql endpoint (same as server-routes.js)
157
170
  async function query(sql, params) {
@@ -226,12 +239,6 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
226
239
  ? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
227
240
  : null;
228
241
 
229
- // emit() — no-op in dev mode (logs to console)
230
- function emit(event, payload) {
231
- console.log(`[agent-dev] emit("${event}",`, JSON.stringify(payload), ')');
232
- return { ok: true };
233
- }
234
-
235
242
  // notify/email — delivery is a console-logged no-op in dev, but required-field
236
243
  // validation mirrors prod so a tool that passes here won't 500 in production.
237
244
  const { notify, email } = buildDevMessaging('[agent-dev]');
@@ -319,10 +326,17 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
319
326
  // Build the typed dependency context + env the same way the
320
327
  // server-routes dev middleware does, so tools using
321
328
  // `context.<slot>.<method>(...)` and `env` work locally and match
322
- // the prod sandbox bag.
323
- const deps = await loadDependencies(projectRoot);
329
+ // the prod sandbox bag. The manifest is already in hand.
330
+ const deps = manifestBlock(yaml, 'dependencies');
324
331
  const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
325
- const env = await loadAppEnv(projectRoot);
332
+ const env = manifestBlock(yaml, 'env');
333
+
334
+ // emit() writes no app_event row in dev, but still relays a
335
+ // listed event to its `channels:` channel; broadcast() publishes
336
+ // a live frame to the page (see dev-channels.js).
337
+ const manifestChannels = manifestBlock(yaml, 'channels');
338
+ const emit = createDevEmit({ channels, manifestChannels, logPrefix: '[agent-dev]' });
339
+ const { broadcast } = channels;
326
340
 
327
341
  // Load tool handlers via ssrLoadModule
328
342
  const localTools = await scanLocalTools(projectRoot);
@@ -441,7 +455,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
441
455
 
442
456
  if (tool) {
443
457
  try {
444
- result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, notify, email, crypto: cryptoHelper, markdown, log, env });
458
+ result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, broadcast, notify, email, embed, crypto: cryptoHelper, markdown, log, env, platform: devPlatform() });
445
459
  } catch (err) {
446
460
  console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
447
461
  result = { error: err.message };
package/src/assemble.js CHANGED
@@ -22,7 +22,11 @@ import { join, relative, posix } from 'node:path';
22
22
  // the Vite build lands it at the library root). Without README.md here, a
23
23
  // CLI-deployed app could never light those surfaces up via its README.
24
24
  const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml', 'API.md', 'README.md'];
25
- const SOURCE_DIRS = ['migrations', 'tools', 'mcp', 'server', 'webhooks', 'lib', 'shared'];
25
+ // channels/ holds live-channel join/leave handlers (App Channels); the server
26
+ // scans and bundles it like server/ and webhooks/. `embeddings` holds the pump
27
+ // use-case files: server-side like server/, so they ship with the library and
28
+ // the platform can scan them.
29
+ const SOURCE_DIRS = ['migrations', 'tools', 'mcp', 'server', 'webhooks', 'channels', 'embeddings', 'lib', 'shared'];
26
30
 
27
31
  // Entries never worth shipping in an app's server-side library: OS/editor
28
32
  // dotfiles (.DS_Store, .env), nested dependency trees, and test files. Applied
package/src/compat.js ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * What the CLI needs to know about the Informer it is deploying to.
3
+ *
4
+ * The server exposes no route that reports its capability flags —
5
+ * describePlatform() rides on the app page and on the handler bag, never on the
6
+ * API — so compatibility is decided from the build version that GET /api/about
7
+ * reports. That route is `open: true` and has been unchanged across every
8
+ * release this plugin supports, which makes it the one probe that cannot itself
9
+ * be a compatibility problem.
10
+ *
11
+ * Floors, not probes: each feature names the release that introduced it
12
+ * server-side. Comparison mirrors the server's own versionSatisfies()
13
+ * (app/lib/platform-descriptor.js) — hotfix and RC builds carry a prerelease tag
14
+ * (`2026.1.3-hot2026…`) that plain semver ranks BELOW its base, which would
15
+ * refuse a feature on the very release that ships it, so only the numeric base
16
+ * is ever compared.
17
+ */
18
+
19
+ /** The release that introduced each app feature server-side. */
20
+ export const FEATURE_FLOORS = Object.freeze({
21
+ // App Channels: the `channels:` relay block, channels/ handlers, broadcast().
22
+ channels: '2026.1.3',
23
+ // The embedding pump: embeddings/ use cases and embed() on the handler bag.
24
+ embeddings: '2026.1.3',
25
+ // Staged uploads/downloads: the uploads/downloads services, /view/_uploads
26
+ // and /view/_downloads, and __INFORMER__.upload() in the page.
27
+ streams: '2026.1.3',
28
+ // The platform descriptor itself (window.__INFORMER__.platform and the
29
+ // handler bag's `platform`). Absent entirely below this floor, which is why
30
+ // feature detection has to be optional-chained.
31
+ platform: '2026.1.3',
32
+ // Server-side enforcement of the manifest's `requires:` block. Below this
33
+ // floor the CLI is the only thing in the loop that can honour a declared
34
+ // Informer floor, so it enforces it itself.
35
+ requiresGate: '2026.1.3'
36
+ });
37
+
38
+ /**
39
+ * Source trees whose contents must never reach a browser, paired with the
40
+ * release whose FOLDER_GATES list first covered them (app/routes/deploy.js).
41
+ *
42
+ * A server below the floor does not recognise the directory as server-side, so
43
+ * isServerSidePath() returns false for it and view-assets.js serves the file to
44
+ * anyone who can open the app. Uploading these to such a server would publish
45
+ * the App's workspace SQL and channel authorization logic, so the deploy leaves
46
+ * them behind instead.
47
+ *
48
+ * server/, webhooks/, tools/, mcp/ and migrations/ were gated long before any
49
+ * release this plugin supports and so need no floor here.
50
+ */
51
+ export const GATED_SOURCE_DIRS = Object.freeze([
52
+ Object.freeze({ dir: 'channels', feature: 'channels', holds: 'channel join/leave handlers' }),
53
+ Object.freeze({ dir: 'embeddings', feature: 'embeddings', holds: 'embedding pump handlers (and the workspace SQL in them)' })
54
+ ]);
55
+
56
+ /**
57
+ * The leading numeric triple of a build version, as semver.coerce() would read
58
+ * it: `2026.1.3-hot20260904` -> `2026.1.3`. Returns null when the string is not
59
+ * a version at all (`dev`), which callers treat as "unknown".
60
+ *
61
+ * @param {string} version
62
+ * @returns {{ major: number, minor: number, patch: number, version: string }|null}
63
+ */
64
+ export function baseVersion(version) {
65
+ const match = /^\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(version == null ? '' : version));
66
+ if (!match) return null;
67
+ const [major, minor, patch] = [match[1], match[2], match[3]].map(part => Number(part || 0));
68
+ return { major, minor, patch, version: `${major}.${minor}.${patch}` };
69
+ }
70
+
71
+ /** -1, 0 or 1, comparing two parsed bases field by field. */
72
+ function compareBase(a, b) {
73
+ for (const field of ['major', 'minor', 'patch']) {
74
+ if (a[field] !== b[field]) return a[field] < b[field] ? -1 : 1;
75
+ }
76
+ return 0;
77
+ }
78
+
79
+ /**
80
+ * Is `version` at or above `floor`? Unknown versions answer `true` — a build
81
+ * whose version is not semver is a developer build, and gating those would
82
+ * break the very people who build Informer from source.
83
+ *
84
+ * @param {string} version - a build version, as /about reports it
85
+ * @param {string} floor - a plain `X.Y.Z`
86
+ * @returns {boolean}
87
+ */
88
+ export function atLeast(version, floor) {
89
+ const actual = baseVersion(version);
90
+ if (!actual) return true;
91
+ return compareBase(actual, baseVersion(floor)) >= 0;
92
+ }
93
+
94
+ // The comparator subset a manifest floor realistically uses. Full semver range
95
+ // syntax would mean taking on the `semver` dependency, which this package
96
+ // deliberately does without; anything outside this subset is reported as
97
+ // unevaluated rather than guessed at.
98
+ const RANGE = /^\s*(>=|<=|>|<|\^|~|=)?\s*v?(\d+(?:\.\d+){0,2})\s*$/;
99
+
100
+ /**
101
+ * Evaluate a manifest `requires.informer` range against a build version, with
102
+ * the same coerced-base semantics the server uses.
103
+ *
104
+ * @param {string} version - the running build version
105
+ * @param {string} range - a range from informer.yaml
106
+ * @returns {{ ok: boolean, unknown: boolean, reason?: string }} `unknown` marks
107
+ * a verdict the caller must not act on: an unparseable range, or a build
108
+ * version that is not semver.
109
+ */
110
+ export function satisfiesRange(version, range) {
111
+ const actual = baseVersion(version);
112
+ if (!actual) return { ok: true, unknown: true, reason: 'the server did not report a comparable version' };
113
+
114
+ const match = RANGE.exec(String(range == null ? '' : range));
115
+ if (!match) return { ok: true, unknown: true, reason: `"${range}" is not a range this CLI can evaluate` };
116
+
117
+ const [, operator = '=', literal] = match;
118
+ const wanted = baseVersion(literal);
119
+ const cmp = compareBase(actual, wanted);
120
+
121
+ switch (operator) {
122
+ case '>=': return { ok: cmp >= 0, unknown: false };
123
+ case '>': return { ok: cmp > 0, unknown: false };
124
+ case '<=': return { ok: cmp <= 0, unknown: false };
125
+ case '<': return { ok: cmp < 0, unknown: false };
126
+ // ^ allows the rest of the major, ~ the rest of the minor. Both still
127
+ // need the floor met, so a too-old server fails either way.
128
+ case '^': return { ok: cmp >= 0 && actual.major === wanted.major, unknown: false };
129
+ case '~': return { ok: cmp >= 0 && actual.major === wanted.major && actual.minor === wanted.minor, unknown: false };
130
+ default: return { ok: cmp === 0, unknown: false };
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Ask a server what it is. Never throws: a probe that fails leaves the version
136
+ * unknown, and every gate built on it fails open with a warning rather than
137
+ * turning an unreachable /about into a failed deploy.
138
+ *
139
+ * @param {{ get: (path: string) => Promise<unknown> }} api
140
+ * @returns {Promise<{ version: string|null, base: string|null, unknown: boolean, supports: (feature: string) => boolean }>}
141
+ */
142
+ export async function describeServer(api) {
143
+ let about = null;
144
+ try {
145
+ about = await api.get('about');
146
+ } catch {
147
+ // A proxy, a WAF, or an auth strategy in front of /about — none of which
148
+ // say anything about the server's age.
149
+ }
150
+
151
+ const version = (about && typeof about === 'object' && about.version) || null;
152
+ const base = baseVersion(version);
153
+
154
+ return {
155
+ version,
156
+ base: base ? base.version : null,
157
+ unknown: !base,
158
+ supports: (feature) => atLeast(version, FEATURE_FLOORS[feature] || '0.0.0')
159
+ };
160
+ }
161
+
162
+ /** The top-level library directory a collected file belongs to. */
163
+ function topSegment(rel) {
164
+ return String(rel || '').replace(/^\/+/, '').split('/')[0].toLowerCase();
165
+ }
166
+
167
+ /**
168
+ * Decide what a deploy to this server may upload and what the developer has to
169
+ * be told. Pure, so the whole compatibility matrix is testable without a server.
170
+ *
171
+ * @param {Object} opts
172
+ * @param {{ version: string|null, base: string|null, unknown: boolean, supports: Function }} opts.server
173
+ * @param {Array<{ abs: string, rel: string }>} opts.files - from collectAppFiles()
174
+ * @param {Object} opts.manifest - the parsed informer.yaml, or {}
175
+ * @returns {{ files: Array, warnings: string[], refusal: string|null }}
176
+ */
177
+ export function planDeploy({ server, files, manifest }) {
178
+ const warnings = [];
179
+ const where = server.version ? `Informer ${server.version}` : 'this Informer';
180
+
181
+ if (server.unknown) {
182
+ warnings.push(
183
+ `Could not read a comparable version from ${server.version ? `"${server.version}"` : 'GET /api/about'}. `
184
+ + 'Deploying everything on the assumption the server is current — if it is older than 2026.1.3, '
185
+ + `${GATED_SOURCE_DIRS.map(d => `${d.dir}/`).join(' and ')} will be readable from the App's view URL.`
186
+ );
187
+ }
188
+
189
+ // 1. Leave behind source trees this server would serve to browsers.
190
+ let kept = files;
191
+ for (const { dir, feature, holds } of GATED_SOURCE_DIRS) {
192
+ if (server.supports(feature)) continue;
193
+ const dropped = kept.filter(file => topSegment(file.rel) === dir);
194
+ if (dropped.length === 0) continue;
195
+ kept = kept.filter(file => topSegment(file.rel) !== dir);
196
+ warnings.push(
197
+ `Skipped ${dropped.length} file(s) in ${dir}/ — ${where} predates ${FEATURE_FLOORS[feature]} and does not treat `
198
+ + `${dir}/ as server-side, so uploading your ${holds} would leave them readable from the App's view URL. `
199
+ + `The App deploys without them; upgrade the server to ${FEATURE_FLOORS[feature]} to use this feature.`
200
+ );
201
+ }
202
+
203
+ // 2. Name every feature the App uses that is simply inert here, so a
204
+ // successful deploy can't be mistaken for a working one.
205
+ const uploaded = new Set(kept.map(file => topSegment(file.rel)));
206
+ if (!server.supports('channels') && isMap(manifest.channels)) {
207
+ warnings.push(
208
+ `informer.yaml declares a channels: block, which ${where} ignores (App Channels needs ${FEATURE_FLOORS.channels}). `
209
+ + 'Nothing is relayed and broadcast() does not reach the page.'
210
+ );
211
+ }
212
+ if (!server.supports('streams') && uploaded.has('server')) {
213
+ warnings.push(
214
+ `${where} predates ${FEATURE_FLOORS.streams}: if a handler destructures uploads/downloads from its bag, or the page `
215
+ + 'calls __INFORMER__.upload() / downloadUrl(), those are undefined here and the staged stream routes answer 404.'
216
+ );
217
+ }
218
+ if (!server.supports('platform')) {
219
+ warnings.push(
220
+ `${where} injects no platform descriptor. Feature-detect with optional chaining `
221
+ + '(`platform?.capabilities?.x`, `window.__INFORMER__.platform?.capabilities?.x`) — a bare '
222
+ + '`platform.capabilities.x` throws here rather than reporting false.'
223
+ );
224
+ }
225
+
226
+ return { files: kept, warnings, refusal: refuseOnRequires({ server, manifest, where }) };
227
+ }
228
+
229
+ /** True for a YAML mapping (not a list, not a scalar). */
230
+ function isMap(value) {
231
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
232
+ }
233
+
234
+ /**
235
+ * The manifest's `requires.informer` floor, enforced by the CLI on servers that
236
+ * do not enforce it themselves. Below FEATURE_FLOORS.requiresGate the server
237
+ * ignores the block entirely, so without this an App that declared a floor
238
+ * deploys clean onto a server that cannot run it and fails at runtime instead.
239
+ *
240
+ * @returns {string|null} the refusal message, or null to proceed
241
+ */
242
+ function refuseOnRequires({ server, manifest, where }) {
243
+ const range = isMap(manifest.requires) && manifest.requires.informer;
244
+ if (!range || server.supports('requiresGate')) return null;
245
+
246
+ const verdict = satisfiesRange(server.version, range);
247
+ if (verdict.unknown || verdict.ok) return null;
248
+
249
+ // No "Deploy refused:" lead-in — bin/deploy.js already prints "Deploy failed:".
250
+ return `this app requires Informer ${range}, but this server is ${server.version}. `
251
+ + `Update Informer, or remove the requires.informer floor from informer.yaml if the app can run on ${where}.`;
252
+ }