@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.
@@ -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 { gzipSync } from "node:zlib";
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: { aborted: boolean; reader?: ReadableStreamDefaultReader<Uint8Array> } = { aborted: false };
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) are buffered so large text payloads
146
- // can be gzipped. Neither uWS nor the DO load balancer compresses, so without this
147
- // every byte travels identity-encoded — the widget boot alone was ~660KB of JS/JSON
148
- // that gzips to ~150KB. SSE must stream, so it is never buffered or compressed.
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
- let payload: Buffer | null = null;
153
- try {
154
- payload = Buffer.from(await response.arrayBuffer());
155
- } catch {
156
- payload = null; // body unreadable — fall through and end empty below
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
- if (state.aborted) return;
159
- const shouldCompress =
160
- acceptsGzip &&
161
- payload !== null &&
162
- payload.length >= COMPRESS_MIN_BYTES &&
163
- COMPRESSIBLE.test(contentType) &&
164
- !response.headers.get("content-encoding");
165
- const body = shouldCompress && payload ? gzipSync(payload) : payload;
166
- res.cork(() => {
167
- res.writeStatus(statusLine(response.status));
168
- response.headers.forEach((value, key) => {
169
- const k = key.toLowerCase();
170
- // uWS generates these itself on every response. Forwarding an upstream copy
171
- // (e.g. the engine's own `date`) yields a DUPLICATE header — nginx just warns
172
- // and serves 200, but HTTP/3 (QUIC) rejects the malformed header block with
173
- // QUIC_PACKET_READ_ERROR. Skip them so uWS's single copy is authoritative.
174
- // access-control-*: writeCors() below is authoritative — forwarding an upstream
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
- if (shouldCompress) {
180
- res.writeHeader("Content-Encoding", "gzip");
181
- // Encoding varies by the request's Accept-Encoding, so shared caches must key on it.
182
- res.writeHeader("Vary", "Accept-Encoding");
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
- writeCors(res);
185
- // `res.end()` for the empty case, NEVER `endWithoutBody()`. On HTTP/1.1 with no
186
- // Content-Length, endWithoutBody() leaves the response unterminated: the client
187
- // waits on a reply that never finishes until the socket's idle timeout kills the
188
- // connection ~12s later. Every bodyless response paid it — the MCP handshake's
189
- // 202 ack among them, so every app open stalled ~10s before its first paint
190
- // (observed live on bppunoverse 2026-08-21; reproduced in isolation: a JSON
191
- // response 28ms, a null-body 202 11.9s). end() closes with content-length: 0.
192
- if (body && body.length) res.end(body);
193
- else res.end();
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
- if (!state.aborted) res.cork(() => res.write(": keepalive\n\n"));
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.cork(() => res.write(value));
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
- definition: { files: Record<string, string> };
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: { files: Record<string, string> }) =>
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, join(home, "_schema"));
55
+ reset(home, schemaHome(home));
38
56
 
39
57
  if (!existsSync(home)) {
40
58
  report("error", home, "no nodes directory here");