@entrinsik/vite-plugin-informer 2.11.0 → 2.13.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 +8 -4
- package/bin/deploy.js +3 -0
- package/bin/init.js +13 -2
- package/index.d.ts +21 -5
- package/package.json +1 -1
- package/src/agent-dev.js +9 -12
- package/src/client.js +29 -3
- package/src/deploy.js +198 -45
- package/src/dev-bag.js +75 -19
- package/src/dev-channel-actors.js +352 -0
- package/src/dev-channel-handlers.js +99 -31
- package/src/dev-channel-shim.js +65 -11
- package/src/dev-channels.js +33 -15
- package/src/dev-dependencies.js +197 -19
- package/src/dev-platform.js +82 -7
- package/src/dev-streams.js +176 -27
- package/src/index.js +61 -8
- package/src/openapi-to-dts.js +4 -0
- package/src/server-routes.js +26 -15
- package/src/streams-client.js +121 -8
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ npm run dev # local dev against the Informer in .
|
|
|
13
13
|
npm run deploy # assemble + upload + deploy
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
`informer-init` leaves an existing `@entrinsik/vite-plugin-informer` entry alone, and writes one only when the dependency is missing — in which case it uses its own version (2.12.0 and later; before that it wrote `^1.0.0`). A project scaffolded earlier still carries `^1.0.0`, which resolves to 1.0.5 and predates everything below: bump it by hand.
|
|
17
|
+
|
|
16
18
|
## What a deploy ships
|
|
17
19
|
|
|
18
20
|
The assembler uploads your built frontend plus these source trees, matching how an App's library is structured in Informer:
|
|
@@ -23,7 +25,7 @@ The assembler uploads your built frontend plus these source trees, matching how
|
|
|
23
25
|
| `webhooks/` | Publicly callable webhook routes |
|
|
24
26
|
| `tools/` | AI tools, private to the App's agents |
|
|
25
27
|
| `mcp/` | Tools exposed over the App's MCP endpoint |
|
|
26
|
-
| `channels/` | Live channel handlers: `join` / `joined` / `leave` and event-named handlers for `channel.send()
|
|
28
|
+
| `channels/` | Live channel handlers: `join` / `joined` / `leave` and event-named handlers for `channel.send()`, or a channel actor (`config.actor`: one live instance per channel, with `start` / `tick` / `stop` / `snapshot`); `npm run dev` runs both locally, and the `channels:` block in `informer.yaml` declares relays only (each entry needs `on`) |
|
|
27
29
|
| `migrations/` | Workspace database migrations, run in order on deploy |
|
|
28
30
|
| `embeddings/` | Declarative embedding use cases (see below) |
|
|
29
31
|
| `lib/`, `shared/` | Modules importable by the trees above |
|
|
@@ -44,7 +46,9 @@ Against an older server the CLI:
|
|
|
44
46
|
|
|
45
47
|
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
48
|
|
|
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.
|
|
49
|
+
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; plugin 2.12.0 aligns its channel semantics with the server's (wildcard gating, replay expiry, and the per-user send budget), so on 2.11.0 the dev mirror has the phase 2 surface but not all of its behavior.
|
|
50
|
+
|
|
51
|
+
Channel actors (plugin ≥ 2.13.0: `config.actor` files, `request.member`, `__INFORMER__.serverNow()`) need a server that reports `platform.capabilities.channelActors`; on one that does not, the same file runs as an ordinary channel handler, one fresh module per message. Under `npm run dev` each concrete channel gets its own module instance, calls run one at a time, `tick()` runs on a timer, and the actor stops `idleMs` after its last page leaves. `snapshot()` is kept in memory, so editing a file under `channels/`, `shared/`, `lib/` or `server/` restarts the running actors on the new code from their snapshots with the same pages subscribed: a game in progress survives the edit. The server's cluster, leases, billing and broadcast limits have no dev counterpart.
|
|
48
52
|
|
|
49
53
|
Feature-detect at runtime with optional chaining, since `platform` is absent entirely before 2026.1.3:
|
|
50
54
|
|
|
@@ -118,11 +122,11 @@ const hits = await query(
|
|
|
118
122
|
|
|
119
123
|
### Dev loop
|
|
120
124
|
|
|
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.
|
|
125
|
+
`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. Plugin ≥ 2.12.0 can opt in: `informer({ mock: { platform: { capabilities: { embeddings: true } } } })` binds the dev `embed()` to the deployed App's `_embed` route on the configured server (addressed by package.json `informer.id`; the dev credentials need write access to that App, and each call is billed to it), so the query vector under `npm run dev` is the deployed one — same model, same `revision`. The corpus is not: `query()` still reads the dev workspace datasource, which the pump never writes to, so a search route compares a deployed vector against local rows. 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
126
|
|
|
123
127
|
### Upgrading an App that already has an `embeddings/` folder
|
|
124
128
|
|
|
125
|
-
`embeddings/` is a server-side folder from
|
|
129
|
+
`embeddings/` is a server-side folder from Informer 2026.1.3 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
130
|
|
|
127
131
|
### Older Informer releases
|
|
128
132
|
|
package/bin/deploy.js
CHANGED
package/bin/init.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFile, writeFile,
|
|
3
|
+
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
4
4
|
import { resolve, basename } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
@@ -118,6 +118,16 @@ function prompt(question, defaultValue) {
|
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The devDependency range a fresh scaffold gets. Read inside init() rather than
|
|
123
|
+
* at module scope so a failure reaches init()'s error handler.
|
|
124
|
+
*/
|
|
125
|
+
async function pluginVersionRange() {
|
|
126
|
+
const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
127
|
+
if (!version) throw new Error('could not read the version of @entrinsik/vite-plugin-informer; reinstall the plugin');
|
|
128
|
+
return `^${version}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
121
131
|
/**
|
|
122
132
|
* Convert package name to friendly display name.
|
|
123
133
|
* "magic-quickbooks-report" -> "Magic Quickbooks Report"
|
|
@@ -174,7 +184,8 @@ async function init() {
|
|
|
174
184
|
// 5. Add plugin to dependencies if not present
|
|
175
185
|
if (!pkg.devDependencies) pkg.devDependencies = {};
|
|
176
186
|
if (!pkg.devDependencies['@entrinsik/vite-plugin-informer']) {
|
|
177
|
-
|
|
187
|
+
// Float the scaffold on the version that scaffolded it, not a fixed range.
|
|
188
|
+
pkg.devDependencies['@entrinsik/vite-plugin-informer'] = await pluginVersionRange();
|
|
178
189
|
console.log('Added @entrinsik/vite-plugin-informer to devDependencies');
|
|
179
190
|
}
|
|
180
191
|
|
package/index.d.ts
CHANGED
|
@@ -2,12 +2,14 @@ import type { Plugin } from 'vite';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Dev-only binding for a `target: app` or `target: pack` dependency slot.
|
|
5
|
-
* Points `request()` at the target app in dev. For app slots it
|
|
6
|
-
* manifest `defaultBinding` when both are present; for pack
|
|
7
|
-
* only way to bind — the marketplace pin has no install to
|
|
8
|
-
* dev, so point it at your locally-installed copy of the
|
|
5
|
+
* Points `request()` and `url()` at the target app in dev. For app slots it
|
|
6
|
+
* overrides the manifest `defaultBinding` when both are present; for pack
|
|
7
|
+
* slots it is the only way to bind — the marketplace pin has no install to
|
|
8
|
+
* resolve against in dev, so point it at your locally-installed copy of the
|
|
9
|
+
* pack's app.
|
|
9
10
|
*
|
|
10
|
-
* - `app` — the target app (`owner:slug` or UUID). Powers `request()
|
|
11
|
+
* - `app` — the target app (`owner:slug` or UUID). Powers `request()`, and
|
|
12
|
+
* names the app `url()` links into (on `INFORMER_URL`).
|
|
11
13
|
*
|
|
12
14
|
* A bare string is shorthand for `{ app }`.
|
|
13
15
|
*/
|
|
@@ -15,11 +17,25 @@ export interface AppDevBinding {
|
|
|
15
17
|
app?: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* What the platform offers the app, as the server injects it
|
|
22
|
+
* (`window.__INFORMER__.platform`). A mock override merges into the dev
|
|
23
|
+
* defaults, so naming one capability leaves the rest alone.
|
|
24
|
+
*/
|
|
25
|
+
export interface MockPlatform {
|
|
26
|
+
version?: string;
|
|
27
|
+
originMode?: boolean;
|
|
28
|
+
capabilities?: Record<string, boolean>;
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
export interface InformerPluginOptions {
|
|
19
32
|
mock?: {
|
|
20
33
|
report?: { id?: string; name?: string };
|
|
21
34
|
theme?: 'light' | 'dark';
|
|
22
35
|
roles?: string[];
|
|
36
|
+
/** The viewer identity, for testing `@user/<username>` channels as someone else. */
|
|
37
|
+
user?: { username?: string; displayName?: string };
|
|
38
|
+
platform?: MockPlatform;
|
|
23
39
|
};
|
|
24
40
|
devBindings?: Record<string, string | AppDevBinding>;
|
|
25
41
|
proxy?: Record<string, unknown>;
|
package/package.json
CHANGED
package/src/agent-dev.js
CHANGED
|
@@ -4,15 +4,7 @@ import { parse as parseUrl } from 'node:url';
|
|
|
4
4
|
import yaml from 'yaml';
|
|
5
5
|
import { manifestBlock, buildDevContext, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
|
|
6
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
|
-
};
|
|
7
|
+
import { devPlatform, createDevEmbed } from './dev-platform.js';
|
|
16
8
|
|
|
17
9
|
const parseYaml = yaml.parse;
|
|
18
10
|
|
|
@@ -164,7 +156,7 @@ async function readSSE(response) {
|
|
|
164
156
|
* behind `broadcast()` and the `channels:` relay (a private one when omitted)
|
|
165
157
|
* @returns {Function} Connect middleware
|
|
166
158
|
*/
|
|
167
|
-
export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels = createDevChannels() }) {
|
|
159
|
+
export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels = createDevChannels(), platform = devPlatform(), appId = null }) {
|
|
168
160
|
|
|
169
161
|
// query() — proxies to the workspace _sql endpoint (same as server-routes.js)
|
|
170
162
|
async function query(sql, params) {
|
|
@@ -223,6 +215,11 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
223
215
|
return { status, body, contentType };
|
|
224
216
|
}
|
|
225
217
|
|
|
218
|
+
// Throws the written explanation unless the mock platform opts into
|
|
219
|
+
// embeddings; then posts to the deployed app's _embed route through
|
|
220
|
+
// apiFetch (hoisted below). See createDevEmbed.
|
|
221
|
+
const embed = createDevEmbed({ platform, apiFetch, appId });
|
|
222
|
+
|
|
226
223
|
async function apiFetch(path, opts = {}) {
|
|
227
224
|
return await fetchAs(authHeader, path, opts);
|
|
228
225
|
}
|
|
@@ -328,7 +325,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
328
325
|
// `context.<slot>.<method>(...)` and `env` work locally and match
|
|
329
326
|
// the prod sandbox bag. The manifest is already in hand.
|
|
330
327
|
const deps = manifestBlock(yaml, 'dependencies');
|
|
331
|
-
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
|
|
328
|
+
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch, serverOrigin });
|
|
332
329
|
const env = manifestBlock(yaml, 'env');
|
|
333
330
|
|
|
334
331
|
// emit() writes no app_event row in dev, but still relays a
|
|
@@ -455,7 +452,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
455
452
|
|
|
456
453
|
if (tool) {
|
|
457
454
|
try {
|
|
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
|
|
455
|
+
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 });
|
|
459
456
|
} catch (err) {
|
|
460
457
|
console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
|
|
461
458
|
result = { error: err.message };
|
package/src/client.js
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
* Informer API client using native fetch with Basic auth.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The server's own explanation, dug out of an error body.
|
|
7
|
+
*
|
|
8
|
+
* Boom replies carry the useful sentence in `message`, and for a refused deploy
|
|
9
|
+
* that sentence is the whole value of the response — it names the call that
|
|
10
|
+
* unblocks the app. A bare status line leaves the author with nothing to act on.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} body - raw response body
|
|
13
|
+
* @returns {string} the server's message, or '' when the body says nothing
|
|
14
|
+
*/
|
|
15
|
+
function errorDetail(body) {
|
|
16
|
+
if (!body) return '';
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(body);
|
|
19
|
+
return parsed.message || parsed.error || '';
|
|
20
|
+
} catch {
|
|
21
|
+
return String(body).trim().slice(0, 500);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
5
25
|
class InformerApiError extends Error {
|
|
6
26
|
constructor(status, statusText, body, url) {
|
|
7
27
|
super(`Informer API ${status} ${statusText}${url ? ` (${url})` : ''}`);
|
|
@@ -9,6 +29,7 @@ class InformerApiError extends Error {
|
|
|
9
29
|
this.status = status;
|
|
10
30
|
this.statusText = statusText;
|
|
11
31
|
this.body = body;
|
|
32
|
+
this.detail = errorDetail(body);
|
|
12
33
|
this.url = url;
|
|
13
34
|
}
|
|
14
35
|
}
|
|
@@ -63,7 +84,7 @@ export function createClient({ baseUrl, apiKey, user, pass }) {
|
|
|
63
84
|
* Upload a file using chunked Flow.js protocol, then assemble into an entity's library.
|
|
64
85
|
* @param {{ entityPath?: string, reportId?: string, path: string, buffer: Buffer, filename: string }} opts
|
|
65
86
|
*/
|
|
66
|
-
async function uploadChunked({ entityPath, reportId, path, buffer, filename }) {
|
|
87
|
+
async function uploadChunked({ entityPath, reportId, path, buffer, filename, stagingLibraryId }) {
|
|
67
88
|
const basePath = entityPath || `reports/${reportId}`;
|
|
68
89
|
const totalChunks = Math.ceil(buffer.length / CHUNK_SIZE);
|
|
69
90
|
const uploadId = `publish-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
@@ -98,14 +119,19 @@ export function createClient({ baseUrl, apiKey, user, pass }) {
|
|
|
98
119
|
}
|
|
99
120
|
}
|
|
100
121
|
|
|
101
|
-
// Assemble chunks into a file in the entity's library at the specified
|
|
122
|
+
// Assemble chunks into a file in the entity's library at the specified
|
|
123
|
+
// path. `stagingLibraryId` claims the in-flight deploy — without it the
|
|
124
|
+
// server refuses the write rather than folding it into someone else's
|
|
125
|
+
// build (see deploy-target.js).
|
|
102
126
|
await request('POST', `${basePath}/_upload`, {
|
|
103
127
|
uploadId,
|
|
104
|
-
path
|
|
128
|
+
path,
|
|
129
|
+
...(stagingLibraryId ? { stagingLibraryId } : {})
|
|
105
130
|
});
|
|
106
131
|
}
|
|
107
132
|
|
|
108
133
|
return {
|
|
134
|
+
baseUrl: origin,
|
|
109
135
|
get: (path) => request('GET', path),
|
|
110
136
|
post: (path, body) => request('POST', path, body),
|
|
111
137
|
put: (path, body) => request('PUT', path, body),
|
package/src/deploy.js
CHANGED
|
@@ -71,19 +71,18 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
71
71
|
};
|
|
72
72
|
if (id) payload.id = id;
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
74
|
+
console.log(`Creating new app "${name}"...`);
|
|
75
|
+
entity = await api.post('apps', { ...payload, type: 'app' });
|
|
76
|
+
apiPrefix = 'apps';
|
|
77
|
+
|
|
78
|
+
// Null, not a throw: client.js turns a 404 into a null reply, so the
|
|
79
|
+
// legacy-reports fallback has to branch on the value. As a catch on
|
|
80
|
+
// `e.status === 404` it could never fire, and a genuine missing-apps-API
|
|
81
|
+
// surfaced as "Failed to create" instead of falling back.
|
|
82
|
+
if (entity === null) {
|
|
83
|
+
console.log('Apps API not available, using legacy reports API...');
|
|
84
|
+
entity = await api.post('reports', { ...payload, type: 'magicReport' });
|
|
85
|
+
apiPrefix = 'reports';
|
|
87
86
|
}
|
|
88
87
|
|
|
89
88
|
if (!entity || !entity.id) {
|
|
@@ -116,9 +115,10 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
116
115
|
limit: 10
|
|
117
116
|
});
|
|
118
117
|
|
|
119
|
-
// 5.
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
// 5. Open a staging library so the upload pass below doesn't touch what the
|
|
119
|
+
// app is currently serving.
|
|
120
|
+
console.log('Staging deploy...');
|
|
121
|
+
const stagingLibraryId = await openStagingDeploy(api, entityPath);
|
|
122
122
|
|
|
123
123
|
// 6. Upload the app-library file set assembled in step 0: dist output at the
|
|
124
124
|
// library root, plus informer.yaml / data-access.yaml and the
|
|
@@ -126,37 +126,59 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
126
126
|
// tree this server is too old to keep out of browsers (see compat.js).
|
|
127
127
|
// Sourced from the shared collectAppFiles() so a deploy and a marketplace
|
|
128
128
|
// publish package byte-identical contents.
|
|
129
|
-
|
|
129
|
+
//
|
|
130
|
+
// Everything from here until the deploy completes runs inside the try, so
|
|
131
|
+
// nothing can leave the app staged: a live staging pointer redirects later
|
|
132
|
+
// content writes into a library nothing serves while reads keep returning
|
|
133
|
+
// the old build, and the routes that cannot redirect refuse with 409
|
|
134
|
+
// (I5-12961). `files` is declared outside the try so step 13 can still
|
|
135
|
+
// report on it once the staging lock has released.
|
|
130
136
|
const files = plan.files;
|
|
137
|
+
let deployed = false;
|
|
138
|
+
const releaseStaging = installStagingSignalHandlers(api, entityPath, stagingLibraryId);
|
|
139
|
+
try {
|
|
140
|
+
console.log('Uploading files...');
|
|
131
141
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
142
|
+
// Presented on every write so the server folds it into THIS deploy. A
|
|
143
|
+
// write that doesn't claim the staging library is refused rather than
|
|
144
|
+
// quietly joining it (see deploy-target.js). Empty on the _clear
|
|
145
|
+
// fallback, where there is no staging library to claim.
|
|
146
|
+
const claim = stagingLibraryId ? { stagingLibraryId } : {};
|
|
147
|
+
|
|
148
|
+
for (const { abs, rel } of files) {
|
|
149
|
+
const content = await readFile(abs);
|
|
150
|
+
|
|
151
|
+
if (content.length > CHUNK_THRESHOLD) {
|
|
152
|
+
// Large file: chunked upload via Flow.js protocol
|
|
153
|
+
await api.uploadChunked({
|
|
154
|
+
entityPath,
|
|
155
|
+
path: rel,
|
|
156
|
+
buffer: content,
|
|
157
|
+
filename: basename(abs),
|
|
158
|
+
stagingLibraryId
|
|
159
|
+
});
|
|
160
|
+
console.log(` ${rel} (${formatSize(content.length)}, chunked)`);
|
|
161
|
+
} else {
|
|
162
|
+
// Small file: direct JSON upload
|
|
163
|
+
const ext = '.' + rel.split('.').pop();
|
|
164
|
+
const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
|
|
165
|
+
const payload = isText
|
|
166
|
+
? { content: content.toString('utf8'), encoding: 'utf8', ...claim }
|
|
167
|
+
: { content: content.toString('base64'), encoding: 'base64', ...claim };
|
|
168
|
+
|
|
169
|
+
await api.put(`${entityPath}/contents/${rel}`, payload);
|
|
170
|
+
console.log(` ${rel}`);
|
|
171
|
+
}
|
|
154
172
|
}
|
|
155
|
-
}
|
|
156
173
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
174
|
+
// 12. Deploy: run migrations + scan/bundle server routes + webhooks + embeddings + tools + agents
|
|
175
|
+
if (apiPrefix === 'apps') {
|
|
176
|
+
await runServerDeploy(api, entityPath, stagingLibraryId);
|
|
177
|
+
}
|
|
178
|
+
deployed = true;
|
|
179
|
+
} finally {
|
|
180
|
+
releaseStaging();
|
|
181
|
+
if (stagingLibraryId && !deployed) await discardStaging(api, entityPath, stagingLibraryId);
|
|
160
182
|
}
|
|
161
183
|
|
|
162
184
|
// 13. Print URL and return UUID for saving
|
|
@@ -170,6 +192,129 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
170
192
|
return { id: entity.id, url: entityUrl };
|
|
171
193
|
}
|
|
172
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Discard this deploy's staged upload so a failure doesn't leave the app locked.
|
|
197
|
+
*
|
|
198
|
+
* Presents the id so a cleanup can never destroy a DIFFERENT deploy's in-flight
|
|
199
|
+
* upload, and is best-effort: the original failure is what the user needs to
|
|
200
|
+
* see, so a cleanup problem is reported but never replaces it.
|
|
201
|
+
*
|
|
202
|
+
* @param {Object} api - Informer API client
|
|
203
|
+
* @param {string} entityPath
|
|
204
|
+
* @param {string} stagingLibraryId
|
|
205
|
+
*/
|
|
206
|
+
export async function discardStaging(api, entityPath, stagingLibraryId) {
|
|
207
|
+
try {
|
|
208
|
+
await api.del(`${entityPath}/files/_stage?stagingLibraryId=${encodeURIComponent(stagingLibraryId)}`);
|
|
209
|
+
console.log('Discarded the staged upload; the app is unchanged and editable.');
|
|
210
|
+
} catch (cleanupErr) {
|
|
211
|
+
console.log(`Could not discard the staged upload (${cleanupErr.message}).`);
|
|
212
|
+
console.log(`The app will keep redirecting content edits until it is cleared.`);
|
|
213
|
+
console.log(`Run: DELETE ${entityPath}/files/_stage`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Discard the staged upload if the process is interrupted.
|
|
219
|
+
*
|
|
220
|
+
* Ctrl-C during a long upload is an ordinary way to end a deploy, and without a
|
|
221
|
+
* handler it leaves the app staged with no cleanup at all. Returns a function
|
|
222
|
+
* that removes the handlers so a completed deploy doesn't keep them installed.
|
|
223
|
+
*
|
|
224
|
+
* @returns {Function} release
|
|
225
|
+
*/
|
|
226
|
+
function installStagingSignalHandlers(api, entityPath, stagingLibraryId) {
|
|
227
|
+
if (!stagingLibraryId) return () => {};
|
|
228
|
+
|
|
229
|
+
// A second Ctrl-C during the cleanup await would re-enter and start a
|
|
230
|
+
// competing discard, so the first one wins and later signals exit at once.
|
|
231
|
+
let discarding = false;
|
|
232
|
+
|
|
233
|
+
const handlers = ['SIGINT', 'SIGTERM'].map((signal) => {
|
|
234
|
+
const handler = async () => {
|
|
235
|
+
if (discarding) {
|
|
236
|
+
console.log(`\nInterrupted again; exiting without waiting for the discard.`);
|
|
237
|
+
process.exit(130);
|
|
238
|
+
}
|
|
239
|
+
discarding = true;
|
|
240
|
+
console.log(`\nInterrupted (${signal}); discarding the staged upload...`);
|
|
241
|
+
await discardStaging(api, entityPath, stagingLibraryId);
|
|
242
|
+
process.exit(130);
|
|
243
|
+
};
|
|
244
|
+
process.on(signal, handler);
|
|
245
|
+
return { signal, handler };
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return () => handlers.forEach(({ signal, handler }) => process.off(signal, handler));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* POST `files/_stage`: open a staging library for this deploy.
|
|
253
|
+
*
|
|
254
|
+
* Everything uploaded afterwards lands in the staging library while the app
|
|
255
|
+
* goes on serving its current one; POST `_deploy`, given this library's id,
|
|
256
|
+
* then replaces the live library's files with the staged ones inside a single
|
|
257
|
+
* transaction. That is what keeps an app usable, and complete, for the
|
|
258
|
+
* whole length of its own deploy (I5-12961).
|
|
259
|
+
*
|
|
260
|
+
* Falls back to the old wipe-then-upload flow against a server with no _stage
|
|
261
|
+
* route. That flow is precisely what staging exists to avoid, so it announces
|
|
262
|
+
* itself rather than degrading silently.
|
|
263
|
+
*
|
|
264
|
+
* Note the null check rather than a try/catch: client.js turns a 404 into a
|
|
265
|
+
* `null` reply instead of throwing, so an unsupported route arrives here as a
|
|
266
|
+
* value, not an exception.
|
|
267
|
+
*
|
|
268
|
+
* @param {Object} api - Informer API client
|
|
269
|
+
* @param {string} entityPath - e.g. `apps/team:my-app`
|
|
270
|
+
* @returns {Promise<string|null>} staging library id to present at deploy time, or
|
|
271
|
+
* null when it fell back to _clear and there is nothing to promote
|
|
272
|
+
*/
|
|
273
|
+
export async function openStagingDeploy(api, entityPath) {
|
|
274
|
+
let staged;
|
|
275
|
+
try {
|
|
276
|
+
staged = await api.post(`${entityPath}/files/_stage`);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
// 409 means a previous deploy died and left its upload staged. The
|
|
279
|
+
// server refuses to stage over it (destroying it silently is how two
|
|
280
|
+
// deploys interleave), so the app stays locked until someone discards
|
|
281
|
+
// it — and this message is the only place that instruction appears.
|
|
282
|
+
if (err.status === 409) {
|
|
283
|
+
const base = api.baseUrl ? `${api.baseUrl}/api` : '<informer-url>/api';
|
|
284
|
+
throw new Error(
|
|
285
|
+
`${err.detail || 'This app already has a staged upload from an earlier deploy.'}\n\n`
|
|
286
|
+
+ `A previous deploy did not finish and left its upload staged. Discard it, then re-run this deploy:\n`
|
|
287
|
+
+ ` curl -u <user>:<pass> -X DELETE '${base}/${entityPath}/files/_stage'`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// `null` means 404 and ONLY 404 — client.js returns it for a missing route
|
|
294
|
+
// and throws for every other non-ok status. Any other falsy-id reply is a
|
|
295
|
+
// server that DID stage, so it must not take the destructive branch.
|
|
296
|
+
if (staged === null) {
|
|
297
|
+
console.log(' Server does not support staged deploys; clearing files instead.');
|
|
298
|
+
console.log(' The app will be unavailable to its users until this deploy finishes.');
|
|
299
|
+
await api.post(`${entityPath}/files/_clear`);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!staged.id) {
|
|
304
|
+
// _stage SUCCEEDED, so the server has already pointed the app at a
|
|
305
|
+
// staging library this deploy cannot name. Clearing here would empty the
|
|
306
|
+
// live library while every upload silently redirected into that staging
|
|
307
|
+
// library, and the unclaimed deploy would then publish the empty one.
|
|
308
|
+
throw new Error(
|
|
309
|
+
`POST ${entityPath}/files/_stage succeeded but returned no library id `
|
|
310
|
+
+ `(got: ${JSON.stringify(staged).slice(0, 200)}). The app now has a staging library `
|
|
311
|
+
+ `this deploy cannot claim. Nothing was uploaded or cleared.`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return staged.id;
|
|
316
|
+
}
|
|
317
|
+
|
|
173
318
|
/**
|
|
174
319
|
* POST `_deploy`: run migrations, scan/bundle server routes, webhooks, tools and
|
|
175
320
|
* agents, and claim the app as platform-managed.
|
|
@@ -179,8 +324,10 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
179
324
|
*
|
|
180
325
|
* @param {{post: (path: string, body?: unknown) => Promise<unknown>}} api
|
|
181
326
|
* @param {string} entityPath e.g. `apps/my-app`
|
|
327
|
+
* @param {string} [stagingLibraryId] id returned by openStagingDeploy; presenting
|
|
328
|
+
* it is what authorises the server to promote that staged upload
|
|
182
329
|
*/
|
|
183
|
-
export async function runServerDeploy(api, entityPath) {
|
|
330
|
+
export async function runServerDeploy(api, entityPath, stagingLibraryId) {
|
|
184
331
|
try {
|
|
185
332
|
console.log('Deploying...');
|
|
186
333
|
// `managed: true` claims the app as platform-managed (origin
|
|
@@ -189,7 +336,13 @@ export async function runServerDeploy(api, entityPath) {
|
|
|
189
336
|
// flag is absent, so the GO admin panel's Redeploy and the builder's
|
|
190
337
|
// Save can share this route without converting user-built apps. See
|
|
191
338
|
// app/routes/deploy.js.
|
|
192
|
-
|
|
339
|
+
// Presenting the staged id is what authorises the server to promote that
|
|
340
|
+
// upload. Omitting it (the _clear fallback path) leaves the server
|
|
341
|
+
// deploying the live library, which is the pre-staging behaviour.
|
|
342
|
+
const result = await api.post(`${entityPath}/_deploy`, {
|
|
343
|
+
managed: true,
|
|
344
|
+
...(stagingLibraryId ? { stagingLibraryId } : {})
|
|
345
|
+
});
|
|
193
346
|
if (result === null) {
|
|
194
347
|
// api.post turns a 404 into null instead of throwing, so this is the
|
|
195
348
|
// only place it can be caught. Files uploaded, but migrations, server
|