@unoverse-platform/studio 0.1.91 → 0.1.92
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/local/plugin.ts +1 -53
- package/package.json +1 -1
- package/vendor/base/src/agent-mcp/invoke.ts +14 -1
- package/vendor/base/src/agent-mcp/lean.ts +3 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +72 -10
- package/vendor/base/src/agent-mcp/types.ts +8 -0
- package/vendor/base/src/auth/auth.ts +29 -0
- package/vendor/base/src/definitions/fsCache.ts +27 -0
- package/vendor/base/src/definitions/registry/catalog.ts +19 -3
- package/vendor/base/src/http/uwsFetchAdapter.ts +196 -59
- package/vendor/base/src/items/collect.ts +49 -2
- package/vendor/base/src/items/publish.ts +14 -0
- package/vendor/base/src/lint/nodes/index.d.mts +28 -0
- package/vendor/base/src/lint/nodes/index.mjs +20 -2
- package/vendor/base/src/lint/nodes/node.mjs +37 -37
- package/vendor/base/src/lint/nodes/package.mjs +2 -2
- package/vendor/base/src/manifests/executor/context.ts +3 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +16 -3
- package/vendor/base/src/manifests/runtime/context.ts +6 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +8 -0
- package/vendor/base/src/manifests/source.ts +11 -2
- package/vendor/base/src/platform/executionContext.ts +3 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +35 -24
- package/local/mcp.ts +0 -265
|
@@ -14,9 +14,14 @@
|
|
|
14
14
|
* - every write happens inside `res.cork()`.
|
|
15
15
|
* - body chunks are copied synchronously (uWS reuses the chunk's backing memory).
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
17
|
+
import { gzip as gzipCallback, createGzip, constants as zlibConstants } from "node:zlib";
|
|
18
|
+
import { promisify } from "node:util";
|
|
19
|
+
import { Readable } from "node:stream";
|
|
20
|
+
import { pipeline } from "node:stream/promises";
|
|
18
21
|
import type { HttpRequest, HttpResponse } from "uWebSockets.js";
|
|
19
22
|
|
|
23
|
+
const gzipAsync = promisify(gzipCallback);
|
|
24
|
+
|
|
20
25
|
const STATUS_TEXT: Record<number, string> = {
|
|
21
26
|
200: "OK",
|
|
22
27
|
202: "Accepted",
|
|
@@ -34,6 +39,91 @@ const statusLine = (code: number): string => `${code} ${STATUS_TEXT[code] ?? "OK
|
|
|
34
39
|
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|vnd\.unoverse))/;
|
|
35
40
|
/** Below this, the gzip header + CPU outweigh the shrink. */
|
|
36
41
|
const COMPRESS_MIN_BYTES = 1024;
|
|
42
|
+
/**
|
|
43
|
+
* BUFFER THEN SPILL. A single-shot body is held up to this many bytes so a small
|
|
44
|
+
* response (every MCP reply, most JSON) still goes out in ONE write with an exact
|
|
45
|
+
* length and the cached-gzip path. A body that runs past it is STREAMED from here:
|
|
46
|
+
* head first, then chunks as they arrive, gzip applied on the fly. Before this every
|
|
47
|
+
* response was buffered whole, so time-to-first-byte equalled time-to-last-byte, and a
|
|
48
|
+
* large proxied list cost its full size twice in memory (buffer plus gzip output).
|
|
49
|
+
*/
|
|
50
|
+
const STREAM_AFTER_BYTES = 64 * 1024;
|
|
51
|
+
|
|
52
|
+
type PumpState = { aborted: boolean; reader?: ReadableStreamDefaultReader<Uint8Array>; drained?: () => void };
|
|
53
|
+
|
|
54
|
+
/** Status, the upstream headers uWS must not see twice, encoding, CORS. Inside a cork. */
|
|
55
|
+
function writeHead(res: HttpResponse, response: Response, gzip: boolean): void {
|
|
56
|
+
res.writeStatus(statusLine(response.status));
|
|
57
|
+
response.headers.forEach((value, key) => {
|
|
58
|
+
const k = key.toLowerCase();
|
|
59
|
+
// uWS generates these itself on every response. Forwarding an upstream copy
|
|
60
|
+
// (e.g. the engine's own `date`) yields a DUPLICATE header — nginx just warns
|
|
61
|
+
// and serves 200, but HTTP/3 (QUIC) rejects the malformed header block with
|
|
62
|
+
// QUIC_PACKET_READ_ERROR. Skip them so uWS's single copy is authoritative.
|
|
63
|
+
// access-control-*: writeCors() below is authoritative — forwarding an upstream
|
|
64
|
+
// copy too produced the doubled `access-control-allow-origin: *,*` seen live.
|
|
65
|
+
if (k === "content-length" || k === "date" || k === "server" || k.startsWith("access-control-")) return;
|
|
66
|
+
res.writeHeader(key, value);
|
|
67
|
+
});
|
|
68
|
+
if (gzip) {
|
|
69
|
+
res.writeHeader("Content-Encoding", "gzip");
|
|
70
|
+
// Encoding varies by the request's Accept-Encoding, so shared caches must key on it.
|
|
71
|
+
res.writeHeader("Vary", "Accept-Encoding");
|
|
72
|
+
}
|
|
73
|
+
writeCors(res);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One chunk onto the socket, honouring BACKPRESSURE. `res.write` returns false when
|
|
78
|
+
* uWS's socket buffer is full; the bytes are queued, but reading on regardless meant a
|
|
79
|
+
* stalled client (a backgrounded tab, a slow tunnel) grew that queue without bound
|
|
80
|
+
* while the engine kept streaming into it. Park until uWS reports the socket writable
|
|
81
|
+
* again, or the client aborts (onAborted wakes the parked pump).
|
|
82
|
+
*/
|
|
83
|
+
async function writeChunk(res: HttpResponse, state: PumpState, chunk: Uint8Array | string): Promise<void> {
|
|
84
|
+
let ok = true;
|
|
85
|
+
res.cork(() => {
|
|
86
|
+
ok = res.write(chunk);
|
|
87
|
+
});
|
|
88
|
+
if (ok || state.aborted) return;
|
|
89
|
+
await new Promise<void>((resolve) => {
|
|
90
|
+
state.drained = () => {
|
|
91
|
+
state.drained = undefined;
|
|
92
|
+
resolve();
|
|
93
|
+
};
|
|
94
|
+
res.onWritable(() => {
|
|
95
|
+
state.drained?.();
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Compressed bytes for responses that carry an ETag, keyed on it. An ETag says the body
|
|
103
|
+
* is a fixed thing (the app shell, the embed bundle, keyed on the build's mtime), and
|
|
104
|
+
* those are the biggest payloads this server sends: 427KB of shell and 186KB of embed
|
|
105
|
+
* were gzipped afresh on EVERY 200, ~10-30ms of CPU each. Bounded and insertion-evicted;
|
|
106
|
+
* a new build has a new ETag, so a stale entry is never served, only aged out.
|
|
107
|
+
*/
|
|
108
|
+
const GZIP_CACHE_MAX = 32;
|
|
109
|
+
const gzipCache = new Map<string, Buffer>();
|
|
110
|
+
|
|
111
|
+
/** gzip `payload` OFF the event loop (zlib's threadpool), reusing the cached bytes
|
|
112
|
+
* when the response names itself with an ETag. The old `gzipSync` ran on the same
|
|
113
|
+
* loop that carries live audio frames and every SSE pump. */
|
|
114
|
+
async function compress(payload: Buffer, etag: string | null, contentType: string): Promise<Buffer> {
|
|
115
|
+
const key = etag ? `${etag}|${contentType}|${payload.length}` : null;
|
|
116
|
+
if (key) {
|
|
117
|
+
const hit = gzipCache.get(key);
|
|
118
|
+
if (hit) return hit;
|
|
119
|
+
}
|
|
120
|
+
const gz = await gzipAsync(payload);
|
|
121
|
+
if (key) {
|
|
122
|
+
if (gzipCache.size >= GZIP_CACHE_MAX) gzipCache.delete(gzipCache.keys().next().value as string);
|
|
123
|
+
gzipCache.set(key, gz);
|
|
124
|
+
}
|
|
125
|
+
return gz;
|
|
126
|
+
}
|
|
37
127
|
|
|
38
128
|
function writeCors(res: HttpResponse): void {
|
|
39
129
|
// The workbench is same-origin (via the Vite proxy) so needs none of this; kept so
|
|
@@ -92,9 +182,18 @@ export async function handleFetch(
|
|
|
92
182
|
req: HttpRequest,
|
|
93
183
|
handler: (request: Request) => Promise<Response>,
|
|
94
184
|
): Promise<void> {
|
|
95
|
-
const state:
|
|
185
|
+
const state: PumpState = { aborted: false };
|
|
186
|
+
// THE HANDLER LEARNS OF THE DISCONNECT. `Request.signal` is how a Fetch handler is told
|
|
187
|
+
// its caller went away; without it a streaming handler (a node that runs for minutes)
|
|
188
|
+
// kept working for a client that had hung up, and an engine that had reaped a run could
|
|
189
|
+
// not stop the node it had started. Observed live 2026-09-05.
|
|
190
|
+
const abort = new AbortController();
|
|
96
191
|
res.onAborted(() => {
|
|
97
192
|
state.aborted = true;
|
|
193
|
+
abort.abort();
|
|
194
|
+
// A pump parked on backpressure must wake to see the abort, or it (and its
|
|
195
|
+
// keepalive timer) would hang for the life of the process.
|
|
196
|
+
state.drained?.();
|
|
98
197
|
void state.reader?.cancel().catch(() => {});
|
|
99
198
|
});
|
|
100
199
|
|
|
@@ -121,7 +220,7 @@ export async function handleFetch(
|
|
|
121
220
|
|
|
122
221
|
let response: Response;
|
|
123
222
|
try {
|
|
124
|
-
const init: RequestInit = { method, headers };
|
|
223
|
+
const init: RequestInit = { method, headers, signal: abort.signal };
|
|
125
224
|
if (body && body.length) init.body = new Uint8Array(body); // BodyInit-compatible copy of the Buffer
|
|
126
225
|
response = await handler(new Request(url, init));
|
|
127
226
|
} catch (err) {
|
|
@@ -142,72 +241,109 @@ export async function handleFetch(
|
|
|
142
241
|
|
|
143
242
|
const isSse = (response.headers.get("content-type") ?? "").startsWith("text/event-stream");
|
|
144
243
|
|
|
145
|
-
// Single-shot responses (everything except SSE)
|
|
146
|
-
// can be gzipped
|
|
147
|
-
//
|
|
148
|
-
//
|
|
244
|
+
// Single-shot responses (everything except SSE): buffered up to STREAM_AFTER_BYTES so
|
|
245
|
+
// text payloads can be gzipped in one write (neither uWS nor the DO load balancer
|
|
246
|
+
// compresses — the widget boot alone was ~660KB of JS/JSON that gzips to ~150KB), and
|
|
247
|
+
// STREAMED past that so a large body's first bytes leave before its last arrive.
|
|
149
248
|
if (!isSse) {
|
|
150
249
|
const contentType = response.headers.get("content-type") ?? "";
|
|
151
250
|
const acceptsGzip = /(^|,)\s*gzip\s*(;|,|$)/.test(headers["accept-encoding"] ?? "");
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
251
|
+
const compressible = acceptsGzip && COMPRESSIBLE.test(contentType) && !response.headers.get("content-encoding");
|
|
252
|
+
|
|
253
|
+
// An ETag names a FIXED body (the app shell, the embed bundle): those are buffered
|
|
254
|
+
// whole so the cached-gzip path serves them, however large. Only ETag-less bodies
|
|
255
|
+
// (proxied lists, anything dynamic) spill into streaming.
|
|
256
|
+
const spillAfter = response.headers.get("etag") ? Infinity : STREAM_AFTER_BYTES;
|
|
257
|
+
const reader = response.body?.getReader();
|
|
258
|
+
state.reader = reader;
|
|
259
|
+
const prefix: Uint8Array[] = [];
|
|
260
|
+
let size = 0;
|
|
261
|
+
let complete = !reader;
|
|
262
|
+
if (reader) {
|
|
263
|
+
try {
|
|
264
|
+
for (;;) {
|
|
265
|
+
const { done, value } = await reader.read();
|
|
266
|
+
if (state.aborted) {
|
|
267
|
+
void reader.cancel().catch(() => {});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (done) {
|
|
271
|
+
complete = true;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
if (value?.length) {
|
|
275
|
+
prefix.push(value);
|
|
276
|
+
size += value.length;
|
|
277
|
+
}
|
|
278
|
+
if (size >= spillAfter) break;
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
complete = true; // body unreadable — end empty below, as before
|
|
282
|
+
prefix.length = 0;
|
|
283
|
+
size = 0;
|
|
284
|
+
}
|
|
157
285
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
payload !== null &&
|
|
162
|
-
payload.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
// (
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// copy too produced the doubled `access-control-allow-origin: *,*` seen live.
|
|
176
|
-
if (k === "content-length" || k === "date" || k === "server" || k.startsWith("access-control-")) return;
|
|
177
|
-
res.writeHeader(key, value);
|
|
286
|
+
|
|
287
|
+
if (complete) {
|
|
288
|
+
const payload = size ? Buffer.concat(prefix) : null;
|
|
289
|
+
const shouldCompress = compressible && payload !== null && payload.length >= COMPRESS_MIN_BYTES;
|
|
290
|
+
const body = shouldCompress && payload ? await compress(payload, response.headers.get("etag"), contentType) : payload;
|
|
291
|
+
if (state.aborted) return;
|
|
292
|
+
res.cork(() => {
|
|
293
|
+
writeHead(res, response, shouldCompress);
|
|
294
|
+
// `res.end()` for the empty case, NEVER `endWithoutBody()`. On HTTP/1.1 with no
|
|
295
|
+
// Content-Length, endWithoutBody() leaves the response unterminated: the client
|
|
296
|
+
// waits on a reply that never finishes until the socket's idle timeout kills the
|
|
297
|
+
// connection ~12s later. Every bodyless response paid it — the MCP handshake's
|
|
298
|
+
// 202 ack among them, so every app open stalled ~10s before its first paint
|
|
299
|
+
// (observed live on bppunoverse 2026-08-21; reproduced in isolation: a JSON
|
|
300
|
+
// response 28ms, a null-body 202 11.9s). end() closes with content-length: 0.
|
|
301
|
+
if (body && body.length) res.end(body);
|
|
302
|
+
else res.end();
|
|
178
303
|
});
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// SPILL: the body is larger than the buffer. Head now, then the prefix and the rest
|
|
308
|
+
// as they arrive, chunked. Compression rides a streaming gzip that flushes per input
|
|
309
|
+
// chunk (Z_SYNC_FLUSH), so nothing waits inside zlib for a fuller block.
|
|
310
|
+
res.cork(() => writeHead(res, response, compressible));
|
|
311
|
+
const rest = async function* (): AsyncGenerator<Uint8Array> {
|
|
312
|
+
yield* prefix;
|
|
313
|
+
for (;;) {
|
|
314
|
+
const { done, value } = await reader!.read();
|
|
315
|
+
if (done || state.aborted) return;
|
|
316
|
+
if (value?.length) yield value;
|
|
183
317
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
318
|
+
};
|
|
319
|
+
let source: AsyncIterable<Uint8Array> = rest();
|
|
320
|
+
let gz: ReturnType<typeof createGzip> | null = null;
|
|
321
|
+
if (compressible) {
|
|
322
|
+
gz = createGzip({ flush: zlibConstants.Z_SYNC_FLUSH });
|
|
323
|
+
void pipeline(Readable.from(rest()), gz).catch(() => {});
|
|
324
|
+
source = gz as AsyncIterable<Uint8Array>;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
for await (const chunk of source) {
|
|
328
|
+
if (state.aborted) break;
|
|
329
|
+
await writeChunk(res, state, chunk);
|
|
330
|
+
}
|
|
331
|
+
if (!state.aborted) res.cork(() => res.end());
|
|
332
|
+
} catch {
|
|
333
|
+
if (!state.aborted) res.cork(() => res.end());
|
|
334
|
+
} finally {
|
|
335
|
+
if (state.aborted) {
|
|
336
|
+
void reader!.cancel().catch(() => {});
|
|
337
|
+
gz?.destroy();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
195
340
|
return;
|
|
196
341
|
}
|
|
197
342
|
|
|
198
343
|
// SSE: write head, then pump chunks until the transport closes the stream.
|
|
199
344
|
const reader = response.body?.getReader();
|
|
200
345
|
state.reader = reader;
|
|
201
|
-
res.cork(() =>
|
|
202
|
-
res.writeStatus(statusLine(response.status));
|
|
203
|
-
response.headers.forEach((value, key) => {
|
|
204
|
-
const k = key.toLowerCase();
|
|
205
|
-
// See the single-shot branch for why these are skipped.
|
|
206
|
-
if (k === "content-length" || k === "date" || k === "server" || k.startsWith("access-control-")) return;
|
|
207
|
-
res.writeHeader(key, value);
|
|
208
|
-
});
|
|
209
|
-
writeCors(res);
|
|
210
|
-
});
|
|
346
|
+
res.cork(() => writeHead(res, response, false));
|
|
211
347
|
|
|
212
348
|
if (!reader) {
|
|
213
349
|
if (!state.aborted) res.cork(() => res.end());
|
|
@@ -233,7 +369,8 @@ export async function handleFetch(
|
|
|
233
369
|
// blind the canvas. Comment lines are ~18 bytes — frequency is free.
|
|
234
370
|
const keepalive = isSse
|
|
235
371
|
? setInterval(() => {
|
|
236
|
-
|
|
372
|
+
// Not while parked on backpressure: a beat would only deepen the queue.
|
|
373
|
+
if (!state.aborted && !state.drained) res.cork(() => res.write(": keepalive\n\n"));
|
|
237
374
|
}, 10_000)
|
|
238
375
|
: null;
|
|
239
376
|
|
|
@@ -246,7 +383,7 @@ export async function handleFetch(
|
|
|
246
383
|
return;
|
|
247
384
|
}
|
|
248
385
|
if (done) break;
|
|
249
|
-
if (value) res
|
|
386
|
+
if (value) await writeChunk(res, state, value);
|
|
250
387
|
}
|
|
251
388
|
if (!state.aborted) res.cork(() => res.end());
|
|
252
389
|
if (isSse) console.log(`[unoverse:sse] pump CLOSED (stream ended) ${method} ${path} sid=${sid}`);
|
|
@@ -20,12 +20,15 @@
|
|
|
20
20
|
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
|
21
21
|
import { join, relative, basename } from "node:path";
|
|
22
22
|
import { fingerprintOf } from "./fingerprint.js";
|
|
23
|
+
import { parse as parseYaml } from "yaml";
|
|
24
|
+
import { readDiskPackages } from "../manifests/source.js";
|
|
23
25
|
|
|
24
26
|
/** One publishable thing: what it is, what it contains, and what it was built on. */
|
|
25
27
|
export interface CollectedItem {
|
|
26
28
|
kind: string;
|
|
27
29
|
name: string;
|
|
28
|
-
|
|
30
|
+
/** The files as authored. A node row also carries its package envelope and folder name. */
|
|
31
|
+
definition: { files: Record<string, string>; package?: Record<string, unknown>; dir?: string };
|
|
29
32
|
fingerprint: string;
|
|
30
33
|
org: string;
|
|
31
34
|
base_version: string;
|
|
@@ -106,7 +109,7 @@ export function collectProject(designRoot: string, project: string): CollectedIt
|
|
|
106
109
|
// Apps keep their bare ids: those are org-qualified by convention
|
|
107
110
|
// (`<org>-chat`). docs/unoverse/UNOVERSE_COMPONENT_ORGS.md.
|
|
108
111
|
const QUALIFIED_KINDS = new Set(["component", "skill", "template"]);
|
|
109
|
-
const add = (kind: string, name: string, definition:
|
|
112
|
+
const add = (kind: string, name: string, definition: CollectedItem["definition"]) =>
|
|
110
113
|
items.push({
|
|
111
114
|
kind,
|
|
112
115
|
name: QUALIFIED_KINDS.has(kind) ? `${project}/${name}` : name,
|
|
@@ -195,5 +198,49 @@ export function collectProject(designRoot: string, project: string): CollectedIt
|
|
|
195
198
|
walk(blocksHome);
|
|
196
199
|
}
|
|
197
200
|
|
|
201
|
+
// nodes/ — THE WORKSPACE'S NODE PACKAGES, one row per node, shaped exactly as the
|
|
202
|
+
// marketplace stores a node (core/items/catalogue.ts) so the row source composes them
|
|
203
|
+
// on the same path as disk: the raw files, plus the package envelope their $refs and
|
|
204
|
+
// credential shapes resolve against. The name is the node's `type`, bare: a saved
|
|
205
|
+
// workflow stores that identity and nothing else.
|
|
206
|
+
//
|
|
207
|
+
// Until 2026-09-05 deploy never looked here, so `deploy studio` shipped a developer's
|
|
208
|
+
// components and skills and silently left every node behind.
|
|
209
|
+
//
|
|
210
|
+
// NOT FROM THE PLATFORM TREE. The monorepo's nodes ship inside the core image and are
|
|
211
|
+
// read from disk there; publishing them as rows under whichever org happened to be
|
|
212
|
+
// deployed would double every platform node. A design system on disk is the mark of
|
|
213
|
+
// the platform tree (the same test designSystemDir makes), and a developer's workspace
|
|
214
|
+
// never has one.
|
|
215
|
+
const nodesHome = join(designRoot, "..", "nodes");
|
|
216
|
+
if (existsSync(nodesHome) && !existsSync(join(designRoot, "marketplace"))) {
|
|
217
|
+
for (const pkg of collectNodePackages(nodesHome)) {
|
|
218
|
+
for (const raw of pkg.nodes) {
|
|
219
|
+
const type = nodeType(raw.files["node.yaml"]);
|
|
220
|
+
if (!type) continue; // lint has already refused it; nothing to name a row by
|
|
221
|
+
add("node", type, {
|
|
222
|
+
package: { name: pkg.name, packageFile: pkg.packageFile ?? null, credentials: pkg.credentials, shared: pkg.shared },
|
|
223
|
+
dir: raw.dir,
|
|
224
|
+
files: raw.files,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
198
230
|
return items;
|
|
199
231
|
}
|
|
232
|
+
|
|
233
|
+
/** The workspace's node packages, read the way the platform reads its own. */
|
|
234
|
+
function collectNodePackages(nodesHome: string) {
|
|
235
|
+
return readDiskPackages(nodesHome).filter((pkg) => pkg.name !== "marketplace");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** `type:` off a node.yaml: the one scalar the row is named by. Unparseable means none. */
|
|
239
|
+
function nodeType(nodeYaml: string | undefined): string {
|
|
240
|
+
try {
|
|
241
|
+
const doc = parseYaml(nodeYaml ?? "");
|
|
242
|
+
return typeof doc?.type === "string" ? doc.type : "";
|
|
243
|
+
} catch {
|
|
244
|
+
return "";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*
|
|
13
13
|
* See docs/architecture/DECLARATIVE_NODES.md §9.
|
|
14
14
|
*/
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
15
17
|
import { collectProject, type CollectedItem } from "./collect.js";
|
|
16
18
|
|
|
17
19
|
/** One lint finding. `error` blocks a publish; `warn` and `hint` inform. */
|
|
@@ -46,6 +48,18 @@ export interface PublishPlan {
|
|
|
46
48
|
export async function lintForPublish(designRoot: string, project: string): Promise<{ problems: Finding[]; errors: Finding[] }> {
|
|
47
49
|
const { lintDefinitions } = await import("../lint/design/index.mjs");
|
|
48
50
|
const result = lintDefinitions(designRoot);
|
|
51
|
+
/**
|
|
52
|
+
* NODES TOO. A workspace keeps its node packages beside design/ (the same `nodes/` the
|
|
53
|
+
* Studio Nodes screen reads), and until 2026-09-05 this gate never looked at them: a
|
|
54
|
+
* node with an events row out of order or a host missing from allowedHosts deployed as
|
|
55
|
+
* cleanly as a correct one. The node linter is the same library the platform's own CI
|
|
56
|
+
* runs. A workspace with no nodes/ folder has nothing to lint and is not an error.
|
|
57
|
+
*/
|
|
58
|
+
const nodesHome = join(designRoot, "..", "nodes");
|
|
59
|
+
if (existsSync(nodesHome)) {
|
|
60
|
+
const { lintNodes } = await import("../lint/nodes/index.mjs");
|
|
61
|
+
result.problems.push(...lintNodes(nodesHome).problems);
|
|
62
|
+
}
|
|
49
63
|
/**
|
|
50
64
|
* SCOPED TO THE PROJECT BEING DEPLOYED, on paths that may be relative. The old
|
|
51
65
|
* check looked for "/design/" with a leading slash, which a relative path never
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the node linter, which is plain JavaScript.
|
|
3
|
+
*
|
|
4
|
+
* Same arrangement as ../design/index.d.mts, for the same reason: the linter stays .mjs so
|
|
5
|
+
* the CLIs run it under bare node, and TypeScript consumers (the publish gate) get a shape
|
|
6
|
+
* rather than `any`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface Finding {
|
|
10
|
+
level: "error" | "warn" | "hint";
|
|
11
|
+
file: string;
|
|
12
|
+
line?: number;
|
|
13
|
+
msg: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface NodesResult {
|
|
17
|
+
problems: Finding[];
|
|
18
|
+
derivedKinds: Map<string, string>;
|
|
19
|
+
nodeCount: number;
|
|
20
|
+
pkgCount: number;
|
|
21
|
+
nodesHome: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Lint every node package under `nodesHome`. Prints nothing, throws nothing for a lint failure. */
|
|
25
|
+
export function lintNodes(nodesHome: string): NodesResult;
|
|
26
|
+
|
|
27
|
+
/** True when anything would fail a build. */
|
|
28
|
+
export function hasErrors(result: NodesResult): boolean;
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* 4 dry run `unoverse node test`, executes against testData, not here
|
|
21
21
|
*/
|
|
22
22
|
import { readdirSync, existsSync, statSync } from "node:fs";
|
|
23
|
-
import { join, resolve } from "node:path";
|
|
23
|
+
import { join, resolve, dirname } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
24
25
|
import { problems, derivedKinds, reset, report } from "../context.mjs";
|
|
25
26
|
import { loadSchemas } from "./schema.mjs";
|
|
26
27
|
import { lintPackage } from "./package.mjs";
|
|
@@ -32,9 +33,26 @@ import { lintPackage } from "./package.mjs";
|
|
|
32
33
|
* missing directory or unreadable schema is itself reported, so a caller gets one shape
|
|
33
34
|
* of answer whatever went wrong.
|
|
34
35
|
*/
|
|
36
|
+
/**
|
|
37
|
+
* Where the node schemas are, for this run.
|
|
38
|
+
*
|
|
39
|
+
* The platform's own tree carries them at `nodes/_schema/`, and every node file points at
|
|
40
|
+
* them with `$schema:` for editor squiggles. A DEVELOPER'S WORKSPACE DOES NOT: it holds only
|
|
41
|
+
* their packages, and a linter that then warned "no schema found" on every file was
|
|
42
|
+
* validating nothing while looking busy. So the schemas travel with the linter. In dist
|
|
43
|
+
* they sit beside this file (copy-lint.mjs puts them there, and the CLI vendors that
|
|
44
|
+
* folder); from source they are the monorepo's copy, five levels up.
|
|
45
|
+
*/
|
|
46
|
+
function schemaHome(home) {
|
|
47
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
for (const c of [join(home, "_schema"), join(here, "_schema"), resolve(here, "../../../../../apps/unoverse/nodes/_schema")])
|
|
49
|
+
if (existsSync(c)) return c;
|
|
50
|
+
return join(home, "_schema");
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
export function lintNodes(nodesHome) {
|
|
36
54
|
const home = resolve(nodesHome);
|
|
37
|
-
reset(home,
|
|
55
|
+
reset(home, schemaHome(home));
|
|
38
56
|
|
|
39
57
|
if (!existsSync(home)) {
|
|
40
58
|
report("error", home, "no nodes directory here");
|